Compare commits

..

1 Commits

Author SHA1 Message Date
enricobuehler dab40ed98e feat(client/windows): PyroWave decode + surface it in the GUI codec picker
The decoder was gated to Linux because, when it landed, the Windows client
still had its own in-process WinUI/D3D11 presenter and the PyroWave present
path there was an open question. That client has since been retired: Windows
now spawns the SAME Vulkan session presenter as Linux, and the decoder is
plain Vulkan compute on the presenter's device (no fds, no dmabuf, no D3D11
interop), so the question that gated it answered itself. pyrowave-sys already
builds on Windows too -- the Windows HOST encoder ships on it.

So this is a port by un-gating: every cfg(all(target_os = "linux", feature =
"pyrowave")) becomes any(linux, windows) -- decoder module, backend variant,
Decoder::new_pyrowave, the CODEC_PYROWAVE advertisement, the session pump's
opt-in/build/label arms, and the presenter's planar CSC pass. No new code.

Then offer it in the Windows GUI, which is what prompted this. It stays
preference-only (resolve_codec never auto-picks it) and a host or device that
can't do PyroWave just falls back down the ladder to HEVC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:10:42 +02:00
95 changed files with 1424 additions and 5960 deletions
+2 -3
View File
@@ -104,9 +104,8 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# All three client binaries — the shell spawns punktfunk-session.exe (a package
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
Generated
+27 -27
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.15.0"
version = "0.14.0"
[[package]]
name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"bindgen",
"cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"punktfunk-core",
]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ash",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"ash",
"bindgen",
@@ -2903,7 +2903,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"libc",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2929,11 +2929,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.15.0"
version = "0.14.0"
[[package]]
name = "pf-inject"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2961,14 +2961,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ash",
@@ -2983,7 +2983,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3025,7 +3025,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ash",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"android_logger",
"jni",
@@ -3237,7 +3237,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3268,7 +3268,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3400,7 +3400,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3414,7 +3414,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"anyhow",
"ksni",
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.15.0"
version = "0.14.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.15.0"
version = "0.14.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -150
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.15.0"
"version": "0.14.0"
},
"paths": {
"/api/v1/clients": {
@@ -1348,117 +1348,6 @@
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
"library"
],
"summary": "List the library scanners",
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
"operationId": "listLibraryScanners",
"responses": {
"200": {
"description": "This host's scanners with their enable state",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScannerInfo"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners/{id}": {
"put": {
"tags": [
"library"
],
"summary": "Enable or disable a library scanner",
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
"operationId": "setLibraryScanner",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The scanner id (e.g. `steam`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScannerToggle"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Toggle stored; the full scanner list",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScannerInfo"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No such scanner on this platform",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/local/summary": {
"get": {
"tags": [
@@ -4782,44 +4671,6 @@
}
}
},
"ScannerInfo": {
"type": "object",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"required": [
"id",
"label",
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the scanner (default true)."
},
"id": {
"type": "string",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
}
}
},
"ScannerToggle": {
"type": "object",
"description": "Request body for `setLibraryScanner`.",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the scanner should run on this host."
}
}
},
"SessionInfo": {
"type": "object",
"description": "Client-requested launch parameters (key material is never exposed here).",
@@ -13,8 +13,8 @@
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -504,7 +504,6 @@
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -541,7 +540,6 @@
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -721,7 +719,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.9.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -766,7 +764,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.9.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -863,15 +861,15 @@
isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
};
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
isa = XCSwiftPackageProductDependency;
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = AA000000000000000000000D /* Project object */;
@@ -55,11 +55,6 @@
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "MTL_HUD_ENABLED"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "play.tv.fill")
.font(.title2)
.foregroundStyle(Color.brand)
.foregroundStyle(.tint)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
VStack(alignment: .leading, spacing: 8) {
Text("Punktfunk")
.font(.caption).bold()
.foregroundStyle(Color.brand)
.foregroundStyle(.tint)
ForEach(hosts.prefix(4)) { host in
Link(destination: connectURL(host)) {
HStack {
Image(systemName: "play.tv.fill")
.foregroundStyle(Color.brand)
.foregroundStyle(.tint)
Text(host.displayName)
.font(.subheadline)
.lineLimit(1)
@@ -184,47 +184,3 @@ private struct EmptyHostView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Previews (Xcode canvas)
//
// Select the PunktfunkWidgetsExtension scheme and open the canvas (). The widget
// `#Preview(as:widget:timeline:)` form feeds sample entries directly the App-Group store is
// never read, so the canvas works without a paired device or saved hosts. The small preview's
// second entry shows the empty state one timeline click away.
private let previewHosts: [StoredHost] = [
StoredHost(
name: "Studio", address: "192.168.1.20",
lastConnected: .now.addingTimeInterval(-40 * 60)),
StoredHost(
name: "Living Room", address: "192.168.1.30",
lastConnected: .now.addingTimeInterval(-26 * 3600)),
StoredHost(
name: "Workstation", address: "10.0.0.5",
lastConnected: .now.addingTimeInterval(-6 * 86400)),
]
#Preview("Small", as: .systemSmall) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
HostsEntry(date: .now, hosts: [])
}
#Preview("Medium", as: .systemMedium) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
#Preview("Lock Screen circular", as: .accessoryCircular) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
@@ -19,69 +19,43 @@ struct PunktfunkSessionLiveActivity: Widget {
LockScreenView(context: context)
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
// region one purposeful row session status + the live numbers on the left, the
// End action on the right. COMPACT shows the one number worth a glance: live
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
// everywhere `.tint` used to leak system blue.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 6) {
Label {
Text(context.attributes.hostName).font(.caption).lineLimit(1)
} icon: {
Image(systemName: "play.tv.fill")
.font(.subheadline)
.foregroundStyle(Color.brand)
Text(context.attributes.hostName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
}
.padding(.leading, 4)
.padding(.top, 2)
.foregroundStyle(.tint)
}
DynamicIslandExpandedRegion(.trailing) {
ElapsedClock(startedAt: context.state.startedAt)
.font(.subheadline)
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.caption).monospacedDigit()
.frame(maxWidth: 56)
.foregroundStyle(.secondary)
.frame(maxWidth: 64, alignment: .trailing)
.padding(.trailing, 4)
.padding(.top, 2)
}
DynamicIslandExpandedRegion(.center) {
if let title = context.attributes.launchTitle {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
// The expanded island's height is there to be used: one info row (status
// leading, live numbers trailing the mode string stays on the Lock
// Screen), then the platform-conventional LARGE full-width action button.
VStack(spacing: 10) {
HStack {
StatusLine(state: context.state)
Spacer(minLength: 8)
StatsLine(state: context.state, showMode: false)
}
Spacer(minLength: 0) // any slack height goes here button hugs the bottom
EndButton(fullWidth: true)
VStack(spacing: 6) {
Text(context.state.modeLine)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
StageLine(state: context.state)
EndButton()
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 4)
.padding(.top, 6)
}
} compactLeading: {
Image(systemName: "play.tv.fill")
.foregroundStyle(Color.brand)
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
} compactTrailing: {
CompactReadout(state: context.state)
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.monospacedDigit()
.frame(maxWidth: 44)
} minimal: {
Image(systemName: "play.tv.fill")
.foregroundStyle(Color.brand)
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
}
.keylineTint(Color.brand)
}
}
}
@@ -95,20 +69,21 @@ private struct LockScreenView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "play.tv.fill")
.font(.title2)
.foregroundStyle(Color.brand)
VStack(alignment: .leading, spacing: 4) {
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(context.attributes.hostName).font(.headline).lineLimit(1)
Spacer()
ElapsedClock(startedAt: context.state.startedAt)
.font(.subheadline)
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.subheadline).monospacedDigit()
.foregroundStyle(.secondary)
}
if let title = context.attributes.launchTitle {
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
StatusLine(state: context.state)
StatsLine(state: context.state)
Text(context.state.modeLine)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
StageLine(state: context.state)
}
if context.state.stage == .background {
EndButton()
@@ -120,237 +95,46 @@ private struct LockScreenView: View {
// MARK: - Shared pieces
/// The ticking elapsed-session clock client-side via `timerInterval`, no per-second push.
private struct ElapsedClock: View {
let startedAt: Date
var body: some View {
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
.monospacedDigit()
.multilineTextAlignment(.trailing)
}
}
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
private struct StatusLine: View {
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
private struct StageLine: View {
let state: PunktfunkSessionAttributes.ContentState
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
label
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
private var color: Color {
switch state.stage {
case .streaming: return .green
case .background: return .orange
case .reconnecting: return .yellow
case .ending: return .secondary
}
}
@ViewBuilder private var label: some View {
switch state.stage {
case .streaming:
Text("Streaming")
EmptyView()
case .background:
if let deadline = state.backgroundDeadline {
HStack(spacing: 3) {
Text("Background · ends in")
Text("Keeps running for")
Text(timerInterval: Date()...deadline, countsDown: true)
.monospacedDigit()
}
} else {
Text("Running in background")
}
case .reconnecting:
Text("Reconnecting…")
case .ending:
Text("Session ended")
}
}
}
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
/// mode (`showMode` false on the island, where the row shares space with the status line).
/// Anything unreported simply doesn't appear.
private struct StatsLine: View {
let state: PunktfunkSessionAttributes.ContentState
var showMode = true
var body: some View {
HStack(spacing: 8) {
if let ms = state.latencyMs {
stat("bolt.fill", "\(ms) ms")
}
if let mbps = state.mbps {
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
}
if showMode {
Text(state.modeLine)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
private func stat(_ icon: String, _ text: String) -> some View {
HStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 8, weight: .semibold))
Text(text)
.font(.caption2)
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
}
/// The compact trailing readout the island's one glanceable number. Streaming: the live
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
private struct CompactReadout: View {
let state: PunktfunkSessionAttributes.ContentState
var body: some View {
switch state.stage {
case .streaming:
if let ms = state.latencyMs {
Text("\(ms) ms")
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(.green)
.foregroundStyle(.secondary)
} else {
ElapsedClock(startedAt: state.startedAt)
.font(.caption2)
.frame(maxWidth: 48)
}
case .background:
if let deadline = state.backgroundDeadline {
Text(timerInterval: Date()...deadline, countsDown: true)
.font(.caption2)
.monospacedDigit()
.multilineTextAlignment(.trailing)
.frame(maxWidth: 48)
.foregroundStyle(.orange)
} else {
Image(systemName: "moon.fill").foregroundStyle(.orange)
badge("Running in background", .orange)
}
case .reconnecting:
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
badge("Reconnecting…", .yellow)
case .ending:
Image(systemName: "stop.fill").foregroundStyle(.secondary)
badge("Session ended", .secondary)
}
}
private func badge(_ text: String, _ color: Color) -> some View {
Text(text).font(.caption2).foregroundStyle(color)
}
}
/// End-stream button runs EndStreamIntent in the app process (LiveActivityIntent).
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
private struct EndButton: View {
var fullWidth = false
var body: some View {
if fullWidth {
Button(intent: EndStreamIntent()) {
Label("End Session", systemImage: "stop.fill")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
}
.tint(.red)
.buttonStyle(.bordered)
} else {
Button(intent: EndStreamIntent()) {
Label("End", systemImage: "stop.fill")
.font(.caption).bold()
}
.tint(.red)
.buttonStyle(.bordered)
Button(intent: EndStreamIntent()) {
Label("End", systemImage: "stop.fill")
.font(.caption).bold()
}
.tint(.red)
.buttonStyle(.bordered)
}
}
// MARK: - Previews (Xcode canvas)
//
// Select the PunktfunkWidgetsExtension scheme and open the canvas () the activity
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
extension PunktfunkSessionAttributes {
fileprivate static var preview: PunktfunkSessionAttributes {
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
}
}
extension PunktfunkSessionAttributes.ContentState {
fileprivate static var streaming: Self {
.init(
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
}
fileprivate static var backgrounded: Self {
.init(
stage: .background, startedAt: .now.addingTimeInterval(-1975),
modeLine: "2752×2064 @120 · HEVC · HDR",
backgroundDeadline: .now.addingTimeInterval(9 * 60))
}
fileprivate static var reconnecting: Self {
.init(
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
modeLine: "2752×2064 @120 · HEVC · HDR")
}
fileprivate static var ended: Self {
.init(
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
modeLine: "2752×2064 @120 · HEVC · HDR")
}
}
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
PunktfunkSessionAttributes.ContentState.backgrounded
PunktfunkSessionAttributes.ContentState.reconnecting
PunktfunkSessionAttributes.ContentState.ended
}
#Preview(
"Island expanded", as: .dynamicIsland(.expanded),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
PunktfunkSessionAttributes.ContentState.backgrounded
}
#Preview(
"Island compact", as: .dynamicIsland(.compact),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
}
#Preview(
"Island minimal", as: .dynamicIsland(.minimal),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
}
@@ -89,11 +89,6 @@ struct ContentView: View {
/// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false
#endif
#if os(iOS)
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
/// the disc must LEAVE the hierarchy so nothing composites over the metal layer).
@State private var showTouchExit = false
#endif
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -198,9 +193,6 @@ struct ContentView: View {
#if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
#if os(iOS)
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
#endif
// A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host).
guard let host = model.activeHost else { break }
@@ -500,19 +492,12 @@ struct ContentView: View {
}
// The resize spinner rides over the (blurred) stream; suppressed under the trust
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
// steering and the next click still reaches the stream. Mounted ONLY while a
// resize is live: resident structure above the CAMetalLayer is what the stage-4
// direct-to-display hunt is eliminating composited presents reach glass a full
// refresh later. The enter/exit fade rides the call-site transition + the
// .animation(value: resizing) below (the view's internal `if active` fade can't
// run when the whole view unmounts).
// steering and the next click still reaches the stream.
.overlay {
if pendingFingerprint == nil, model.resizing {
ResizeIndicatorView(active: true)
.transition(.opacity.combined(with: .scale(scale: 0.92)))
if pendingFingerprint == nil {
ResizeIndicatorView(active: model.resizing)
}
}
.animation(.easeInOut(duration: 0.22), value: model.resizing)
if let fp = pendingFingerprint {
TrustCardView(
fingerprint: fp,
@@ -602,8 +587,7 @@ struct ContentView: View {
},
endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage,
displayMeter: model.displayStage,
presentFloorMeter: model.presentFloor
displayMeter: model.displayStage
)
.overlay(alignment: placement.alignment) {
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
@@ -652,27 +636,18 @@ struct ContentView: View {
#if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// screen the overlay off, or the compact pill (which carries no button)
// keep a minimal touch exit in a corner. It rides a material disc (like the
// HUD) so the glyph stays legible over a bright frame.
//
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
// above the stream a glass one doubly so, its blur SAMPLES the video layer
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
// latency and blocking direct-to-display promotion. Off is the immersive/
// measurement tier; after the fade, touch-only exits are backgrounding the app
// or re-enabling the stats overlay. Compact keeps its disc permanently that
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
// keep a minimal always-reachable exit in a corner. It rides a material disc
// (like the HUD) so the glyph stays legible over a bright frame this is the
// sole touch disconnect path in those tiers.
.overlay(alignment: .topLeading) {
if captureEnabled,
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
Button { model.disconnect() } label: {
Image(systemName: "xmark")
.font(.headline.weight(.semibold))
.frame(width: 36, height: 36)
// Floating glass disc over the frame (26+, material fallback).
// interactive: the disc IS the tap target, so the glass reacts
// to press.
// Sole touch exit in the off/compact tiers a floating glass disc
// over the frame (26+, material fallback). interactive: the disc
// IS the tap target, so the glass reacts to press.
.glassBackground(Circle(), interactive: true)
// Match the hit region to the visible disc so every tap also
// triggers the interactive-glass press highlight.
@@ -681,12 +656,6 @@ struct ContentView: View {
.buttonStyle(.plain)
.padding(12)
.accessibilityLabel("Disconnect")
.transition(.opacity)
.task {
guard statsVerbosity == .off else { return }
try? await Task.sleep(for: .seconds(8))
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
}
}
}
#endif
@@ -102,20 +102,6 @@ final class SessionModel: ObservableObject {
@Published var decodeValid = false
@Published var displayP50Ms = 0.0
@Published var displayValid = false
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
/// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off no floor)
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The floor-shaved values every HUD tier displays (raw floor, never below 0). Identical
/// to the raw values whenever no floor is measured.
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
@Published var lostFrames = 0
@@ -147,9 +133,6 @@ final class SessionModel: ObservableObject {
let endToEnd = LatencyMeter()
let decodeStage = LatencyMeter()
let displayStage = LatencyMeter()
/// The OS present floor sampler (see `osFloorP50Ms`) fed one sample per display-link
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter()
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
private var lastFramesDropped: UInt64 = 0
private var statsTimer: Timer?
@@ -489,7 +472,6 @@ final class SessionModel: ObservableObject {
endToEndValid = false
decodeValid = false
displayValid = false
osFloorValid = false
lostFrames = 0
lostPct = 0
mouseCaptured = false
@@ -673,25 +655,15 @@ final class SessionModel: ObservableObject {
} else {
self.displayValid = false
}
if let f = self.presentFloor.drain() {
self.osFloorP50Ms = f.p50Ms
self.osFloorValid = true
} else {
self.osFloorValid = false
}
// Mirror the window to the unified log (see statsLog) one line per second,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count)
// a presentsfps gap is the presenter dropping/serializing, an fps deficit is
// upstream (host capture/encode or the network).
if frames > 0 {
// The classic fields stay RAW (cross-session comparability with every log
// captured before the 2026-07 floor policy); the appended trio carries the
// measured OS present floor and the floor-shaved values the HUD displays.
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -699,10 +671,7 @@ final class SessionModel: ObservableObject {
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
lost)
statsLog.info("\(line, privacy: .public)")
}
}
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
private var compactLine: String {
var parts = ["\(model.fps) fps"]
if model.endToEndValid {
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
} else if model.hostNetworkValid {
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
}
@@ -88,36 +86,24 @@ struct StreamHUDView: View {
}
if model.endToEndValid {
// Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// corrected) "(same-host clock)" when the host didn't answer the skew
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
// latency; the detailed tier shows the excluded floor as its own line, and the
// stats log keeps the raw values.
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
// corrected) "(same-host clock)" when the host didn't answer the skew handshake.
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
// The equation (detailed tier only): the stages tiling the headline interval
// (per-window p50s they only approximately sum to the directly-measured
// total). With a host that reports per-AU timings (0xCF) the first term splits
// into host + network (phase 2); an old host keeps the combined term. The
// display term is floor-shaved like the headline, so the equation still sums.
// into host + network (phase 2); an old host keeps the combined term.
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
} else {
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
if model.osFloorValid {
// The excluded OS term, kept visible for honesty: display-pipeline
// minimum no client can pace under (~2 refresh intervals composited).
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
}
} else if model.hostNetworkValid {
// Stage-1 fallback presenter: the layer decodes + presents internally with no
@@ -40,9 +40,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
#if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@@ -290,19 +288,11 @@ struct GamepadSettingsView: View {
+ "hardware decode.",
value: $enable444),
choiceRow(
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
detail: "Lowest latency shows each frame the moment the display can take it; "
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
+ "from the next session.",
options: SettingsOptions.presentPriorities, current: presentPriority
) { presentPriority = $0 },
choiceRow(
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
detail: "How many frames Smoothness holds — each adds about a refresh of "
+ "display latency and absorbs about a refresh of jitter. Only applies "
+ "when prioritizing smoothness.",
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
) { smoothBuffer = $0 },
id: "presenter", icon: "rectangle.stack", label: "Presenter",
detail: "Stage 3 paces presents to the display — lowest display latency. "
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
options: SettingsOptions.presenters, current: presenter
) { presenter = $0 },
choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -8,10 +8,7 @@ import SwiftUI
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
/// private) so the screenshot harness can open SettingsView on a specific category.
enum SettingsCategory: String, CaseIterable, Identifiable {
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
case general, display, input, audio, controllers, about
case general, display, audio, controllers, advanced, about
var id: Self { self }
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
switch self {
case .general: return "General"
case .display: return "Display"
case .input: return "Input"
case .audio: return "Audio"
case .controllers: return "Controllers"
case .advanced: return "Advanced"
case .about: return "About"
}
}
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
switch self {
case .general: return "gearshape"
case .display: return "display"
case .input: return "keyboard"
case .audio: return "speaker.wave.2"
case .controllers: return "gamecontroller"
case .advanced: return "slider.horizontal.3"
case .about: return "info.circle"
}
}
@@ -37,31 +37,28 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
/// PUNKTFUNK_PRESENTER debug env lever.
static let presentPriorities: [(label: String, tag: String)] = [
("Lowest latency", "latency"),
("Smoothness", "smooth"),
]
static let presentPriorityDefault = "latency"
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
/// frames). The ms hints derive from the chosen refresh setting each buffered frame costs
/// about one refresh interval of display latency and absorbs about one interval of arrival
/// jitter.
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
let periodMs = 1000.0 / Double(max(24, refreshHz))
func hint(_ frames: Int) -> String {
String(format: "+%.0f ms", Double(frames) * periodMs)
}
return [
("Automatic", 0),
("1 frame (\(hint(1)))", 1),
("2 frames (\(hint(2)))", 2),
("3 frames (\(hint(3)))", 3),
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS and
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
/// this so an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(macOS)
"stage2"
#else
"stage3"
#endif
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
@@ -1,15 +1,5 @@
// SettingsView's shared sections each setting's Section is defined exactly once here and
// composed by the per-platform bodies in SettingsView.swift.
//
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
// cell (the `described` helper in SettingsView+Support) the old per-section footer paragraphs
// collected several fields' explanations into one blob nobody could match back to its row.
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
// the description is DYNAMIC it explains the current choice. The only footers left are the
// one-line "Applies from the next session." form notes.
//
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
#if os(iOS)
import CoreHaptics
@@ -18,23 +8,18 @@ import PunktfunkKit
import SwiftUI
extension SettingsView {
// MARK: - Display: Resolution
// MARK: - Sections (shared)
// NOTE: the Section content is deliberately split into the small named builders below as one
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
// type-checker budget ("unable to type-check this expression in reasonable time"), which
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var resolutionSection: some View {
Section("Resolution") {
@ViewBuilder var streamModeSection: some View {
Section {
#if os(iOS) || os(macOS)
// Match-window (design/midstream-resolution-resize.md D1): follow the session
// window/scene, renegotiating the host mode on a resize. Off the explicit mode below.
described(matchWindow
? "The host resizes its output to follow this window — the picture stays "
+ "pixel-exact (1:1) through every resize."
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
Toggle("Match window", isOn: $matchWindow)
}
Toggle("Match window", isOn: $matchWindow)
#endif
#if os(iOS)
iosResolutionWheel
@@ -47,19 +32,75 @@ extension SettingsView {
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
described("The host drives a real virtual output at exactly this size and refresh — "
+ "true pixels, no scaling.") {
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
#endif
#if !os(tvOS)
renderScaleRow
bitrateRows
#endif
} header: {
Text("Stream mode")
} footer: {
Text(matchWindow
? "The stream follows this window — the host resizes its virtual output to match "
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
+ "\(Self.bitrateFooter)"
: "The host creates a virtual output at exactly this mode — native resolution, but "
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
// (match-window derives the base from the live window, not these fields).
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
Toggle("Invert scroll direction", isOn: $invertScroll)
} header: {
Text("Keyboard & mouse")
} footer: {
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#endif
#if os(iOS)
// MARK: - Display: Resolution (iOS wheel)
// MARK: - Stream mode (iOS wheel)
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) the
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
@@ -78,11 +119,6 @@ extension SettingsView {
.labelsHidden()
.pickerStyle(.wheel)
.frame(maxHeight: 140)
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
}
}
@@ -174,69 +210,10 @@ extension SettingsView {
}
#endif
// MARK: - Display: Quality
@ViewBuilder var qualitySection: some View {
Section("Quality") {
#if !os(tvOS)
renderScaleRow
bitrateRows
#endif
described("A preference — the host falls back if it can't encode it.") {
Picker("Video codec", selection: $codec) {
ForEach(SettingsOptions.codecs, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
described("HDR10, when the host has HDR content and this display supports it. "
+ "HEVC only; otherwise the stream stays SDR.") {
Toggle("10-bit HDR", isOn: $hdrEnabled)
}
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
+ "bits are better spent at 4:2:0. HEVC only.") {
Toggle("Full chroma (4:4:4)", isOn: $enable444)
}
}
}
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
described(renderScaleDescription) {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
}
}
/// Render scale explained, with the CONCRETE host resolution when it applies the cost made
/// legible. Only the explicit mode can show it (match-window derives the base from the live
/// window, not these fields).
private var renderScaleDescription: String {
var text = "Above native supersamples for sharpness; below renders lighter on the host "
+ "and the link."
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
+ "it to your display."
}
return text
}
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View {
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
+ "fixed rate — a host card's context menu has a network speed test.") {
Toggle("Automatic bitrate", isOn: automaticBitrate)
}
Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
@@ -256,228 +233,26 @@ extension SettingsView {
}
#endif
// MARK: - Display: Presentation
// The presentation intent (design/apple-presentation-rebuild.md replaced the visible
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
@ViewBuilder var presentationSection: some View {
Section("Presentation") {
described(presentPriority == "smooth"
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
+ "added display latency."
: "Every frame shows the moment the display can take it — a network hiccup is "
+ "an occasional repeated or skipped frame.") {
Picker("Prioritize", selection: $presentPriority) {
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
Text(option.tag == SettingsOptions.presentPriorityDefault
? "\(option.label) (default)" : option.label)
.tag(option.tag)
}
}
}
if presentPriority == "smooth" {
described("Frames held back — each absorbs about one refresh of jitter and "
+ "adds one refresh of delay.") {
Picker("Buffer", selection: $smoothBuffer) {
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
#if !os(tvOS)
described("A ProMotion or adaptive-sync display follows the stream's rate — "
+ "smoother motion. No effect on fixed-refresh displays.") {
Toggle("Allow VRR", isOn: $allowVRR)
}
#endif
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
// only exists on the Mac (the layer's own sync stays off see MetalVideoPresenter).
#if os(macOS)
described("Flips align to the display's refresh — even pacing, up to one refresh "
+ "of added latency. Off shows frames as soon as they're ready.") {
Toggle("V-Sync", isOn: $vsync)
}
#endif
}
}
// MARK: - Display: Host output
@ViewBuilder var hostOutputSection: some View {
Section {
described("The backend the host uses for its virtual output. A specific choice "
+ "falls back to auto-detection when that backend isn't available.") {
Picker("Compositor", selection: $compositor) {
ForEach(SettingsOptions.compositors, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
} header: {
Text("Host output")
} footer: {
// The one form-level note (deliberately not repeated on every row above).
Text("Display changes apply from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
// MARK: - General: Session
@ViewBuilder var sessionSection: some View {
Section("Session") {
#if os(macOS)
described("Go fullscreen when a session starts; return to a window on the host "
+ "list.") {
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
}
#endif
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
+ "aren't.") {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
}
#if os(iOS)
described("Audio and the connection stay live after you switch away; video pauses "
+ "to save power and resumes instantly when you return. Off, backgrounding "
+ "freezes the session.") {
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
}
if backgroundKeepAlive {
described("Ends a backgrounded session so it can't run down the battery.") {
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
Text("1 minute").tag(1)
Text("5 minutes").tag(5)
Text("10 minutes").tag(10)
Text("30 minutes").tag(30)
}
}
}
#endif
}
}
// MARK: - General: Statistics overlay
@ViewBuilder var overlaySection: some View {
Section("Statistics") {
described(Self.statisticsDescription) {
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
}
Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue)
}
}
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
}
}
// MARK: - General: Library
@ViewBuilder var librarySection: some View {
Section("Library") {
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
+ "games and launch one directly. No extra host setup.") {
Toggle("Show game library", isOn: $libraryEnabled)
}
}
}
// MARK: - Input
#if os(iOS)
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
@ViewBuilder var pointerSection: some View {
Section("Touch & pointer") {
described(touchModeDescription) {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
}
if UIDevice.current.userInterfaceIdiom == .pad {
described("Locks a hardware mouse for relative mouse-look in games; off sends "
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
}
}
}
/// The SELECTED touch mode explained dynamic, so the caption always describes what the
/// picker currently does instead of narrating all three modes at once.
private var touchModeDescription: String {
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
case .trackpad:
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
case .pointer:
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
case .touch:
return "Real multi-touch reaches the host — for touch-native apps and games."
}
}
#endif
#if !os(tvOS)
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section("Keyboard & mouse") {
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
}
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
Toggle("Invert scroll direction", isOn: $invertScroll)
}
}
}
#endif
// MARK: - Audio
@ViewBuilder var audioSection: some View {
Section {
described("The speaker layout requested from the host.") {
Picker("Audio channels", selection: $audioChannels) {
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
Picker("Audio channels", selection: $audioChannels) {
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
#if os(macOS)
described("Host audio plays through this device; System default follows your "
+ "Mac's output changes.") {
Picker("Speaker", selection: $speakerUID) {
Text("System default").tag("")
ForEach(outputDevices) { device in
Text(device.name).tag(device.uid)
}
if !speakerUID.isEmpty,
!outputDevices.contains(where: { $0.uid == speakerUID }) {
Text("Unavailable device").tag(speakerUID)
}
Picker("Speaker", selection: $speakerUID) {
Text("System default").tag("")
ForEach(outputDevices) { device in
Text(device.name).tag(device.uid)
}
if !speakerUID.isEmpty,
!outputDevices.contains(where: { $0.uid == speakerUID }) {
Text("Unavailable device").tag(speakerUID)
}
}
#endif
described("This device's microphone feeds the host's virtual mic.") {
Toggle("Send microphone to the host", isOn: $micEnabled)
}
Toggle("Send microphone to the host", isOn: $micEnabled)
#if os(macOS)
Picker("Microphone", selection: $micUID) {
Text("System default").tag("")
@@ -493,27 +268,265 @@ extension SettingsView {
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
if micChannelCount > 1 {
described("Pick the input your mic is on; Auto sums every channel.") {
Picker("Microphone channel", selection: $micChannel) {
Text("Auto (all channels)").tag(0)
ForEach(1...micChannelCount, id: \.self) { ch in
Text("Channel \(ch)").tag(ch)
}
Picker("Microphone channel", selection: $micChannel) {
Text("Auto (all channels)").tag(0)
ForEach(1...micChannelCount, id: \.self) { ch in
Text("Channel \(ch)").tag(ch)
}
.disabled(!micEnabled)
}
.disabled(!micEnabled)
}
#endif
} header: {
Text("Audio")
} footer: {
Text("Applies from the next session.")
Text(Self.audioFooter)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
// MARK: - Controllers
#if os(iOS)
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
@ViewBuilder var pointerSection: some View {
Section {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
if UIDevice.current.userInterfaceIdiom == .pad {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: {
Text("Touch & pointer")
} footer: {
Text(pointerFooterText)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
/// `+` chain (with a ternary) inside the ViewBuilder that single expression blew Swift's
/// type-checker budget and was what actually broke the iOS archive.
private var pointerFooterText: String {
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
text += "the host. Applies from the next touch."
if UIDevice.current.userInterfaceIdiom == .pad {
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
text += "absolute positions. Needs the stream full-screen and frontmost."
}
return text
}
#endif
@ViewBuilder var compositorSection: some View {
Section {
Picker("Compositor", selection: $compositor) {
ForEach(SettingsOptions.compositors, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
} header: {
Text("Host compositor")
} footer: {
Text("Which compositor drives the virtual output on the host. A specific "
+ "choice is honored only if that backend is available there — "
+ "otherwise the host falls back to auto-detection.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Auto-wake on connect fire Wake-on-LAN + wait for a sleeping saved host to come back before
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
@ViewBuilder var wakeSection: some View {
Section {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
} header: {
Text("Wake-on-LAN")
} footer: {
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
+ "way.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var windowSection: some View {
#if os(macOS)
Section {
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
} header: {
Text("Window")
} footer: {
Text("Take the window fullscreen when a session starts and restore it on the host "
+ "list, so only the stream is fullscreen — not the picker.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
@ViewBuilder var vrrSection: some View {
#if !os(tvOS)
Section {
Toggle("Allow VRR", isOn: $allowVRR)
} header: {
Text("Variable refresh rate")
} footer: {
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
// exists on the Mac (where the layer's own sync must stay off see MetalVideoPresenter).
@ViewBuilder var vsyncSection: some View {
#if os(macOS)
Section {
Toggle("V-Sync", isOn: $vsync)
} header: {
Text("Presentation")
} footer: {
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
+ "in step with the display's refresh — evenly paced, up to one refresh of "
+ "added latency. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
// pipeline with glass-gated present pacing). The default is per-platform glass on iOS/tvOS,
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
// the queue-saturation rationale) so the "(default)" marker rides presenterDefault instead
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
// DEBUG-only diagnostic it freezes hard on a lost HEVC reference.
@ViewBuilder var presenterSection: some View {
Section {
Picker("Presenter", selection: $presenter) {
ForEach(SettingsOptions.presenters, id: \.tag) { option in
Text(option.tag == SettingsOptions.presenterDefault
? "\(option.label) (default)" : option.label)
.tag(option.tag)
}
}
} header: {
Text("Video presenter")
} footer: {
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
+ "running near the stream's frame rate, queued frames can add two to three "
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var hdrSection: some View {
Section {
Picker("Video codec", selection: $codec) {
ForEach(SettingsOptions.codecs, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
Toggle("10-bit HDR", isOn: $hdrEnabled)
Toggle("Full chroma (4:4:4)", isOn: $enable444)
} header: {
Text("Video quality")
} footer: {
Text("Codec is a preference; the host falls back if it can't encode your choice. "
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
+ "both this device and the host support it — otherwise the stream stays 8-bit "
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
+ "session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var statisticsSection: some View {
Section {
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue)
}
}
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
} header: {
Text("Statistics")
} footer: {
Text(Self.statisticsFooter)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
/// detail can reference it unconditionally.
@ViewBuilder var keepAliveSection: some View {
#if os(iOS)
Section {
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
if backgroundKeepAlive {
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
Text("1 minute").tag(1)
Text("5 minutes").tag(5)
Text("10 minutes").tag(10)
Text("30 minutes").tag(30)
}
}
} header: {
Text("Background")
} footer: {
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
+ "playing and the connection stays live (video is dropped to save power) after you "
+ "switch away — and the session auto-disconnects after the time above so it can't "
+ "run down your battery. Returning to the app resumes video instantly.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
@ViewBuilder var experimentalSection: some View {
Section {
Toggle("Show game library", isOn: $libraryEnabled)
} header: {
Text("Experimental")
} footer: {
Text("Adds a “Browse Library…” action to each host that lists its games "
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
+ "extra host setup.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var controllersSection: some View {
Section {
@@ -525,37 +538,24 @@ extension SettingsView {
controllerRow(controller)
}
}
described("One controller is forwarded as player 1 — Automatic picks the most "
+ "recently connected.") {
Picker("Use controller", selection: $gamepads.preferredID) {
ForEach(controllerOptions, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
Picker("Use controller", selection: $gamepads.preferredID) {
ForEach(controllerOptions, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
Picker("Controller type", selection: $gamepadType) {
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
Picker("Controller type", selection: $gamepadType) {
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
+ "clip-on controllers without motors of their own.") {
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
}
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
}
#endif
#if !os(tvOS)
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
#endif
#if DEBUG && !os(tvOS)
Button("Test Controller…") { showControllerTest = true }
@@ -565,9 +565,22 @@ extension SettingsView {
} header: {
Text("Controllers")
} footer: {
Text("Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
// The gamepad-UI blurb is appended here, not merged into the shared
// `controllersFooter` constant tvOS's `tvBody` reuses that exact string (line ~348)
// for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter)
#if os(iOS)
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Text(Self.deviceRumbleFooter)
}
#endif
#if !os(tvOS)
Text(Self.gamepadUIFooter)
#endif
}
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
}
@@ -9,31 +9,6 @@ import PunktfunkKit
import SwiftUI
extension SettingsView {
// MARK: - Described rows (the 2026-07 revamp's field idiom)
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
/// directly under it. This replaced the per-section footer paragraphs a description the eye
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
/// choice.
@ViewBuilder
func described<Content: View>(
_ caption: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 5) {
content()
Text(caption)
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
// Cap the caption's line length well short of the cell: a full-width caption runs
// its text right up to the control column (toggles especially), reading as one
// colliding block. ~46 chars/line also just measures better.
.frame(maxWidth: 360, alignment: .leading)
}
.padding(.vertical, 2)
}
// MARK: - Bitrate
/// Slider domain, log-scale: the useful range spans three orders of magnitude
@@ -42,7 +17,6 @@ extension SettingsView {
private static let minSliderKbps = 2_000.0
private static let maxSliderKbps = 3_000_000.0
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
static let bitrateFooter =
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
+ "to its supported range. Run a speed test from a host card's context menu to "
@@ -79,27 +53,55 @@ extension SettingsView {
// MARK: - Statistics
static var statisticsDescription: String {
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
+ "Detailed adds the latency stage breakdown."
static var statisticsFooter: String {
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
+ "breakdown."
#if os(macOS)
return base + " ⌃⌥⇧S cycles the tiers any time."
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
#elseif os(iOS)
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
+ "any time."
#else
return base
#endif
}
// MARK: - Audio
static var audioFooter: String {
#if os(macOS)
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
+ "next session."
#else
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
+ "Applies from the next session."
#endif
}
// MARK: - Controllers
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
static let controllersFooter =
"One controller is forwarded as player 1 — Automatic picks the most recently "
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session."
#if os(iOS)
static let deviceRumbleFooter =
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
+ "from the next session."
#endif
#if !os(tvOS)
static let gamepadUIFooter =
"When a controller connects, the host list and library switch to a controller-"
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
+ "off to always use the standard layout."
#endif
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
var controllerOptions: [(label: String, tag: String)] {
SettingsOptions.controllerOptions(gamepads)
@@ -2,15 +2,13 @@
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
// downscales supersampling for sharpness, or under-rendering for a lighter host/link).
//
// Navigation differs per platform, but all three follow the same category map (General =
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
// About see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
// adaptive NavigationSplitView a category sidebar + detail pane on iPad, auto-collapsing to
// Navigation differs per platform, but all three group the same categories (General, Display,
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
// an adaptive NavigationSplitView a category sidebar + detail pane on iPad, auto-collapsing to
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
// focus-native pushed-picker layout in the same order. The individual sections
// (`resolutionSection`, `audioSection`, ) are shared across all three so a setting is defined
// exactly once they live in SettingsView+Sections.swift, with their helpers (including the
// per-field `described` caption idiom) in SettingsView+Support.swift.
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
// `audioSection`, ) are shared across all three so a setting is defined exactly once they
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
#if os(macOS)
import AppKit
@@ -35,9 +33,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
#if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false
#endif
@@ -124,32 +120,27 @@ struct SettingsView: View {
#if os(macOS)
private var macBody: some View {
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
// picture (resolution lives here), Input = keyboard & mouse.
TabView {
Form {
sessionSection
overlaySection
librarySection
streamModeSection
inputSection
compositorSection
wakeSection
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
Form {
resolutionSection
qualitySection
presentationSection
hostOutputSection
presenterSection
hdrSection
vrrSection
vsyncSection
windowSection
statisticsSection
}
.formStyle(.grouped)
.tabItem { Label("Display", systemImage: "display") }
Form {
inputSection
}
.formStyle(.grouped)
.tabItem { Label("Input", systemImage: "keyboard") }
Form {
audioSection
}
@@ -177,10 +168,16 @@ struct SettingsView: View {
.onDisappear { gamepads.stopDiscovery() }
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
Form {
experimentalSection
}
.formStyle(.grouped)
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
AcknowledgementsView()
.tabItem { Label("About", systemImage: "info.circle") }
}
.frame(width: 500, height: 520)
.frame(width: 480, height: 460)
}
#endif
@@ -255,31 +252,26 @@ struct SettingsView: View {
switch category {
case .general:
Form {
sessionSection
overlaySection
librarySection
streamModeSection
pointerSection
inputSection
compositorSection
wakeSection
keepAliveSection // iOS-only content; empty on tvOS
}
.formStyle(.grouped)
.navigationTitle("General")
.navigationBarTitleDisplayMode(.inline)
case .display:
Form {
resolutionSection
qualitySection
presentationSection
hostOutputSection
presenterSection
hdrSection
vrrSection
statisticsSection
}
.formStyle(.grouped)
.navigationTitle("Display")
.navigationBarTitleDisplayMode(.inline)
case .input:
Form {
pointerSection
inputSection
}
.formStyle(.grouped)
.navigationTitle("Input")
.navigationBarTitleDisplayMode(.inline)
case .audio:
Form { audioSection }
.formStyle(.grouped)
@@ -290,6 +282,11 @@ struct SettingsView: View {
.formStyle(.grouped)
.navigationTitle("Controllers")
.navigationBarTitleDisplayMode(.inline)
case .advanced:
Form { experimentalSection }
.formStyle(.grouped)
.navigationTitle("Advanced")
.navigationBarTitleDisplayMode(.inline)
case .about:
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
// display mode inline to match the five sibling detail pages (it would otherwise inherit
@@ -335,16 +332,6 @@ struct SettingsView: View {
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
}
/// One cluster caption, TV-legible the 10-foot analogue of the touch/desktop per-row
/// `described` captions (per-row text doesn't scale to TV type sizes).
private func tvCaption(_ text: String) -> some View {
Text(text)
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
}
private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds
@@ -357,9 +344,6 @@ struct SettingsView: View {
if !options.contains(where: { $0.tag == currentTag }) {
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
}
// Row order mirrors the touch/desktop category map: Display (mode quality
// presentation host output), then Audio, General, Statistics, Controllers with one
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
return ScrollView {
VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
@@ -371,41 +355,37 @@ struct SettingsView: View {
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps)
TVSelectionRow(
title: "Audio channels",
options: SettingsOptions.audioChannels,
selection: $audioChannels)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor)
TVSelectionRow(
title: "Presenter",
options: SettingsOptions.presenters,
selection: $presenter)
TVSelectionRow(
title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
TVSelectionRow(
title: "Prioritize",
options: SettingsOptions.presentPriorities,
selection: $presentPriority)
if presentPriority == "smooth" {
TVSelectionRow(
title: "Smoothness buffer",
options: SettingsOptions.smoothBuffers(refreshHz: hz),
selection: $smoothBuffer)
}
TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor)
tvCaption("The host drives a real output at exactly the chosen mode. "
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
+ "Smoothness buffers a few to even out network hiccups. A specific "
+ "compositor is honored only if available on the host.")
TVSelectionRow(
title: "Audio channels",
options: SettingsOptions.audioChannels,
selection: $audioChannels)
TVSelectionRow(
title: "Auto-wake on connect",
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
+ "it before streaming.")
Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
+ "sleeping saved host and waits for it before streaming.")
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
TVSelectionRow(
title: "Statistics overlay",
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
@@ -425,7 +405,11 @@ struct SettingsView: View {
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
tvCaption(Self.controllersFooter)
Text(Self.controllersFooter)
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
NavigationLink("Acknowledgements") { AcknowledgementsView() }
.padding(.top, 8)
}
@@ -57,8 +57,30 @@ public enum BrandFont {
}
}
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
// extension links Shared alone and must render the same purple.
public extension Color {
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
/// independent of the asset-catalog accent `Color.accentColor` resolution is environment- and
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
static let brand: Color = {
#if canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#elseif canImport(AppKit)
return Color(NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#else
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
#endif
}()
}
public extension Font {
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
@@ -602,36 +602,6 @@ public final class MetalVideoPresenter {
}
#endif
/// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a
/// drawable exists. The link vends from the layer's CURRENT config, and the layer starts
/// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's
/// frame isn't even laid out when the link spins up) so leaving all reconciliation to the
/// render path (which needs a frame AND a vended drawable) deadlocks at session start:
/// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and
/// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable]
/// returning nil because allocation failed" every refresh). Called on EVERY frame arrival:
/// drains the same staging the render path drains (both are idempotent about it) and
/// applies size + HDR config, so the next vend always matches the frame about to present
/// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting
/// for a paired present to retag the layer.
func reconcileLayer(decodedSize: CGSize, isHDR: Bool) {
stagingLock.lock()
let targetFromLayout = drawableTarget
let newHdrMeta = pendingHdrMeta
pendingHdrMeta = nil
stagingLock.unlock()
configure(hdr: isHDR)
if let newHdrMeta {
self.lastHdrMeta = newHdrMeta
#if !os(tvOS)
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
#endif
}
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
}
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
/// `nextDrawable()` may block up to a frame that wait belongs here, never on main). `isHDR`
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
@@ -647,15 +617,10 @@ public final class MetalVideoPresenter {
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
/// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B).
///
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
/// render into instead of calling `nextDrawable()` see `encodePresent` for the format
/// guard that skips a vend the layer's config outran.
@discardableResult
public func render(
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil
) -> Bool {
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
@@ -709,8 +674,7 @@ public final class MetalVideoPresenter {
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
return encodePresent(
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
onPresented: onPresented,
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
// finishes sampling releasing them at scope exit could free the backing mid-read.
keepAlive: [luma, chroma, pixelBuffer]
@@ -729,7 +693,6 @@ public final class MetalVideoPresenter {
func renderPlanar(
_ planes: WaveletPlanes,
presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil
) -> Bool {
stagingLock.lock()
@@ -779,8 +742,7 @@ public final class MetalVideoPresenter {
return encodePresent(
decodedSize: CGSize(width: planes.width, height: planes.height),
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
onPresented: onPresented,
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
// The ring textures stay valid by ring depth; retaining them here also pins the
// slot's set until the sample completes (mirrors the biplanar keep-alive).
keepAlive: [planes.y, planes.cb, planes.cr]
@@ -907,17 +869,9 @@ public final class MetalVideoPresenter {
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
/// the present and the on-glass callback.
///
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
/// layer) its pixel format can lag the pipeline's attachment format encoding would be a
/// Metal validation failure. The guard returns false instead: the drawable drops back to
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
private func encodePresent(
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)?,
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
) -> Bool {
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
@@ -930,17 +884,11 @@ public final class MetalVideoPresenter {
// (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine).
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
// Under a provided (link-vended) drawable this sizes the NEXT vend the one in hand
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
#if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
#endif
if let providedDrawable,
providedDrawable.texture.pixelFormat != layer.pixelFormat {
return false // config outran the vend (HDR flip) next vend has the new format
}
guard let drawable = providedDrawable ?? layer.nextDrawable(),
guard let drawable = layer.nextDrawable(),
let commandBuffer = queue.makeCommandBuffer()
else { return false }
@@ -1,12 +1,10 @@
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
// (explicit VTDecompressionSession decode CAMetalLayer) is the default deadline-paced
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer the
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
// Stage-1 (StreamPump AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
// The views own the platform bits capture, window/scale tracking, and constructing the
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink)
// and delegate the shared presenter lifecycle here.
// (explicit VTDecompressionSession decode CAMetalLayer, driven by the hosting view's
// CADisplayLink) is the default glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump AVSampleBufferDisplayLayer)
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits capture,
// window/scale tracking, and constructing the display link and delegate the shared presenter
// lifecycle here.
//
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
@@ -30,17 +28,15 @@ public final class DisplayLinkProxy: NSObject {
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
}
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
/// pacing (`PresentPacing` see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
/// CAMetalDisplayLink-vended drawables (deadline pacing iOS/tvOS only; see `PresentPacing`'s
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// glass-gated present pacing (`PresentPacing` see Stage2Pipeline for the tradeoff, and why
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable {
case stage1
case stage2
case stage3
case stage4
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
@@ -54,92 +50,36 @@ enum PresenterChoice: Equatable {
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage an
/// explicit "stage2" must stay a faithful A/B of arrival pacing. "stage4" resolves only on
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
/// deliberately validated there a synced "stage4" value maps back to the platform default.
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : nil
case "stage2": return .stage2
case "stage3": return .stage3
case "stage4":
#if os(macOS)
return nil
#else
return .stage4
#endif
default: return nil
}
}
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
///
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
/// (`displaySyncEnabled` is macOS-only API), and at stream rate panel rate an Apple TV's
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
/// steering the panel to the stream that queue's depth is STICKY: one burst fills it and,
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
/// read ~30 ms display on arrival (~3 refreshes of queue), 2228 ms glass-gated at depth 2
/// (a standing queue of 2 the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
/// drawable per refresh, presented the moment a frame decodes the queue cannot exist and
/// nothing waits on callbacks (see `PresentPacing.deadline`).
///
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
/// (design/apple-presentation-rebuild.md the engine is field-proven on iOS and strictly
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
/// lowest-latency there.
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
/// near the stream rate an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
/// makes the COMMON case arrival pacing pins the queue at ~`maximumDrawableCount` and every
/// frame rides ~23 refreshes of it (measured: ~50 ms on Apple TV; 2330 ms at 120 Hz on
/// ProMotion iPads the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
/// so arrival is genuinely lowest-latency there.
static var platformDefault: PresenterChoice {
#if os(iOS) || os(tvOS)
.stage4
#if os(tvOS) || os(iOS)
.stage3
#else
.stage2
#endif
}
}
/// The user's presentation INTENT what replaced the visible stage picker in the 2026-07
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
///
/// - `.latency` (the default): every frame shows as soon as the display can take it the
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
/// cadence at the cost of `buffer` refresh intervals of added display latency which the HUD
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` 13;
/// the "Automatic" setting (stored 0) currently maps to 2.
///
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
/// env lever. Internal (not private) for unit tests.
enum PresentPriority: Equatable {
case latency
case smooth(buffer: Int)
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
/// anything but "smooth" unset, garbage, a synced unknown future value falls back to
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
guard setting == "smooth" else { return .latency }
let raw = bufferSetting ?? 0
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
}
/// The frame hand-off policy this intent runs (see `FrameStore`).
var storePolicy: FrameStorePolicy {
switch self {
case .latency: return .newestWins
case .smooth(let buffer): return .fifo(capacity: buffer)
}
}
}
final class SessionPresenter {
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
/// default, macOS PyroWave sessions ALSO get glass gating a kernel-panic mitigation, not a
@@ -159,7 +99,6 @@ final class SessionPresenter {
static func pacing(
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
) -> PresentPacing {
if choice == .stage4 { return .deadline }
if choice == .stage3 { return .glass }
#if os(macOS)
if explicit == nil, codec == .pyrowave { return .glass }
@@ -167,27 +106,31 @@ final class SessionPresenter {
return .arrival
}
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
///
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
/// out + one queued, predicted ~58 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
/// 2228 ms vs depth 1's 14: any second gate slot becomes a STANDING queue a burst fills
/// it, and with presents and latches then running at the same rate the occupancy never
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
/// that can't queue at all that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
/// - iOS/iPadOS: 2 one flip scanning out plus one queued for the next latch. A decoded
/// frame presents immediately (the gate is open in steady state) and latches the very next
/// vsync, so the present cadence never serializes on the on-glass callback's own latency
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
/// FIFO saturation (2330 ms) stays gone.
/// - tvOS: 1 the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
/// - macOS: pinned to 1, env ignored glass pacing exists there as the DCP swapID
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
///
/// `PUNKTFUNK_GATE_DEPTH` (13) still overrides on iOS/tvOS so the standing-queue ladder
/// stays reproducible on-device; macOS is pinned to 1, env ignored glass pacing exists
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
/// serialization is its point. Internal (not private) for unit tests.
/// `PUNKTFUNK_GATE_DEPTH` (13) overrides on iOS/tvOS for on-device A/B, mirroring the other
/// presenter env levers. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int {
#if os(macOS)
return 1
#else
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
#if os(tvOS)
return 1
#else
return 2
#endif
#endif
}
@@ -224,7 +167,6 @@ final class SessionPresenter {
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
@@ -233,50 +175,31 @@ final class SessionPresenter {
stop()
self.connection = connection
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
// PresenterChoice.platformDefault deadline on iOS/tvOS, arrival on macOS overridable
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
// value is deliberately ignored). The user-facing choice is the INTENT
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
// fallback).
// Presenter choice the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
// on macOS see PresenterChoice.platformDefault); the settings picker is the live A/B.
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
#if DEBUG
let allowStage1 = true
#else
let allowStage1 = false
#endif
let explicit = PresenterChoice.explicit(
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
let priority = PresentPriority.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
// display, so the FIFO alone provides the buffering.
#if os(macOS)
let vsyncPaced = priority != .latency && pacing == .arrival
#else
let vsyncPaced = false
#endif
if choice != .stage1,
let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
pacing: pacing,
pacing: Self.pacing(
for: choice, explicit: explicit, codec: connection.videoCodec),
gateDepth: Self.gateDepth(
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
storePolicy: priority.storePolicy,
vsyncPaced: vsyncPaced) {
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
@@ -293,19 +216,14 @@ final class SessionPresenter {
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes).
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
// clock, and every one of its updates re-checks the ring, which IS the retry tick
// a second link would only fight it over the frame-rate hint.
if pacing != .deadline {
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
@@ -333,12 +251,7 @@ final class SessionPresenter {
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
private func syncFrameRate(hz: UInt32) {
guard hz > 0 else { return }
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
// applied from the link's own thread see Stage2Pipeline.setFrameRateHint). A no-op
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
stage2?.setFrameRateHint(hz: Float(hz))
guard let link = stage2Link else { return }
guard hz > 0, let link = stage2Link else { return }
let hzF = Float(hz)
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
#if os(macOS)
@@ -18,14 +18,11 @@
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage.
// Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
// CAMetalDisplayLink-vended drawables the moment a frame decodes deadline pacing, where the
// queue cannot exist at all see PresentPacing's doc for the full rationale. Under deadline
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
// Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
// drawables (the gate depth see PresentPacing + SessionPresenter.gateDepth) so the layer's
// FIFO image queue can never saturate see PresentPacing's doc for the full rationale.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
//
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
@@ -44,7 +41,6 @@ import Foundation
import Metal
import PunktfunkShared
import QuartzCore
import os
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for
@@ -52,127 +48,33 @@ import os
/// stdout is the cheapest reliable capture channel.
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") the
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
/// replaced the visible stage picker):
///
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring the decoder
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem
/// see SessionPresenter.gateDepth).
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
/// decoder appends; overflow drops the OLDEST (bounded added latency the newest keeps
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once
/// without preroll a steady stream drains every frame on arrival and headroom never builds
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
/// a repeat by omission, while headroom rebuilds). Each buffered frame one refresh interval
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
///
/// Sendable; lock-guarded decoder callbacks and the render thread cross here.
public enum FrameStorePolicy: Sendable, Equatable {
case newestWins
case fifo(capacity: Int)
}
public final class FrameStore<Frame>: @unchecked Sendable {
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
private final class ReadyRing: @unchecked Sendable {
private let lock = NSLock()
private let capacity: Int // 1 = newest-wins semantics
private let isFifo: Bool
private var frames: [Frame] = []
private var prerolled = false
/// Submissions since the last `drainSubmitted` the decode rate for the pf-present line.
private var frame: ReadyFrame?
/// Ring submissions since the last `drainSubmitted` the decode rate for the
/// PUNKTFUNK_PRESENT_DEBUG stat line.
private var submitted = 0
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
/// runs-dry that re-armed preroll.
private var overflowDrops = 0
private var underflows = 0
public init(policy: FrameStorePolicy) {
switch policy {
case .newestWins:
capacity = 1
isFifo = false
case .fifo(let k):
capacity = max(1, k)
isFifo = true
}
func submit(_ f: ReadyFrame) {
lock.lock(); frame = f; submitted += 1; lock.unlock()
}
func submit(_ f: Frame) {
lock.lock()
if isFifo {
frames.append(f)
if frames.count > capacity {
frames.removeFirst() // oldest goes bounded latency, the newest keeps flowing
overflowDrops += 1
}
} else {
frames = [f] // newest wins; the replaced frame is the intended drop point
}
submitted += 1
lock.unlock()
}
func drainSubmitted() -> Int {
lock.lock()
defer { lock.unlock() }
let n = submitted
submitted = 0
return n
lock.lock(); defer { lock.unlock() }
let n = submitted; submitted = 0; return n
}
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
lock.lock()
defer { lock.unlock() }
let out = (overflowDrops, underflows)
overflowDrops = 0
underflows = 0
return out
func take() -> ReadyFrame? {
lock.lock(); defer { lock.unlock() }
let f = frame; frame = nil; return f
}
func take() -> Frame? {
/// Return a frame the display link took but could not present (a transient `nextDrawable`
/// failure). Kept only while the slot is still empty a newer decoded frame wins, so
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
/// next damage the stale picture would persist.
func putBack(_ f: ReadyFrame) {
lock.lock()
defer { lock.unlock() }
if isFifo {
if !prerolled {
guard frames.count >= capacity else { return nil } // still building headroom
prerolled = true
}
guard !frames.isEmpty else {
underflows += 1 // ran dry repeat by omission, rebuild headroom
prerolled = false
return nil
}
return frames.removeFirst()
}
let f = frames.first
frames.removeAll(keepingCapacity: true)
return f
}
/// Return a frame the render thread took but could not present (no drawable yet, or a
/// transient render failure). Newest-wins keeps it only while the slot is still empty a
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
/// the next damage the stale picture would persist.
func putBack(_ f: Frame) {
lock.lock()
if isFifo {
frames.insert(f, at: 0)
} else if frames.isEmpty {
frames = [f]
}
if frame == nil { frame = f }
lock.unlock()
}
}
@@ -215,24 +117,13 @@ private final class VsyncClock: @unchecked Sendable {
/// ~23 refreshes of queue (the measured 2330 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports).
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; depth 1 see `SessionPresenter.gateDepth`
/// for why deeper is a regression). The render thread presents only while a gate slot is free
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
/// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; the depth 1 or 2 is per-platform, see
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
/// drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
/// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only see
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
/// it with the newest decoded frame THE MOMENT either half arrives usually the frame, into
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
/// explicit, correct frame drops.
///
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
@@ -241,122 +132,6 @@ private final class VsyncClock: @unchecked Sendable {
public enum PresentPacing: Sendable {
case arrival
case glass
case deadline
}
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
/// drawable stash the link thread `put`s each update's vended drawable (replacing an
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
/// Sendable; lock-guarded.
final class LatestBox<T>: @unchecked Sendable {
private let lock = NSLock()
private var value: T?
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
func putBack(_ v: T) {
lock.lock()
if value == nil { value = v }
lock.unlock()
}
func take() -> T? {
lock.lock()
defer { lock.unlock() }
let v = value
value = nil
return v
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
/// floor is PINNED at the stream rate no idle ramp-down: with a low floor the link idles toward
/// it on a static scene (infinite GOP no frames), and the first damage frame after idle would
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
private final class FrameRateHint: @unchecked Sendable {
private let lock = NSLock()
private var pending: CAFrameRateRange?
func stage(hz: Float) {
guard hz > 0 else { return }
lock.lock()
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
lock.unlock()
}
func drain() -> CAFrameRateRange? {
lock.lock()
defer { lock.unlock() }
let p = pending
pending = nil
return p
}
}
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
/// vended drawable (newest wins) and nudges the render thread which also wakes on decoder
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline the
/// same no-self-capture rule as the pump/render threads.
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let stash: LatestBox<CAMetalDrawable>
private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint
private let stats: PresentDebugStats?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
/// would read ~1 under direct-to-display, tracks VRR rate changes.
private let floorMeter: LatencyMeter?
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert
/// reads 1 while vendLeadMs sits at ~2 periods the scheduler ignores the request while
/// the layer is composited (the promotion hunt); reads 2 the system clamped it outright.
private var loggedEffective = false
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
) {
self.stash = stash
self.renderSignal = renderSignal
self.hint = hint
self.stats = stats
self.floorMeter = floorMeter
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range
}
// Re-assert the minimum-latency request every update (cheap compare): it was set once
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// thing the vendLeadMs stat exists to catch belt and braces.
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
if !loggedEffective {
loggedEffective = true
let range = link.preferredFrameRateRange
let msg = String(
format: "deadline link up: effective preferredFrameLatency=%.2f "
+ "range=%.0f-%.0f preferred=%.0f",
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
presentLog.info("\(msg, privacy: .public)")
}
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
floorMeter.record(
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
}
stash.put(update.drawable)
renderSignal.signal()
}
}
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
@@ -431,20 +206,10 @@ final class PresentGate: @unchecked Sendable {
private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock()
private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = []
/// Present-issue on-glass delay per frame (system presentedTime minus the render call's
/// start) the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
/// a healthy latch reads under one period.
private var latchMs: [Double] = []
/// Deadline pacing: the link's own pipeline depth `targetPresentationTimestamp - now` at
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
private var vendLeadMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
@@ -458,14 +223,6 @@ private final class PresentDebugStats: @unchecked Sendable {
/// is normal, it just shows the gate working.
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
/// drawable yet the frame presents on the link's next update. A high count just means
/// decode outruns the link's phase; the wait is bounded by one refresh.
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock()
if rendered {
@@ -479,59 +236,40 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock()
}
func presented(atNs: Int64?, issuedNs: Int64) {
func presented(atNs: Int64?) {
lock.lock()
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
} else {
dropped += 1
}
lock.unlock()
}
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
lock.lock()
let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return }
last = now
let decoded = ring.drainSubmitted()
let smoothing = ring.drainSmoothing()
let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0
let latches = latchMs.sorted()
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
let latchMax = latches.last ?? 0
let vends = vendLeadMs.sorted()
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
let vendMax = vends.last ?? 0
let inflightMax = maxInFlight
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
+ "vendLeadMs p50=%.2f max=%.2f",
decoded, ok, failed, empty, gated, noDrawable, dropped,
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true)
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock()
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
if presentDebug {
print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
}
print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
}
}
@@ -558,27 +296,18 @@ private final class DecodeReport: @unchecked Sendable {
}
public final class Stage2Pipeline {
private let ring: FrameStore<ReadyFrame>
private let ring = ReadyRing()
private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder
/// Present cadence `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
/// SessionPresenter resolves it per session (see PresentPacing).
/// Present cadence `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
private let pacing: PresentPacing
/// The glass gate's in-flight present budget (`PresentGate` capacity) meaningful only under
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
private let gateDepth: Int
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
private let vsyncPaced: Bool
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
/// The measured OS present floor (deadline pacing only): each link update's vendglass lead
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
/// numbers the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
private let presentFloorMeter: LatencyMeter?
private let recovery = KeyframeRecovery()
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
/// binds the live connection + arming flag (see DecodeReport).
@@ -609,9 +338,6 @@ public final class Stage2Pipeline {
private let vsyncClock = VsyncClock()
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
/// Created unconditionally (cheap); only the deadline link thread drains it.
private let frameRateHint = FrameRateHint()
/// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer }
@@ -628,22 +354,16 @@ public final class Stage2Pipeline {
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
pacing: PresentPacing = .arrival,
gateDepth: Int = 1,
storePolicy: FrameStorePolicy = .newestWins,
vsyncPaced: Bool = false
gateDepth: Int = 1
) {
guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter
self.pacing = pacing
self.gateDepth = gateDepth
self.vsyncPaced = vsyncPaced
self.ring = FrameStore(policy: storePolicy)
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
let ring = ring
let recovery = recovery
let renderSignal = renderSignal
@@ -818,17 +538,6 @@ public final class Stage2Pipeline {
pumpJoinable = true
thread.start()
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
// vends the drawables and its per-refresh updates co-drive the render thread see
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
}
// The render thread: one present per display-link signal. It owns every layer format/colour/
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
@@ -843,21 +552,16 @@ public final class Stage2Pipeline {
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
// Resolved once per session.
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling the FIFO store must drain
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
let vsyncPaced = vsyncPaced
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
let vsyncEnabled = presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
let renderThread = Thread {
defer { renderStopped.signal() }
// macOS smoothness: the vsync this thread last presented onto at most ONE present
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
var lastPresentTarget: CFTimeInterval = 0
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable
// without a per-iteration pool every presented frame's drawable object (plus its
@@ -867,15 +571,6 @@ public final class Stage2Pipeline {
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
// Smoothness pacing: this vsync's present slot already taken the frame stays
// in the store, and the next display-link tick re-signals. (Tolerance well under
// any refresh period; a stale clock nil target no dedup, present flows.)
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
abs(t - lastPresentTarget) < 0.002 {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
// Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
@@ -896,7 +591,6 @@ public final class Stage2Pipeline {
let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
// Stage-3: the flip reached glass (or was dropped) free the present slot,
// then re-signal so the freshest waiting ring frame goes out immediately.
@@ -914,7 +608,7 @@ public final class Stage2Pipeline {
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
debugStats?.presented(atNs: presentedNs)
}
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
// PyroWave Metal planes the ring, pacing and meters are agnostic to which.
@@ -933,8 +627,6 @@ public final class Stage2Pipeline {
if !rendered {
gate?.release() // no present registered its handler will never fire
ring.putBack(frame)
} else if vsyncPaced, let presentAt {
lastPresentTarget = presentAt // this vsync's slot is now taken
}
debugStats?.flushIfDue(ring: ring, gate: gate)
} }
@@ -945,168 +637,16 @@ public final class Stage2Pipeline {
renderThread.start()
}
/// Deadline pacing's present half (stage-4 see `PresentPacing.deadline`): a
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
/// moment either half completes the pair the common case is a decoded frame presenting
/// instantly into an already-vended drawable, which the system then latches at the upcoming
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
/// is never stale by more than a refresh while the link runs.
///
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
/// ever touched there the frame-rate hint crosses via `FrameRateHint`); the link thread's
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
/// autoreleased like a `nextDrawable()` one see the render loop's identical rule); the
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
let token = token
let ring = ring
let renderSignal = renderSignal
let renderStopped = renderStopped
let presenter = presenter
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
let floorMeter = presentFloorMeter
// The link starts LAZILY the render thread triggers this after the FIRST decoded
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
// drawableSize for the whole connect window: every vend fails allocation and the system
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
// refresh until the first frame arrives. Before that frame there is nothing to present
// anyway, and the first frame waits at most one refresh for the first vend.
let startLink: () -> Void = {
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
link.add(to: RunLoop.current, forMode: .default)
while !token.isStopped {
autoreleasepool {
_ = RunLoop.current.run(
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
}
}
link.invalidate()
}
linkThread.name = "punktfunk-stage4-link"
linkThread.qualityOfService = .userInteractive
linkThread.start()
}
let renderThread = Thread {
defer { renderStopped.signal() }
// Whether startLink ran render-thread confined (only this thread triggers it).
var linkLive = false
// Per-iteration autorelease pool same contract as the arrival/glass loop (the
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
while !token.isStopped { autoreleasepool {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
// Present needs the PAIR frame first. The frame drives the layer reconcile,
// which must run even when NO drawable is vended yet: the link vends from the
// layer's CURRENT config, so drawableSize/format have to be right before a vend
// can succeed at all (see reconcileLayer the session-start bootstrap, where
// the layer still has its initial 0×0 size and every vend fails allocation).
guard !token.isStopped, let frame = ring.take() else {
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
switch frame.image {
case .video(let pixelBuffer, let isHDR):
presenter.reconcileLayer(
decodedSize: CGSize(
width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer)),
isHDR: isHDR)
case .planar(let planes):
presenter.reconcileLayer(
decodedSize: CGSize(width: planes.width, height: planes.height),
isHDR: planes.pq)
}
// First frame: the layer now has a real config start vending (see startLink).
if !linkLive {
linkLive = true
startLink()
}
guard let drawable = stash.take() else {
// No vend yet (session start: the reconcile above just unblocked the
// allocator, the link's next update delivers; steady state: decode beat the
// link's phase). putBack keeps newest-wins a fresher decode replaces this
// frame while it waits, and the update's signal retries the pairing.
ring.putBack(frame)
debugStats?.noDrawableWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
let rendered: Bool
switch frame.image {
case .video(let pixelBuffer, let isHDR):
rendered = presenter.render(
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
case .planar(let planes):
rendered = presenter.renderPlanar(
planes, into: drawable, onPresented: onGlass)
}
debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered {
// The vended drawable is spent either way (an unused/mismatched one drops
// back to the pool); the frame retries on the link's next vend. A format
// mismatch (mid-session HDR flip caught between the layer reconfigure and
// the next vend) self-heals the same way see encodePresent's guard.
ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: nil)
} }
}
renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
renderJoinable = true
renderThread.start()
}
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
/// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is
/// (see the header) it only retries a frame a transient `nextDrawable` failure put back into
/// the ring, which matters under the host's infinite GOP where a static scene sends no
/// replacement frame. Arrival/glass pacing only deadline sessions have no CADisplayLink
/// (their CAMetalDisplayLink's updates are both clock and retry).
/// replacement frame.
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
renderSignal.signal()
}
/// MAIN thread (SessionPresenter session start + every layout/Reconfigure): hint the
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
/// is the hinted link.
public func setFrameRateHint(hz: Float) {
frameRateHint.stage(hz: hz)
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) {
@@ -1169,7 +709,7 @@ public final class Stage2Pipeline {
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
private static func makePyroWavePump(
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
ring: ReadyRing, renderSignal: DispatchSemaphore,
device: MTLDevice, queue: MTLCommandQueue,
decodeMeter: LatencyMeter?,
onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -65,21 +65,19 @@ public enum Stage444Probe {
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
var produced: OSType = 0
// SYNCHRONOUS decode no `._EnableAsynchronousDecompression`, so the output callback
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
// nothing from decode parallelism; the lazy statics still cache the result.
let done = DispatchSemaphore(value: 0)
let status = VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample,
flags: [], infoFlagsOut: nil
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in
if status == noErr, let imageBuffer {
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
}
done.signal()
}
guard status == noErr else { return false }
VTDecompressionSessionWaitForAsynchronousFrames(session)
_ = done.wait(timeout: .now() + 1.0)
return produced == want || produced == fullRangeSibling
}
}
@@ -93,7 +93,6 @@ public struct StreamView: NSViewRepresentable {
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
@@ -116,8 +115,7 @@ public struct StreamView: NSViewRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil
displayMeter: LatencyMeter? = nil
) {
self.connection = connection
self.captureEnabled = captureEnabled
@@ -130,7 +128,6 @@ public struct StreamView: NSViewRepresentable {
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
}
public func makeNSView(context: Context) -> StreamLayerView {
@@ -141,7 +138,6 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -155,7 +151,6 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the
@@ -177,7 +172,6 @@ public final class StreamLayerView: NSView {
var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
@@ -667,7 +661,6 @@ public final class StreamLayerView: NSView {
endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd,
@@ -61,7 +61,6 @@ public struct StreamView: UIViewControllerRepresentable {
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
/// captured-state D combo is detected by the macOS NSEvent monitor only); on iOS a
@@ -78,8 +77,7 @@ public struct StreamView: UIViewControllerRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil
displayMeter: LatencyMeter? = nil
) {
self.connection = connection
self.captureEnabled = captureEnabled
@@ -91,7 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
}
public func makeUIViewController(context: Context) -> StreamViewController {
@@ -101,7 +98,6 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -114,7 +110,6 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
if controller.connection !== connection {
@@ -150,7 +145,6 @@ public final class StreamViewController: StreamViewControllerBase {
var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
@@ -412,7 +406,6 @@ public final class StreamViewController: StreamViewControllerBase {
endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd,
@@ -1,30 +0,0 @@
// The brand color, in the dependency-free foundation so EVERY process can use it the widget
// extension links PunktfunkShared alone (never PunktfunkKit's Rust staticlib), and before this
// moved here the Live Activity / widgets fell back to `.tint` = system blue.
import SwiftUI
public extension Color {
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
/// independent of the asset-catalog accent `Color.accentColor` resolution is environment- and
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
static let brand: Color = {
#if canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#elseif canImport(AppKit)
return Color(NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#else
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
#endif
}()
}
@@ -50,21 +50,12 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel"
/// LEGACY (2026-07 presentation rebuild design/apple-presentation-rebuild.md): the old
/// user-visible stage picker's key. No longer read the presenter is resolved from
/// `presentPriority` below; the stage ladder survives only as the
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
/// value is documented, not mysterious.
/// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// system-layer fallback). Resolved once per session by SessionPresenter;
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
public static let presenter = "punktfunk.presenter"
/// The user's presentation intent: "latency" (default every frame shows as soon as the
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
/// jitter buffer evens the cadence at the cost of added, visible display latency).
/// Resolved once per session by SessionPresenter see PresentPriority.
public static let presentPriority = "punktfunk.presentPriority"
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 13.
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
public static let smoothBuffer = "punktfunk.smoothBuffer"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
/// (lowest latency the default, OFF). Resolved once per session;
@@ -4,10 +4,9 @@ import XCTest
import QuartzCore
@testable import PunktfunkKit
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
/// stage-4 gate), and the per-platform glass-gate depth.
/// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
/// release-build stage-1 gate), and the per-platform glass-gate depth.
final class PresentPacingTests: XCTestCase {
// MARK: - PresentGate
@@ -22,10 +21,8 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
}
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung no longer a default; see
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
/// behind the flip scanning out the bound only bites at the THIRD. One release (a glass
/// callback) reopens exactly one slot.
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out the
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
func testGateDepthTwoAdmitsTwoInFlightPresents() {
let gate = PresentGate(capacity: 2)
XCTAssertTrue(gate.tryAcquire(now: 0))
@@ -75,143 +72,16 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0)
}
// MARK: - PresentPriority (the user-facing latency/smoothness intent)
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
func testPresentPriorityResolution() {
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
.smooth(buffer: 2), "unset buffer = Automatic = 2")
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
.smooth(buffer: 2), "out-of-range buffer = Automatic")
}
/// The intentstore mapping: latency runs the zero-queue newest-wins slot, smoothness the
/// FIFO jitter buffer at the resolved capacity.
func testPresentPriorityStorePolicy() {
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
XCTAssertEqual(
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
}
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
/// restores only into an empty slot the exact pre-rebuild ReadyRing semantics.
func testFrameStoreNewestWins() {
let store = FrameStore<Int>(policy: .newestWins)
XCTAssertNil(store.take())
store.submit(1)
store.submit(2)
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
XCTAssertNil(store.take())
store.putBack(7)
store.submit(8) // a fresh decode beats the putBack
store.putBack(7)
XCTAssertEqual(store.take(), 8)
XCTAssertEqual(store.drainSubmitted(), 3)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 0)
XCTAssertEqual(smoothing.underflows, 0)
}
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity
/// without preroll a steady stream drains on arrival and headroom never builds then pops
/// oldest-first.
func testFrameStoreFifoPrerollsToCapacity() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
store.submit(2)
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
store.submit(3)
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
XCTAssertEqual(store.take(), 3)
}
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
func testFrameStoreFifoOverflowAndUnderflow() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
store.submit(3) // full 1 (the oldest) goes
XCTAssertEqual(store.take(), 2)
XCTAssertEqual(store.take(), 3)
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
store.submit(4)
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
store.submit(5)
XCTAssertEqual(store.take(), 4)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 1)
XCTAssertEqual(smoothing.underflows, 1)
}
/// FIFO putBack reinserts at the FRONT a frame the render thread couldn't present is
/// still the oldest, so present order is preserved.
func testFrameStoreFifoPutBackPreservesOrder() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
let f = store.take()
XCTAssertEqual(f, 1)
store.putBack(f!)
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
XCTAssertEqual(store.take(), 2)
}
// MARK: - LatestBox (stage-4's drawable hand-off)
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
/// layer's pool by release), `take` empties the slot.
func testLatestBoxNewestWins() {
let box = LatestBox<Int>()
XCTAssertNil(box.take())
box.put(1)
box.put(2)
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
XCTAssertNil(box.take(), "take empties the slot")
}
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
/// didn't present must never clobber a fresher one the link vended in between.
func testLatestBoxPutBackNeverClobbersAFresherPut() {
let box = LatestBox<Int>()
box.put(1)
let stale = box.take()
XCTAssertEqual(stale, 1)
box.putBack(stale!)
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
box.put(2)
let taken = box.take()
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
box.putBack(taken!)
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
}
// MARK: - PresenterChoice
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
/// platforms where any bounded-FIFO pacing keeps a standing queue tvOS joined in the
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
/// No selection / garbage falls back to it.
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
/// tvOS arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
/// (sync-off presents don't queue). No selection / garbage falls back to it.
func testPresenterChoiceFallsBackToPlatformDefault() {
#if os(iOS) || os(tvOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
#else
#if os(macOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
#else
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
#endif
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
@@ -236,29 +106,6 @@ final class PresentPacingTests: XCTestCase {
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
}
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS whose present path is
/// entangled with the sync-off/DCP-panic saga a synced-over "stage4" value maps back to
/// the platform default instead of engaging an unvalidated pacing.
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
#if os(macOS)
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
#else
XCTAssertEqual(
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
// The env override wins over the persisted setting, both directions.
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
#endif
}
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
func testPresenterChoiceGatesStage1() {
@@ -309,30 +156,25 @@ final class PresentPacingTests: XCTestCase {
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
// Stage-4 means deadline regardless of codec or how it was chosen.
XCTAssertEqual(
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
}
// MARK: - Glass-gate depth
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue
/// the 2026-07 iPad depth-2 experiment regressed display latency 142228 ms (see
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
/// there is the swapID-panic mitigation strict serialization is its point);
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
/// Out-of-range/garbage values are ignored.
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
/// queued the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
/// there is the swapID-panic mitigation strict serialization is its point, so the env
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
/// values are ignored.
func testGateDepthPlatformDefaultsAndEnvOverride() {
#if os(macOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
#elseif os(tvOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
#else
XCTAssertEqual(
SessionPresenter.gateDepth(env: nil), 1,
"any depth >1 is a standing queue — one refresh of display latency per slot")
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder lever")
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
#endif
XCTAssertEqual(
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
+2 -52
View File
@@ -55,11 +55,6 @@ pub struct AppModel {
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
/// run in the session binary, which has its own.
pub gamepad: crate::gamepad::GamepadService,
/// Device lists for the settings pickers (GPUs via `punktfunk-session
/// --list-adapters` — the shell deliberately links no Vulkan itself — and audio
/// endpoints via the PipeWire registry), probed once at startup on a worker thread.
/// Empty until the probe lands — empty lists simply hide their pickers.
pub probes: Rc<RefCell<crate::ui_settings::DeviceProbes>>,
hosts: Controller<HostsPage>,
/// One session child at a time — connects while one runs are ignored.
busy: bool,
@@ -165,42 +160,6 @@ impl SimpleComponent for AppModel {
}
let settings = Rc::new(RefCell::new(Settings::load()));
// Device lists for the settings pickers: probe in the background, ready long
// before the dialog opens. A missing session binary or absent PipeWire just
// leaves the corresponding list empty (and its picker hidden).
let probes: Rc<RefCell<crate::ui_settings::DeviceProbes>> = Rc::default();
{
let (tx, rx) = async_channel::bounded::<crate::ui_settings::DeviceProbes>(1);
std::thread::spawn(move || {
let adapters: Vec<String> =
std::process::Command::new(crate::spawn::session_binary())
.arg("--list-adapters")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
let _ = tx.send_blocking(crate::ui_settings::DeviceProbes {
adapters,
speakers,
mics,
});
});
let probes = probes.clone();
glib::spawn_future_local(async move {
if let Ok(found) = rx.recv().await {
*probes.borrow_mut() = found;
}
});
}
// Re-apply the persisted forwarded-controller pin (stable key; the service
// matches it whenever such a pad connects).
{
@@ -238,7 +197,6 @@ impl SimpleComponent for AppModel {
settings,
identity,
gamepad: init.gamepad,
probes,
hosts,
busy: false,
wake_fallback: None,
@@ -349,15 +307,8 @@ impl SimpleComponent for AppModel {
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
// box is already booting while the dial times out, arm the wake-wait fallback
// for THIS request, and connect immediately.
//
// Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when
// they aren't): no packet and no wake-and-wait fallback — the dial either
// succeeds or fails with the normal error. The host-card menu's explicit
// "Wake host" is deliberately not gated.
if self.settings.borrow().auto_wake {
crate::wol::wake(&req.mac, req.addr.parse().ok());
self.wake_fallback = Some(req.clone());
}
crate::wol::wake(&req.mac, req.addr.parse().ok());
self.wake_fallback = Some(req.clone());
sender.input(AppMsg::Connect(req));
}
}
@@ -484,7 +435,6 @@ impl SimpleComponent for AppModel {
&self.window,
self.settings.clone(),
&self.gamepad,
&self.probes.borrow(),
move || {
// The library toggle changes the saved cards' menu — re-render.
let _ = hosts.send(HostsMsg::Refresh);
+1 -29
View File
@@ -352,7 +352,6 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
match known.save() {
@@ -535,34 +534,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
)));
}
"settings" | "03-settings" => {
// Mock devices so the shot shows the probe-dependent pickers populated.
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
name: name.to_string(),
description: description.to_string(),
};
let probes = crate::ui_settings::DeviceProbes {
adapters: vec![
"NVIDIA GeForce RTX 4070".to_string(),
"AMD Radeon 780M".to_string(),
],
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
};
let dialog = crate::ui_settings::show(
&ctx.window,
ctx.settings.clone(),
&ctx.gamepad,
&probes,
|| {},
);
// Optional page for the capture (general/display/input/audio/controllers);
// the dialog opens on General otherwise.
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
if !page.is_empty() {
use adw::prelude::PreferencesDialogExt as _;
dialog.set_visible_page_name(&page);
}
}
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
}
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
"pair" | "05-pair" => {
+102 -414
View File
@@ -1,9 +1,5 @@
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
//! everything about the picture — with per-field captions in each row's subtitle,
//! dynamic where the meaning depends on the selection (touch mode). Written back to
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
//! rather than as a page.
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
//! capture behavior. Written back to disk when the dialog closes.
use crate::trust::Settings;
use adw::prelude::*;
@@ -44,31 +40,14 @@ const GAMEPADS: &[&str] = &[
"steamdeck",
];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
/// HEVC when either side can't do it.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
const CODEC_LABELS: &[&str] = &[
"Automatic",
"HEVC (H.265)",
"H.264 (AVC)",
"AV1",
"PyroWave (wired LAN)",
];
/// Codec setting values (persisted) paired with their display labels below.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
const TOUCH_MODE_CAPTIONS: &[&str] = &[
"Drives the cursor like a laptop trackpad — tap to click",
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!(
@@ -287,92 +266,22 @@ impl ChoiceRow {
}
}
/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
r.set_subtitle(text);
}
}
/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
/// 2.. = explicit sizes) — one line each, see the caption-width note on
/// [`TOUCH_MODE_CAPTIONS`].
fn resolution_caption(i: u32) -> &'static str {
match i {
0 => "The native mode of this monitor, resolved at connect",
1 => "Follows the stream window — resizes renegotiate the host output",
_ => "The host drives a virtual output at exactly this size",
}
}
/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
/// spelled out; everything else shares the soft-preference line.
fn codec_caption(i: u32) -> &'static str {
if CODECS.get(i as usize) == Some(&"pyrowave") {
"Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
} else {
"A preference — the host falls back if it can't encode it"
}
}
/// A settings category page for the dialog's view switcher.
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
adw::PreferencesPage::builder()
// The name addresses the page programmatically (`set_visible_page_name` — the
// screenshot harness's page knob); the title is what the view switcher shows.
.name(title.to_lowercase())
.title(title)
.icon_name(icon)
.build()
}
/// Startup device probes for the pickers — filled by the app shell in the background
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
/// registry); any list may still be empty when the dialog opens, which simply hides
/// that picker.
#[derive(Default)]
pub struct DeviceProbes {
pub adapters: Vec<String>,
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
pub mics: Vec<pf_client_core::audio::AudioDevice>,
}
/// A titled group of rows; `description` (may be empty) is the one form-level note —
/// per-field explanations belong in row subtitles, not here.
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
let g = adw::PreferencesGroup::builder().title(title).build();
if !description.is_empty() {
g.set_description(Some(description));
}
g
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
/// there so the experimental library toggle takes effect without a nav round-trip).
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
probes: &DeviceProbes,
on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog {
) {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.set_search_enabled(true);
// Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
// Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
// breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
// can't give the dialog this width it still collapses to the bottom bar on its own.
dialog.set_content_width(830);
let inline = gamescope_session();
let page = adw::PreferencesPage::new();
// ---- Display: Resolution ----
let stream = adw::PreferencesGroup::builder().title("Stream").build();
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes.
let res_names: Vec<String> = std::iter::once("Native display".to_string())
@@ -388,13 +297,10 @@ pub fn show(
&dialog,
inline,
"Resolution",
resolution_caption(0),
"The host creates a virtual output at exactly this size — Match window follows \
the stream window, including mid-stream resizes",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
{
let w = res_row.widget().clone();
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
}
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
@@ -409,11 +315,9 @@ pub fn show(
&dialog,
inline,
"Refresh rate",
"Native follows the monitor the window is on",
"",
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
// ---- Display: Quality ----
let scale_names: Vec<String> = RENDER_SCALES
.iter()
.map(|&s| render_scale_label(s))
@@ -422,69 +326,13 @@ pub fn show(
&dialog,
inline,
"Render scale",
"Above 1× supersamples for sharpness; below is lighter on the host",
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
(< 1×) for a lighter host this device resamples to the window",
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate");
bitrate_row
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
codec_caption(0),
CODEC_LABELS,
);
{
let w = codec_row.widget().clone();
codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
}
let hdr_row = adw::SwitchRow::builder()
.title("10-bit HDR")
.subtitle(
"Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
the display supports it, tone-mapped otherwise",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
"Video decoder",
"Automatic picks the best hardware decode, then software",
&["Automatic", "Vulkan Video", "VAAPI", "Software"],
);
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
let saved_adapter = settings.borrow().adapter.clone();
let mut gpu_names = vec!["Automatic".to_string()];
let mut gpu_keys: Vec<String> = vec![String::new()];
for a in &probes.adapters {
gpu_names.push(a.clone());
gpu_keys.push(a.clone());
}
if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
gpu_names.push(format!("{saved_adapter} (not detected)"));
gpu_keys.push(saved_adapter.clone());
}
let gpu_row = (gpu_keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
"GPU",
"Decodes and presents the stream",
&gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let i = gpu_keys
.iter()
.position(|k| k == &saved_adapter)
.unwrap_or(0);
row.set_selected(i as u32);
row
});
// ---- Display: Host output ----
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = ChoiceRow::new(
&dialog,
inline,
@@ -498,19 +346,19 @@ pub fn show(
"gamescope",
],
);
// ---- General ----
let fullscreen_row = adw::SwitchRow::builder()
.title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
let wake_row = adw::SwitchRow::builder()
.title("Auto-wake on connect")
.subtitle(
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
off if hosts behind a VPN look offline when they aren't",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
"Video decoder",
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
Vulkan Video on NVIDIA), falling back to software",
&[
"Automatic (hardware → software)",
"Vulkan Video",
"VAAPI",
"Software",
],
);
let stats_row = ChoiceRow::new(
&dialog,
inline,
@@ -518,101 +366,20 @@ pub fn show(
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"],
);
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
and launch one directly. No extra host setup",
)
let fullscreen_row = adw::SwitchRow::builder()
.title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(scale_row.widget());
stream.add(&bitrate_row);
stream.add(compositor_row.widget());
stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(stats_row.widget());
// ---- Input ----
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
TOUCH_MODE_CAPTIONS[0],
TOUCH_MODE_LABELS,
);
// Dynamic caption: describe the SELECTED mode, not all three at once.
{
let w = touch_row.widget().clone();
touch_row.connect_changed(move |i| {
let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
});
}
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
let invert_row = adw::SwitchRow::builder()
.title("Invert scroll direction")
.subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
.build();
// ---- Audio ----
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Stereo or surround — the host downmixes if its output has fewer",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Sends your microphone to the host's virtual mic")
.build();
// Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
// stored value is the node name. Hidden when the probe found nothing; a saved
// device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
let dev_row = |saved: String,
devs: &[pf_client_core::audio::AudioDevice],
title: &str,
subtitle: &str| {
let mut names = vec!["System default".to_string()];
let mut keys = vec![String::new()];
for d in devs {
names.push(d.description.clone());
keys.push(d.name.clone());
}
if !saved.is_empty() && !keys.contains(&saved) {
names.push(format!("{saved} (not detected)"));
keys.push(saved.clone());
}
let row = (keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
title,
subtitle,
&names.iter().map(String::as_str).collect::<Vec<_>>(),
);
row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
row
});
(row, keys)
};
let (speaker_row, speaker_keys) = dev_row(
settings.borrow().speaker_device.clone(),
&probes.speakers,
"Speaker",
"Host audio plays here — System default follows the desktop",
);
let (micdev_row, micdev_keys) = dev_row(
settings.borrow().mic_device.clone(),
&probes.mics,
"Microphone",
"The input that feeds the host's virtual mic",
);
// The device pick only matters while the mic streams at all.
if let Some(r) = &micdev_row {
let w = r.widget().clone();
w.set_sensitive(mic_row.is_active());
mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
}
// ---- Controllers ----
let input = adw::PreferencesGroup::builder().title("Input").build();
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
// (Steam's virtual pad skipped); pinning one restricts the session to that single
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
@@ -646,7 +413,7 @@ pub fn show(
if pads.is_empty() {
"No controllers detected"
} else {
"Every pad is its own player pick one to force single-player"
"All controllers are forwarded, each as its own player; pick one to force single-player"
},
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
@@ -676,7 +443,7 @@ pub fn show(
&dialog,
inline,
"Gamepad type",
"The virtual pad on the host — Automatic matches your controller",
"The virtual pad the host creates — Automatic matches the physical pad",
&[
"Automatic",
"Xbox 360",
@@ -686,8 +453,66 @@ pub fn show(
"Steam Deck",
],
);
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
Direct pointer jumps to your finger; Touch passthrough sends real touches",
TOUCH_MODE_LABELS,
);
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(forward_row.widget());
input.add(pad_row.widget());
input.add(touch_row.widget());
input.add(&inhibit_row);
// ---- Seed from the current settings ----
let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Request stereo or surround (the host downmixes if its output has fewer)",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
audio.add(surround_row.widget());
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
"Preferred codec — the host falls back if it can't encode this one",
CODEC_LABELS,
);
stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
// Experimental — mirrors the Apple client's Experimental section (wording included).
let experimental = adw::PreferencesGroup::builder()
.title("Experimental")
.build();
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds a “Browse library…” action to each saved host that lists its games \
(Steam + custom) via the host's management API works once you've paired",
)
.build();
experimental.add(&library_row);
// About (with the license/third-party Legal pages) lives in the primary menu now.
page.add(&stream);
page.add(&input);
page.add(&audio);
page.add(&experimental);
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = if s.match_window {
@@ -700,7 +525,6 @@ pub fn show(
.unwrap_or(0)
};
res_row.set_selected(res_i as u32);
set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32);
let scale_i = RENDER_SCALES
@@ -716,8 +540,6 @@ pub fn show(
.position(|&t| t == s.touch_mode)
.unwrap_or(0);
touch_row.set_selected(touch_i as u32);
// set_selected never fires the changed hook, so seed the dynamic caption directly.
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
@@ -731,11 +553,8 @@ pub fn show(
.unwrap_or(0);
stats_row.set_selected(stats_i as u32);
fullscreen_row.set_active(s.fullscreen_on_stream);
wake_row.set_active(s.auto_wake);
inhibit_row.set_active(s.inhibit_shortcuts);
invert_row.set_active(s.invert_scroll);
mic_row.set_active(s.mic_enabled);
hdr_row.set_active(s.hdr_enabled);
library_row.set_active(s.library_enabled);
surround_row.set_selected(match s.audio_channels {
6 => 1,
@@ -744,104 +563,9 @@ pub fn show(
});
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
codec_row.set_selected(codec_i as u32);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
}
// ---- Assemble the category pages (the Apple revamp's map) ----
let general = page("General", "preferences-system-symbolic");
let session_group = group("Session", "");
session_group.add(&fullscreen_row);
session_group.add(&wake_row);
let stats_group = group("Statistics", "");
stats_group.add(stats_row.widget());
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&session_group);
general.add(&stats_group);
general.add(&library_group);
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
resolution_group.add(res_row.widget());
resolution_group.add(hz_row.widget());
let quality_group = group("Quality", "");
quality_group.add(scale_row.widget());
quality_group.add(&bitrate_row);
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(decoder_row.widget());
if let Some(r) = &gpu_row {
quality_group.add(r.widget());
}
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
"Display changes apply from the next session.",
);
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
let touch_group = group("Touch", "");
touch_group.add(touch_row.widget());
// Group titles are Pango markup — the ampersand must be an entity.
let kbm_group = group("Keyboard &amp; mouse", "");
kbm_group.add(&inhibit_row);
kbm_group.add(&invert_row);
input.add(&touch_group);
input.add(&kbm_group);
let audio = page("Audio", "audio-volume-high-symbolic");
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
if let Some(r) = &speaker_row {
audio_group.add(r.widget());
}
audio_group.add(&mic_row);
if let Some(r) = &micdev_row {
audio_group.add(r.widget());
}
audio.add(&audio_group);
let controllers = page("Controllers", "input-gaming-symbolic");
let controllers_group = group("", "");
// The detected-pad list (mirrors the Apple Controllers section): informational rows
// above the pickers, from the same snapshot that feeds the forwarding picker.
if pads.is_empty() {
let none = adw::ActionRow::builder()
.title("No controllers detected")
.css_classes(["dim-label"])
.build();
controllers_group.add(&none);
} else {
for p in &pads {
let row = adw::ActionRow::builder()
.title(&p.name)
.use_markup(false)
.build();
if p.steam_virtual {
row.set_subtitle(
"Steam Input's virtual pad — Automatic skips it while a real pad is connected",
);
} else {
row.set_subtitle(p.kind_label());
}
row.add_prefix(&gtk::Image::from_icon_name("input-gaming-symbolic"));
controllers_group.add(&row);
}
}
controllers_group.add(forward_row.widget());
controllers_group.add(pad_row.widget());
controllers.add(&controllers_group);
dialog.add(&general);
dialog.add(&display);
dialog.add(&input);
dialog.add(&audio);
dialog.add(&controllers);
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
@@ -863,25 +587,12 @@ pub fn show(
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
if let Some(r) = &gpu_row {
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
}
if let Some(r) = &speaker_row {
s.speaker_device =
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
}
if let Some(r) = &micdev_row {
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
}
s.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_row.is_active();
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.mic_enabled = mic_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
@@ -894,7 +605,6 @@ pub fn show(
on_closed();
});
dialog.present(Some(parent));
dialog
}
#[cfg(test)]
@@ -968,17 +678,6 @@ mod tests {
assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// The dynamic-caption hook drives the same subtitle both row shapes expose.
set_row_subtitle(row.widget(), "swapped");
assert_eq!(
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("swapped")
);
// Re-activating shows the check on the new selection (fresh subpage each time).
row.widget()
.downcast_ref::<adw::ActionRow>()
@@ -999,16 +698,5 @@ mod tests {
combo.set_selected(0);
assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0);
// ComboRow derives from ActionRow, so the caption hook reaches it too.
set_row_subtitle(combo.widget(), "combo caption");
assert_eq!(
combo
.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("combo caption")
);
}
}
+2 -6
View File
@@ -27,13 +27,9 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
# binary.
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
pf-presenter = { path = "../../crates/pf-presenter" }
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
pf-client-core = { path = "../../crates/pf-client-core" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
serde_json = { version = "1", optional = true }
-2
View File
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
v => v,
},
touch_mode: settings_at_start.touch_mode(),
invert_scroll: settings_at_start.invert_scroll,
json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
let fp_hex = trust::hex(&fingerprint);
@@ -453,7 +452,6 @@ impl ServiceState {
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
if let Err(e) = known.save() {
+7 -64
View File
@@ -103,14 +103,6 @@ mod session_main {
force_software: Arc<AtomicBool>,
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
) -> SessionParams {
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
// here rather than passed in so every caller — a direct connect and the console's
// own launches — honors the same stored decision. `addr` is moved into the struct
// below, so read it first.
let clipboard = trust::KnownHosts::load()
.hosts
.iter()
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
// key) to OUR gamepad service — the shells' in-process services can't reach this
// process. Applied per params-build (idempotent; browse re-launches included) so
@@ -173,7 +165,6 @@ mod session_main {
// pump) pins one manually.
display_hdr: None,
mic_enabled: settings.mic_enabled,
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
@@ -268,66 +259,19 @@ mod session_main {
)
.init();
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
// line, discrete first) for the desktop shells' GPU picker, then exit.
if arg_flag("--list-adapters") {
return match pf_presenter::vk::list_adapters() {
Ok(names) => {
for n in names {
println!("{n}");
}
0
}
Err(e) => {
eprintln!("list-adapters: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
// same enumeration the GTK shell probes.
#[cfg(target_os = "linux")]
if arg_flag("--list-audio") {
return match pf_client_core::audio::devices() {
Ok((sinks, sources)) => {
for d in sinks {
println!("sink\t{}\t{}", d.name, d.description);
}
for d in sources {
println!("source\t{}\t{}", d.name, d.description);
}
0
}
Err(e) => {
eprintln!("list-audio: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
#[cfg(target_os = "linux")]
enable_radv_video_decode();
// The Settings device picks → env, unless the user already forced one by hand:
// the GPU (the shells' pickers store the adapter's marketing name) for the
// presenter's device selection, and the audio endpoints (PipeWire node names)
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
// the RADV knob (covers --connect and --browse).
{
let s = trust::Settings::load();
for (var, value) in [
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
] {
if std::env::var_os(var).is_none() && !value.is_empty() {
std::env::set_var(var, value);
}
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
// name) → the presenter's device selection, unless the user already forced one.
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
let adapter = trust::Settings::load().adapter;
if !adapter.is_empty() {
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
}
}
@@ -429,7 +373,6 @@ mod session_main {
v => v,
},
touch_mode: settings.touch_mode(),
invert_scroll: settings.invert_scroll,
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
+1 -17
View File
@@ -12,12 +12,6 @@ repository.workspace = true
name = "punktfunk-client"
path = "src/main.rs"
# The couch/HTPC Start-menu entry. Its own executable because an MSIX <Application> cannot
# pass arguments to a full-trust exe — see the binary's own docs.
[[bin]]
name = "punktfunk-console"
path = "src/bin/punktfunk-console.rs"
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
# other native clients live in clients/linux and clients/apple); on other
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
@@ -29,17 +23,7 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
# data model (fetch + art pipeline) behind the library page.
#
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
# nothing enables it and the C++ is never compiled.
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
pf-client-core = { path = "../../crates/pf-client-core" }
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
@@ -57,28 +57,6 @@
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
<!--
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe)
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands
straight off to the session binary's controller-driven browse mode (host list,
pairing, settings, library) fullscreen.
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
-->
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Punktfunk Console"
Description="Controller-driven couch interface for TVs and HTPCs"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
+1 -1
View File
@@ -72,7 +72,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Nul
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
# adds no DLLs of its own.
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
foreach ($f in $required) {
$src = Join-Path $TargetDir $f
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
+8 -26
View File
@@ -36,9 +36,7 @@ pub(crate) fn initiate_waking(
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
if ctx.settings.lock().unwrap().auto_wake {
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
crate::wol::wake(&target.mac, target.addr.parse().ok());
initiate_opts(ctx, target, set_screen, set_status, true)
}
@@ -274,7 +272,6 @@ fn connect_spawn(
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
}
@@ -294,13 +291,9 @@ fn connect_spawn(
*shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair);
}
Some((_, false))
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
{
Some((_, false)) if wake_on_fail => {
// The dial-first attempt to a non-advertising host failed — it
// may genuinely be asleep. NOW wake and wait. Skipped entirely
// when auto-wake is off: the wait is only worth showing if we
// are actually sending magic packets to end it.
// may genuinely be asleep. NOW wake and wait.
wake_and_connect(&ctx2, target.clone(), &ss, &st);
}
Some((msg, false)) => {
@@ -328,12 +321,9 @@ fn connect_spawn(
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
/// the library window's `ready`, restored when the child exits (launched titles stream
/// in that same window, so the whole couch round-trip happens without the shell).
/// `target = None` opens the console's own host view (discovery, pairing, settings) — the
/// couch entry point that isn't tied to one host; `Some` opens straight into that host's
/// library.
pub(crate) fn open_console(
ctx: &Arc<AppCtx>,
target: Option<Target>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
@@ -341,21 +331,15 @@ pub(crate) fn open_console(
*ctx.shared.session.lock().unwrap() = child.clone();
ctx.shared.stats_line.lock().unwrap().clear();
ctx.shared.browse.store(true, Ordering::SeqCst);
if let Some(t) = target.clone() {
*ctx.shared.target.lock().unwrap() = t;
}
*ctx.shared.target.lock().unwrap() = target.clone();
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
set_status.call(String::new());
set_screen.call(Screen::Connecting);
let shared = ctx.shared.clone();
let (ss, st) = (set_screen.clone(), set_status.clone());
let addr_port = target.as_ref().map(|t| (t.addr.clone(), t.port));
let spawned = crate::spawn::spawn_browse(
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
fullscreen,
child,
move |event| {
let spawned =
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
use crate::spawn::SpawnEvent;
match event {
SpawnEvent::Ready => {
@@ -373,8 +357,7 @@ pub(crate) fn open_console(
ss.call(Screen::Hosts);
}
}
},
);
});
if let Err(e) = spawned {
set_status.call(e);
set_screen.call(Screen::Hosts);
@@ -484,7 +467,6 @@ fn wake_and_connect(
paired: false,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
}
+130 -192
View File
@@ -1,5 +1,5 @@
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit /
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, initiate_waking, open_console};
@@ -14,12 +14,10 @@ use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect";
const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_CONSOLE: &str = "Open console UI";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host";
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
const MENU_RENAME: &str = "Rename\u{2026}";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships
@@ -189,114 +187,43 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.into()
}
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
///
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
/// through a re-render.
#[allow(clippy::too_many_arguments)]
fn edit_editor(
fp: &str,
initial_name: &str,
name_draft: HookRef<String>,
addr_draft: HookRef<String>,
port_draft: HookRef<String>,
mac_draft: HookRef<String>,
clip_draft: HookRef<bool>,
set_edit: AsyncSetState<Option<(String, String)>>,
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
fn rename_editor(
initial: &str,
fp: String,
live: HookRef<String>,
set_rename: AsyncSetState<Option<(String, String)>>,
) -> Element {
let commit = {
let (fp, se) = (fp.to_string(), set_edit.clone());
let (name_draft, addr_draft, port_draft, mac_draft, clip_draft) = (
name_draft.clone(),
addr_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
);
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
move || {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
// Each field falls back to what was stored: a cleared box means "leave it",
// never "erase it" — except the MAC, which is legitimately clearable.
let name = name_draft.borrow().trim().to_string();
if !name.is_empty() {
h.name = name;
let draft = live.borrow();
let name = draft.trim();
if !name.is_empty() {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.name = name.to_string();
}
let addr = addr_draft.borrow().trim().to_string();
if !addr.is_empty() {
h.addr = addr;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
if p != 0 {
h.port = p;
}
}
let mac = mac_draft.borrow().trim().to_string();
h.mac = if mac.is_empty() {
Vec::new()
} else {
mac.split(&[',', ' '][..])
.filter(|m| !m.trim().is_empty())
.map(|m| m.trim().to_string())
.collect()
};
h.clipboard_sync = *clip_draft.borrow();
let _ = known.save();
}
let _ = known.save();
se.call(None);
sr.call(None);
}
};
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack((
text_block(label)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Left),
text_box(&value)
.placeholder_text(placeholder)
.on_text_changed(move |t: String| draft.set(t)),
))
.spacing(2.0)
let on_changed = {
let live = live.clone();
move |s: String| live.set(s)
};
let (name0, addr0, port0, mac0, clip0) = (
name_draft.borrow().clone(),
addr_draft.borrow().clone(),
port_draft.borrow().clone(),
mac_draft.borrow().clone(),
*clip_draft.borrow(),
);
let _ = initial_name;
card(
vstack((
field("Name", name0, "e.g. Living Room", name_draft),
field("Address", addr0, "IP or hostname", addr_draft),
field("Port", port0, "9777", port_draft),
field(
"MAC (Wake-on-LAN)",
mac0,
"auto-filled when known",
mac_draft,
),
vstack((
ToggleSwitch::new(clip0)
.header("Share clipboard with this host")
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| clip_draft.set(v)),
text_block(
"Copy on one machine, paste on the other. Off for every host until you \
turn it on here; the host must allow it too.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
text_box(initial)
.placeholder_text("Host name")
.on_text_changed(on_changed),
hstack((
button("Save")
.accent()
@@ -304,7 +231,7 @@ fn edit_editor(
.on_click(commit),
button("Cancel")
.subtle()
.on_click(move || set_edit.call(None)),
.on_click(move || set_rename.call(None)),
))
.spacing(4.0),
))
@@ -337,41 +264,16 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let rename = props.rename.clone();
let set_forget = &props.set_forget;
let set_rename = &props.set_rename;
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
// the target's fingerprint + initial name, so typing never round-trips through a
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
// changes (open, cancel, or switching to another host).
let name_draft = cx.use_ref(String::new());
let addr_draft = cx.use_ref(String::new());
let port_draft = cx.use_ref(String::new());
let mac_draft = cx.use_ref(String::new());
let clip_draft = cx.use_ref(false);
let edit_seed = cx.use_ref(Option::<String>::None);
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
// TARGET changes (start, cancel, or a switch to another host).
let rename_draft = cx.use_ref(String::new());
let rename_seed = cx.use_ref(Option::<String>::None);
{
let active = rename.as_ref().map(|(fp, _)| fp.clone());
if *edit_seed.borrow() != active {
let stored = active.as_ref().and_then(|fp| {
KnownHosts::load()
.hosts
.into_iter()
.find(|h| &h.fp_hex == fp)
});
name_draft.set(stored.as_ref().map(|h| h.name.clone()).unwrap_or_default());
addr_draft.set(stored.as_ref().map(|h| h.addr.clone()).unwrap_or_default());
port_draft.set(
stored
.as_ref()
.map(|h| h.port.to_string())
.unwrap_or_default(),
);
mac_draft.set(
stored
.as_ref()
.map(|h| h.mac.join(", "))
.unwrap_or_default(),
);
clip_draft.set(stored.as_ref().is_some_and(|h| h.clipboard_sync));
edit_seed.set(active);
if *rename_seed.borrow() != active {
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
rename_seed.set(active);
}
}
let hover = Hover {
@@ -412,51 +314,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
hstack({
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add)
.accent()
.on_click({
let sa = set_show_add.clone();
move || sa.call(true)
})
.into()];
// The couch UI's front door, beside the other page actions. Absent on ARM64,
// where the session binary ships without its Skia console.
if CONSOLE_UI_AVAILABLE {
actions.push(
header_btn("Console UI", Symbol::Play)
.tooltip(
"The controller-driven couch interface \u{2014} host list, \
pairing and libraries, launching streams in the same window.",
)
.on_click({
let (c, ss, st) =
(ctx.clone(), set_screen.clone(), set_status.clone());
// No target: the console opens its OWN host view rather than
// one host's library — the couch counterpart of this page.
move || open_console(&c, None, &ss, &st)
})
.into(),
);
}
actions.push(
header_btn("Shortcuts", Symbol::Keyboard)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Help)
})
.into(),
);
actions.push(
header_btn("Settings", Symbol::Setting)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
})
.into(),
);
actions
})
hstack((
header_btn("Add host", Symbol::Add).accent().on_click({
let sa = set_show_add.clone();
move || sa.call(true)
}),
header_btn("Shortcuts", Symbol::Keyboard).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Help)
}),
header_btn("Settings", Symbol::Setting).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
}),
))
.spacing(8.0)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
@@ -476,23 +347,84 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
// A controller is connected and a paired host is REACHABLE (advertising or probed —
// an offline host would just open the console onto an error scene): offer the couch
// experience — the console (gamepad) UI on the most recently used such host.
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
let reachable = |k: &&crate::trust::KnownHost| {
hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false)
};
if let Some(k) = known
.hosts
.iter()
.filter(|h| h.paired)
.filter(reachable)
.max_by_key(|h| h.last_used.unwrap_or(0))
{
let target = Target {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
};
let svc = props.svc.clone();
body.push(
card(
grid((
vstack((
text_block("Controller detected").font_size(14.0).semibold(),
text_block(format!(
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
launches stream in the same window.",
k.name
))
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Open console UI")
.accent()
.icon(Symbol::Play)
.on_click(move || {
open_console(
&svc.ctx,
target.clone(),
&svc.set_screen,
&svc.set_status,
)
})
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(12.0, 0.0, 0.0, 0.0)),
))
.columns([GridLength::Star(1.0), GridLength::Auto]),
)
.into(),
);
}
}
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
// being advertised right now shows as Online (and is deduped out of the discovery section).
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new();
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
let (fp, initial) = rename.clone().unwrap();
tiles.push(edit_editor(
&fp,
tiles.push(rename_editor(
&initial,
name_draft.clone(),
addr_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
fp,
rename_draft.clone(),
set_rename.clone(),
));
continue;
@@ -539,12 +471,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
if CONSOLE_UI_AVAILABLE && k.paired {
items.push(menu_item(MENU_CONSOLE));
}
items.push(menu_item(MENU_SPEED));
// Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake {
items.push(menu_item(MENU_WAKE));
}
items.push(menu_item(MENU_EDIT));
items.push(menu_item(MENU_RENAME));
items.push(menu_separator());
items.push(menu_item(MENU_FORGET));
items
@@ -558,6 +493,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
MENU_CONSOLE => {
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
@@ -569,7 +507,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
svc.set_speed.call(SpeedState::Running);
svc.set_screen.call(Screen::SpeedTest);
}
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
_ => {}
})
+1 -2
View File
@@ -241,8 +241,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
// Which Settings section the NavigationView shows (persists across visits this run).
// Opens on General — the first sidebar item, matching the Apple client's landing category.
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
// Connected-controller count, mirrored from the gamepad service by a poll thread
// (thread-driven state must be root state — see the module docs). Drives the hosts
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
-1
View File
@@ -59,7 +59,6 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
paired: true,
last_used: None,
mac: target3.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
+126 -321
View File
@@ -1,15 +1,5 @@
//! The settings screen. Every control writes straight back to the persisted [`Settings`]
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
//!
//! **Structure mirrors the Apple client's 2026-07 settings revamp** (its
//! `SettingsCategory` + `SettingsView+Sections.swift`), so the two desktop clients read the
//! same way: General = session/app behavior, Display = everything about the picture,
//! Input = touch/keyboard/mouse, Audio, Controllers, About. Each field carries its
//! explanation DIRECTLY under it ([`described`]) rather than only on hover — the same move
//! Apple made, for the same reason (guidance nobody hovers for is guidance nobody reads).
//! Wording is shared verbatim wherever the setting means the same thing on both platforms;
//! where the BEHAVIOR differs the text is deliberately Windows-specific (the forwarded-
//! controller picker especially: Apple forwards one pad, this client forwards them all).
use super::style::*;
use super::{AppCtx, Screen};
@@ -48,8 +38,7 @@ fn render_scale_label(scale: f64) -> String {
// Automatic — which is exactly how the session's decoder chain reads that value.
const DECODERS: &[(&str, &str)] = &[
("auto", "Automatic (GPU, fall back to CPU)"),
("vulkan", "Hardware (Vulkan Video)"),
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
("vulkan", "Hardware (GPU / Vulkan Video)"),
("software", "Software (CPU)"),
];
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
@@ -148,63 +137,11 @@ fn setting_toggle(
})
}
/// One field: the control with its explanation directly underneath (Apple's `described`).
///
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in
/// hover tooltips because a paragraph *above* a control reads as that control's label — true,
/// but a caption under it reads as a caption, which is how every Windows Settings page and
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
/// full-width caption runs into the control column and the whole cell reads as one block.
fn described(control: impl Into<Element>, caption: &str) -> Element {
vstack((
control.into(),
text_block(caption)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.max_width(420.0)
// Stretch (the TextBlock default) CENTRES a MaxWidth-capped block in the leftover
// width — the caption must be pinned left or it drifts away from its control.
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(5.0)
.into()
}
/// A settings sub-section heading. Deliberately NOT the shared [`section`] helper: that one
/// carries a 2px left inset (fine over the hosts/licenses lists it was written for), which
/// here left every heading hanging one nudge right of the card edge below it. Flush left, so
/// heading and card share one line.
fn group_heading(label: &str) -> Element {
text_block(label)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 14.0, 0.0, 2.0))
.into()
}
/// One settings group: an optional sub-section label, a card of fields, and an optional
/// form-level note under it (Apple's Section header/footer). Groups stack down the page.
fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Vec<Element> {
let mut out = Vec::with_capacity(3);
if let Some(h) = header {
out.push(group_heading(h));
}
out.push(card(vstack(fields).spacing(14.0)).into());
if let Some(f) = footer {
out.push(
text_block(f)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 6.0, 0.0, 0.0))
.into(),
);
}
out
/// A settings card: just the controls. No heading (the section title is the NavigationView
/// header) and no description paragraph — per-control guidance is a `.tooltip(...)` on the
/// control itself (a paragraph in the card reads as the first control's label).
fn settings_card(controls: Vec<Element>) -> Element {
card(vstack(controls).spacing(10.0)).into()
}
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
@@ -249,7 +186,12 @@ pub(crate) fn settings_page(
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
});
})
.tooltip(
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
follows the stream window, including mid-stream resizes.",
);
let (hz_names, hz_i) = {
let names: Vec<String> = REFRESH
.iter()
@@ -266,7 +208,8 @@ pub(crate) fn settings_page(
};
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
s.refresh_hz = REFRESH[i];
});
})
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
let (scale_names, scale_i) = {
let names: Vec<String> = RENDER_SCALES
.iter()
@@ -280,26 +223,36 @@ pub(crate) fn settings_page(
};
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
s.render_scale = RENDER_SCALES[i];
});
})
.tooltip(
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
);
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string();
});
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
})
.tooltip(
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
choice is unavailable.",
);
let fullscreen_toggle = setting_toggle(
ctx,
"Start streams fullscreen",
s.fullscreen_on_stream,
|s, on| s.fullscreen_on_stream = on,
);
)
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
// --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
})
.tooltip(
"Hardware decode (Vulkan Video) is far lighter than software \u{2014} keep it on \
Automatic unless debugging.",
);
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
let gpus = crate::gpu::adapter_names();
@@ -318,11 +271,18 @@ pub(crate) fn settings_page(
gpus[i - 1].clone()
};
})
.tooltip(
"Which adapter decodes and presents the stream. Applies to the next stream; \
Automatic uses the GPU driving this window's display.",
)
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string();
});
})
.tooltip(
"A soft preference \u{2014} the host falls back to the best codec both sides support. PyroWave is the low-latency wavelet codec for a WIRED link: it trades bitrate (hundreds of Mb/s) for near-zero decode time, so it needs gigabit Ethernet.",
);
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
// round-trips exactly.
let bitrate_box = {
@@ -335,10 +295,18 @@ pub(crate) fn settings_page(
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
s.save();
})
.tooltip(
"0 lets the host decide. Run a per-host speed test from the host list for a \
recommendation.",
)
};
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
s.hdr_enabled = on
});
})
.tooltip(
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
SDR content is unaffected.",
);
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -383,44 +351,60 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default();
s.save();
})
.tooltip(
"Every connected controller is forwarded, each as its own player. Pick one \
to force single-player \u{2014} only it reaches the host.",
)
};
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
})
.tooltip(
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
controller.",
);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
});
let invert_scroll_toggle =
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
s.invert_scroll = on
});
})
.tooltip(
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
pointer jumps to your finger, Touch passthrough sends real touches.",
);
let shortcuts_toggle = setting_toggle(
ctx,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
s.inhibit_shortcuts,
|s, on| s.inhibit_shortcuts = on,
);
)
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
// --- Audio -----------------------------------------------------------------------------
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
s.audio_channels = AUDIO_CHANNELS[i].0;
});
})
.tooltip("The host downmixes if its output has fewer channels.");
let mic_toggle = setting_toggle(
ctx,
"Stream microphone to the host",
s.mic_enabled,
|s, on| s.mic_enabled = on,
);
)
.tooltip("Sends the default microphone to the host's virtual mic source.");
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
});
})
.tooltip(
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
);
let licenses_button = {
let ss = set_screen.clone();
@@ -431,6 +415,10 @@ pub(crate) fn settings_page(
"Show game library (experimental)",
s.library_enabled,
|s, on| s.library_enabled = on,
)
.tooltip(
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
launches in the stream. Mirrors the Apple client's toggle.",
);
// App identity + version at the top of the About card (the WinUI Settings convention; the About
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
@@ -443,227 +431,70 @@ pub(crate) fn settings_page(
))
.spacing(2.0);
// The selected section's content, grouped exactly like the Apple client's categories
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under
// it; the only form-level notes are the "applies from the next session" footers, matching
// Apple's decision to keep exactly one of those per affected category.
let (title, groups): (&str, Vec<Element>) = match section {
"display" => {
let mut out = group(
Some("Resolution"),
vec![
described(
res_combo,
"The host drives a real virtual output at exactly this size \u{2014} true \
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
(1:1) through every resize.",
),
described(
hz_combo,
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
connect.",
),
],
None,
);
out.extend(group(
Some("Quality"),
vec![
described(
scale_combo,
"Above native supersamples for sharpness; below renders lighter on the \
host and the link. This device resamples the result to the window.",
),
described(
bitrate_box,
"0 lets the host decide (its default, clamped to what it supports). A \
host card\u{2019}s context menu has a network speed test.",
),
described(
codec_combo,
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
gigabit Ethernet.",
),
described(
hdr_toggle,
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
),
],
None,
));
out.extend(group(
Some("Decoding"),
{
let mut fields = vec![described(
decoder_combo,
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
11 on Intel, Vulkan Video on NVIDIA and AMD \u{2014} and falls back to \
the CPU. Change it only when debugging.",
)];
if let Some(c) = gpu_combo {
fields.push(described(
c,
"Which adapter decodes and presents the stream. Automatic uses the \
GPU driving this window\u{2019}s display.",
));
}
fields
},
None,
));
out.extend(group(
Some("Host output"),
vec![described(
comp_combo,
"The backend the host uses for its virtual output (Linux hosts only). A \
specific choice falls back to auto-detection when that backend \
isn\u{2019}t available.",
)],
// The one form-level note, exactly as on Apple.
Some("Display changes apply from the next session."),
));
("Display", out)
}
"input" => {
let mut out = group(
Some("Touch & pointer"),
vec![described(
touch_combo,
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
you touch, Touch passthrough sends real multi-touch through.",
)],
None,
);
out.extend(group(
Some("Keyboard & mouse"),
vec![
described(
shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \
has input captured. Off, they act on this machine instead.",
),
described(
invert_scroll_toggle,
"Reverses the wheel and trackpad scroll direction sent to the host.",
),
],
None,
));
("Input", out)
}
"controllers" => (
"Controllers",
group(
None,
vec![
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
described(
forward_combo,
"Every connected controller is forwarded, each as its own player. Pick \
one to force single-player \u{2014} only it reaches the host.",
),
described(
pad_combo,
"The virtual pad created on the host. Automatic matches your controller \
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
),
],
Some("Applies from the next session."),
),
// The selected section's content — per-control guidance lives on hover tooltips, so the
// card is just the controls.
let (title, card): (&str, Element) = match section {
"video" => (
"Video",
settings_card({
let mut controls: Vec<Element> = vec![decoder_combo.into()];
if let Some(c) = gpu_combo {
controls.push(c.into());
}
controls.extend([
codec_combo.into(),
bitrate_box.into(),
hdr_toggle.into(),
hud_combo.into(),
]);
controls
}),
),
"input" => (
"Input",
settings_card(vec![
forward_combo.into(),
pad_combo.into(),
touch_combo.into(),
shortcuts_toggle.into(),
]),
),
"audio" => (
"Audio",
group(
None,
vec![
described(
channels_combo,
"The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.",
),
described(
mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
),
],
Some("Applies from the next session."),
),
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
),
"about" => (
"About",
group(
None,
vec![about_identity.into(), licenses_button.into()],
None,
),
settings_card(vec![
about_identity.into(),
library_toggle.into(),
licenses_button.into(),
]),
),
_ => (
"Display",
settings_card(vec![
res_combo.into(),
hz_combo.into(),
scale_combo.into(),
fullscreen_toggle.into(),
comp_combo.into(),
]),
),
// "general" and anything unrecognized.
_ => {
let mut out = group(
Some("Session"),
vec![
described(
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
live.",
),
described(
auto_wake_toggle,
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
waits for it to boot. Turn off if hosts behind a VPN look offline when \
they aren\u{2019}t.",
),
],
None,
);
out.extend(group(
Some("Statistics"),
vec![described(
hud_combo,
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
tiers any time.",
)],
None,
));
out.extend(group(
Some("Library"),
vec![described(
library_toggle,
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
their Steam and custom games and launch one directly. No extra host setup.",
)],
None,
));
("General", out)
}
};
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
// as content, the NavigationView's own back arrow returning to the host list. Auto display
// mode collapses the pane on a narrow window, exactly like Windows Settings.
// Category order mirrors the Apple client's sidebar exactly.
let items = vec![
NavViewItem::new("General")
.tag("general")
.icon(Symbol::Setting),
NavViewItem::new("Display")
.tag("display")
.icon(Symbol::FullScreen),
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
NavViewItem::new("Input")
.tag("input")
.icon(Symbol::Keyboard),
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
NavViewItem::new("Controllers")
.tag("controllers")
.icon(Symbol::Play),
NavViewItem::new("About").tag("about").icon(Symbol::Help),
];
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
@@ -674,38 +505,12 @@ pub(crate) fn settings_page(
//
// The content column (not the NavigationView — the sidebar must stay put) carries the
// section-switch entrance: fade + slide-up from the root-driven tween.
// No max-width cap here (unlike the other pages): the NavigationView already spends the
// left third on its pane, so a 640-wide column left the cards as a narrow ribbon.
// The category title is rendered HERE, not via NavigationView's Header: that header's
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
// sat noticeably right of the cards under it. In the content column it shares the cards'
// left edge by construction.
let titled: Vec<Element> = std::iter::once(
text_block(title)
.font_size(28.0)
.semibold()
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 0.0, 0.0, 6.0))
.into(),
)
.chain(groups)
.collect();
// The keyed column MUST sit inside a panel's child list, not directly under the
// scroll_view: `ScrollView::children()` is `Children::PositionalSingle`, which
// reconciles its one child POSITIONALLY and ignores keys outright. Keyed straight onto
// the scroll_view's child, the section switch silently diffs one section's controls into
// another's — which re-sets each reused ComboBox's items (clearing WinUI's selection)
// but skips `selected_index` whenever the two sections' values compare equal, so the
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
// remounts the whole column and every prop is applied fresh.
let content = scroll_view(
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
)
.opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
let content = page_wide(vec![card.with_key(section)])
.opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
NavigationView::new(items, content)
.pane_title("Settings")
.header(title)
.selected_tag(section)
.on_selection_changed({
let ss = set_section.clone();
@@ -1,38 +0,0 @@
//! `punktfunk-console.exe` — the couch/HTPC entry point.
//!
//! Exists because an MSIX `<Application>` cannot pass ARGUMENTS to a full-trust executable:
//! a second Start-menu tile therefore cannot simply be "punktfunk-client.exe --console", it
//! needs its own executable. This is that executable, and it is deliberately nothing but a
//! hand-off — it starts the session binary's `--browse` mode (the complete controller-driven
//! client: host list, discovery, PIN pairing, settings, Wake-on-LAN, library) fullscreen and
//! mirrors its exit code, so whatever supervises this process sees the real result.
//!
//! `--windowed` keeps it in a window; everything else is the session binary's own business.
// No console window: this is launched from a Start-menu tile / shortcut, and a flashing
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(windows)]
fn main() {
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
.map(|e| e.with_file_name("punktfunk-session.exe"))
.filter(|p| p.exists())
.unwrap_or_else(|| "punktfunk-session".into());
let mut cmd = std::process::Command::new(session);
cmd.arg("--browse");
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
}
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
#[cfg(not(windows))]
fn main() {}
-21
View File
@@ -76,27 +76,6 @@ fn main() {
return;
}
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
// its exit code, so anything supervising this process sees the real result.
if flag("--console") {
let mut cmd = std::process::Command::new(spawn::session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
+6 -11
View File
@@ -126,26 +126,21 @@ pub(crate) fn spawn_session(
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
/// window presents, `error` on a failed start, EOF on quit.
pub(crate) fn spawn_browse(
target: Option<(&str, u16)>,
addr: &str,
port: u16,
fullscreen: bool,
slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> {
let mut cmd = Command::new(session_binary());
cmd.arg("--browse");
// A target opens straight into that host's library; bare `--browse` opens the console's
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of
// the shell's hosts page.
if let Some((addr, port)) = target {
cmd.arg(format!("{addr}:{port}"));
}
cmd.arg("--json-status");
cmd.arg("--browse")
.arg(format!("{addr}:{port}"))
.arg("--json-status");
if fullscreen {
cmd.arg("--fullscreen");
}
add_window_pos(&mut cmd);
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
spawn_with(cmd, &label, slot, on_event)
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
}
/// Hand the shell window's position to the child (`--window-pos`) so the session window
+1 -6
View File
@@ -244,13 +244,8 @@ pub struct ZeroCopyPolicy {
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
pub backend_is_gpu: bool,
/// THIS session encodes PyroWave: the frames' consumer is the wavelet encoder's own Vulkan
/// device, which imports raw dmabufs on ANY vendor — so the capturer takes the raw-dmabuf
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
pub pyrowave_session: bool,
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
/// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
pub pyrowave_modifiers: Vec<u64>,
}
+16 -23
View File
@@ -255,11 +255,9 @@ fn spawn_pipewire(
want_hdr
};
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
// backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf
// passthrough offer.
let vaapi_dmabuf =
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi;
let join = thread::Builder::new()
.name("punktfunk-pipewire".into())
.spawn(move || {
@@ -1947,18 +1945,16 @@ mod pipewire {
// Build the GPU importer up front — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
// (we simply won't request dmabuf below). Skipped entirely when the frames go to the
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU
// stack must not crash-loop).
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
let backend_is_vaapi = policy.backend_is_vaapi;
let raw_passthrough = backend_is_vaapi || policy.pyrowave_session;
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
let mut importer = if zerocopy && !raw_passthrough && !want_hdr {
let mut importer = if zerocopy && !backend_is_vaapi && !want_hdr {
if pf_zerocopy::gpu_import_disabled() {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
@@ -1984,11 +1980,9 @@ mod pipewire {
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
// race).
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames'
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
// dmabuf straight to the encoder.
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi;
// 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:
@@ -2004,9 +1998,8 @@ mod pipewire {
// advertisement with every modifier its device samples from, so compositors that
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
// 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).
// the host's `pyrowave` feature is on AND the 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) {
@@ -2026,11 +2019,11 @@ mod pipewire {
);
} else if zerocopy && !want_dmabuf {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
} else if vaapi_passthrough {
tracing::info!(
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
);
} else if want_dmabuf && !vaapi_passthrough {
} else if want_dmabuf {
tracing::info!(
count = modifiers.len(),
sample = ?&modifiers[..modifiers.len().min(6)],
-4
View File
@@ -64,10 +64,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
# method itself is feature-gated behind this.
"Win32_Security",
# The OS-clipboard bridge (clipboard.rs): Open/Get/SetClipboardData + the sequence
# number, and the GlobalAlloc block the clipboard takes ownership of.
"Win32_System_DataExchange",
"Win32_System_Memory",
] }
[features]
+26 -114
View File
@@ -20,83 +20,6 @@ const MIC_FRAME: usize = 960;
struct Terminate;
/// A selectable PipeWire endpoint for the settings pickers.
#[derive(Clone, Debug)]
pub struct AudioDevice {
/// `node.name` — the stable key the streams target via `target.object`.
pub name: String,
/// `node.description` — the human label the picker shows.
pub description: String,
}
/// Enumerate audio endpoints: `(sinks, sources)`. One registry roundtrip on a private
/// mainloop (a few ms against a live PipeWire); no daemon errors out and the caller
/// simply shows no pickers.
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
use pipewire as pw;
use std::cell::RefCell;
use std::rc::Rc;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_rc(None)
.context("pw connect (is PipeWire running in this session?)")?;
let registry = core.get_registry_rc().context("pw registry")?;
let found: Rc<RefCell<(Vec<AudioDevice>, Vec<AudioDevice>)>> = Rc::default();
let _reg_listener = registry
.add_listener_local()
.global({
let found = found.clone();
move |g| {
let Some(props) = g.props else { return };
let sink = match props.get("media.class") {
Some("Audio/Sink") => true,
Some("Audio/Source") => false,
_ => return,
};
let Some(name) = props.get("node.name") else {
return;
};
let description = props
.get("node.description")
.or_else(|| props.get("node.nick"))
.unwrap_or(name)
.to_string();
let dev = AudioDevice {
name: name.to_string(),
description,
};
let mut f = found.borrow_mut();
if sink { &mut f.0 } else { &mut f.1 }.push(dev);
}
})
.register();
// The registry replays existing globals asynchronously; one core sync marks the
// point they've all been delivered — quit the loop there.
let pending = core.sync(0).context("pw sync")?;
let _core_listener = core
.add_listener_local()
.done({
let mainloop = mainloop.clone();
move |_, seq| {
if seq == pending {
mainloop.quit();
}
}
})
.register();
mainloop.run();
let result = found.borrow().clone();
Ok(result)
}
pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>,
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
@@ -195,26 +118,20 @@ fn pw_thread(
move |_| mainloop.quit()
});
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game",
*pw::keys::NODE_NAME => "punktfunk-client",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
*pw::keys::NODE_LATENCY => "240/48000",
};
// The Settings speaker pick (session main maps `Settings::speaker_device` here);
// unset/empty = PipeWire's default routing.
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") {
if !target.is_empty() {
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
// libpipewire than we require; the wire name is stable.
props.insert("target.object", target);
}
}
let stream =
pw::stream::StreamBox::new(&core, "punktfunk-client", props).context("pw Stream")?;
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-client",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game",
*pw::keys::NODE_NAME => "punktfunk-client",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
*pw::keys::NODE_LATENCY => "240/48000",
},
)
.context("pw Stream")?;
let ud = PlayerData {
rx: pcm_rx,
@@ -399,23 +316,18 @@ fn mic_thread(
move |_| mainloop.quit()
});
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
};
// The Settings microphone pick (`Settings::mic_device` via session main).
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") {
if !target.is_empty() {
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
// libpipewire than we require; the wire name is stable.
props.insert("target.object", target);
}
}
let stream = pw::stream::StreamBox::new(&core, "punktfunk-mic-capture", props)
.context("pw mic Stream")?;
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-mic-capture",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
},
)
.context("pw mic Stream")?;
let ud = MicData {
connector: connector.clone(),
-421
View File
@@ -1,421 +0,0 @@
//! OS-clipboard bridge for the spawned session client (`design/clipboard-and-file-transfer.md`
//! §5). The protocol half already exists in `punktfunk_core::clipboard` — the per-session task
//! that runs fetch streams — and `NativeClient` exposes it as `clip_control` / `clip_offer` /
//! `clip_fetch` / `clip_serve` / `next_clip`. What was missing on Windows is exactly what §5.2
//! writes in Swift for macOS: the code that talks to the actual pasteboard. This is that half.
//!
//! Shape (one thread, owned by the session pump):
//!
//! * **Local → remote** stays lazy by construction. A poll of `GetClipboardSequenceNumber`
//! spots a local copy, we announce the FORMAT LIST (`clip_offer`) and nothing else; the
//! bytes are read only if the host actually pastes and sends a `FetchRequest`.
//! * **Remote → local** is EAGER in this first cut, and that is a deliberate deviation from
//! §5.2's promise-based apply. macOS gets laziness free from `NSPasteboardItemDataProvider`;
//! the Windows equivalent is delayed rendering (`SetClipboardData(fmt, NULL)` answered on
//! `WM_RENDERFORMAT`), which needs a clipboard-owning window running its own message pump —
//! a bigger piece than this. So we fetch on the offer and place real bytes, under
//! [`EAGER_FETCH_CAP`] so a huge host-side copy can't pull megabytes nobody pastes. Text is
//! tiny and always crosses; a large image simply isn't mirrored until delayed rendering lands.
//! * **Echo suppression** is §3.4's Windows rule verbatim: record the clipboard sequence
//! number right after our own `SetClipboardData` and ignore exactly that change, or every
//! copy ping-pongs between the two machines forever.
//!
//! Secrets are respected: a clipboard carrying `ExcludeClipboardContentFromMonitorProcessing`
//! (what password managers set) is never announced and never served — the Windows counterpart
//! of §5.2's `org.nspasteboard.ConcealedType` skip.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use punktfunk_core::client::NativeClient;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
/// Wire mime for UTF-8 text — the one format every peer must handle (§3.5).
const MIME_TEXT: &str = "text/plain;charset=utf-8";
/// Wire mime for the image floor (§3.5). Read/written through the "PNG" registered clipboard
/// format; apps that only publish `CF_DIB` are a follow-up (the conversion the host already
/// has in `image_to_dib`).
const MIME_PNG: &str = "image/png";
/// Ceiling on an EAGERLY fetched remote payload (see the module docs). Text never approaches
/// it; it exists so a host-side copy of something enormous doesn't cross for a paste that may
/// never happen. Lifted once delayed rendering makes the fetch lazy.
const EAGER_FETCH_CAP: u64 = 4 << 20;
/// How often the local clipboard is polled for changes. §3.2 asks for ≥ 100 ms between offers;
/// 400 ms keeps a copy→focus→paste round trip comfortably ahead of the user.
const POLL: Duration = Duration::from_millis(400);
/// Drain-and-poll cadence: how long `next_clip` blocks before we re-check the clipboard and
/// the stop flag.
const EVENT_WAIT: Duration = Duration::from_millis(120);
/// Run the clipboard bridge until `stop` is set or the session closes. Returns immediately
/// (doing nothing) when the host didn't advertise `HOST_CAP_CLIPBOARD` — an older host, or one
/// whose backend can't do it — so this is safe to spawn unconditionally.
pub fn run(client: Arc<NativeClient>, stop: Arc<AtomicBool>) {
if client.host_caps() & HOST_CAP_CLIPBOARD == 0 {
tracing::info!("host has no clipboard capability — shared clipboard off");
return;
}
// Opt-in per §3.1: nothing is announced or served until this crosses enabled.
if let Err(e) = client.clip_control(true, 0) {
tracing::warn!(error = %e, "clipboard: enable failed");
return;
}
tracing::info!("shared clipboard enabled");
let mut state = State {
// Adopt the CURRENT sequence number without announcing: whatever is on the clipboard
// from before the session started is the user's, not a copy they made for this stream.
last_seq: os::sequence_number(),
..Default::default()
};
let mut next_poll = Instant::now() + POLL;
while !stop.load(Ordering::SeqCst) {
// Inbound first — a pending FetchRequest is the host waiting on us. `NoFrame` is the
// ordinary poll timeout (nothing pending); anything else means the connection is gone
// and the session teardown is already on its way.
match client.next_clip(EVENT_WAIT) {
Ok(ev) => handle_event(&client, &mut state, ev),
Err(punktfunk_core::error::PunktfunkError::NoFrame) => {}
Err(_) => break,
}
// The local clipboard is polled on its OWN cadence, not once per inbound wait: the
// event wait is short (it bounds teardown latency), and hammering the Win32 clipboard
// eight times a second would contend with whatever app the user is actually copying in.
let now = Instant::now();
if now >= next_poll {
poll_local(&client, &mut state);
next_poll = now + POLL;
}
}
// Best-effort: tell the host to stop announcing into a session that's ending.
let _ = client.clip_control(false, 0);
}
#[derive(Default)]
struct State {
/// Clipboard sequence number as of our last look — a change means someone copied.
last_seq: u32,
/// The sequence number our OWN `SetClipboardData` produced (§3.4 echo suppression).
self_written_seq: Option<u32>,
/// Monotonic offer counter (§3.2 — newest wins).
offer_seq: u32,
/// Rate-limit guard for offers (§3.2 asks ≥ 100 ms).
last_offer: Option<Instant>,
/// The host's current offer, so a fetch can name its `seq`.
remote_offer: Option<u32>,
/// In-flight eager fetch → the mime it will deliver, so `Data` knows how to place it.
pending_fetch: Option<(u32, String)>,
/// The last payload we placed locally, kept only to answer a host fetch of our own echo
/// without re-reading the OS clipboard.
last_applied: Option<(String, Vec<u8>)>,
}
/// A local clipboard change → announce the format list (never the bytes).
fn poll_local(client: &NativeClient, state: &mut State) {
let seq = os::sequence_number();
if seq == state.last_seq {
return;
}
state.last_seq = seq;
// Our own apply — swallow it, or the two clipboards chase each other forever.
if state.self_written_seq == Some(seq) {
state.self_written_seq = None;
return;
}
if let Some(t) = state.last_offer {
if t.elapsed() < Duration::from_millis(100) {
return;
}
}
if os::is_concealed() {
tracing::debug!("clipboard: concealed content — not announced");
return;
}
let mut kinds: Vec<ClipKind> = Vec::new();
for (mime, size) in os::available_kinds() {
kinds.push(ClipKind {
mime: mime.to_string(),
size_hint: size,
});
}
if kinds.is_empty() {
return;
}
state.offer_seq = state.offer_seq.wrapping_add(1);
state.last_offer = Some(Instant::now());
let seq_id = state.offer_seq;
tracing::debug!(
seq = seq_id,
kinds = kinds.len(),
"clipboard: offering local copy"
);
if let Err(e) = client.clip_offer(seq_id, kinds) {
tracing::warn!(error = %e, "clipboard: offer failed");
}
}
fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
match ev {
ClipEventCore::State {
enabled,
policy,
reason,
} => {
tracing::info!(enabled, policy, reason, "clipboard: host state");
}
// The host copied. Pull the best format we can place (see the module docs on why this
// is eager for now) — text preferred, then PNG.
ClipEventCore::RemoteOffer { seq, kinds } => {
state.remote_offer = Some(seq);
let pick = kinds
.iter()
.find(|k| k.mime == MIME_TEXT)
.or_else(|| kinds.iter().find(|k| k.mime == MIME_PNG));
let Some(kind) = pick else {
tracing::debug!("clipboard: remote offer has no format we can place");
return;
};
if kind.size_hint > EAGER_FETCH_CAP {
tracing::info!(
mime = %kind.mime,
size = kind.size_hint,
"clipboard: remote payload over the eager-fetch cap — not mirrored"
);
return;
}
match client.clip_fetch(seq, kind.mime.clone(), CLIP_FILE_INDEX_NONE) {
Ok(xfer) => state.pending_fetch = Some((xfer, kind.mime.clone())),
Err(e) => tracing::warn!(error = %e, "clipboard: fetch failed to start"),
}
}
// Bytes for the fetch above — place them, then record the sequence number they cause.
ClipEventCore::Data {
xfer_id,
bytes,
last,
} => {
let Some((pending, mime)) = state.pending_fetch.clone() else {
return;
};
if pending != xfer_id {
return;
}
if last {
state.pending_fetch = None;
}
match os::set(&mime, &bytes) {
Ok(()) => {
// §3.4: this is the change WE caused; ignore exactly it.
state.self_written_seq = Some(os::sequence_number());
state.last_applied = Some((mime.clone(), bytes));
tracing::debug!(mime = %mime, "clipboard: applied remote content");
}
Err(e) => tracing::warn!(error = %e, mime = %mime, "clipboard: apply failed"),
}
}
// The host is pasting what we offered: read the bytes NOW (this is the lazy half) and
// answer. A read failure still answers — with a cancel — so the host isn't left waiting.
ClipEventCore::FetchRequest {
req_id,
seq: _,
file_index: _,
mime,
} => {
if os::is_concealed() {
let _ = client.clip_cancel(req_id);
return;
}
// Serve our own last-applied payload verbatim when it still matches — avoids a
// lossy OS round trip for content that originated on the host anyway.
let bytes = match &state.last_applied {
Some((m, b)) if *m == mime && state.self_written_seq.is_some() => Ok(b.clone()),
_ => os::get(&mime),
};
match bytes {
Ok(b) => {
tracing::debug!(mime = %mime, len = b.len(), "clipboard: serving to host");
if let Err(e) = client.clip_serve(req_id, b, true) {
tracing::warn!(error = %e, "clipboard: serve failed");
}
}
Err(e) => {
tracing::debug!(error = %e, mime = %mime, "clipboard: nothing to serve");
let _ = client.clip_cancel(req_id);
}
}
}
ClipEventCore::Cancelled { id } => tracing::debug!(id, "clipboard: transfer cancelled"),
ClipEventCore::Error { id, code } => {
tracing::debug!(id, code, "clipboard: transfer error");
if state.pending_fetch.as_ref().is_some_and(|(x, _)| *x == id) {
state.pending_fetch = None;
}
}
}
}
#[cfg(windows)]
mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
//! closes it — holding it across a network fetch would block every other app on the box.
use super::{MIME_PNG, MIME_TEXT};
use anyhow::{anyhow, bail, Result};
use windows::core::PCWSTR;
use windows::Win32::Foundation::{HANDLE, HGLOBAL};
use windows::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber,
IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
};
use windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
};
const CF_UNICODETEXT: u32 = 13;
/// A registered clipboard format id, by name (`PNG`, the concealed marker, …).
fn registered(name: &str) -> u32 {
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) }
}
fn png_format() -> u32 {
registered("PNG")
}
/// RAII clipboard open — `CloseClipboard` must run even on the error paths.
struct Clip;
impl Clip {
fn open() -> Result<Clip> {
// A retry loop: another app can hold the clipboard for a moment.
for _ in 0..10 {
if unsafe { OpenClipboard(None) }.is_ok() {
return Ok(Clip);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
bail!("clipboard busy")
}
}
impl Drop for Clip {
fn drop(&mut self) {
let _ = unsafe { CloseClipboard() };
}
}
pub fn sequence_number() -> u32 {
unsafe { GetClipboardSequenceNumber() }
}
/// Password managers mark secrets with this format; §5.2's concealed-type rule.
pub fn is_concealed() -> bool {
let fmt = registered("ExcludeClipboardContentFromMonitorProcessing");
unsafe { IsClipboardFormatAvailable(fmt) }.is_ok()
}
/// The wire kinds the current clipboard can supply, with size hints where they're free.
pub fn available_kinds() -> Vec<(&'static str, u64)> {
let mut out = Vec::new();
if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_ok() {
out.push((MIME_TEXT, 0));
}
if unsafe { IsClipboardFormatAvailable(png_format()) }.is_ok() {
out.push((MIME_PNG, 0));
}
out
}
/// Read one wire format off the clipboard.
pub fn get(mime: &str) -> Result<Vec<u8>> {
let _clip = Clip::open()?;
match mime {
MIME_TEXT => {
let h = unsafe { GetClipboardData(CF_UNICODETEXT) }?;
let g = HGLOBAL(h.0);
let p = unsafe { GlobalLock(g) } as *const u16;
if p.is_null() {
bail!("clipboard text lock failed");
}
// GlobalSize is a byte count of a NUL-terminated UTF-16 buffer.
let bytes = unsafe { GlobalSize(g) };
let mut len = bytes / 2;
let slice = unsafe { std::slice::from_raw_parts(p, len) };
if let Some(nul) = slice.iter().position(|&c| c == 0) {
len = nul;
}
let text = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) });
let _ = unsafe { GlobalUnlock(g) };
Ok(text.into_bytes())
}
MIME_PNG => {
let h = unsafe { GetClipboardData(png_format()) }?;
let g = HGLOBAL(h.0);
let p = unsafe { GlobalLock(g) } as *const u8;
if p.is_null() {
bail!("clipboard png lock failed");
}
let len = unsafe { GlobalSize(g) };
let out = unsafe { std::slice::from_raw_parts(p, len) }.to_vec();
let _ = unsafe { GlobalUnlock(g) };
Ok(out)
}
other => Err(anyhow!("unsupported clipboard format {other}")),
}
}
/// Place one wire format on the clipboard, replacing its contents.
pub fn set(mime: &str, bytes: &[u8]) -> Result<()> {
let (fmt, payload) = match mime {
MIME_TEXT => {
let text = String::from_utf8_lossy(bytes);
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
let raw: Vec<u8> = wide.iter().flat_map(|c| c.to_le_bytes()).collect();
(CF_UNICODETEXT, raw)
}
MIME_PNG => (png_format(), bytes.to_vec()),
other => bail!("unsupported clipboard format {other}"),
};
let _clip = Clip::open()?;
unsafe { EmptyClipboard() }?;
// The clipboard OWNS this block once SetClipboardData succeeds — do not free it.
let g = unsafe { GlobalAlloc(GMEM_MOVEABLE, payload.len()) }?;
let p = unsafe { GlobalLock(g) } as *mut u8;
if p.is_null() {
bail!("clipboard alloc lock failed");
}
unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), p, payload.len()) };
let _ = unsafe { GlobalUnlock(g) };
unsafe { SetClipboardData(fmt, Some(HANDLE(g.0))) }?;
Ok(())
}
}
#[cfg(not(windows))]
mod os {
//! Non-Windows stub. Linux needs the Wayland `data-control` seam (the same protocol the
//! host side already speaks) — the bridge above is platform-neutral and will drive it
//! unchanged once this module grows a real implementation.
use anyhow::{bail, Result};
pub fn sequence_number() -> u32 {
0
}
pub fn is_concealed() -> bool {
false
}
pub fn available_kinds() -> Vec<(&'static str, u64)> {
Vec::new()
}
pub fn get(_mime: &str) -> Result<Vec<u8>> {
bail!("clipboard unsupported on this platform")
}
pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> {
bail!("clipboard unsupported on this platform")
}
}
-5
View File
@@ -41,11 +41,6 @@ mod video_software;
mod video_vaapi;
#[cfg(any(target_os = "linux", windows))]
mod video_vulkan;
// The OS-clipboard bridge for the shared clipboard (design/clipboard-and-file-transfer.md §5).
// Built everywhere the session client is; the platform seam inside is Windows-real,
// stub elsewhere.
#[cfg(any(target_os = "linux", windows))]
pub mod clipboard;
// PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase).
// Windows joined once its client moved to the SAME spawned Vulkan session presenter as
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
-20
View File
@@ -41,9 +41,6 @@ pub struct SessionParams {
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
/// `video::Decoder::new`).
pub decoder: String,
@@ -342,20 +339,6 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone());
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
let clipboard_thread = params
.clipboard
.then(|| {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
let _mic = params
.mic_enabled
.then(|| {
@@ -816,9 +799,6 @@ fn pump(
if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
}
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
}
-32
View File
@@ -124,12 +124,6 @@ pub struct KnownHost {
/// pre-existing stores load; empty until first learned.
#[serde(default)]
pub mac: Vec<String>,
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
/// host your clipboard is a trust decision about that host. Default off; the host must
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
#[serde(default)]
pub clipboard_sync: bool,
}
#[derive(Default, Serialize, Deserialize)]
@@ -207,7 +201,6 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
paired,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
let _ = known.save();
}
@@ -533,27 +526,6 @@ pub struct Settings {
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
/// an offline-looking host is really just unreachable by broadcast and the wake +
/// wait only adds a delay.
#[serde(default = "default_true")]
pub auto_wake: bool,
/// Reverse the wheel/trackpad scroll direction sent to the host (the Apple client's
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
#[serde(default)]
pub invert_scroll: bool,
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
/// playback stream targets (`target.object`); empty = the session default (the
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
/// Ignored on Windows until the WASAPI endpoint leg exists.
#[serde(default)]
pub speaker_device: String,
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -642,10 +614,6 @@ impl Default for Settings {
stats_verbosity: None,
fullscreen_on_stream: true,
library_enabled: false,
auto_wake: true,
invert_scroll: false,
speaker_device: String::new(),
mic_device: String::new(),
match_window: false,
last_window_w: 0,
last_window_h: 0,
+1 -12
View File
@@ -1078,18 +1078,7 @@ impl Encoder for NvencCudaEncoder {
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
self.chroma_444 = self.chroma_444 && buf.yuv444;
// `init_session` publishes `self.encoder` before its remaining fallible steps (bitstream
// buffers, input-surface alloc, `register_resource`), so a failure there leaves a live
// session with `inited == false`. Every guard on the re-init path keys off `inited`, so
// without this the next submit would skip teardown and overwrite `self.encoder`, leaking
// the session and its registered input surfaces permanently. `teardown` keys off
// `encoder.is_null()`, not `inited`, so it cleans up exactly this half-built state.
if let Err(e) = self.init_session() {
// SAFETY: the encode thread owns the session and a failed init leaves nothing
// mid-encode to race with.
unsafe { self.teardown() };
return Err(e);
}
self.init_session()?;
} else {
// Steady state: the copy helpers need the shared context current on this thread.
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
+1 -22
View File
@@ -865,13 +865,6 @@ impl PyroWaveEncoder {
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null. Callers today turn that into
// a session error and never resubmit, but a null here would be a use-after-free inside
// pyrowave rather than a clean error — so fail loudly instead of relying on that.
anyhow::ensure!(
!self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
);
let dev = self.device.clone();
let (w, h) = (self.width, self.height);
dev.begin_command_buffer(
@@ -1202,12 +1195,6 @@ impl Encoder for PyroWaveEncoder {
unsafe {
self.device.device_wait_idle().ok();
pw::pyrowave_encoder_destroy(self.pw_enc);
// Publish the null IMMEDIATELY: the create below is fallible, and its failure path
// must not leave a freed pointer in the field. `pyrowave_encoder_destroy` is a plain
// `delete` (pyrowave_c.cpp) with no null check, so `Drop` running on a stale handle
// is a double free — the exact shape this reset hits when the rebuild fails because
// the device is already lost, which is the state that made the watchdog fire.
self.pw_enc = std::ptr::null_mut();
let einfo = pw::pyrowave_encoder_create_info {
device: self.pw_dev,
width: self.width as i32,
@@ -1222,10 +1209,6 @@ impl Encoder for PyroWaveEncoder {
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it. The queued
// AUs are forfeit either way (the caller turns a false reset into a session
// error), so drop them rather than shipping output from a dead encoder.
self.pending.clear();
return false;
}
self.pw_enc = enc;
@@ -1271,11 +1254,7 @@ impl Drop for PyroWaveEncoder {
// before the VkDevice they borrow (encoder before device, per pyrowave.h).
unsafe {
self.device.device_wait_idle().ok();
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
// is not null-safe.
if !self.pw_enc.is_null() {
pw::pyrowave_encoder_destroy(self.pw_enc);
}
pw::pyrowave_encoder_destroy(self.pw_enc);
pw::pyrowave_device_destroy(self.pw_dev);
for (_, _, i, m, v) in self.import_cache.drain(..) {
self.device.destroy_image_view(v, None);
+2 -21
View File
@@ -2077,15 +2077,9 @@ impl Encoder for AmfEncoder {
}
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
// Guard against a slot the taint sweep emptied since the force was queued: the
// HARDWARE slot still holds the tainted mark, so forcing it would re-reference the
// very corruption being recovered from — and the frame must not ship tagged
// `recovery_anchor` either (the client lifts its post-loss freeze on that tag).
if let Some(slot) = self.pending_force.take() {
if self.ltr_slots[slot].is_some() {
force_slot = Some(slot);
recovery_anchor = true;
}
force_slot = Some(slot);
recovery_anchor = true;
}
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
@@ -2369,16 +2363,6 @@ impl Encoder for AmfEncoder {
if !self.ltr_active || first < 0 || first > last {
return false;
}
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
// encoded inside the client's corrupt window — the client either never received it or
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
for marked in self.ltr_slots.iter_mut() {
if marked.is_some_and(|idx| idx >= first) {
*marked = None;
}
}
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
@@ -2407,9 +2391,6 @@ impl Encoder for AmfEncoder {
true
}
None => {
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
// at — clear it so the next submit can't force-reference a tainted hardware slot.
self.pending_force = None;
tracing::info!(
first,
last,
+1 -13
View File
@@ -1135,19 +1135,7 @@ impl Encoder for NvencD3d11Encoder {
self.chroma_444 = false;
}
let device = frame.device.clone();
// `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
// last fallible steps, so a failure there leaves a live session with `inited == false`.
// Every guard on the re-init path keys off `inited`, so without this the next submit
// would skip teardown and overwrite `self.encoder` — leaking the session permanently
// (toward the driver's per-process cap) along with its session-budget units.
// `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
// half-built state and is a no-op when nothing was opened.
if let Err(e) = self.init_session(&device) {
// SAFETY: same contract as the teardown above — the encode thread owns the session,
// and a failed init leaves nothing mid-encode to race with.
unsafe { self.teardown() };
return Err(e);
}
self.init_session(&device)?;
self.init_device = dev_raw;
}
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
+1 -17
View File
@@ -417,12 +417,6 @@ impl PyroWaveEncoder {
/// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
anyhow::ensure!(
!self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
);
let FramePayload::D3d11(d3d) = &frame.payload else {
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
};
@@ -645,10 +639,6 @@ impl Encoder for PyroWaveEncoder {
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
unsafe {
pw::pyrowave_encoder_destroy(self.pw_enc);
// Publish the null IMMEDIATELY — see the Linux twin. The create below is fallible and
// `pyrowave_encoder_destroy` is a plain `delete` with no null check, so leaving the
// freed pointer in the field makes `Drop` a double free.
self.pw_enc = std::ptr::null_mut();
let einfo = pw::pyrowave_encoder_create_info {
device: self.pw_dev,
width: self.width as i32,
@@ -665,8 +655,6 @@ impl Encoder for PyroWaveEncoder {
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it.
self.pending.clear();
return false;
}
self.pw_enc = enc;
@@ -710,11 +698,7 @@ impl Drop for PyroWaveEncoder {
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
// before the device they borrow (per pyrowave.h).
unsafe {
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
// is not null-safe (same guard `sync` below has had all along).
if !self.pw_enc.is_null() {
pw::pyrowave_encoder_destroy(self.pw_enc);
}
pw::pyrowave_encoder_destroy(self.pw_enc);
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
pw::pyrowave_image_destroy(img);
}
+30 -132
View File
@@ -146,12 +146,7 @@ const NUM_LTR_SLOTS: usize = 2;
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
fn ltr_disabled() -> bool {
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
// silently left LTR enabled on Intel while the identical value worked on AMD.
std::env::var("PUNKTFUNK_NO_QSV_LTR")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
@@ -176,22 +171,13 @@ fn ltr_test_force_at() -> Option<i64> {
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
fn intra_refresh_requested() -> bool {
// Spelling parity with AMF (see `ltr_disabled` above).
std::env::var("PUNKTFUNK_INTRA_REFRESH")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
/// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF.
fn intra_refresh_period(fps: u32) -> u16 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or(fps / 2)
.clamp(8, 240) as u16
(fps / 2).clamp(8, 240) as u16
}
// ---------------------------------------------------------------------------------------------
@@ -1031,7 +1017,7 @@ impl Encoder for QsvEncoder {
self.frame_idx += 1;
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
let mut mark_slot: Option<usize> = None;
let mut force_ltr: Option<(usize, i64)> = None;
let mut force_slot: Option<usize> = None;
let mut recovery_anchor = false;
if self.ltr_active {
if forced {
@@ -1049,16 +1035,10 @@ impl Encoder for QsvEncoder {
);
}
if let Some(slot) = self.pending_force.take() {
// Resolve the anchor NOW: a taint sweep in `invalidate_ref_frames` may have
// emptied the slot since the force was queued. An empty slot means there is
// nothing clean to re-reference — the frame must ship as a plain P WITHOUT the
// `recovery_anchor` tag (the client lifts its post-loss freeze on that tag).
if let Some(idx) = self.ltr_slots[slot] {
force_ltr = Some((slot, idx));
recovery_anchor = true;
}
force_slot = Some(slot);
recovery_anchor = true;
}
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
let slot = self.next_ltr_slot;
self.ltr_slots[slot] = Some(cur_idx);
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
@@ -1066,7 +1046,6 @@ impl Encoder for QsvEncoder {
}
}
let ltr_slots = self.ltr_slots;
let reject_ok = self.codec != Codec::Av1;
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
// `poll`) instead of letting the queue grow under overload.
@@ -1151,7 +1130,7 @@ impl Encoder for QsvEncoder {
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
// Per-frame control: forced IDR and/or the LTR reflist.
let mut ctrl: Option<Box<FrameCtrl>> = None;
if forced || mark_slot.is_some() || force_ltr.is_some() {
if forced || mark_slot.is_some() || force_slot.is_some() {
let mut c = FrameCtrl::new();
if forced {
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
@@ -1169,55 +1148,24 @@ impl Encoder for QsvEncoder {
c.reflist.ApplyLongTermIdx = 1;
use_reflist = true;
}
if let Some((slot, ltr_frame)) = force_ltr {
// Force THIS frame to predict only from the known-good LTR — the
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
// FrameOrder).
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
c.reflist.PreferredRefList[0].PicStruct =
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
// A preference alone is a reorder HINT (VPL spec) — the encoder may
// still predict from the tainted short-term refs alongside it. AMF's
// ForceLTRReferenceBitfield and NVENC's invalidation are hard
// exclusions; emulate that here by rejecting every other DPB
// candidate — the short-term sliding window (the 2 most recent
// frames) and the other LTR slot — and capping L0 at one active
// entry. Without this the "clean recovery" frame can carry the
// corruption forward, which the client cannot detect (the field
// failure: permanent macroblock soup under sustained loss).
// AVC/HEVC only: the AV1 runtime's universal-reflist rejection path
// is unvalidated, and an unhonored hint there still converges via
// the host's IDR escalation.
if reject_ok {
let mut rej = 0;
let mut reject = |idx: i64| {
if idx >= 0 && idx != ltr_frame {
c.reflist.RejectedRefList[rej].FrameOrder = idx as u32;
c.reflist.RejectedRefList[rej].PicStruct =
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
rej += 1;
}
};
reject(cur_idx - 1);
reject(cur_idx - 2);
for (s, marked) in ltr_slots.iter().enumerate() {
if s != slot {
if let Some(idx) = *marked {
reject(idx);
}
}
}
c.reflist.NumRefIdxL0Active = 1;
if let Some(slot) = force_slot {
if let Some(ltr_frame) = ltr_slots[slot] {
// Force THIS frame to predict only from the known-good LTR — the
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
// FrameOrder).
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
c.reflist.PreferredRefList[0].PicStruct =
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
use_reflist = true;
tracing::info!(
slot,
ltr_frame,
frame = cur_idx,
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
no IDR)"
);
}
use_reflist = true;
tracing::info!(
slot,
ltr_frame,
frame = cur_idx,
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
no IDR)"
);
}
if use_reflist {
c.attach_reflist();
@@ -1317,17 +1265,6 @@ impl Encoder for QsvEncoder {
if !self.ltr_active || first < 0 || first > last {
return false;
}
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
// encoded inside the client's corrupt window — the client either never received it or
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
// the soup — the sustained-loss field failure where the picture never healed. Dropped
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
for marked in self.ltr_slots.iter_mut() {
if marked.is_some_and(|idx| idx >= first) {
*marked = None;
}
}
let mut best: Option<(usize, i64)> = None;
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if let Some(idx) = *marked {
@@ -1350,9 +1287,6 @@ impl Encoder for QsvEncoder {
true
}
None => {
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
// at — clear it so the next submit can't half-apply a stale recovery.
self.pending_force = None;
tracing::info!(
first,
last,
@@ -1432,21 +1366,15 @@ impl Encoder for QsvEncoder {
let inner = self.inner.as_mut().expect("checked above");
// Best-effort settle of in-flight operations (Close aborts them anyway).
while sync_one(inner, 5).ok().flatten().is_some() {}
// Close BEFORE dropping `pending`. Each `Pending` owns the `Box<BsBuf>` the runtime
// is writing into asynchronously (and a `Box<FrameCtrl>` it reads), so clearing first
// frees that heap while the operation is still live — a use-after-free by the VPL
// runtime. The drain above is best-effort and bails on the first `Err`, which is
// exactly the wedged-encoder case that triggers this reset, so it cannot be relied on
// to have retired everything. Close aborts the operations; only then is the drop safe.
inner.pending.clear();
inner.ready.clear();
inner.frames_submitted = 0;
inner.first_au_logged = false;
// SAFETY: the session is live on this thread; Close on a wedged encoder is legal
// (result deliberately ignored) and re-Init happens through `init_encode`.
unsafe {
let _ = vpl::MFXVideoENCODE_Close(inner.session.0);
}
inner.pending.clear();
inner.ready.clear();
inner.frames_submitted = 0;
inner.first_au_logged = false;
inner.session.0
};
match self.init_encode(rebuilt) {
@@ -1917,36 +1845,6 @@ mod tests {
);
}
/// Taint sweep: a loss that predates every live LTR mark leaves NO clean anchor — every
/// slot was marked inside the client's corrupt window. The invalidate must decline (the
/// caller then serves the IDR) and no recovery_anchor AU may ship; before the sweep this
/// force-referenced a tainted mark and shipped corruption tagged as a clean recovery.
#[test]
fn qsv_live_ltr_rfi_taint_sweep_declines() {
let mut rfi_answered = None;
let Some(aus) = drive_live(Codec::H264, false, 60, |enc, i| {
if i == 30 && enc.caps().supports_rfi {
// Frame 0 lost: the IDR itself — every mark (0, 15, ...) is at-or-after it.
rfi_answered = Some(enc.invalidate_ref_frames(0, 2));
}
}) else {
return;
};
assert_stream_shape(&aus, 60, true);
let Some(answered) = rfi_answered else {
eprintln!("note: driver declined LTR (supports_rfi=false) — sweep not exercised");
return;
};
assert!(
!answered,
"a loss covering every live LTR mark must fall back to IDR recovery"
);
assert!(
!aus.iter().any(|a| a.recovery_anchor),
"no recovery_anchor AU may ship when the sweep left no clean LTR"
);
}
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
#[test]
+5 -28
View File
@@ -223,13 +223,6 @@ impl Encoder for TrackedEncoder {
}
}
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
/// arm was missing the clamp the Windows arm applied.
#[cfg(any(target_os = "linux", target_os = "windows"))]
const SW_BITRATE_CEIL: u64 = 100_000_000;
/// Open the platform encoder backend. Returns the encoder together with the display label of the
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
@@ -414,14 +407,8 @@ fn open_video_backend(
);
}
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
sw::OpenH264Encoder::open(
format,
width,
height,
fps,
bitrate_bps.min(SW_BITRATE_CEIL),
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
}
"auto" | "" => {
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
@@ -623,6 +610,8 @@ fn open_video_backend(
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
);
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
// Software H.264 realistically caps far below the negotiated hardware rates.
const SW_BITRATE_CEIL: u64 = 100_000_000;
sw::OpenH264Encoder::open(
format,
width,
@@ -795,12 +784,6 @@ fn nvidia_present() -> bool {
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
/// today's NVIDIA-presence probe, unchanged.
///
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
/// forced one.)
#[cfg(target_os = "linux")]
fn linux_auto_is_vaapi() -> bool {
if let Some(g) = pf_gpu::manual_selection() {
@@ -1014,13 +997,7 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
// honor), since this probe can't know what the compositor will negotiate.
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
// never runs. That is exactly the dishonesty this probe exists to prevent.
if linux_zero_copy_is_vaapi() {
if linux_auto_is_vaapi() {
vaapi::probe_can_encode_10bit(codec)
} else {
linux::probe_can_encode_10bit(codec)
+1 -5
View File
@@ -11,11 +11,7 @@ repository.workspace = true
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
# Linux-only module — see lib.rs).
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own
# `pyrowave` feature (which re-exports it below), never by inheriting the dependency's default.
# Otherwise a consumer that deliberately builds us without `pyrowave` still drags the vendored
# C++ in — fatal on Windows ARM64, where Granite has no SIMD path.
pf-client-core = { path = "../pf-client-core", default-features = false }
pf-client-core = { path = "../pf-client-core" }
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
pf-ffvk = { path = "../pf-ffvk" }
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
+3 -11
View File
@@ -53,8 +53,6 @@ pub struct Capture {
/// The touchscreen input model for this session, and — for trackpad/pointer — the
/// gesture state machine finger events feed.
touch_mode: TouchMode,
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
invert_scroll: bool,
gestures: Gestures,
}
@@ -70,11 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
}
impl Capture {
pub fn new(
connector: Arc<NativeClient>,
touch_mode: TouchMode,
invert_scroll: bool,
) -> Capture {
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
Capture {
connector,
captured: false,
@@ -85,7 +79,6 @@ impl Capture {
scroll_acc: (0.0, 0.0),
touch_slots: HashMap::new(),
touch_mode,
invert_scroll,
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
}
}
@@ -201,10 +194,9 @@ impl Capture {
return;
}
self.flush_motion(); // scroll happens at the latest cursor position
let sign = if self.invert_scroll { -1.0 } else { 1.0 };
let (mut ax, mut ay) = self.scroll_acc;
ay += f64::from(dy) * 120.0 * sign;
ax += f64::from(dx) * 120.0 * sign;
ay += f64::from(dy) * 120.0;
ax += f64::from(dx) * 120.0;
let vy = ay.trunc() as i32;
if vy != 0 {
ay -= f64::from(vy);
+1 -3
View File
@@ -48,8 +48,6 @@ pub struct SessionOpts {
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
/// session — a mouse-only client leaves this at the default and never sees a finger.
pub touch_mode: TouchMode,
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
pub invert_scroll: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -813,7 +811,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.ok();
gamepad.attach(c.clone());
st.clock_offset = Some(c.clock_offset_shared());
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
let mut cap = Capture::new(c.clone(), opts.touch_mode);
cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true);
st.capture = Some(cap);
-2
View File
@@ -33,8 +33,6 @@ mod reconfig;
mod resources;
mod setup;
pub use setup::list_adapters;
/// One presenter iteration's video input.
pub enum FrameInput<'a> {
/// No new frame — re-composite the retained video image (expose/resize).
-46
View File
@@ -497,52 +497,6 @@ impl Presenter {
}
}
/// The physical devices' marketing names — the shells' GPU-picker source
/// (`punktfunk-session --list-adapters`). No surface and no logical device; discrete
/// GPUs first (mirroring `pick_device`'s tie-break), duplicates collapsed (the name is
/// the whole `PUNKTFUNK_VK_ADAPTER` match key, so a second identical card adds nothing).
/// Same 1.3 instance the presenter creates, so the list matches what streaming sees.
pub fn list_adapters() -> Result<Vec<String>> {
let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?;
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
let instance = unsafe {
entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app_info),
None,
)
}
.context("vkCreateInstance")?;
let mut ranked: Vec<(u8, String)> = unsafe { instance.enumerate_physical_devices() }?
.into_iter()
.map(|d| {
let props = unsafe { instance.get_physical_device_properties(d) };
let rank = match props.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => 0u8,
vk::PhysicalDeviceType::INTEGRATED_GPU => 1,
_ => 2,
};
let name = props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default();
(rank, name)
})
.filter(|(_, n)| !n.is_empty())
.collect();
unsafe { instance.destroy_instance(None) };
ranked.sort_by_key(|(r, _)| *r); // stable: enumeration order within each tier
let mut names: Vec<String> = Vec::new();
for (_, n) in ranked {
if !names.contains(&n) {
names.push(n);
}
}
Ok(names)
}
/// First physical device with a queue family that does graphics + present here;
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
fn pick_device(
@@ -48,8 +48,7 @@ impl WasapiLoopbackCapturer {
let (tx, rx) = sync_channel::<Vec<f32>>(64);
let stop = Arc::new(AtomicBool::new(false));
// Bring-up handshake: report open success/failure before returning, so a missing render
// endpoint surfaces as Err (the native plane then keeps retrying the open with backoff)
// rather than a silent dead thread.
// endpoint surfaces as Err (caller continues without audio) rather than a silent dead thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let stop_t = stop.clone();
let join = thread::Builder::new()
@@ -135,14 +134,6 @@ enum Next {
const REOPEN_BACKOFF: Duration = Duration::from_secs(2);
/// Watchdog cadence for "did the default render device change under us?" checks.
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
/// Total attempts for the FIRST open before its failure surfaces through the `ready` handshake.
/// Session start is peak endpoint churn — the virtual-display attach and this module's own
/// IPolicyConfig default flips race the activate, which then fails transiently (0x80070002,
/// endpoint mid-re-registration) — so a couple of quick retries absorb it within the
/// handshake budget.
const FIRST_OPEN_ATTEMPTS: u32 = 3;
/// Pause between first-open attempts (endpoint churn settles in well under a second).
const FIRST_OPEN_RETRY_PAUSE: Duration = Duration::from_secs(1);
fn capture_thread(
tx: SyncSender<Vec<f32>>,
@@ -160,14 +151,11 @@ fn capture_thread(
}
// Self-heal for the capturer's whole life: each `capture_once` is one endpoint open + inner
// capture loop; it returns to reopen (default-device change) or errors (device invalidated,
// engine restart). The FIRST open gets [`FIRST_OPEN_ATTEMPTS`] tries (session-start endpoint
// churn — see the constant) before its failure surfaces as `open()`'s Err; the caller keeps
// retrying the whole open with its own backoff after that, so a bad start delays audio
// rather than ending it.
// engine restart), and only the FIRST open's failure is fatal — it surfaces as `open()`'s Err
// so the session honestly runs without audio (the native plane retries with its own backoff).
let mut ready = Some(ready);
let mut mode = TargetMode::Assert;
let mut failures: u64 = 0;
let mut first_attempts: u32 = 0;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
Ok(Next::Stopped) => break,
@@ -175,21 +163,11 @@ fn capture_thread(
mode = m;
failures = 0;
}
Err(e) if ready.is_some() => {
first_attempts += 1;
if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) {
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
Err(e) => {
if let Some(r) = ready.take() {
let _ = r.send(Err(anyhow!("{e:#}")));
break;
}
tracing::info!(error = %format!("{e:#}"), attempt = first_attempts,
"audio loopback first open failed — retrying");
// Stop-responsive pause (same discipline as the reopen backoff below).
let until = Instant::now() + FIRST_OPEN_RETRY_PAUSE;
while Instant::now() < until && !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(100));
}
}
Err(e) => {
failures += 1;
if failures.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), count = failures,
@@ -219,8 +197,7 @@ fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
}
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
/// retried [`FIRST_OPEN_ATTEMPTS`] times, then fatal via the `ready` handshake; later: reopen
/// with backoff).
/// fatal via the `ready` handshake; later: reopen with backoff).
fn capture_once(
tx: &SyncSender<Vec<f32>>,
stop: &AtomicBool,
+13 -26
View File
@@ -26,35 +26,24 @@ pub use pf_capture::{dxgi, synthetic_nv12};
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
/// (plan §2.4 / §W6).
#[cfg(target_os = "linux")]
fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
fn zero_copy_policy() -> pf_capture::ZeroCopyPolicy {
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
// global `PUNKTFUNK_ENCODER=pyrowave` lab lever (which also flips `backend_is_vaapi`).
#[cfg(feature = "pyrowave")]
let pyrowave_session =
pyrowave_session || pf_host_config::config().encoder_pref.as_str() == "pyrowave";
#[cfg(not(feature = "pyrowave"))]
let pyrowave_session = {
let _ = pyrowave_session;
false
};
#[cfg(feature = "pyrowave")]
let pyrowave_modifiers = if pyrowave_session {
// BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys
// on it). `drm_fourcc(Bgrx)` is always `Some`.
pf_frame::drm_fourcc(PixelFormat::Bgrx)
.map(crate::encode::pyrowave_capture_modifiers)
.unwrap_or_default()
} else {
Vec::new()
};
let pyrowave_modifiers =
if backend_is_vaapi && pf_host_config::config().encoder_pref.as_str() == "pyrowave" {
// BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys
// on it). `drm_fourcc(Bgrx)` is always `Some`.
pf_frame::drm_fourcc(PixelFormat::Bgrx)
.map(crate::encode::pyrowave_capture_modifiers)
.unwrap_or_default()
} else {
Vec::new()
};
#[cfg(not(feature = "pyrowave"))]
let pyrowave_modifiers = Vec::new();
pf_capture::ZeroCopyPolicy {
backend_is_vaapi,
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
pyrowave_session,
pyrowave_modifiers,
}
}
@@ -68,9 +57,7 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
// so use a plain ScreenCast session there.
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false))
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy())
}
#[cfg(not(target_os = "linux"))]
@@ -101,7 +88,7 @@ pub fn capture_virtual_output(
vout.keepalive,
want.gpu,
want.chroma_444,
zero_copy_policy(want.pyrowave),
zero_copy_policy(),
)
}
+14 -17
View File
@@ -61,28 +61,25 @@ pub fn start(
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
crate::native::boost_thread_priority(true);
tracing::info!(?cfg, "video stream starting");
// Lifecycle events + the script-facing marker file, plane parity with the native loop
// (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for
// the span between. It runs BEFORE `run` because `run` is what launches the app — the
// marker has to exist by the time the title's own wrapper script executes, or the
// wrapper takes its "not streaming" branch mid-stream. The RTSP layer carries no client
// device name, so `client` is empty here — the `plane` field is what hooks key on.
// `client.connected` fires alongside `stream.started` because a Moonlight client has no
// persistent connection to anchor it to.
let stream_marker = crate::stream_marker::announce(crate::stream_marker::StreamInfo {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
// Lifecycle events, plane parity with the native loop (RFC §4): the RTSP layer
// carries no client device name, so `client` is empty here — the `plane` field is
// what hooks key on. `client.connected` fires alongside `stream.started` because a
// Moonlight client has no persistent connection to anchor it to.
let event_stream = crate::events::StreamRef {
mode: crate::events::mode_str(cfg.width, cfg.height, cfg.fps),
hdr: cfg.hdr,
client: String::new(),
launch: app.as_ref().map(|a| a.title.clone()),
app: app.as_ref().map(|a| a.title.clone()),
plane: crate::events::Plane::Gamestream,
});
};
let event_client = crate::events::ClientRef {
name: String::new(),
fingerprint: None,
plane: crate::events::Plane::Gamestream,
};
crate::events::emit(crate::events::EventKind::StreamStarted {
stream: event_stream.clone(),
});
crate::events::emit(crate::events::EventKind::ClientConnected {
client: event_client.clone(),
});
@@ -106,9 +103,9 @@ pub fn start(
tracing::error!(error = %format!("{e:#}"), "video stream failed");
}
running.store(false, Ordering::SeqCst);
// Retract the marker and fire `stream.stopped` — explicitly here, before
// `client.disconnected`, so the compat plane keeps the native loop's event order.
drop(stream_marker);
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: event_stream,
});
crate::events::emit(crate::events::EventKind::ClientDisconnected {
client: event_client,
reason,
+7 -26
View File
@@ -31,7 +31,6 @@ mod heroic;
mod launch;
#[cfg(target_os = "linux")]
mod lutris;
mod scanners;
mod steam;
#[cfg(windows)]
mod xbox;
@@ -47,7 +46,6 @@ pub use heroic::*;
pub use launch::*;
#[cfg(target_os = "linux")]
pub use lutris::*;
pub use scanners::*;
pub use steam::*;
#[cfg(windows)]
pub use xbox::*;
@@ -163,39 +161,22 @@ impl ArtKind {
}
}
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
/// store is not a scanner and always contributes.
/// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let on = |id: &str| !off.contains(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
}
let mut games = SteamProvider.list();
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
#[cfg(target_os = "linux")]
{
if on("lutris") {
games.extend(LutrisProvider.list());
}
if on("heroic") {
games.extend(HeroicProvider.list());
}
games.extend(LutrisProvider.list());
games.extend(HeroicProvider.list());
}
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
#[cfg(windows)]
{
if on("epic") {
games.extend(EpicProvider.list());
}
if on("gog") {
games.extend(GogProvider.list());
}
if on("xbox") {
games.extend(XboxProvider.list());
}
games.extend(EpicProvider.list());
games.extend(GogProvider.list());
games.extend(XboxProvider.list());
}
games.extend(load_custom().into_iter().map(GameEntry::from));
games.sort_by_key(|g| g.title.to_lowercase());
@@ -1,153 +0,0 @@
//! Library-scanner settings: which installed-store scanners run on this host. Every scanner is
//! **on by default** (the shipped behavior before this existed); the operator can turn one off in
//! the web console, which hides its titles from every library surface (console grid, native
//! clients, the GameStream app list, launch resolution) from the next read. Only the *disabled*
//! set is persisted, so a scanner added in a future build starts enabled without a migration.
//!
//! The user-curated **custom** store is not a scanner (nothing is scanned — the operator typed the
//! entries in) and cannot be disabled here; provider plugins (RFC §8) likewise own their entries
//! through the reconcile API. Down the road the scanners themselves are slated to become plugins —
//! the stable per-scanner ids this module fixes (`steam`, `lutris`, …, matching each entry's
//! `store` field) are the forward seam for that migration.
use super::*;
/// One installed-store scanner this host build supports, with its enable state — the unit the
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
/// so the console never shows a toggle that cannot do anything on this host.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ScannerInfo {
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
#[schema(example = "steam")]
pub id: String,
/// Human-facing name for the console toggle.
#[schema(example = "Steam")]
pub label: String,
/// Whether this host runs the scanner (default true).
pub enabled: bool,
}
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
/// platform-gated exactly like their provider modules in `library.rs` — keep the two in sync when
/// adding a store.
fn scanner_defs() -> Vec<(&'static str, &'static str)> {
let mut defs = vec![("steam", "Steam")];
#[cfg(target_os = "linux")]
{
defs.push(("lutris", "Lutris"));
defs.push(("heroic", "Heroic (Epic / GOG / Amazon)"));
}
#[cfg(windows)]
{
defs.push(("epic", "Epic Games Launcher"));
defs.push(("gog", "GOG Galaxy"));
defs.push(("xbox", "Xbox / Game Pass"));
}
defs
}
/// Persisted shape (`library-scanners.json`): only the ids the operator turned OFF. Absent file =
/// nothing disabled = the pre-existing all-scanners-on behavior.
#[derive(Debug, Default, Serialize, Deserialize)]
struct ScannerSettings {
#[serde(default)]
disabled: Vec<String>,
}
fn settings_path() -> PathBuf {
// Same hardened config dir as library.json / hooks.json (see `custom_path` for the rationale).
pf_paths::config_dir().join("library-scanners.json")
}
/// Load the settings (default + non-fatal if the file is absent or malformed).
fn load_settings() -> ScannerSettings {
match std::fs::read_to_string(settings_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library-scanners.json malformed — all scanners on");
ScannerSettings::default()
}),
Err(_) => ScannerSettings::default(),
}
}
fn save_settings(settings: &ScannerSettings) -> Result<()> {
let dir = pf_paths::config_dir();
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(settings)?;
// Write-then-rename like the catalog, so a crash mid-write never truncates the settings.
let tmp = settings_path().with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, json.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, settings_path()).context("rename library-scanners.json")?;
Ok(())
}
/// The disabled-scanner ids, loaded once per library read ([`all_games`] consults it per store).
pub(crate) fn disabled_scanners() -> HashSet<String> {
load_settings().disabled.into_iter().collect()
}
/// The scanners available on this platform with their current enable state, in the fixed
/// definition order (stable for the console).
pub fn list_scanners() -> Vec<ScannerInfo> {
let off = disabled_scanners();
scanner_defs()
.into_iter()
.map(|(id, label)| ScannerInfo {
id: id.to_string(),
label: label.to_string(),
enabled: !off.contains(id),
})
.collect()
}
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
/// repeated PUT is a cheap no-op.
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
return Ok(None);
}
let mut settings = load_settings();
let was_disabled = settings.disabled.iter().any(|d| d == id);
if enabled != was_disabled {
return Ok(Some(list_scanners()));
}
if enabled {
settings.disabled.retain(|d| d != id);
} else {
settings.disabled.push(id.to_string());
settings.disabled.sort();
settings.disabled.dedup();
}
save_settings(&settings)?;
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: id.to_string(),
});
Ok(Some(list_scanners()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn steam_is_always_a_scanner_and_ids_are_unique() {
let defs = scanner_defs();
assert!(defs.iter().any(|(id, _)| *id == "steam"));
let ids: HashSet<_> = defs.iter().map(|(id, _)| *id).collect();
assert_eq!(ids.len(), defs.len(), "scanner ids must be unique");
// `custom` is a store but never a scanner — the toggle surface must not offer it.
assert!(!ids.contains("custom"));
}
#[test]
fn absent_or_malformed_settings_mean_all_on() {
// The default (absent-file) settings disable nothing — the pre-feature behavior.
let s = ScannerSettings::default();
assert!(s.disabled.is_empty());
let parsed: ScannerSettings = serde_json::from_str("{}").unwrap();
assert!(parsed.disabled.is_empty(), "missing key defaults to empty");
}
}
-7
View File
@@ -65,7 +65,6 @@ mod mgmt_token;
mod native;
mod native_pairing;
mod pipeline;
mod plugins;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
@@ -250,10 +249,6 @@ fn real_main() -> Result<()> {
std::process::exit(1);
}
}
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
// PunktfunkScripting scheduled task (Windows). See plugins.rs.
Some("plugins") => plugins::main(&args[1..]),
// Print the management API's OpenAPI document (for client codegen).
Some("openapi") => {
print!("{}", mgmt::openapi_json());
@@ -628,8 +623,6 @@ fn print_usage() {
USAGE:
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
(secure default; add --gamestream for Moonlight compat)
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
disable, status) `plugins --help` for details
punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
-2
View File
@@ -203,8 +203,6 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(session::stop_session))
.routes(routes!(session::request_idr))
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
.routes(routes!(library::create_custom_game))
.routes(routes!(
library::update_custom_game,
-66
View File
@@ -45,72 +45,6 @@ pub(crate) async fn get_library(
Json(games)
}
/// Request body for `setLibraryScanner`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ScannerToggle {
/// Whether the scanner should run on this host.
enabled: bool,
}
/// List the library scanners
///
/// The installed-store scanners this host supports — the list is platform-dependent (Steam
/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console
/// renders a toggle only for scanners that can do anything here. Scanners default to enabled;
/// disabling one hides its titles from every library surface from the next read. The user-curated
/// custom store is not a scanner and is always on.
#[utoipa::path(
get,
path = "/library/scanners",
tag = "library",
operation_id = "listLibraryScanners",
responses(
(status = OK, description = "This host's scanners with their enable state", body = [crate::library::ScannerInfo]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_library_scanners() -> Json<Vec<crate::library::ScannerInfo>> {
Json(crate::library::list_scanners())
}
/// Enable or disable a library scanner
///
/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner
/// hides its titles everywhere — the console grid, native clients, and the GameStream app list —
/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits
/// `library.changed` with the scanner id as `source` when the state changed.
#[utoipa::path(
put,
path = "/library/scanners/{id}",
tag = "library",
operation_id = "setLibraryScanner",
params(("id" = String, Path, description = "The scanner id (e.g. `steam`)")),
request_body = ScannerToggle,
responses(
(status = OK, description = "Toggle stored; the full scanner list", body = [crate::library::ScannerInfo]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No such scanner on this platform", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
)
)]
pub(crate) async fn set_library_scanner(
Path(id): Path<String>,
ApiJson(toggle): ApiJson<ScannerToggle>,
) -> Response {
match crate::library::set_scanner_enabled(&id, toggle.enabled) {
Ok(Some(scanners)) => {
tracing::info!(
scanner = %id,
enabled = toggle.enabled,
"management API: library scanner toggled"
);
Json(scanners).into_response()
}
Ok(None) => api_error(StatusCode::NOT_FOUND, "no such scanner on this platform"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Add a custom library entry
///
/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied
-46
View File
@@ -138,13 +138,6 @@ async fn cert_auth_is_a_read_only_allowlist() {
"the client roster {p} must require the bearer token, not just a paired cert"
);
}
// The scanner settings are admin-only in BOTH directions: the exact-path `/api/v1/library`
// cert match must not leak the settings GET, and the toggle PUT is operator configuration.
assert_eq!(
send_cert(&app, get_req("/api/v1/library/scanners"), fp).await,
StatusCode::UNAUTHORIZED,
"the scanner settings must require the bearer token, not just a paired cert"
);
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
for p in [
@@ -1316,45 +1309,6 @@ async fn hooks_get_shape_and_put_validation() {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ------------------------------------------------------------------ library scanners
/// The scanner list is platform-shaped and read-only-safe; the toggle rejects unknown ids with
/// 404. (A successful toggle PUT would write the developer's real `library-scanners.json`, so the
/// write path is exercised only through the unknown-id rejection here — the settings round-trip
/// itself is unit-tested in `library::scanners` against pure shapes.)
#[tokio::test]
async fn library_scanner_list_and_unknown_toggle() {
let app = test_app(test_state(), None);
let (s, json) = send(&app, get_req("/api/v1/library/scanners")).await;
assert_eq!(s, StatusCode::OK);
let scanners = json.as_array().expect("a scanner array");
assert!(
scanners
.iter()
.any(|sc| sc["id"] == "steam" && sc["label"].is_string() && sc["enabled"].is_boolean()),
"steam must be a scanner on every platform: {json}"
);
// Only platform-available scanners appear (`custom` is a store, never a scanner).
assert!(scanners.iter().all(|sc| sc["id"] != "custom"));
let (s, json) = send(
&app,
axum::http::Request::put("/api/v1/library/scanners/not-a-store")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({"enabled": false}).to_string(),
))
.unwrap(),
)
.await;
assert_eq!(
s,
StatusCode::NOT_FOUND,
"unknown scanner id must 404: {json}"
);
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
-1
View File
@@ -1173,7 +1173,6 @@ async fn serve_session(
hdr: welcome.color.is_hdr(),
client: hello.name.clone().unwrap_or_default(),
launch: hello.launch.clone(),
plane: crate::events::Plane::Native,
});
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
// (spawned into the interactive user session once capture is live); other hosts resolve the id
+18 -27
View File
@@ -71,23 +71,18 @@ pub(super) fn audio_thread(
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
// decoder are sized for `want`, so a mismatched capturer would garble/desync the audio).
// A FAILED first open does not end the session's audio: session start is peak endpoint churn
// on Windows (the virtual-display attach and the wiring plan's own default-device flips race
// the WASAPI activate — 0x80070002 mid-re-registration), so it enters the same
// reopen-with-backoff loop a mid-session capture death does; audio then starts a few seconds
// late instead of never.
let capturer = match audio_cap.lock().unwrap().take() {
let mut capturer = match audio_cap.lock().unwrap().take() {
Some(mut c) if c.channels() == want as u32 => {
c.drain(); // discard audio captured between sessions (also re-claims routing)
Some(c)
c
}
prev => {
drop(prev); // wrong channel count (or none): clean teardown, open fresh at `want`
match crate::audio::open_audio_capture(want as u32) {
Ok(c) => Some(c),
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio failed to open — retrying in the background until it comes up");
None
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
return;
}
}
}
@@ -96,10 +91,8 @@ pub(super) fn audio_thread(
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
if let Some(mut c) = capturer {
c.idle(); // parked, not streaming — release the routing claim
crate::audio::park_audio_capture(&audio_cap, c);
}
capturer.idle(); // parked, not streaming — release the routing claim
crate::audio::park_audio_capture(&audio_cap, capturer);
return;
}
};
@@ -110,22 +103,20 @@ pub(super) fn audio_thread(
let mut opus_buf = vec![0u8; 4096];
let mut seq: u32 = 0;
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
// (device unplug, daemon restart) — or a first open lost to session-start churn above —
// reopens instead of muting the rest of a multi-hour session. A quiet sink is NOT a death —
// `next_chunk` returns an empty chunk on its idle timeout — so only a genuine thread-ended
// Err drops the capturer. Reopens are throttled by INJECTOR_REOPEN_BACKOFF. The Opus encoder
// and the monotonic `seq` are kept across reopens (the client sees a gap, not a restart).
let mut last_failed = capturer.is_none().then(std::time::Instant::now);
let mut capturer = capturer;
// (device unplug, daemon restart) reopens instead of muting the rest of a multi-hour session.
// A quiet sink is NOT a death — `next_chunk` returns an empty chunk on its idle timeout — so only
// a genuine thread-ended Err drops the capturer. Reopens are throttled by INJECTOR_REOPEN_BACKOFF.
// The Opus encoder and the monotonic `seq` are kept across reopens (the client sees a gap, not a
// restart). The first open already happened above; failing THAT still ends the session quietly.
let mut capturer = Some(capturer);
let mut last_failed: Option<std::time::Instant> = None;
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
let mut opus_encode_errs: u64 = 0;
if capturer.is_some() {
tracing::info!(
channels = want,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
);
}
tracing::info!(
channels = want,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
);
'session: while !stop.load(Ordering::SeqCst) {
if capturer.is_none() {
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
+8 -51
View File
@@ -1166,21 +1166,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// clock now — that coalesces the keyframe storm a client fires while its decoder wedges on the cold
// opening GOP, instead of answering it with a redundant second IDR.
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
// A successful LTR-RFI recovery anchors THIS clock, not the IDR cooldown: it justifies
// swallowing the client's `frames_dropped`-driven echo of the SAME loss (arriving ~one
// loss-window later), but must never indefinitely defer the client's ESCALATION — a
// keyframe request that keeps coming because the RFI recovery did not actually heal its
// decoder. Re-anchoring the full IDR cooldown here (the old behavior) livelocked under
// sustained loss: each new loss → RFI → cooldown re-anchored → the wedged client's IDR
// pleas coalesced away forever, and the picture never recovered (the lid-closed Intel
// laptop field report: permanent macroblock soup, dozens of swallowed requests per IDR).
let mut last_rfi: Option<std::time::Instant> = None;
// Keyframe requests swallowed on RFI-echo grounds since the last real IDR / quiet period.
// Capped: requests past the cap mean RFI is not healing this client — escalate to the IDR.
let mut rfi_echo_swallowed: u32 = 0;
// When the previous keyframe request arrived — a long quiet gap means the client healed
// and the next request opens a NEW loss episode (the echo-swallow budget resets).
let mut last_kf_request: Option<std::time::Instant> = None;
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
// into a stable multi-second rhythm (see [`pf_frame::metronome::Metronome`]).
let mut recovery_cadence = pf_frame::metronome::Metronome::new();
@@ -1551,12 +1536,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} else if enc.caps().supports_rfi
&& enc.invalidate_ref_frames(first as i64, last as i64)
{
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor
// the RFI-echo window (NOT the IDR cooldown — see `last_rfi`) so the client's
// echo of the SAME loss — its frames_dropped-driven keyframe request, arriving
// ~one loss-window later — is coalesced away instead of emitting a redundant
// full IDR right after the cheap recovery.
last_rfi = Some(std::time::Instant::now());
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor the
// keyframe cooldown so the client's echo of the SAME loss — its frames_dropped-
// driven keyframe request, arriving ~one loss-window later — is coalesced away
// instead of emitting a redundant full IDR right after the cheap recovery.
last_forced_idr = Some(std::time::Instant::now());
} else {
want_kf = true; // range too old / no RFI backend → coalesced keyframe below
}
@@ -1578,46 +1562,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// promptly.
const IDR_COOLDOWN_INTRA: std::time::Duration = std::time::Duration::from_secs(2);
const IDR_COOLDOWN_FULL: std::time::Duration = std::time::Duration::from_millis(750);
// The RFI-echo window: how long after a successful LTR-RFI recovery a keyframe
// request is presumed to be the client's echo of the SAME loss (the recovery frame
// is still in flight / just decoding) rather than an escalation. Field data: the
// echo lands ~110-130 ms after the RFI on a LAN-ish RTT.
const RFI_ECHO_WINDOW: std::time::Duration = std::time::Duration::from_millis(300);
// How many requests the echo window may swallow per loss episode. Requests past
// this budget mean the LTR-RFI recoveries are NOT healing the client (anchor lost,
// or corrupt client-side) — serve the IDR it is asking for. Without the cap, a
// sustained-loss session (RFI every few hundred ms, each re-opening the window)
// suppressed the client's escalation indefinitely.
const RFI_ECHO_MAX_SWALLOWED: u32 = 2;
// A quiet gap since the last keyframe request = the client healed; the next
// request opens a NEW loss episode with a fresh echo-swallow budget.
const KF_EPISODE_RESET: std::time::Duration = std::time::Duration::from_secs(1);
let window = if enc.caps().intra_refresh {
IDR_COOLDOWN_INTRA
} else {
IDR_COOLDOWN_FULL
};
let now = std::time::Instant::now();
if last_kf_request.is_some_and(|t| now.duration_since(t) > KF_EPISODE_RESET) {
rfi_echo_swallowed = 0;
}
last_kf_request = Some(now);
let idr_recent = last_forced_idr.is_some_and(|t| t.elapsed() < window);
let rfi_echo = last_rfi.is_some_and(|t| t.elapsed() < RFI_ECHO_WINDOW)
&& rfi_echo_swallowed < RFI_ECHO_MAX_SWALLOWED;
if idr_recent {
let suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window);
if suppress {
tracing::debug!("keyframe request coalesced — within the IDR cooldown");
} else if rfi_echo {
rfi_echo_swallowed += 1;
tracing::debug!(
swallowed = rfi_echo_swallowed,
"keyframe request coalesced — echo of an RFI-recovered loss"
);
} else {
tracing::debug!("forcing keyframe (client decode recovery)");
enc.request_keyframe();
let now = std::time::Instant::now();
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
if let Some(period) = recovery_cadence.note(now) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
-389
View File
@@ -1,389 +0,0 @@
//! `punktfunk-host plugins …` — the one-liner plugin CLI.
//!
//! Installing a plugin used to be a hand ritual: create the plugins dir, hand-write a `bunfig.toml`
//! registry scope map, `bun add` the package, then hand-enable a systemd unit (Linux) or a scheduled
//! task (Windows) — all with platform-divergent paths. This subcommand collapses that to
//! `punktfunk-host plugins add playnite` + `punktfunk-host plugins enable`.
//!
//! Split of duties (matching where the machinery already lives):
//! - **Package ops** (`add`/`remove`/`list`) are forwarded to the bun runner (`sdk/src/plugins.ts`),
//! which owns the vendored bun, the `@punktfunk` registry scope, and the plugins dir. We locate the
//! runner rather than reimplementing npm resolution in Rust.
//! - **Service ops** (`enable`/`disable`/`status`) run natively here — `systemctl --user` on Linux,
//! the `PunktfunkScripting` scheduled task on Windows — so they work even without the runner
//! package present.
//!
//! Windows needs elevation for both halves: the plugins dir lives under the ACL'd
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task runs as SYSTEM. We
//! check up front and print one actionable line instead of letting `bun add` fail with a bare
//! EACCES.
use anyhow::{bail, Context, Result};
use std::process::Command;
/// The systemd user unit / Windows scheduled task that supervises plugins.
#[cfg(target_os = "linux")]
const UNIT: &str = "punktfunk-scripting";
#[cfg(target_os = "windows")]
const TASK: &str = "PunktfunkScripting";
pub fn main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("add") | Some("remove") | Some("rm") | Some("uninstall") | Some("list")
| Some("ls") => {
// Package ops write into the (ACL'd, on Windows) plugins dir.
#[cfg(target_os = "windows")]
if !matches!(args.first().map(String::as_str), Some("list") | Some("ls")) {
require_elevation("installing or removing plugins")?;
}
forward_to_runner(args)
}
Some("enable") => {
#[cfg(target_os = "windows")]
require_elevation("enabling the plugin runner")?;
enable()
}
Some("disable") => {
#[cfg(target_os = "windows")]
require_elevation("disabling the plugin runner")?;
disable()
}
Some("status") => status(),
Some("-h") | Some("--help") | Some("help") | None => {
print_usage();
Ok(())
}
Some(other) => bail!("unknown plugins command '{other}' (try `plugins --help`)"),
}
}
fn print_usage() {
eprintln!(
"punktfunk-host plugins — install and run host plugins
USAGE:
punktfunk-host plugins add <name> install a plugin (playnite, rom-manager, )
punktfunk-host plugins remove <name> uninstall a plugin
punktfunk-host plugins list list installed plugins
punktfunk-host plugins enable enable + start the plugin runner (opt-in)
punktfunk-host plugins disable stop + disable the plugin runner
punktfunk-host plugins status is the runner enabled/running?
NAMES:
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
NOTES:
Plugins run under the runner, which is OPT-IN `plugins add` installs, `plugins enable`
turns the runner on. Plugins are operator-installed code that runs as the host user;
install only plugins you trust.
"
);
#[cfg(target_os = "windows")]
eprintln!(
" On Windows, `add`/`remove`/`enable`/`disable` need an ELEVATED prompt (the plugins\n \
directory and the runner task are admin-owned)."
);
}
// ---- package ops: forward to the bun runner ---------------------------------------------------
/// Locate the runner and hand it the argv verbatim, inheriting stdio so bun's progress output goes
/// straight to the user's terminal. Exits with the runner's own status code.
fn forward_to_runner(args: &[String]) -> Result<()> {
let (program, prefix) = runner_command()?;
let status = Command::new(&program)
.args(&prefix)
.args(args)
.status()
.with_context(|| format!("failed to run the plugin runner ({})", program.display()))?;
if !status.success() {
// The runner already printed the reason; propagate its code without a second error line.
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
/// the runner script path passed to it).
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
#[cfg(target_os = "windows")]
{
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
// {app}\scripting\runner-cli.js (packaging/windows/punktfunk-host.iss).
let app = std::env::current_exe()
.context("resolve current exe")?
.parent()
.context("resolve install dir")?
.to_path_buf();
let bun = app.join("bun").join("bun.exe");
let runner = app.join("scripting").join("runner-cli.js");
if !bun.exists() || !runner.exists() {
bail!(
"the plugin runner isn't installed (looked for {} and {}) — reinstall punktfunk \
with the scripting component",
bun.display(),
runner.display()
);
}
// Tail expression, not `return`: after cfg-stripping this block is the whole fn body on
// Windows, and a `return` here trips clippy's needless_return under CI's -D warnings.
Ok((bun, vec![runner.to_string_lossy().into_owned()]))
}
#[cfg(not(target_os = "windows"))]
{
// The scripting package ships /usr/bin/punktfunk-scripting, a wrapper that runs the bundled
// bun on the runner bundle and forwards "$@" (packaging/debian/build-scripting-deb.sh).
let wrapper = std::path::PathBuf::from("/usr/bin/punktfunk-scripting");
if wrapper.exists() {
return Ok((wrapper, Vec::new()));
}
// Fall back to the package's private layout in case the wrapper is absent.
let bun = std::path::PathBuf::from("/usr/lib/punktfunk-scripting/bun");
let runner = std::path::PathBuf::from("/usr/share/punktfunk-scripting/runner-cli.js");
if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
}
bail!(
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
`sudo apt install punktfunk-scripting`)"
)
}
}
// ---- service ops ------------------------------------------------------------------------------
#[cfg(target_os = "linux")]
fn enable() -> Result<()> {
run_systemctl(&["enable", "--now", UNIT])?;
println!("Plugin runner enabled and started ({UNIT}).");
Ok(())
}
#[cfg(target_os = "linux")]
fn disable() -> Result<()> {
run_systemctl(&["disable", "--now", UNIT])?;
println!("Plugin runner stopped and disabled ({UNIT}).");
Ok(())
}
#[cfg(target_os = "linux")]
fn status() -> Result<()> {
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into());
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into());
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}");
if active != "active" {
println!("\nStart it with: punktfunk-host plugins enable");
}
Ok(())
}
#[cfg(target_os = "linux")]
fn run_systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl")
.arg("--user")
.args(args)
.status()
.context("failed to run systemctl (is systemd available in this session?)")?;
if !status.success() {
bail!(
"systemctl --user {} failed — is the punktfunk-scripting package installed?",
args.join(" ")
);
}
Ok(())
}
/// Trimmed stdout of a `systemctl --user` query, or `None` if it couldn't run. These queries exit
/// non-zero for a normal "inactive"/"disabled" answer, so the status text is what matters.
#[cfg(target_os = "linux")]
fn systemctl_output(args: &[&str]) -> Option<String> {
let out = Command::new("systemctl")
.arg("--user")
.args(args)
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
fn enable() -> Result<()> {
powershell(&format!(
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?;
println!("Plugin runner enabled and started ({TASK}).");
Ok(())
}
#[cfg(target_os = "windows")]
fn disable() -> Result<()> {
powershell(&format!(
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
))?;
println!("Plugin runner stopped and disabled ({TASK}).");
Ok(())
}
#[cfg(target_os = "windows")]
fn status() -> Result<()> {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \
$i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
\"$($t.State)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => {
println!(
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
scripting component to get the plugin runner."
);
}
Some(state) => {
println!("runner: {TASK}\nstate: {state}");
if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable");
}
}
}
Ok(())
}
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
#[cfg(target_os = "windows")]
fn powershell_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string())
}
#[cfg(target_os = "windows")]
fn powershell(command: &str) -> Result<()> {
let status = Command::new(powershell_path())
.args(["-NoProfile", "-NonInteractive", "-Command", command])
.status()
.context("failed to run powershell")?;
if !status.success() {
bail!(
"the {TASK} scheduled task couldn't be changed — is punktfunk installed with the \
scripting component, and is this prompt elevated?"
);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn powershell_output(command: &str) -> Option<String> {
let out = Command::new(powershell_path())
.args(["-NoProfile", "-NonInteractive", "-Command", command])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
// ---- elevation --------------------------------------------------------------------------------
/// Refuse early, with an actionable message, when an admin-only operation is run unelevated. We do
/// NOT self-elevate via UAC: that spawns a separate console window which closes on exit, hiding
/// bun's install output and any error the user needs to read.
#[cfg(target_os = "windows")]
fn require_elevation(what: &str) -> Result<()> {
if is_elevated() {
return Ok(());
}
// ASCII only: the Windows console's default codepage drops non-ASCII (an em-dash or arrow
// renders as a blank), which mangles the one message the user most needs to read.
bail!(
"{what} needs administrator rights (the plugins directory under %ProgramData%\\punktfunk \
and the runner task are admin-owned).\n\nOpen an elevated prompt: Start -> type \
\"PowerShell\" -> right-click -> Run as administrator, then run this command again."
)
}
/// Does this process have local-Administrator rights *in effect*?
///
/// Deliberately `CheckTokenMembership` against the built-in Administrators group, NOT
/// `GetTokenInformation(TokenElevation)`. `TokenElevation` answers "was this token elevated via
/// UAC", which is not the same question: a restricted/SAFER token derived from an elevated one
/// (`runas /trustlevel:0x20000`) still reports `TokenIsElevated = 1` while the Administrators SID
/// is deny-only, so the guard waved through a process that then failed on the ACL'd plugins dir.
/// Verified on-glass 2026-07-19: under such a token this returns false where `TokenElevation`
/// returned true. `CheckTokenMembership(None, …)` uses the effective token and honors deny-only
/// SIDs — the same test PowerShell's `IsInRole([…]::Administrator)` performs.
#[cfg(target_os = "windows")]
fn is_elevated() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
AllocateAndInitializeSid, CheckTokenMembership, FreeSid, PSID, SID_IDENTIFIER_AUTHORITY,
};
// The well-known BUILTIN\Administrators SID, S-1-5-32-544: NT authority (5) + the
// SECURITY_BUILTIN_DOMAIN_RID (32) and DOMAIN_ALIAS_RID_ADMINS (544) sub-authorities. Spelled
// out rather than imported so this doesn't depend on which module the crate exposes the RID
// constants from.
const NT_AUTHORITY: SID_IDENTIFIER_AUTHORITY = SID_IDENTIFIER_AUTHORITY {
Value: [0, 0, 0, 0, 0, 5],
};
const BUILTIN_DOMAIN_RID: u32 = 32;
const ALIAS_RID_ADMINS: u32 = 544;
let mut admins = PSID::default();
// SAFETY: AllocateAndInitializeSid is given a valid authority and exactly the 2 sub-authorities
// its count argument declares (the remaining 6 are the API's required zero padding). On success
// it yields a valid PSID that we pass to CheckTokenMembership and free on every path below;
// `None` for the token means "the calling thread's effective token".
unsafe {
if AllocateAndInitializeSid(
&NT_AUTHORITY,
2,
BUILTIN_DOMAIN_RID,
ALIAS_RID_ADMINS,
0,
0,
0,
0,
0,
0,
&mut admins,
)
.is_err()
{
return false;
}
let mut is_member = windows::core::BOOL::default();
let ok = CheckTokenMembership(Some(HANDLE::default()), admins, &mut is_member).is_ok();
FreeSid(admins);
ok && is_member.as_bool()
}
}
// Non-Linux, non-Windows (macOS dev builds): the runner and its service manager don't exist there.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn enable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn disable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn status() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
+24 -10
View File
@@ -155,22 +155,36 @@ impl SessionPlan {
}
gpu && !force_cpu_for_nvenc_444
};
// PyroWave on Linux keeps `gpu = true`: the capture facade sees `pyrowave` below and
// routes the session onto the raw-dmabuf passthrough (the wavelet encoder's own Vulkan
// device imports the compositor's dmabuf on ANY vendor — `ZeroCopyPolicy::pyrowave_session`
// advertises its importable modifiers, so Mutter+NVIDIA negotiates tiled zero-copy instead
// of the old forced CPU-RGB readback). The EGL→CUDA importer is skipped there — its
// payloads only NVENC consumes.
// PyroWave on an NVIDIA-auto host: the `gpu` capture path resolves to the EGL→CUDA
// import that only NVENC can consume — the wavelet backend ingests raw dmabufs
// (the AMD/Intel path) or CPU RGB. Flip THIS session to CPU RGB capture; the
// Phase-2 exit sessions ran exactly this shape at 60 fps (the encode itself stays
// sub-ms GPU compute). Per-session raw-dmabuf passthrough on NVIDIA (true
// zero-copy without the PUNKTFUNK_ENCODER=pyrowave capture policy) is the
// follow-up; the AMD/Intel dmabuf path is untouched.
#[cfg(target_os = "linux")]
let gpu = {
let pyro_needs_cpu = self.codec == crate::encode::Codec::PyroWave
&& !crate::encode::linux_zero_copy_is_vaapi();
if gpu && pyro_needs_cpu {
tracing::info!(
"PyroWave session on the NVIDIA capture path: GPU (CUDA) capture disabled \
for this session frames arrive as CPU RGB and upload to the wavelet \
encoder (raw-dmabuf zero-copy on NVIDIA is a follow-up)"
);
}
gpu && !pyro_needs_cpu
};
crate::capture::OutputFormat {
gpu,
hdr: self.hdr,
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
chroma_444: self.chroma.is_444(),
// PyroWave: on Windows the IDD-push capturer makes its NV12 out-ring shareable + signals
// a shared fence so the wavelet encoder can zero-copy-import the texture into its own
// Vulkan device; on Linux the capture facade flips the zero-copy policy to the
// raw-dmabuf passthrough (see above).
// PyroWave (Windows): the IDD-push capturer makes its NV12 out-ring shareable + signals a
// shared fence so the wavelet encoder can zero-copy-import the texture into its own Vulkan
// device. Inert on Linux (the wavelet backend ingests dmabufs / CPU RGB there — handled
// by the `gpu` flips above, not this flag).
pyrowave: self.codec == crate::encode::Codec::PyroWave,
}
}
+4 -48
View File
@@ -38,11 +38,6 @@
//!
//! ## Semantics
//!
//! Both serving planes announce — the native punktfunk/1 loop and the GameStream/Moonlight compat
//! plane — so a wrapper script behaves identically whichever client is connected. (Until 2026-07
//! only the native loop did, so a Moonlight session left the marker absent and every wrapper took
//! its "not streaming" branch mid-stream.)
//!
//! The marker reflects the **primary** session — the oldest still-live one — which is the single
//! mode a single-user host is at. With several concurrent clients at different modes there is only
//! one file, so `PF_STREAM_SESSIONS>1` is the script's cue that width/height/refresh describe just
@@ -58,25 +53,20 @@ pub struct StreamInfo {
pub hdr: bool,
/// Client-supplied device name (may be empty); sanitized before it reaches the file.
pub client: String,
/// The launch this session requested, if any (store-qualified id on the native plane, app
/// title on the compat plane) — carried on the stream lifecycle events, NOT written to the
/// marker file (its key set is a stable contract).
/// Store-qualified launch id this session requested, if any — carried on the stream
/// lifecycle events, NOT written to the marker file (its key set is a stable contract).
pub launch: Option<String>,
/// Which protocol plane serves this session. Carried on the lifecycle events — hooks filter
/// on it — and never written to the marker file: a launch wrapper branches on "is anything
/// streaming", not on how the pixels leave the box.
pub plane: crate::events::Plane,
}
/// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites
/// (RFC §4), for every plane that announces.
/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane.
fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef {
crate::events::StreamRef {
mode: crate::events::mode_str(info.width, info.height, info.refresh_hz),
hdr: info.hdr,
client: info.client.clone(),
app: info.launch.clone(),
plane: info.plane,
plane: crate::events::Plane::Native,
}
}
@@ -281,7 +271,6 @@ mod imp {
hdr: true,
client: "Couch'TV".to_string(),
launch: None,
plane: crate::events::Plane::Native,
});
rewrite_to(&path, &reg);
let text = std::fs::read_to_string(&path).expect("marker exists while streaming");
@@ -301,7 +290,6 @@ mod imp {
hdr: false,
client: "Phone".to_string(),
launch: None,
plane: crate::events::Plane::Gamestream,
});
rewrite_to(&path, &reg);
let text = std::fs::read_to_string(&path).unwrap();
@@ -323,35 +311,3 @@ mod imp {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::Plane;
/// The announcing plane must reach the lifecycle events. It was hardcoded to `Native` while the
/// native loop was the only announcer, so every compat-plane session would report as native to
/// a hook filtering on it.
#[test]
fn stream_ref_carries_the_announcing_plane() {
let info = StreamInfo {
width: 1920,
height: 1080,
refresh_hz: 60,
hdr: false,
client: String::new(),
launch: Some("Hades".to_string()),
plane: Plane::Gamestream,
};
let r = stream_ref(&info);
assert_eq!(r.plane, Plane::Gamestream);
assert_eq!(r.mode, "1920x1080@60");
assert_eq!(r.app.as_deref(), Some("Hades"));
let native = stream_ref(&StreamInfo {
plane: Plane::Native,
..info
});
assert_eq!(native.plane, Plane::Native);
}
}
+1 -2
View File
@@ -149,8 +149,7 @@ directory of scripts and installed plugins as one service: crash-restarts with b
for the five-line quickstart and unit templates.
For ready-made plugins — sync your ROM collection or your Playnite library into the game library,
with a console page to manage them — see [Plugins](/docs/plugins). Installing one is two commands:
`punktfunk-host plugins add <name>`, then `punktfunk-host plugins enable`.
with a console page to manage them — see [Plugins](/docs/plugins).
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
`pairing.pending`, send yourself a notification, and call
+100
View File
@@ -0,0 +1,100 @@
---
title: Plugins
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Two first-party plugins today:
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin
Plugins install as packages into the runner's plugins directory, and the runner
(`punktfunk-scripting`) supervises them. It's a one-time registry setup, then `bun add`:
```sh
# The runner's plugins directory:
# Linux ~/.config/punktfunk/plugins
# Windows %ProgramData%\punktfunk\plugins
cd ~/.config/punktfunk/plugins # create it if it doesn't exist yet
# Point the @punktfunk scope at the registry (once):
cat > bunfig.toml <<'EOF'
[install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
EOF
bun add @punktfunk/plugin-rom-manager # or @punktfunk/plugin-playnite
```
Then enable the runner — it's opt-in, off until you turn it on:
```sh
systemctl --user enable --now punktfunk-scripting # Linux
Enable-ScheduledTask PunktfunkScripting # Windows
```
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install. Each plugin is also fully configurable from a `config.json` for headless
hosts (see its README).
> Plugins are operator-installed code that runs as the host user — they can launch games and run
> commands. Install only plugins you trust, from a registry you control.
## ROM Manager
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
configure it all.
Install it as [above](#installing-a-plugin), then add a root or two — from the console's **ROM
Manager** page, or in `~/.config/punktfunk/rom-manager/config.json`:
```jsonc
{
"roots": [
{ "dir": "/mnt/roms/snes", "platform": "snes" },
{ "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
Full options and the platform/emulator list are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
## Playnite
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
Because Playnite keeps its library locked while running, this plugin has **two parts**, both on the
Windows host:
1. **The plugin** (on the host) — install `@punktfunk/plugin-playnite` exactly as
[above](#installing-a-plugin).
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
it** to install it in Playnite like any add-on, then restart Playnite once.
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
`~/.config/punktfunk/playnite/config.json`. Details are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## Writing your own
A plugin is a small TypeScript module built on `@punktfunk/host` (`definePlugin`), supervised by the
runner. Start from the [SDK README](https://git.unom.io/unom/punktfunk/src/branch/main/sdk) — or the
two plugins above, which are worked examples of the same shape.
-185
View File
@@ -1,185 +0,0 @@
---
title: Plugins
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Two first-party plugins today:
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
the plugins directory, pointing it at the package registry, and starting the supervisor.
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
```sh
punktfunk-host plugins add playnite # or: rom-manager
punktfunk-host plugins enable # turn the runner on (once)
```
</Tab>
<Tab value="Windows">
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned, and the runner
task runs as SYSTEM.
```powershell
punktfunk-host plugins add playnite # or: rom-manager
punktfunk-host plugins enable # turn the runner on (once)
```
If `punktfunk-host` isn't found, open a **new** terminal (the installer adds it to `PATH`), or use
the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
</Tab>
</Tabs>
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once — plugins you add later are picked up automatically.
### The rest of the commands
| Command | What it does |
|---|---|
| `punktfunk-host plugins add <name…>` | Install one or more plugins. |
| `punktfunk-host plugins remove <name…>` | Uninstall. |
| `punktfunk-host plugins list` | List what's installed, with versions. |
| `punktfunk-host plugins enable` | Enable + start the runner. |
| `punktfunk-host plugins disable` | Stop + disable the runner. |
| `punktfunk-host plugins status` | Is the runner enabled and running? |
A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`.
A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work
the same way.
> Plugins are operator-installed code that runs as the host user — they can launch games and run
> commands. Install only plugins you trust, from a registry you control.
## ROM Manager
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
configure it all.
```sh
punktfunk-host plugins add rom-manager
```
Then add a root or two — from the console's **ROM Manager** page, or in the config file:
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
`~/.config/punktfunk/rom-manager/config.json`:
```jsonc
{
"roots": [
{ "dir": "/mnt/roms/snes", "platform": "snes" },
{ "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
</Tab>
<Tab value="Windows">
`%ProgramData%\punktfunk\rom-manager\config.json`:
```jsonc
{
"roots": [
{ "dir": "D:\\roms\\snes", "platform": "snes" },
{ "dir": "D:\\roms\\ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
</Tab>
</Tabs>
Full options and the platform/emulator list are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
## Playnite
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
Playnite is Windows-only, so both halves of this one live on the **Windows host**. Because Playnite
keeps its library locked while running, there are **two parts**:
1. **The plugin** — from an elevated PowerShell:
```powershell
punktfunk-host plugins add playnite
punktfunk-host plugins enable
```
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
it** to install it in Playnite like any add-on, then restart Playnite once.
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
`%ProgramData%\punktfunk\playnite\config.json`. Details are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## Troubleshooting
**`punktfunk-host: command not found`** — on Windows, open a new terminal so it picks up the
installer's `PATH` change, or call the exe by full path. On Linux the host package installs it to
`/usr/bin/punktfunk-host`.
**"the plugin runner isn't installed"** — the runner ships as its own package. On Debian/Ubuntu:
`sudo apt install punktfunk-scripting`. On Windows, re-run the installer and keep the scripting
component.
**The plugin doesn't show up in the console** — check the runner is actually running with
`punktfunk-host plugins status`, then look at its log:
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
```sh
journalctl --user -u punktfunk-scripting -f
```
</Tab>
<Tab value="Windows">
The runner task doesn't write a log file, so run it in the foreground to watch it start your
plugins (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
```powershell
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
```
</Tab>
</Tabs>
## Writing your own
A plugin is a small TypeScript module built on `@punktfunk/host` (`definePlugin`), supervised by the
runner. Start from the [SDK README](https://git.unom.io/unom/punktfunk/src/branch/main/sdk) — or the
two plugins above, which are worked examples of the same shape.
-4
View File
@@ -1,5 +1,4 @@
import defaultMdxComponents from 'fumadocs-ui/mdx'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import type { MDXComponents } from 'mdx/types'
import BitrateCalculator from '@/components/BitrateCalculator'
@@ -8,9 +7,6 @@ export function getMDXComponents(components?: MDXComponents) {
...defaultMdxComponents,
// Custom components usable in any .md/.mdx without a per-file import.
BitrateCalculator,
// Per-platform instructions: <Tabs items={['Linux', 'Windows']}><Tab value="Linux">…
Tabs,
Tab,
...components,
} satisfies MDXComponents
}
-66
View File
@@ -128,10 +128,6 @@ UninstallDisplayName=punktfunk host {#MyAppVersion}
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
; copy stays as the uninstall-entry icon.
UninstallDisplayIcon={app}\punktfunk.ico
; {app} goes on the machine PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
; documented one-liners — `punktfunk-host plugins add playnite` — work by name from an elevated
; prompt. Broadcasts WM_SETTINGCHANGE so already-open shells pick it up after a restart.
ChangesEnvironment=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
@@ -240,14 +236,6 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
; Put {app} on the MACHINE PATH so `punktfunk-host plugins add …` / `punktfunk-host service …` are
; runnable by name. Appended to the existing value ({olddata}) and guarded by PathNeedsAdd so a
; repair/upgrade never appends a duplicate. Deliberately NOT `uninsdeletevalue` — that would delete
; the whole Path value; the uninstaller surgically removes just our entry (RemoveAppFromPath).
; expandsz preserves the %SystemRoot%-style entries other software puts here.
Root: HKLM64; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Check: PathNeedsAdd(ExpandConstant('{app}'))
#ifdef WithVkLayer
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
@@ -530,60 +518,6 @@ begin
end;
#endif
const
EnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment';
{ Is the install dir missing from the machine PATH? Guards the [Registry] append so a repair or
upgrade can't add a second copy. Compared case-insensitively and semicolon-delimited so a path
that merely CONTAINS ours as a substring (...\punktfunk-old) doesn't count as a match.
NOTE: never write a braced Inno constant inside a Pascal comment - these comments do NOT nest,
so its closing brace ends the comment early and the rest of the line is parsed as code. }
function PathNeedsAdd(Param: String): Boolean;
var
OrigPath: String;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', OrigPath) then
begin
Result := True; { no Path value at all - the append creates it }
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
{ Remove exactly our install-dir entry from the machine PATH on uninstall, leaving every other
entry (and their order) intact. Rebuilds the value entry-by-entry rather than doing a substring
delete, so a partial match can never corrupt a neighbouring path. }
procedure RemoveAppFromPath;
var
OrigPath, NewPath, Entry: String;
Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', OrigPath) then
exit;
Target := Uppercase(ExpandConstant('{app}'));
NewPath := '';
{ Walk the semicolon-delimited list, copying through everything that isn't ours. }
OrigPath := OrigPath + ';';
repeat
P := Pos(';', OrigPath);
Entry := Trim(Copy(OrigPath, 1, P - 1));
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
if (Entry <> '') and (Uppercase(Entry) <> Target) then
begin
if NewPath <> '' then NewPath := NewPath + ';';
NewPath := NewPath + Entry;
end;
until OrigPath = '';
RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', NewPath);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
RemoveAppFromPath;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
-18
View File
@@ -195,24 +195,6 @@ bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punkt
bun src/runner-cli.ts --list # show what it found
```
The same CLI manages plugin packages — it creates the plugins dir, points it at the `@punktfunk`
registry, and installs on the bun it is already running on:
```sh
bun src/runner-cli.ts add playnite # → @punktfunk/plugin-playnite (bare names resolve first-party)
bun src/runner-cli.ts remove playnite
bun src/runner-cli.ts list # installed plugin packages + versions
```
On an installed host these are reached through the host CLI, which also drives the runner service
and checks for elevation on Windows — that is the documented path for operators:
```sh
punktfunk-host plugins add playnite
punktfunk-host plugins enable # enable + start the runner (opt-in)
punktfunk-host plugins status
```
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
restarts them with capped exponential backoff; a clean return completes them.
-154
View File
@@ -1,154 +0,0 @@
// `punktfunk-host plugins …` package operations, run on the vendored bun. The host CLI forwards
// add/remove/list here (crates/punktfunk-host/src/plugins.rs) and the runner-cli exposes them as
// subcommands. Everything a plugin needs to be installed — the plugins dir, the `@punktfunk`
// registry scope in bunfig.toml, and the right bun — is handled here so the operator types one line
// instead of the old create-dir / write-bunfig / `bun add` ritual.
import * as fs from "node:fs";
import * as path from "node:path";
import { configDir } from "./config.js";
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
/**
* Resolve a friendly plugin name to its npm package. A bare first-party name maps into the
* `@punktfunk` scope (`playnite` `@punktfunk/plugin-playnite`, `rom-manager`
* `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
*/
export const resolvePackage = (name: string): string => {
const n = name.trim();
if (!n) throw new Error("empty plugin name");
if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite
if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
if (n.includes("/")) return n; // some other registry path — trust it
return `@punktfunk/plugin-${n}`; // bare first-party name
};
/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
fs.mkdirSync(dir, { recursive: true });
return dir;
};
/**
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a
* fresh table.
*/
export const ensureBunfig = (dir = pluginsDirDefault()): void => {
const file = path.join(dir, "bunfig.toml");
const scopeLine = `"@punktfunk" = "${REGISTRY}"`;
let existing = "";
try {
existing = fs.readFileSync(file, "utf8");
} catch {
// no bunfig yet — write a fresh one below
}
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
const table = `[install.scopes]\n${scopeLine}\n`;
if (!existing.trim()) {
fs.writeFileSync(file, table);
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) {
// Insert our scope line right after the existing table header.
fs.writeFileSync(
file,
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`),
);
} else {
const sep = existing.endsWith("\n") ? "" : "\n";
fs.writeFileSync(file, `${existing}${sep}\n${table}`);
}
};
export interface PkgOpts {
/** Plugins dir. Default `<config_dir>/plugins`. */
dir?: string;
/** Line sink for progress. Default stdout. */
log?: (line: string) => void;
}
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void => {
const dir = opts.dir ?? pluginsDirDefault();
const log = opts.log ?? ((l: string) => console.log(l));
ensurePluginsDir(dir);
if (action === "add") ensureBunfig(dir);
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
// `process.execPath` is the bun running this file (the vendored one under the package), so a
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
const res = Bun.spawnSync([process.execPath, action, ...pkgs], {
cwd: dir,
stdio: ["inherit", "inherit", "inherit"],
});
if (!res.success) {
throw new Error(`bun ${action} exited ${res.exitCode ?? "?"} — see output above`);
}
};
/** Install one or more plugins by friendly name or package. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun("add", names.map(resolvePackage), opts);
/** Uninstall one or more plugins by friendly name or package. */
export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun("remove", names.map(resolvePackage), opts);
export interface InstalledPlugin {
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
pkg: string;
/** Installed version from the package's package.json, if readable. */
version?: string;
}
/**
* Enumerate installed plugin packages under `<dir>/node_modules` both the scoped first-party
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the
* discovery in runner.ts so `list` shows exactly what the runner would supervise.
*/
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
const modules = path.join(dir, "node_modules");
const out: InstalledPlugin[] = [];
const versionOf = (pkgDir: string): string | undefined => {
try {
const m = JSON.parse(
fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"),
) as { version?: string };
return m.version;
} catch {
return undefined;
}
};
let entries: string[];
try {
entries = fs.readdirSync(modules).sort();
} catch {
return out; // no plugins installed yet
}
for (const entry of entries) {
if (entry.startsWith("punktfunk-plugin-")) {
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
} else if (entry === "@punktfunk") {
let scoped: string[] = [];
try {
scoped = fs.readdirSync(path.join(modules, entry)).sort();
} catch {
scoped = [];
}
for (const s of scoped) {
if (s.startsWith("plugin-")) {
out.push({
pkg: `${entry}/${s}`,
version: versionOf(path.join(modules, entry, s)),
});
}
}
}
}
return out;
};
+4 -77
View File
@@ -1,21 +1,10 @@
#!/usr/bin/env bun
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
// every plugin's finalizers run before exit (the systemd-stop story).
//
// With NO subcommand it RUNS the runner: discover the operator's scripts + punktfunk-plugin-*
// packages and supervise them (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree
// structurally, so every plugin's finalizers run before exit (the systemd-stop story). This bare
// form is what the systemd unit / Windows scheduled task launch — do not change its behavior.
//
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here):
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
// remove <name…> uninstall
// list list installed plugin packages
//
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
import { Effect, Fiber } from "effect";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
@@ -28,68 +17,6 @@ const options = {
pluginsDir: arg("--plugins"),
};
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
const positionals = (): string[] => {
const out: string[] = [];
for (let i = 3; i < process.argv.length; i++) {
const a = process.argv[i];
if (a === "--plugins" || a === "--scripts") {
i++; // skip its value too
continue;
}
if (a.startsWith("-")) continue;
out.push(a);
}
return out;
};
const pkgOpts = { dir: options.pluginsDir };
const runPkgOp = (
op: (names: string[], o: typeof pkgOpts) => void,
verb: string,
): never => {
const names = positionals();
if (names.length === 0) {
console.error(
`usage: punktfunk-host plugins ${verb} <name…> (e.g. playnite, rom-manager)`,
);
process.exit(2);
}
try {
op(names, pkgOpts);
process.exit(0);
} catch (e) {
console.error(`[plugins] ${e instanceof Error ? e.message : e}`);
process.exit(1);
}
};
switch (process.argv[2]) {
case "add":
runPkgOp(addPlugins, "add");
break;
case "remove":
case "rm":
case "uninstall":
runPkgOp(removePlugins, "remove");
break;
case "list":
case "ls": {
const installed = listInstalled(options.pluginsDir);
if (installed.length === 0) {
console.log("No plugins installed.");
} else {
for (const p of installed) {
console.log(p.version ? `${p.pkg}\t${p.version}` : p.pkg);
}
}
process.exit(0);
}
}
// ---- run the runner (default; --list keeps the legacy unit-listing behavior) ------------------
if (process.argv.includes("--list")) {
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
process.exit(0);
-132
View File
@@ -1,132 +0,0 @@
// The `punktfunk-host plugins …` package-op helpers: friendly-name resolution, the bunfig scope
// wiring (idempotent + merges into an existing file), and installed-plugin discovery. `add`/`remove`
// shell out to bun over the network, so the live install is verified on-glass, not here.
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import {
ensureBunfig,
ensurePluginsDir,
listInstalled,
REGISTRY,
resolvePackage,
} from "../src/plugins.js";
const ROOT = path.join(import.meta.dir, "..", `.plugins-fixtures-${process.pid}`);
fs.mkdirSync(ROOT, { recursive: true });
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const tmp = (name: string): string => {
const dir = path.join(ROOT, name);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
const writePkg = (dir: string, name: string, version: string) => {
const pkgDir = path.join(dir, "node_modules", name);
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({ name, version }),
);
};
describe("resolvePackage", () => {
test("maps bare first-party names into the @punktfunk scope", () => {
expect(resolvePackage("playnite")).toBe("@punktfunk/plugin-playnite");
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
});
test("passes through scoped, unscoped-convention, and pathed names verbatim", () => {
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
"@punktfunk/plugin-playnite",
);
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
});
test("trims and rejects empty", () => {
expect(resolvePackage(" playnite ")).toBe("@punktfunk/plugin-playnite");
expect(() => resolvePackage(" ")).toThrow();
});
});
describe("ensureBunfig", () => {
test("writes the @punktfunk scope map when absent", () => {
const dir = tmp("bunfig-fresh");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("is idempotent — a second call doesn't duplicate the scope", () => {
const dir = tmp("bunfig-idempotent");
ensureBunfig(dir);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml.match(/@punktfunk/g)?.length).toBe(1);
});
test("merges into an existing [install.scopes] table, keeping other scopes", () => {
const dir = tmp("bunfig-merge");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@other" = "https://example.test/npm/"\n',
);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain('"@other" = "https://example.test/npm/"');
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(toml.match(/\[install\.scopes\]/g)?.length).toBe(1);
});
test("appends a table to an unrelated existing bunfig", () => {
const dir = tmp("bunfig-append");
fs.writeFileSync(path.join(dir, "bunfig.toml"), "telemetry = false\n");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("telemetry = false");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
});
describe("listInstalled", () => {
test("returns empty for a dir with no node_modules", () => {
expect(listInstalled(tmp("list-empty"))).toEqual([]);
});
test("finds both scoped and unscoped plugins with versions, ignoring other packages", () => {
const dir = tmp("list-mixed");
writePkg(dir, "punktfunk-plugin-custom", "1.2.3");
writePkg(dir, path.join("@punktfunk", "plugin-playnite"), "0.2.0");
writePkg(dir, "effect", "4.0.0"); // an ordinary dep — must not be listed
writePkg(dir, path.join("@punktfunk", "host"), "0.1.1"); // scoped non-plugin — ignored
const found = listInstalled(dir);
expect(found).toEqual([
{ pkg: "@punktfunk/plugin-playnite", version: "0.2.0" },
{ pkg: "punktfunk-plugin-custom", version: "1.2.3" },
]);
});
test("tolerates a plugin with an unreadable package.json", () => {
const dir = tmp("list-broken");
fs.mkdirSync(path.join(dir, "node_modules", "punktfunk-plugin-broken"), {
recursive: true,
});
expect(listInstalled(dir)).toEqual([
{ pkg: "punktfunk-plugin-broken", version: undefined },
]);
});
});
describe("ensurePluginsDir", () => {
test("creates the dir (and parents) and returns it", () => {
const dir = path.join(tmp("ensure-dir"), "nested", "plugins");
expect(ensurePluginsDir(dir)).toBe(dir);
expect(fs.statSync(dir).isDirectory()).toBe(true);
ensurePluginsDir(dir); // idempotent
});
});
-22
View File
@@ -13,25 +13,3 @@ cargo run -p latency-probe # from the repo root
```
Companion to [`loss-harness`](../loss-harness/README.md) (FEC loss sweep).
## Comparing against other clients (`glass-timer.html`)
Overlay-vs-overlay comparisons against other streaming clients are apples-to-oranges: their
stats end at decode / render-*submit*, while Punktfunk's display stage stamps the system's real
on-glass time — a ~2-refresh present tail every composited iOS/tvOS client pays but almost none
can measure. The only fair cross-client number is **photon-to-photon**, and `glass-timer.html`
is the host half of that measurement:
1. Open `glass-timer.html` fullscreen on the streamed host display (click for fullscreen).
2. Stream it, and film the host monitor and the client device **in the same shot** with a
high-speed camera — an iPhone at 240 fps slo-mo gives ~4 ms resolution.
3. Scrub the footage: in any single video frame, the difference between the two on-screen
counters IS the glass-to-glass latency. Read ~20 spread-out frames for a p50.
4. Repeat back-to-back per client (same host, game/scene, network) — Punktfunk, Moonlight, ….
Reading aids, designed to survive motion blur + re-encode: the binary strip encodes
centiseconds as fat cells (read it when the last digits smear); the 100 ms sweep bar compares
by eye for quick sub-10 ms estimates; the corner parity block flips every host frame — if it
ever reads GRAY on the client, the client blended two host frames (repeat/tear tell). A
button-to-photon variant needs no page at all: film controller + client screen and count video
frames from the physical press to the visible reaction.
-87
View File
@@ -1,87 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<!--
Glass-to-glass timer — the HOST half of a camera-based latency comparison
(see README.md § "Comparing against other clients").
Fullscreen this page on the streamed host display, film the host monitor and
the client device TOGETHER with a high-speed camera (an iPhone at 240 fps
slo-mo gives ~4 ms resolution), then read any single video frame: the
difference between the two on-screen counters IS the glass-to-glass latency.
Sample ~20 spread-out video frames for a p50. Works identically for ANY
client (punktfunk, Moonlight, VoidLink, …) — this is the only comparison
that includes the present/display tail most clients cannot measure or show.
Design notes, all in service of slo-mo readability:
- requestAnimationFrame-driven, so the counter advances every host refresh
(the encoder sees damage every frame — no static-scene idling).
- Giant high-contrast ms digits; each is read from ONE video frame, so the
digits must survive motion blur + re-encode. Avoid reading the last digit
if blurred — the bars below disambiguate.
- The binary strip encodes centiseconds as fat black/white cells (a crude
machine/eye-checkable code that survives blur far better than digits).
- The sweep bar wraps every 100 ms; comparing sweep positions host-vs-client
gives a quick sub-10 ms visual estimate without reading digits at all.
- Frame parity block flips black/white every rAF tick: if it ever looks gray
on the CLIENT, the client blended two host frames (a repeat/tear tell).
-->
<head>
<meta charset="utf-8">
<title>glass-to-glass timer</title>
<style>
html, body { margin: 0; height: 100%; background: #000; color: #fff;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
-webkit-user-select: none; user-select: none; overflow: hidden; }
#wrap { display: flex; flex-direction: column; justify-content: center;
align-items: center; height: 100%; gap: 4vh; }
#ms { font-size: 18vw; font-weight: 800; letter-spacing: .04em;
font-variant-numeric: tabular-nums; line-height: 1; }
#sub { font-size: 3vw; color: #888; }
#bits { display: flex; gap: .5vw; }
#bits div { width: 5vw; height: 6vh; background: #222; }
#bits div.on { background: #fff; }
#sweepTrack { position: relative; width: 90vw; height: 6vh; background: #111;
outline: 2px solid #333; }
#sweep { position: absolute; top: 0; width: 2%; height: 100%; background: #fff; }
#parity { position: absolute; right: 2vw; bottom: 2vh; width: 10vw; height: 10vw;
background: #000; outline: 2px solid #333; }
#hint { position: absolute; left: 2vw; bottom: 2vh; color: #555; font-size: 1.6vw; }
</style>
</head>
<body>
<div id="wrap">
<div id="ms">000000</div>
<div id="sub">ms since start — film host + client together @ 240 fps; Δcounters = latency</div>
<div id="bits"></div>
<div id="sweepTrack"><div id="sweep"></div></div>
</div>
<div id="parity"></div>
<div id="hint">click for fullscreen</div>
<script>
const bitsEl = document.getElementById("bits");
for (let i = 0; i < 7; i++) bitsEl.appendChild(document.createElement("div"));
const bits = bitsEl.children;
const msEl = document.getElementById("ms");
const sweep = document.getElementById("sweep");
const parity = document.getElementById("parity");
const t0 = performance.now();
let tick = 0;
function frame(now) {
const t = Math.floor(now - t0);
msEl.textContent = String(t).padStart(6, "0");
// Centiseconds 0-99 as 7 fat binary cells (MSB left) — blur-proof redundancy
// for the two digits that matter most.
const cs = Math.floor(t / 10) % 100;
for (let i = 0; i < 7; i++)
bits[i].className = (cs >> (6 - i)) & 1 ? "on" : "";
// 100 ms sweep: position alone estimates sub-10 ms deltas by eye.
sweep.style.left = ((t % 100) / 100 * 98) + "%";
parity.style.background = (tick++ & 1) ? "#fff" : "#000";
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
document.body.addEventListener("click", () =>
document.documentElement.requestFullscreen?.());
</script>
</body>
</html>
-3
View File
@@ -170,9 +170,6 @@
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
"library_title": "Bibliothek",
"library_empty": "Noch keine Spiele gefunden.",
"library_sources_title": "Spielequellen",
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
"library_store_steam": "Steam",
"library_store_custom": "Eigene",
"library_add_title": "Eigenes Spiel hinzufügen",
-3
View File
@@ -170,9 +170,6 @@
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
"library_title": "Library",
"library_empty": "No games found yet.",
"library_sources_title": "Game sources",
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
"library_sources_failed": "Could not update the game source.",
"library_store_steam": "Steam",
"library_store_custom": "Custom",
"library_add_title": "Add a custom game",
@@ -1,85 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Check } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
getListLibraryScannersQueryKey,
useListLibraryScanners,
useSetLibraryScanner,
} from "@/api/gen/library/library";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Container: the game-source (library scanner) toggles owns the scanner query and the toggle
* mutation. The host only reports the scanners its platform actually has (Steam everywhere,
* Lutris/Heroic on Linux, Epic/GOG/Xbox on Windows), so whatever arrives is renderable as-is.
* Rendered only once the list is loaded: this is a secondary control, and when the API is down
* the grid's own QueryState already tells the story no second error banner.
*/
export const SourceTogglesSection: FC = () => {
const qc = useQueryClient();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const onToggle = async (scanner: ScannerInfo) => {
try {
// The PUT answers with the full updated list — seed the query cache with it directly,
// then refetch the library so the grid reflects the new source set.
const list = await toggle.mutateAsync({
id: scanner.id,
data: { enabled: !scanner.enabled },
});
qc.setQueryData(getListLibraryScannersQueryKey(), list);
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
} catch {
toast.error(m.library_sources_failed());
}
};
if (!scanners.data) return null;
return (
<SourceToggles
scanners={scanners.data}
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
onToggle={onToggle}
/>
);
};
/** The sources card: one pressed/unpressed chip per scanner (pressed = the host scans it). */
export const SourceToggles: FC<{
scanners: ScannerInfo[];
/** Scanner id whose toggle is in flight, or null — only that chip disables. */
busyId: string | null;
onToggle: (scanner: ScannerInfo) => void;
}> = ({ scanners, busyId, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
{scanners.map((scanner) => (
<Button
key={scanner.id}
size="sm"
variant={scanner.enabled ? "default" : "outline"}
aria-pressed={scanner.enabled}
disabled={busyId === scanner.id}
onClick={() => onToggle(scanner)}
>
{scanner.enabled && <Check className="size-4" />}
{scanner.label}
</Button>
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">
{m.library_sources_help()}
</p>
</CardContent>
</Card>
);
-3
View File
@@ -6,7 +6,6 @@ import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { SourceTogglesSection } from "./SourceToggles";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
@@ -38,8 +37,6 @@ export const SectionLibrary: FC = () => {
/>
)}
<SourceTogglesSection />
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
</div>
</Section>
-16
View File
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { GameForm } from "@/sections/Library/GameForm";
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
import { SourceToggles } from "@/sections/Library/SourceToggles";
import { library } from "./lib/fixtures";
const noop = () => {};
@@ -47,21 +46,6 @@ export const Empty: Story = {
),
};
export const Sources: Story = {
render: () => (
<SourceToggles
// A Linux host's scanner set, one turned off — the widest built-in list.
scanners={[
{ id: "steam", label: "Steam", enabled: true },
{ id: "lutris", label: "Lutris", enabled: false },
{ id: "heroic", label: "Heroic (Epic / GOG / Amazon)", enabled: true },
]}
busyId={null}
onToggle={noop}
/>
),
};
export const AddForm: Story = {
render: () => (
<GameForm