Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e4394ee0 | |||
| 9296e1bed7 | |||
| c2bba13405 | |||
| 940a260506 | |||
| 7084ebf78d | |||
| 40c2f68231 | |||
| 8fe90a8a4b | |||
| cac23b7a05 | |||
| 2508b72062 | |||
| aa45757a72 | |||
| cab6350723 | |||
| a042dc2850 | |||
| 67d2964db9 | |||
| 344618e1f0 | |||
| 51f7ff1b3f | |||
| 7a1809547b | |||
| 72583b272e | |||
| 1ef0229bd9 | |||
| fecbec2daf | |||
| ba1caf0281 | |||
| 677a31acd7 | |||
| beb7a4dae9 | |||
| a65c47874d | |||
| b781b38b77 | |||
| 0b8e8312a5 | |||
| f0c511c8fa | |||
| f250db96f4 | |||
| 62af85eaea | |||
| 6fc32ee355 | |||
| ffa7ebb3db | |||
| 8a40e46706 | |||
| 17302ee811 | |||
| 70fd8ac0d3 | |||
| c66acebc41 | |||
| 8a71ed3fd1 | |||
| ba977095f7 | |||
| ab2bcc8e68 | |||
| d2daeacc60 | |||
| 1d587a259e | |||
| 1436c0b9b4 | |||
| fb8deb31a5 | |||
| 40030e90c8 | |||
| 2149673f89 | |||
| c4d6e6a877 |
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
|
||||
@@ -104,8 +104,9 @@ jobs:
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
@@ -1348,6 +1348,117 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -4671,6 +4782,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 */; };
|
||||
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, ); }; };
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -504,6 +504,7 @@
|
||||
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;
|
||||
@@ -540,6 +541,7 @@
|
||||
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;
|
||||
@@ -719,7 +721,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -764,7 +766,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -861,15 +863,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,6 +55,11 @@
|
||||
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(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
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(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
@@ -184,3 +184,47 @@ 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,43 +19,69 @@ 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) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.brand)
|
||||
Text(context.attributes.hostName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.leading, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 64, alignment: .trailing)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
// 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)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
CompactReadout(state: context.state)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
}
|
||||
.keylineTint(Color.brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,21 +95,20 @@ private struct LockScreenView: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
.foregroundStyle(Color.brand)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
StatusLine(state: context.state)
|
||||
StatsLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
@@ -95,46 +120,237 @@ private struct LockScreenView: View {
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
/// 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 {
|
||||
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")
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Background · ends in")
|
||||
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:
|
||||
EmptyView()
|
||||
if let ms = state.latencyMs {
|
||||
Text("\(ms) ms")
|
||||
.font(.caption2.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
ElapsedClock(startedAt: state.startedAt)
|
||||
.font(.caption2)
|
||||
.frame(maxWidth: 48)
|
||||
}
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Keeps running for")
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: 48)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
Image(systemName: "stop.fill").foregroundStyle(.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 {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
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)
|
||||
}
|
||||
.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,6 +89,11 @@ 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
|
||||
@@ -193,6 +198,9 @@ 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 }
|
||||
@@ -492,12 +500,19 @@ 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.
|
||||
// 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).
|
||||
.overlay {
|
||||
if pendingFingerprint == nil {
|
||||
ResizeIndicatorView(active: model.resizing)
|
||||
if pendingFingerprint == nil, model.resizing {
|
||||
ResizeIndicatorView(active: true)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||
if let fp = pendingFingerprint {
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
@@ -587,7 +602,8 @@ struct ContentView: View {
|
||||
},
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
displayMeter: model.displayStage,
|
||||
presentFloorMeter: model.presentFloor
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||
@@ -636,18 +652,27 @@ 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 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.
|
||||
// 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.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -656,6 +681,12 @@ 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,6 +102,20 @@ 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 vend→glass 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
|
||||
@@ -133,6 +147,9 @@ 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?
|
||||
@@ -472,6 +489,7 @@ final class SessionModel: ObservableObject {
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
osFloorValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
@@ -655,15 +673,25 @@ 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 presents≪fps 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",
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -671,7 +699,10 @@ final class SessionModel: ObservableObject {
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -1,
|
||||
self.displayValid ? self.displayP50Ms : -1,
|
||||
lost)
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ struct StreamHUDView: View {
|
||||
private var compactLine: String {
|
||||
var parts = ["\(model.fps) fps"]
|
||||
if model.endToEndValid {
|
||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||
// 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))
|
||||
} else if model.hostNetworkValid {
|
||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||
}
|
||||
@@ -86,24 +88,36 @@ struct StreamHUDView: View {
|
||||
}
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// 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)")")
|
||||
// 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)")")
|
||||
.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.
|
||||
// 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.
|
||||
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.displayP50Ms, specifier: "%.1f")")
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, 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,7 +40,9 @@ 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.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -288,11 +290,19 @@ struct GamepadSettingsView: View {
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
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 },
|
||||
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 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
// 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
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@@ -16,9 +19,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"
|
||||
}
|
||||
}
|
||||
@@ -27,9 +30,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,28 +37,31 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// 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
|
||||
}
|
||||
/// 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"
|
||||
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||
/// an untouched picker shows what actually runs.
|
||||
static var presenterDefault: String {
|
||||
#if os(tvOS)
|
||||
"stage3"
|
||||
#else
|
||||
"stage2"
|
||||
#endif
|
||||
/// 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),
|
||||
]
|
||||
}
|
||||
|
||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// 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
|
||||
@@ -8,18 +18,23 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
// MARK: - Display: Resolution
|
||||
|
||||
// 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 streamModeSection: some View {
|
||||
Section {
|
||||
@ViewBuilder var resolutionSection: some View {
|
||||
Section("Resolution") {
|
||||
#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.
|
||||
Toggle("Match window", isOn: $matchWindow)
|
||||
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)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
iosResolutionWheel
|
||||
@@ -32,75 +47,19 @@ extension SettingsView {
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
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))
|
||||
}
|
||||
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: - Stream mode (iOS wheel)
|
||||
// MARK: - Display: Resolution (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
|
||||
@@ -119,6 +78,11 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,10 +174,69 @@ 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 {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
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)
|
||||
}
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
@@ -233,26 +256,228 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
// 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 os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
// 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
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
#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)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
described("This device's microphone feeds the host's virtual mic.") {
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
}
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
@@ -268,264 +493,27 @@ 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 {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text(Self.audioFooter)
|
||||
Text("Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#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) is the proven default;
|
||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||
// 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) {
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 3 (experimental)").tag("stage3")
|
||||
#if DEBUG
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
#endif
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, 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 — at most one undisplayed frame in flight, 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)
|
||||
}
|
||||
}
|
||||
// MARK: - Controllers
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
@@ -537,24 +525,37 @@ extension SettingsView {
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
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("Controller type", selection: $gamepadType) {
|
||||
ForEach(SettingsOptions.padTypes, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
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)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
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)
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
@@ -564,22 +565,9 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
// 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)
|
||||
Text("Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@ 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
|
||||
@@ -17,6 +42,7 @@ 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 "
|
||||
@@ -53,55 +79,27 @@ extension SettingsView {
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
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."
|
||||
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."
|
||||
#if os(macOS)
|
||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
||||
#elseif os(iOS)
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||
+ "any time."
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers 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,13 +2,15 @@
|
||||
// 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 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
|
||||
// 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
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -33,7 +35,9 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
#endif
|
||||
@@ -120,27 +124,32 @@ 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 {
|
||||
streamModeSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
vsyncSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Display", systemImage: "display") }
|
||||
|
||||
Form {
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
||||
|
||||
Form {
|
||||
audioSection
|
||||
}
|
||||
@@ -168,16 +177,10 @@ 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: 480, height: 460)
|
||||
.frame(width: 500, height: 520)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -252,26 +255,31 @@ struct SettingsView: View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
statisticsSection
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .input:
|
||||
Form {
|
||||
pointerSection
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Input")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
@@ -282,11 +290,6 @@ 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
|
||||
@@ -332,6 +335,16 @@ 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
|
||||
@@ -344,6 +357,9 @@ 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)
|
||||
@@ -355,37 +371,41 @@ 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)
|
||||
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)
|
||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
||||
+ "it before streaming.")
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||
@@ -405,11 +425,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@@ -57,30 +57,8 @@ public enum BrandFont {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
||||
// extension links Shared alone and must render the same purple.
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
|
||||
@@ -602,6 +602,36 @@ 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
|
||||
@@ -617,10 +647,15 @@ 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
|
||||
@@ -674,7 +709,8 @@ public final class MetalVideoPresenter {
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
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]
|
||||
@@ -693,6 +729,7 @@ public final class MetalVideoPresenter {
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
@@ -742,7 +779,8 @@ public final class MetalVideoPresenter {
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
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]
|
||||
@@ -869,9 +907,17 @@ 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?, onPresented: ((Int64?) -> Void)?,
|
||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
||||
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
|
||||
@@ -884,11 +930,17 @@ 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
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
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(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit
|
||||
// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the
|
||||
// default; 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.
|
||||
// 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.
|
||||
//
|
||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||
|
||||
@@ -26,15 +30,17 @@ public final class DisplayLinkProxy: NSObject {
|
||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
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)
|
||||
@@ -48,31 +54,92 @@ 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.
|
||||
/// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||
/// 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), 22–28 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.
|
||||
static var platformDefault: PresenterChoice {
|
||||
#if os(tvOS)
|
||||
.stage3
|
||||
#if os(iOS) || os(tvOS)
|
||||
.stage4
|
||||
#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` ∈ 1…3;
|
||||
/// 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
|
||||
@@ -92,6 +159,7 @@ 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 }
|
||||
@@ -99,6 +167,30 @@ final class SessionPresenter {
|
||||
return .arrival
|
||||
}
|
||||
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
|
||||
///
|
||||
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
||||
/// 22–28 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.
|
||||
///
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) 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.
|
||||
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 }
|
||||
return 1
|
||||
#endif
|
||||
}
|
||||
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
@@ -132,6 +224,7 @@ 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)?,
|
||||
@@ -140,28 +233,50 @@ final class SessionPresenter {
|
||||
stop()
|
||||
self.connection = connection
|
||||
|
||||
// Presenter choice — stage-2 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. Stage-3 is
|
||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
// 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).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
let explicit = PresenterChoice.explicit(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
||||
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,
|
||||
pacing: Self.pacing(
|
||||
for: choice, explicit: explicit, codec: connection.videoCodec)) {
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
pacing: pacing,
|
||||
gateDepth: Self.gateDepth(
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
||||
storePolicy: priority.storePolicy,
|
||||
vsyncPaced: vsyncPaced) {
|
||||
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().
|
||||
@@ -178,14 +293,19 @@ final class SessionPresenter {
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||
let proxy = DisplayLinkProxy { [weak self] link in
|
||||
self?.stage2?.renderTick(
|
||||
targetMediaTime: link.targetTimestamp,
|
||||
period: link.targetTimestamp - link.timestamp)
|
||||
// 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 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,
|
||||
@@ -213,7 +333,12 @@ 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, let link = stage2Link else { return }
|
||||
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 }
|
||||
let hzF = Float(hz)
|
||||
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||
#if os(macOS)
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
// – 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 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 ONE undisplayed drawable so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • 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.
|
||||
// • 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 capture→on-glass + decode and
|
||||
@@ -40,6 +44,7 @@ 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
|
||||
@@ -47,33 +52,127 @@ import QuartzCore
|
||||
/// stdout is the cheapest reliable capture channel.
|
||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
private let lock = NSLock()
|
||||
private var frame: ReadyFrame?
|
||||
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||
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 submitted = 0
|
||||
func submit(_ f: ReadyFrame) {
|
||||
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||
/// 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 drainSubmitted() -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
let n = submitted; submitted = 0; return n
|
||||
}
|
||||
func take() -> ReadyFrame? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
let f = frame; frame = nil; return f
|
||||
}
|
||||
/// 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) {
|
||||
|
||||
func submit(_ f: Frame) {
|
||||
lock.lock()
|
||||
if frame == nil { frame = f }
|
||||
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
|
||||
}
|
||||
|
||||
/// 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() -> Frame? {
|
||||
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]
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -107,21 +206,33 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// - `arrival` (stage-2, the macOS default): present the moment a frame is decoded. Lowest latency
|
||||
/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable
|
||||
/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||
/// - `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
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
@@ -130,45 +241,176 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
case deadline
|
||||
}
|
||||
|
||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||
/// releaser runs on a Metal callback thread.
|
||||
/// 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 vend→glass
|
||||
/// 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.
|
||||
/// The render thread `tryAcquire`s before taking a frame; the drawable's presented handler
|
||||
/// `release`s and re-signals the render thread. Depth 1 fully serializes presents on the on-glass
|
||||
/// callback — which costs a refresh whenever the callback's own latency pushes the next present
|
||||
/// past a vsync; depth 2 keeps one flip queued behind the one scanning out, so a decoded frame
|
||||
/// presents immediately and latches the very next vsync while the queue still can't build (see
|
||||
/// `SessionPresenter.gateDepth` for the per-platform choice). `staleAfter` is insurance against a
|
||||
/// present whose handler never fires (the macOS "out-of-band presents aren't damage" hazard class
|
||||
/// — see MetalVideoPresenter's init post-mortem): rather than freezing the stream, a full gate
|
||||
/// force-opens a slot 100 ms after its oldest present, a visible ~10 fps degradation that
|
||||
/// PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 on healthy systems). Internal
|
||||
/// (not private) for unit tests. Sendable; lock-guarded — the releaser runs on a Metal callback
|
||||
/// thread.
|
||||
final class PresentGate: @unchecked Sendable {
|
||||
/// How long one pending present may hold the gate before it's presumed lost.
|
||||
/// How long one pending present may hold its slot before it's presumed lost.
|
||||
static let staleAfter: CFTimeInterval = 0.1
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending = false
|
||||
private var armedAt: CFTimeInterval = 0
|
||||
private let capacity: Int
|
||||
/// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries).
|
||||
private var armed: [CFTimeInterval] = []
|
||||
private var forced = 0
|
||||
|
||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||
/// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc.
|
||||
init(capacity: Int = 1) {
|
||||
self.capacity = max(1, capacity)
|
||||
}
|
||||
|
||||
/// Arm the gate for one present. False = the gate is full of live presents (none stale) —
|
||||
/// leave the frame in the ring; a presented handler's release/re-signal (or the next
|
||||
/// display-link tick) retries with the freshest frame then.
|
||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending {
|
||||
guard now - armedAt > Self.staleAfter else { return false }
|
||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||
if armed.count >= capacity {
|
||||
// Full: reopen only by presuming the OLDEST in-flight present lost (its handler
|
||||
// never fired) rather than stalling the stream.
|
||||
guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false }
|
||||
armed.removeFirst()
|
||||
forced += 1
|
||||
}
|
||||
pending = true
|
||||
armedAt = now
|
||||
armed.append(now)
|
||||
return true
|
||||
}
|
||||
|
||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||
/// One in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — free the oldest slot. A release with nothing in flight is a no-op; a
|
||||
/// lost present's handler firing late after its stale force-open can transiently over-admit
|
||||
/// one flip, which the next glass callback corrects.
|
||||
func release() {
|
||||
lock.lock()
|
||||
pending = false
|
||||
if !armed.isEmpty { armed.removeFirst() }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@@ -189,13 +431,23 @@ 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
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 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 should peg it at 1).
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
@@ -206,6 +458,14 @@ 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 {
|
||||
@@ -219,40 +479,59 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func presented(atNs: Int64?) {
|
||||
func presented(atNs: Int64?, issuedNs: 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: ReadyRing, gate: PresentGate?) {
|
||||
func flushIfDue(ring: FrameStore<ReadyFrame>, 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 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
|
||||
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
|
||||
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()
|
||||
print(line)
|
||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +558,27 @@ private final class DecodeReport: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let ring: FrameStore<ReadyFrame>
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
/// 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).
|
||||
/// 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).
|
||||
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 vend→glass 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).
|
||||
@@ -318,6 +609,9 @@ 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 }
|
||||
@@ -328,19 +622,28 @@ public final class Stage2Pipeline {
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||
/// (glass-gated) present cadence — see PresentPacing.
|
||||
/// (glass-gated) present cadence — see PresentPacing; `gateDepth` is the glass gate's
|
||||
/// in-flight present budget (see `SessionPresenter.gateDepth`).
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival
|
||||
presentFloorMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival,
|
||||
gateDepth: Int = 1,
|
||||
storePolicy: FrameStorePolicy = .newestWins,
|
||||
vsyncPaced: Bool = false
|
||||
) {
|
||||
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
|
||||
@@ -515,6 +818,17 @@ 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
|
||||
@@ -529,16 +843,21 @@ 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"]
|
||||
let vsyncEnabled = presentMode == "vsync"
|
||||
// `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"
|
||||
|| (presentMode != "immediate"
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// Stage-3's one-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() : nil
|
||||
// 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
|
||||
@@ -548,6 +867,15 @@ 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
|
||||
@@ -568,6 +896,7 @@ 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.
|
||||
@@ -585,7 +914,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)
|
||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||
}
|
||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||
@@ -604,6 +933,8 @@ 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)
|
||||
} }
|
||||
@@ -614,16 +945,168 @@ 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.
|
||||
/// replacement frame. Arrival/glass pacing only — deadline sessions have no CADisplayLink
|
||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
||||
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) {
|
||||
@@ -686,7 +1169,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: ReadyRing, renderSignal: DispatchSemaphore,
|
||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
|
||||
@@ -65,19 +65,21 @@ public enum Stage444Probe {
|
||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||
|
||||
var produced: OSType = 0
|
||||
let done = DispatchSemaphore(value: 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 status = VTDecompressionSessionDecodeFrame(
|
||||
session, sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||
flags: [], 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,6 +93,7 @@ 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
|
||||
@@ -115,7 +116,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -128,6 +130,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeNSView(context: Context) -> StreamLayerView {
|
||||
@@ -138,6 +141,7 @@ 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)
|
||||
@@ -151,6 +155,7 @@ 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
|
||||
@@ -172,6 +177,7 @@ 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()
|
||||
@@ -661,6 +667,7 @@ public final class StreamLayerView: NSView {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
|
||||
@@ -61,6 +61,7 @@ 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
|
||||
@@ -77,7 +78,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -89,6 +91,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||
@@ -98,6 +101,7 @@ 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)
|
||||
@@ -110,6 +114,7 @@ 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 {
|
||||
@@ -145,6 +150,7 @@ 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()
|
||||
@@ -406,6 +412,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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,12 +50,21 @@ public enum DefaultsKey {
|
||||
/// discrete channel, and the default N→stereo 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"
|
||||
/// 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.
|
||||
/// 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.
|
||||
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 1…3.
|
||||
/// 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,13 +4,15 @@ import XCTest
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||
/// 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.
|
||||
final class PresentPacingTests: XCTestCase {
|
||||
// MARK: - PresentGate
|
||||
|
||||
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
/// The depth-1 invariant: one present in flight. A second acquire while pending must fail
|
||||
/// (the frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
func testGateAdmitsOneInFlightPresent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||
@@ -20,6 +22,34 @@ 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.
|
||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.001), "depth 2 must admit a queued second flip")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.002), "the third present must wait for glass")
|
||||
gate.release()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.003), "one glass callback frees one slot")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.004))
|
||||
XCTAssertEqual(gate.drainForced(), 0)
|
||||
}
|
||||
|
||||
/// Depth 2 staleness anchors to the OLDEST in-flight present: a full gate stays closed while
|
||||
/// the oldest is live, force-opens once it ages out, and the younger present keeps its slot.
|
||||
func testGateDepthTwoForceOpensOnTheOldestStalePresent() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10.05))
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||
XCTAssertEqual(gate.drainForced(), 1)
|
||||
// The 10.05 present is still live, so the gate is full again right after the force-open.
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.02))
|
||||
}
|
||||
|
||||
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
|
||||
/// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate
|
||||
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||
@@ -45,13 +75,150 @@ 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 intent→store 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
|
||||
|
||||
func testPresenterChoiceDefaultsToStage2() {
|
||||
/// 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.
|
||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||
#if os(iOS) || os(tvOS)
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
||||
#else
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
}
|
||||
@@ -69,15 +236,40 @@ 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 stage-2.
|
||||
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
}
|
||||
|
||||
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
|
||||
@@ -117,6 +309,36 @@ 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 14→22–28 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.
|
||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
||||
#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")
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||
"out-of-range env values fall back to the platform depth")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "garbage"), SessionPresenter.gateDepth(env: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -55,6 +55,11 @@ 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,
|
||||
@@ -160,6 +165,42 @@ 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).
|
||||
{
|
||||
@@ -197,6 +238,7 @@ impl SimpleComponent for AppModel {
|
||||
settings,
|
||||
identity,
|
||||
gamepad: init.gamepad,
|
||||
probes,
|
||||
hosts,
|
||||
busy: false,
|
||||
wake_fallback: None,
|
||||
@@ -307,8 +349,15 @@ 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.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
//
|
||||
// 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());
|
||||
}
|
||||
sender.input(AppMsg::Connect(req));
|
||||
}
|
||||
}
|
||||
@@ -435,6 +484,7 @@ 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);
|
||||
|
||||
@@ -352,6 +352,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
@@ -534,7 +535,34 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||
"pair" | "05-pair" => {
|
||||
|
||||
+414
-102
@@ -1,5 +1,9 @@
|
||||
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||
//! capture behavior. Written back to disk when the dialog closes.
|
||||
//! 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.
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
@@ -40,14 +44,31 @@ const GAMEPADS: &[&str] = &[
|
||||
"steamdeck",
|
||||
];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// 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"];
|
||||
/// 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)",
|
||||
];
|
||||
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!(
|
||||
@@ -266,22 +287,92 @@ 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 experimental library toggle takes effect without a nav round-trip).
|
||||
/// 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.
|
||||
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();
|
||||
|
||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
// ---- Display: Resolution ----
|
||||
// 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())
|
||||
@@ -297,10 +388,13 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Resolution",
|
||||
"The host creates a virtual output at exactly this size — Match window follows \
|
||||
the stream window, including mid-stream resizes",
|
||||
resolution_caption(0),
|
||||
&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| {
|
||||
@@ -315,9 +409,11 @@ 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))
|
||||
@@ -326,13 +422,69 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Render scale",
|
||||
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
|
||||
(< 1×) for a lighter host — this device resamples to the window",
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&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 · run a speed test before going high");
|
||||
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 ----
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -346,19 +498,19 @@ pub fn show(
|
||||
"gamescope",
|
||||
],
|
||||
);
|
||||
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",
|
||||
],
|
||||
);
|
||||
|
||||
// ---- 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 stats_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -366,20 +518,101 @@ pub fn show(
|
||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||
&["Off", "Compact", "Normal", "Detailed"],
|
||||
);
|
||||
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")
|
||||
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",
|
||||
)
|
||||
.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());
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// ---- 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 ----
|
||||
// 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`),
|
||||
@@ -413,7 +646,7 @@ pub fn show(
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} else {
|
||||
"All controllers are forwarded, each as its own player; pick one to force single-player"
|
||||
"Every pad is its own player — pick one to force single-player"
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
@@ -443,7 +676,7 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||
"The virtual pad on the host — Automatic matches your controller",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
@@ -453,66 +686,8 @@ 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);
|
||||
|
||||
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.
|
||||
// ---- Seed from the current settings ----
|
||||
{
|
||||
let s = settings.borrow();
|
||||
let res_i = if s.match_window {
|
||||
@@ -525,6 +700,7 @@ 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
|
||||
@@ -540,6 +716,8 @@ 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)
|
||||
@@ -553,8 +731,11 @@ 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,
|
||||
@@ -563,9 +744,104 @@ 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));
|
||||
}
|
||||
|
||||
dialog.add(&page);
|
||||
// ---- 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 & 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(>k::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.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
@@ -587,12 +863,25 @@ 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,
|
||||
@@ -605,6 +894,7 @@ pub fn show(
|
||||
on_closed();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
dialog
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -678,6 +968,17 @@ 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>()
|
||||
@@ -698,5 +999,16 @@ 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ 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);
|
||||
@@ -452,6 +453,7 @@ impl ServiceState {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
|
||||
@@ -103,6 +103,14 @@ 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
|
||||
@@ -165,6 +173,7 @@ 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.
|
||||
@@ -259,19 +268,66 @@ 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 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +429,7 @@ 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.
|
||||
|
||||
@@ -12,6 +12,12 @@ 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")
|
||||
|
||||
@@ -57,6 +57,23 @@
|
||||
<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. Same full-trust executable, launched with
|
||||
`--console`, which hands straight off to the session binary's controller-driven
|
||||
browse mode (host list, pairing, settings, library) fullscreen.
|
||||
-->
|
||||
<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>
|
||||
|
||||
@@ -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', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.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?)" }
|
||||
|
||||
@@ -36,7 +36,9 @@ pub(crate) fn initiate_waking(
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
if ctx.settings.lock().unwrap().auto_wake {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||
}
|
||||
|
||||
@@ -272,6 +274,7 @@ fn connect_spawn(
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -291,9 +294,13 @@ fn connect_spawn(
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
}
|
||||
Some((_, false)) if wake_on_fail => {
|
||||
Some((_, false))
|
||||
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
|
||||
{
|
||||
// The dial-first attempt to a non-advertising host failed — it
|
||||
// may genuinely be asleep. NOW wake and wait.
|
||||
// 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.
|
||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||
}
|
||||
Some((msg, false)) => {
|
||||
@@ -321,9 +328,12 @@ 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: Target,
|
||||
target: Option<Target>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
@@ -331,15 +341,21 @@ 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);
|
||||
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||
if let Some(t) = target.clone() {
|
||||
*ctx.shared.target.lock().unwrap() = t;
|
||||
}
|
||||
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 spawned =
|
||||
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||
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| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
match event {
|
||||
SpawnEvent::Ready => {
|
||||
@@ -357,7 +373,8 @@ pub(crate) fn open_console(
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Err(e) = spawned {
|
||||
set_status.call(e);
|
||||
set_screen.call(Screen::Hosts);
|
||||
@@ -467,6 +484,7 @@ fn wake_and_connect(
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
|
||||
+192
-130
@@ -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 / rename /
|
||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit /
|
||||
//! 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,10 +14,12 @@ 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";
|
||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||
/// 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_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||
@@ -187,43 +189,114 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 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)>>,
|
||||
/// 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)>>,
|
||||
) -> Element {
|
||||
let commit = {
|
||||
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||
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(),
|
||||
);
|
||||
move || {
|
||||
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 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 _ = known.save();
|
||||
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();
|
||||
}
|
||||
sr.call(None);
|
||||
let _ = known.save();
|
||||
se.call(None);
|
||||
}
|
||||
};
|
||||
let on_changed = {
|
||||
let live = live.clone();
|
||||
move |s: String| live.set(s)
|
||||
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 (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((
|
||||
text_box(initial)
|
||||
.placeholder_text("Host name")
|
||||
.on_text_changed(on_changed),
|
||||
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),
|
||||
hstack((
|
||||
button("Save")
|
||||
.accent()
|
||||
@@ -231,7 +304,7 @@ fn rename_editor(
|
||||
.on_click(commit),
|
||||
button("Cancel")
|
||||
.subtle()
|
||||
.on_click(move || set_rename.call(None)),
|
||||
.on_click(move || set_edit.call(None)),
|
||||
))
|
||||
.spacing(4.0),
|
||||
))
|
||||
@@ -264,16 +337,41 @@ 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 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);
|
||||
// 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);
|
||||
{
|
||||
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||
if *rename_seed.borrow() != active {
|
||||
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||
rename_seed.set(active);
|
||||
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);
|
||||
}
|
||||
}
|
||||
let hover = Hover {
|
||||
@@ -314,20 +412,51 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
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)
|
||||
}),
|
||||
))
|
||||
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
|
||||
})
|
||||
.spacing(8.0)
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
@@ -347,84 +476,23 @@ 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 renamed" case explicitly.
|
||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
let (fp, initial) = rename.clone().unwrap();
|
||||
tiles.push(rename_editor(
|
||||
tiles.push(edit_editor(
|
||||
&fp,
|
||||
&initial,
|
||||
fp,
|
||||
rename_draft.clone(),
|
||||
name_draft.clone(),
|
||||
addr_draft.clone(),
|
||||
port_draft.clone(),
|
||||
mac_draft.clone(),
|
||||
clip_draft.clone(),
|
||||
set_rename.clone(),
|
||||
));
|
||||
continue;
|
||||
@@ -471,15 +539,12 @@ 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_RENAME));
|
||||
items.push(menu_item(MENU_EDIT));
|
||||
items.push(menu_separator());
|
||||
items.push(menu_item(MENU_FORGET));
|
||||
items
|
||||
@@ -493,9 +558,6 @@ 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();
|
||||
@@ -507,7 +569,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_RENAME => sr.call(Some((fp.clone(), name.clone()))),
|
||||
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
|
||||
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
||||
_ => {}
|
||||
})
|
||||
|
||||
@@ -241,7 +241,8 @@ 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).
|
||||
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||
// 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());
|
||||
// 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.
|
||||
|
||||
@@ -59,6 +59,7 @@ 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);
|
||||
|
||||
+324
-126
@@ -1,5 +1,15 @@
|
||||
//! 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};
|
||||
@@ -38,7 +48,8 @@ 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 (GPU / Vulkan Video)"),
|
||||
("vulkan", "Hardware (Vulkan Video)"),
|
||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
@@ -51,6 +62,9 @@ const CODECS: &[(&str, &str)] = &[
|
||||
("hevc", "HEVC (H.265)"),
|
||||
("h264", "H.264 (AVC)"),
|
||||
("av1", "AV1"),
|
||||
// Preference-only by design: `resolve_codec` never auto-picks PyroWave, and asking for
|
||||
// it on a host or device that can't do it simply falls back down the ladder to HEVC.
|
||||
("pyrowave", "PyroWave (wired LAN)"),
|
||||
];
|
||||
/// Virtual-pad presets: `(stored value, display label)` — the pad the HOST creates. Same set the
|
||||
/// GTK client offers; "Automatic" resolves from the physical controller at connect.
|
||||
@@ -134,11 +148,63 @@ fn setting_toggle(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
||||
@@ -183,12 +249,7 @@ 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()
|
||||
@@ -205,8 +266,7 @@ 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()
|
||||
@@ -220,36 +280,26 @@ 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();
|
||||
})
|
||||
.tooltip(
|
||||
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||
choice is unavailable.",
|
||||
);
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
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();
|
||||
@@ -268,18 +318,11 @@ 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.",
|
||||
);
|
||||
});
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
// round-trips exactly.
|
||||
let bitrate_box = {
|
||||
@@ -292,18 +335,10 @@ 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;
|
||||
@@ -348,60 +383,44 @@ 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();
|
||||
})
|
||||
.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 invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
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();
|
||||
@@ -412,10 +431,6 @@ 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
|
||||
@@ -428,70 +443,227 @@ pub(crate) fn settings_page(
|
||||
))
|
||||
.spacing(2.0);
|
||||
|
||||
// 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(),
|
||||
]),
|
||||
// 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."),
|
||||
),
|
||||
),
|
||||
"audio" => (
|
||||
"Audio",
|
||||
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
|
||||
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."),
|
||||
),
|
||||
),
|
||||
"about" => (
|
||||
"About",
|
||||
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(),
|
||||
]),
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
None,
|
||||
),
|
||||
),
|
||||
// "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
|
||||
@@ -502,12 +674,38 @@ 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.
|
||||
let content = page_wide(vec![card.with_key(section)])
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
// 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));
|
||||
NavigationView::new(items, content)
|
||||
.pane_title("Settings")
|
||||
.header(title)
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
let ss = set_section.clone();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `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() {}
|
||||
+35
-10
@@ -41,16 +41,41 @@ fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
|
||||
/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list.
|
||||
/// The picker only shows when this has more than one entry.
|
||||
///
|
||||
/// **Deduplicated by description**, because the description IS the identity everywhere
|
||||
/// downstream: the pick is persisted as that string (`Settings::adapter`) and matched by
|
||||
/// name in the session binary (`PUNKTFUNK_VK_ADAPTER`). So two entries with the same name
|
||||
/// are one selectable choice however many times DXGI enumerates them — listing it twice
|
||||
/// only offers the user a meaningless coin flip. Seen live on an Intel Arc laptop
|
||||
/// (2026-07-19), whose Vulkan ICD likewise enumerates the one physical iGPU twice.
|
||||
pub fn adapter_names() -> Vec<String> {
|
||||
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
||||
all_adapters()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(adapter_name)
|
||||
.collect()
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for a in all_adapters() {
|
||||
let desc1 = a
|
||||
.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.ok();
|
||||
let name = adapter_name(&a);
|
||||
// Forensics for the next duplicate/oddity report — which adapters DXGI actually
|
||||
// returned, and whether the repeats share a LUID (one adapter enumerated twice)
|
||||
// or are distinct devices that merely present the same description.
|
||||
if let Some(d) = &desc1 {
|
||||
tracing::debug!(
|
||||
name = %name,
|
||||
luid = format!("{:08x}-{:08x}", d.AdapterLuid.HighPart, d.AdapterLuid.LowPart),
|
||||
vendor = format_args!("{:#06x}", d.VendorId),
|
||||
device = format_args!("{:#06x}", d.DeviceId),
|
||||
flags = d.Flags,
|
||||
"DXGI adapter"
|
||||
);
|
||||
}
|
||||
if desc1.is_some_and(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE != 0) {
|
||||
continue; // WARP / software renderer — never a streaming target
|
||||
}
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
@@ -76,6 +76,27 @@ 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).
|
||||
|
||||
@@ -126,21 +126,26 @@ 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(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
target: Option<(&str, u16)>,
|
||||
fullscreen: bool,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--browse")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--json-status");
|
||||
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");
|
||||
if fullscreen {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
||||
spawn_with(cmd, &label, slot, on_event)
|
||||
}
|
||||
|
||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||
|
||||
@@ -244,8 +244,13 @@ 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 encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
pub pyrowave_modifiers: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -255,9 +255,11 @@ fn spawn_pipewire(
|
||||
want_hdr
|
||||
};
|
||||
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
||||
// 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;
|
||||
// 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);
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-pipewire".into())
|
||||
.spawn(move || {
|
||||
@@ -1945,16 +1947,18 @@ 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 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).
|
||||
// (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).
|
||||
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 && !backend_is_vaapi && !want_hdr {
|
||||
let mut importer = if zerocopy && !raw_passthrough && !want_hdr {
|
||||
if pf_zerocopy::gpu_import_disabled() {
|
||||
tracing::warn!(
|
||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||
@@ -1980,9 +1984,11 @@ 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");
|
||||
// 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;
|
||||
// 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;
|
||||
// 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:
|
||||
@@ -1998,8 +2004,9 @@ 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 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 session (or the global encoder pref) is
|
||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
||||
// own (the emptiness check gates it).
|
||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !modifiers.contains(&m) {
|
||||
@@ -2019,11 +2026,11 @@ mod pipewire {
|
||||
);
|
||||
} else if zerocopy && !want_dmabuf {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough {
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
tracing::info!(
|
||||
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||
);
|
||||
} else if want_dmabuf {
|
||||
} else if want_dmabuf && !vaapi_passthrough {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
|
||||
@@ -24,6 +24,14 @@ ffmpeg-next = "8"
|
||||
opus = "0.3"
|
||||
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device, so it builds wherever the
|
||||
# spawned Vulkan session presenter runs: Linux AND Windows (pyrowave-sys covers both; it
|
||||
# is an empty stub elsewhere). `ash` only wraps the presenter's existing raw handles
|
||||
# (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
@@ -40,11 +48,6 @@ tracing = "0.1"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pipewire = "0.9"
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device. `ash` only wraps the
|
||||
# presenter's existing raw handles (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
@@ -61,6 +64,10 @@ 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]
|
||||
|
||||
@@ -20,6 +20,83 @@ 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
|
||||
@@ -118,20 +195,26 @@ fn pw_thread(
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
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 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 ud = PlayerData {
|
||||
rx: pcm_rx,
|
||||
@@ -316,18 +399,23 @@ fn mic_thread(
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
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 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 ud = MicData {
|
||||
connector: connector.clone(),
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! 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")
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,19 @@ mod video_software;
|
||||
mod video_vaapi;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod video_vulkan;
|
||||
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||
// present-path decision and the Apple Metal port are their own phases).
|
||||
// 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
|
||||
// dmabuf, no D3D11 interop), so the old "Windows present-path decision" that gated it
|
||||
// resolved itself — the present path is now literally the same code.
|
||||
#[cfg(windows)]
|
||||
pub mod video_d3d11;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pub mod video_pyrowave;
|
||||
|
||||
pub mod wol;
|
||||
|
||||
@@ -41,6 +41,9 @@ 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,
|
||||
@@ -227,7 +230,7 @@ fn pump(
|
||||
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
||||
#[allow(unused_mut)]
|
||||
let mut preferred = params.preferred_codec;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if std::env::var("PUNKTFUNK_PREFER_PYROWAVE").as_deref() == Ok("1") {
|
||||
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
@@ -296,7 +299,7 @@ fn pump(
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
let mode = connector.mode();
|
||||
// The wavelet bitstream has no VUI: the negotiated Welcome colour signalling IS
|
||||
@@ -325,7 +328,7 @@ fn pump(
|
||||
} else {
|
||||
Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref())
|
||||
};
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
#[cfg(not(all(any(target_os = "linux", windows), feature = "pyrowave")))]
|
||||
let built = Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref());
|
||||
let mut decoder = match built {
|
||||
Ok(d) => d,
|
||||
@@ -339,6 +342,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// 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(|| {
|
||||
@@ -528,7 +545,7 @@ fn pump(
|
||||
DecodedImage::VkFrame(_) => "vulkan",
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(_) => "d3d11va",
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(_) => "pyrowave",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
@@ -539,7 +556,10 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(
|
||||
any(target_os = "linux", windows),
|
||||
feature = "pyrowave"
|
||||
))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
@@ -796,6 +816,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,12 @@ 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)]
|
||||
@@ -201,6 +207,7 @@ 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();
|
||||
}
|
||||
@@ -526,6 +533,27 @@ 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
|
||||
@@ -614,6 +642,10 @@ 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,9 +1,10 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||
//!
|
||||
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
||||
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||
//! Three backends, picked at session start (auto is vendor-ordered on BOTH desktop OSes —
|
||||
//! see [`VulkanDecodeDevice::prefer_vulkan_first`]. Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh. Windows: d3d11va → vulkan → software on
|
||||
//! Intel/unknown, vulkan first on NVIDIA/AMD.
|
||||
//! Override: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`):
|
||||
//!
|
||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
|
||||
@@ -22,9 +23,12 @@
|
||||
//! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out.
|
||||
//!
|
||||
//! On Windows the VAAPI/dmabuf backend does not exist (DRM-PRIME is a Linux concept); the
|
||||
//! chain there is Vulkan → **D3D11VA** (`crate::video_d3d11` — the vendor-agnostic DXVA
|
||||
//! path, which is how Intel's Windows driver gets hardware decode without Vulkan Video)
|
||||
//! → software. Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||||
//! hardware pair there is Vulkan Video and **D3D11VA** (`crate::video_d3d11` — the
|
||||
//! vendor-agnostic DXVA path every Windows video player exercises), ordered per vendor:
|
||||
//! Intel's driver DOES advertise Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan
|
||||
//! on it strobes and burns the frame budget (B580 field report, 2026-07) where D3D11VA
|
||||
//! streams clean — so Intel/unknown take D3D11VA first and NVIDIA/AMD keep Vulkan first.
|
||||
//! Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||||
|
||||
// bindgen's C-enum repr is target-dependent (u32 on Linux/clang, i32 on MSVC), so the
|
||||
// pf-ffvk Vulkan flag/enum casts below are required on one platform and no-ops on the
|
||||
@@ -73,7 +77,7 @@ pub enum DecodedImage {
|
||||
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
@@ -151,7 +155,7 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => f.keyframe,
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => f.keyframe,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => f.keyframe,
|
||||
}
|
||||
}
|
||||
@@ -167,7 +171,7 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||||
}
|
||||
}
|
||||
@@ -234,9 +238,10 @@ enum Backend {
|
||||
#[cfg(windows)]
|
||||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||||
/// no FFmpeg involvement. No demotion rung — there is no other decoder for it.
|
||||
/// no FFmpeg involvement (Linux + Windows — same Vulkan presenter on both). No demotion
|
||||
/// rung — there is no other decoder for it.
|
||||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
@@ -250,16 +255,42 @@ pub struct Decoder {
|
||||
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||||
/// session its hardware decoder.
|
||||
vaapi_fails: u32,
|
||||
/// When the current error streak started. Demotion needs the streak to be OLD as well
|
||||
/// as long: one startup loss burst produces 3+ consecutive failing AUs within
|
||||
/// milliseconds — demoting on count alone (live-hit: Intel iGPU, 2026-07-19, three
|
||||
/// errors in 20 ms → software forever) never gives the IDR requested on the FIRST
|
||||
/// error (~100–300 ms round trip) a chance to rescue the hardware decoder.
|
||||
first_fail: Option<std::time::Instant>,
|
||||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||||
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||||
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||||
want_keyframe: bool,
|
||||
/// The presenter has the win32 external-memory import path, so D3D11VA frames can reach
|
||||
/// the screen — kept for the mid-session Vulkan→D3D11VA demotion rung (the Windows
|
||||
/// analog of Linux's Vulkan→VAAPI rung).
|
||||
#[cfg(windows)]
|
||||
d3d11_import: bool,
|
||||
/// The presenter adapter's LUID (see [`VulkanDecodeDevice::adapter_luid`]) so a demotion
|
||||
/// rebuild lands on the SAME GPU.
|
||||
#[cfg(windows)]
|
||||
adapter_luid: Option<[u8; 8]>,
|
||||
/// [`VulkanDecodeDevice::d3d11_hdr10`], for the same demotion rebuild.
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10: bool,
|
||||
}
|
||||
|
||||
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
|
||||
/// transient error just re-requests an IDR and keeps the hardware decoder.
|
||||
/// Demote a hardware backend (Vulkan→VAAPI/D3D11VA, VAAPI/D3D11VA→software) only after
|
||||
/// this many consecutive decode errors; a lone transient error just re-requests an IDR
|
||||
/// and keeps the hardware decoder.
|
||||
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
||||
|
||||
/// ...AND only when the streak has lasted this long. Every error re-requests an IDR, and
|
||||
/// one arriving + decoding resets the streak — so a genuinely broken driver (errors keep
|
||||
/// flowing through multiple IDR cycles) still demotes ~a second in, while a burst of
|
||||
/// consecutive bad AUs from a single loss event no longer strands the session on
|
||||
/// software before the first requested IDR could even arrive.
|
||||
const HW_DEMOTE_MIN_STREAK: std::time::Duration = std::time::Duration::from_millis(1000);
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
match wire {
|
||||
@@ -292,11 +323,11 @@ pub fn decodable_codecs() -> u8 {
|
||||
/// under its explicit opt-in.
|
||||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
||||
let bits = decodable_codecs();
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
#[cfg(not(all(any(target_os = "linux", windows), feature = "pyrowave")))]
|
||||
let _ = vk;
|
||||
bits
|
||||
}
|
||||
@@ -328,10 +359,12 @@ impl Decoder {
|
||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
||||
/// Auto's hardware order on Linux depends on the device
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
|
||||
/// Auto's hardware order depends on the device on BOTH desktop OSes
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_first`]). Linux: VAAPI → Vulkan → software on
|
||||
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
||||
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
|
||||
/// VanGogh. Windows (no VAAPI there): Vulkan → D3D11VA → software on NVIDIA/AMD,
|
||||
/// D3D11VA → Vulkan → software on Intel/unknown (Intel's driver advertises Vulkan
|
||||
/// Video, but FFmpeg-Vulkan on it strobes/overruns the budget — B580 field report).
|
||||
pub fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
pref: &str,
|
||||
@@ -343,12 +376,25 @@ impl Decoder {
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| pref.to_string());
|
||||
#[cfg(windows)]
|
||||
let (d3d11_import, adapter_luid, d3d11_hdr10) = (
|
||||
vk.is_some_and(|v| v.d3d11_import),
|
||||
vk.and_then(|v| v.adapter_luid),
|
||||
vk.is_some_and(|v| v.d3d11_hdr10),
|
||||
);
|
||||
let done = |backend| {
|
||||
Ok(Decoder {
|
||||
backend,
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
first_fail: None,
|
||||
want_keyframe: false,
|
||||
#[cfg(windows)]
|
||||
d3d11_import,
|
||||
#[cfg(windows)]
|
||||
adapter_luid,
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10,
|
||||
})
|
||||
};
|
||||
// Linux `auto`: try VAAPI FIRST unless this device is one where Vulkan Video is
|
||||
@@ -363,7 +409,7 @@ impl Decoder {
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_over_vaapi())
|
||||
.is_some_and(|v| v.prefer_vulkan_first())
|
||||
{
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
@@ -376,6 +422,44 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows `auto`: D3D11VA FIRST unless this device is one where Vulkan Video is
|
||||
// the established right answer (NVIDIA/AMD). Intel's Windows driver advertises
|
||||
// Vulkan Video (Arc drivers since 2023) so the capability gate alone no longer
|
||||
// keeps Intel off FFmpeg-Vulkan — and that combination is field-broken (B580,
|
||||
// 2026-07: strobing between clean anchors and corrupt inter frames that never
|
||||
// trips the error-streak demotion, 7 ms p50 decodes blowing the 120 Hz budget)
|
||||
// where D3D11VA — the DXVA path every Windows video player exercises, and what
|
||||
// this backend was built for — streams clean. Vulkan stays reachable below by
|
||||
// explicit preference and as auto's fallback when D3D11VA can't be built.
|
||||
#[cfg(windows)]
|
||||
let mut d3d11_tried = false;
|
||||
#[cfg(windows)]
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_first())
|
||||
{
|
||||
if let Some(v) = vk.filter(|v| v.d3d11_import) {
|
||||
d3d11_tried = true;
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
codec_id,
|
||||
v.adapter_luid,
|
||||
v.d3d11_hdr10,
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(reason = %format!("{e:#}"),
|
||||
"D3D11VA unavailable — trying Vulkan Video");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
||||
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||||
@@ -426,14 +510,20 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows: D3D11VA is the vendor-agnostic DXVA fallback when Vulkan Video isn't
|
||||
// available (Intel's Windows driver foremost) — gated on the presenter having the
|
||||
// win32 external-memory import path, else its frames could never reach the screen.
|
||||
// Windows: D3D11VA as the fallback rung for NVIDIA/AMD auto (Vulkan Video missing
|
||||
// or failed to open) and the explicit `d3d11va` preference — gated on the presenter
|
||||
// having the win32 external-memory import path, else its frames could never reach
|
||||
// the screen. (On Intel/unknown auto it was already tried above — `d3d11_tried`
|
||||
// skips the repeat.)
|
||||
#[cfg(windows)]
|
||||
if choice != "software" && choice != "vulkan" {
|
||||
if choice != "software" && choice != "vulkan" && !d3d11_tried {
|
||||
match vk.filter(|v| v.d3d11_import) {
|
||||
Some(v) => {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
codec_id,
|
||||
v.adapter_luid,
|
||||
v.d3d11_hdr10,
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
@@ -483,7 +573,7 @@ impl Decoder {
|
||||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
||||
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||||
/// HEVC so an — impossible — demotion path stays well-formed).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pub fn new_pyrowave(
|
||||
vk: &VulkanDecodeDevice,
|
||||
width: u32,
|
||||
@@ -505,7 +595,17 @@ impl Decoder {
|
||||
)?)),
|
||||
codec_id: ffmpeg::codec::Id::HEVC,
|
||||
vaapi_fails: 0,
|
||||
first_fail: None,
|
||||
want_keyframe: false,
|
||||
// A PyroWave session never demotes (nothing else decodes it — a failure
|
||||
// renegotiates the codec instead), so the D3D11VA rebuild facts are unused
|
||||
// here; keep them well-formed rather than plumbing them in for nothing.
|
||||
#[cfg(windows)]
|
||||
d3d11_import: false,
|
||||
#[cfg(windows)]
|
||||
adapter_luid: None,
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,6 +624,7 @@ impl Decoder {
|
||||
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
self.want_keyframe = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -548,7 +649,7 @@ impl Decoder {
|
||||
au: &[u8],
|
||||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||||
#[cfg_attr(
|
||||
not(all(target_os = "linux", feature = "pyrowave")),
|
||||
not(all(any(target_os = "linux", windows), feature = "pyrowave")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
user_flags: u32,
|
||||
@@ -566,7 +667,7 @@ impl Decoder {
|
||||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||||
// error; the pump surfaces it and the session falls back to HEVC by
|
||||
// renegotiation (plan §4.6), not by decoder swap.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
Backend::PyroWave(p) => {
|
||||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||||
return Ok(p
|
||||
@@ -578,6 +679,7 @@ impl Decoder {
|
||||
match result {
|
||||
Ok(f) => {
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
Ok(f)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -589,7 +691,9 @@ impl Decoder {
|
||||
};
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
let first = *self.first_fail.get_or_insert_with(std::time::Instant::now);
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER && first.elapsed() >= HW_DEMOTE_MIN_STREAK
|
||||
{
|
||||
// A failing Vulkan backend still has a hardware rung below it on
|
||||
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
||||
// error-streaking where VAAPI streams perfectly); only when that
|
||||
@@ -602,16 +706,39 @@ impl Decoder {
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(va) => tracing::info!(reason = %va,
|
||||
"VAAPI unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
// Windows' hardware rung below Vulkan is D3D11VA (a 4K120 stream is
|
||||
// not survivable on software) — same-GPU rebuild via the stashed LUID.
|
||||
#[cfg(windows)]
|
||||
if matches!(self.backend, Backend::Vulkan(_)) && self.d3d11_import {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
self.codec_id,
|
||||
self.adapter_luid,
|
||||
self.d3d11_hdr10,
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"Vulkan Video decode failing repeatedly — demoting to D3D11VA");
|
||||
self.backend = Backend::D3d11va(d);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(dx) => tracing::info!(reason = %dx,
|
||||
"D3D11VA unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"{which} decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
} else {
|
||||
tracing::debug!(backend = which, error = %e,
|
||||
"decode error — requesting keyframe, keeping hardware decode");
|
||||
@@ -718,10 +845,10 @@ pub struct VulkanDecodeDevice {
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_over_vaapi`].
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_first`].
|
||||
pub vendor_id: u32,
|
||||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
||||
/// detection for [`Self::prefer_vulkan_over_vaapi`].
|
||||
/// detection for [`Self::prefer_vulkan_first`].
|
||||
pub device_name: String,
|
||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||
pub graphics_qf: u32,
|
||||
@@ -764,6 +891,10 @@ pub struct VulkanDecodeDevice {
|
||||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
||||
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||||
pub d3d11_import: bool,
|
||||
/// The presenter can also import the RGB10A2 hand-off texture AND offers an HDR10
|
||||
/// swapchain — the D3D11VA backend emits its HDR (RGB10 PQ pass-through) ring flavor
|
||||
/// for PQ streams instead of tone-mapping to sRGB. Always `false` off Windows.
|
||||
pub d3d11_hdr10: bool,
|
||||
/// `VkPhysicalDeviceIDProperties::deviceLUID` when the driver reports one — the D3D11VA
|
||||
/// backend creates its decode device on the SAME adapter so shared textures never cross
|
||||
/// GPUs. `None` when not reported (or off Windows, where it's unused).
|
||||
@@ -775,9 +906,11 @@ pub struct VulkanDecodeDevice {
|
||||
}
|
||||
|
||||
impl VulkanDecodeDevice {
|
||||
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device?
|
||||
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
||||
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
||||
/// Should `auto` try Vulkan Video BEFORE the platform's other hardware path (VAAPI on
|
||||
/// Linux, D3D11VA on Windows) on this device?
|
||||
/// * **NVIDIA** — Vulkan Video is the proven path (on Linux the only one: no usable
|
||||
/// VAAPI — the nvidia-vaapi-driver is broken for this, Moonlight blacklists it;
|
||||
/// on Windows it's the validated zero-copy default, 4K@144 with 0.1 ms decode).
|
||||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||||
/// additionally shows chroma fringing; the session binary opts RADV into
|
||||
@@ -785,10 +918,11 @@ impl VulkanDecodeDevice {
|
||||
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
||||
///
|
||||
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
|
||||
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
|
||||
/// Linux client uses there.
|
||||
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
||||
/// Intel and unknown vendors take the battle-tested path first: VAAPI on Linux (ANV's
|
||||
/// Vulkan Video is the least-proven Mesa path), D3D11VA on Windows — Intel's Windows
|
||||
/// driver advertises Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan on it is
|
||||
/// field-broken (B580, 2026-07: strobing + ~7 ms decodes) where DXVA streams clean.
|
||||
pub fn prefer_vulkan_first(&self) -> bool {
|
||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||
const VENDOR_AMD: u32 = 0x1002;
|
||||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||||
@@ -845,30 +979,33 @@ mod tests {
|
||||
pyrowave_decode: false,
|
||||
video_decode: true,
|
||||
d3d11_import: false,
|
||||
d3d11_hdr10: false,
|
||||
adapter_luid: None,
|
||||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto's Linux hardware order: Vulkan-first on NVIDIA (no usable VAAPI) and ALL AMD
|
||||
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
|
||||
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
|
||||
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
|
||||
/// Vulkan-first can never strand a box on software decode.
|
||||
/// Auto's hardware order (both OSes): Vulkan-first on NVIDIA (on Linux: no usable
|
||||
/// VAAPI) and ALL AMD (Vulkan decode outperforms VAAPI on RADV — on-glass verdict;
|
||||
/// VanGogh additionally chroma-fringes over VAAPI); Intel/unknown take the proven
|
||||
/// path first — VAAPI on Linux (ANV's Vulkan Video is the least-proven Mesa path),
|
||||
/// D3D11VA on Windows (Intel's driver advertises Vulkan Video since 2023, but
|
||||
/// FFmpeg-Vulkan on it strobes — B580 field report). A Vulkan failure streak still
|
||||
/// demotes to hardware (VAAPI/D3D11VA), so Vulkan-first can never strand a box on
|
||||
/// software decode.
|
||||
#[test]
|
||||
fn vulkan_over_vaapi_on_nvidia_and_amd() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
|
||||
fn vulkan_first_on_nvidia_and_amd_only() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_first());
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
|
||||
.prefer_vulkan_over_vaapi()
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)").prefer_vulkan_first()
|
||||
);
|
||||
// The Windows-side motivation: discrete Arc advertises Vulkan Video and must
|
||||
// still land on D3D11VA in auto.
|
||||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) B580 Graphics").prefer_vulkan_first());
|
||||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) Pro Graphics").prefer_vulkan_first());
|
||||
}
|
||||
|
||||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! D3D11VA hardware decode (Windows) for the Vulkan presenter — the vendor-agnostic DXVA
|
||||
//! path that covers what Vulkan Video can't (Intel's Windows driver foremost, which has no
|
||||
//! video-decode queue and previously landed on CPU decode).
|
||||
//! path, and auto's FIRST choice on Intel/unknown vendors. Intel's Windows driver DOES
|
||||
//! advertise Vulkan Video (Arc drivers since 2023 — don't trust the capability gate to
|
||||
//! keep Intel off it), but FFmpeg-Vulkan on it is field-broken (B580, 2026-07: strobing +
|
||||
//! ~7 ms decodes) where this path streams clean; on NVIDIA/AMD it is the fallback rung
|
||||
//! below Vulkan Video, in `auto` and via mid-session demotion.
|
||||
//!
|
||||
//! Ported from the retired in-process WinUI presenter's decoder (`clients/windows/src/video.rs`)
|
||||
//! with one structural change: that presenter sampled D3D11 textures directly, while ours draws
|
||||
@@ -24,9 +27,11 @@
|
||||
//! (`VK_KHR_win32_keyed_mutex`); both sides take and release it with **key 0**: a frame the
|
||||
//! presenter drops (arrival-paced, newest wins) is simply never acquired, which a
|
||||
//! key-ping-pong protocol would deadlock on.
|
||||
//! * An HDR (PQ/BT.2020) stream is tone-mapped to SDR by the video processor (input colour
|
||||
//! space `G2084_P2020`, output sRGB): correct picture, no HDR presentation on this backend —
|
||||
//! its targets (Intel iGPU laptops) are SDR panels; HDR-first boxes take Vulkan Video.
|
||||
//! * An HDR (PQ/BT.2020) stream passes through when the presenter can take it (RGB10A2
|
||||
//! import + an HDR10 swapchain — [`crate::video::VulkanDecodeDevice::d3d11_hdr10`]): the
|
||||
//! video processor converts YCbCr G2084 → RGB G2084 into an RGB10A2 ring, colorspace
|
||||
//! only, no tone mapping. On an SDR-only path it tone-maps to sRGB instead (input
|
||||
//! `G2084_P2020`, output sRGB) — correct picture, no HDR presentation.
|
||||
//!
|
||||
//! The decode device is created on the **presenter's adapter** (matched by the Vulkan device's
|
||||
//! LUID) so the shared textures never cross GPUs on a multi-adapter box.
|
||||
@@ -37,7 +42,7 @@ use ffmpeg_next as ffmpeg;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use windows::core::{Interface, GUID};
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Foundation::{HANDLE, RECT};
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||
@@ -52,11 +57,12 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL,
|
||||
DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
@@ -95,10 +101,14 @@ const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59
|
||||
pub struct D3d11Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// What the ring slot actually CONTAINS after the video processor's conversion: sRGB
|
||||
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
||||
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
||||
/// What the ring slot actually CONTAINS after the video processor's conversion:
|
||||
/// sRGB BT.709 full-range RGB normally (a PQ stream was tone-mapped), or PQ BT.2020
|
||||
/// full-range RGB when the HDR pass-through ring is active (`rgb10`) — the presenter
|
||||
/// keys its SDR/HDR handling off this.
|
||||
pub color: ColorDesc,
|
||||
/// The ring slot's texture format: `false` = BGRA8, `true` = RGB10A2 (the HDR PQ
|
||||
/// pass-through flavor) — the presenter's Vulkan import must match it exactly.
|
||||
pub rgb10: bool,
|
||||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See
|
||||
/// `crate::video::VkVideoFrame`.
|
||||
pub keyframe: bool,
|
||||
@@ -331,6 +341,9 @@ struct SharedRing {
|
||||
height: u32,
|
||||
next: usize,
|
||||
generation: u32,
|
||||
/// HDR flavor: RGB10A2 slots the processor fills with PQ BT.2020 RGB (colorspace
|
||||
/// conversion only — both sides G2084, no tone mapping). `false` = BGRA8 sRGB.
|
||||
pq_out: bool,
|
||||
}
|
||||
|
||||
impl SharedRing {
|
||||
@@ -340,6 +353,7 @@ impl SharedRing {
|
||||
width: u32,
|
||||
height: u32,
|
||||
generation: u32,
|
||||
pq_out: bool,
|
||||
) -> Result<SharedRing> {
|
||||
// The video processor: NV12/P010 in, BGRA8 out, 1:1 (no scaling — the Vulkan side
|
||||
// scales at composite time like every other path). Frame rates are advisory.
|
||||
@@ -369,10 +383,15 @@ impl SharedRing {
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
// Single-plane BGRA8: the ONLY hand-off format whose Vulkan import is a
|
||||
// Single-plane RGB: the ONLY hand-off family whose Vulkan import is a
|
||||
// universally exercised driver path (see the module docs — NV12 import TDRs
|
||||
// on NVIDIA despite being advertised).
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
// on NVIDIA despite being advertised). RGB10A2 for the HDR pass-through
|
||||
// flavor (gated on the presenter's probe), BGRA8 otherwise.
|
||||
Format: if pq_out {
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
@@ -430,7 +449,8 @@ impl SharedRing {
|
||||
height,
|
||||
slots = RING_SLOTS,
|
||||
generation,
|
||||
"D3D11 shared hand-off ring built (VideoProcessor → BGRA8)"
|
||||
hdr = pq_out,
|
||||
"D3D11 shared hand-off ring built (VideoProcessor → RGB)"
|
||||
);
|
||||
Ok(SharedRing {
|
||||
slots,
|
||||
@@ -440,6 +460,7 @@ impl SharedRing {
|
||||
height,
|
||||
next: 0,
|
||||
generation,
|
||||
pq_out,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -457,6 +478,10 @@ pub(crate) struct D3d11vaDecoder {
|
||||
/// setters (Win10 1703+, universally present — init fails to software without it).
|
||||
video_context1: ID3D11VideoContext1,
|
||||
ring: Option<SharedRing>,
|
||||
/// The presenter can import RGB10A2 AND offers an HDR10 swapchain
|
||||
/// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR
|
||||
/// pass-through ring; without it they keep the tonemap-to-sRGB ring.
|
||||
hdr10_out: bool,
|
||||
}
|
||||
|
||||
// Single-owner pointers + COM interfaces, only touched from the session pump thread (the
|
||||
@@ -467,6 +492,7 @@ impl D3d11vaDecoder {
|
||||
pub(crate) fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
luid: Option<[u8; 8]>,
|
||||
hdr10_out: bool,
|
||||
) -> Result<D3d11vaDecoder> {
|
||||
use ffmpeg::ffi;
|
||||
let (device, context) = create_device(luid)?;
|
||||
@@ -537,6 +563,7 @@ impl D3d11vaDecoder {
|
||||
video_device,
|
||||
video_context1,
|
||||
ring: None,
|
||||
hdr10_out,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -591,12 +618,15 @@ impl D3d11vaDecoder {
|
||||
let video_device = self.video_device.clone();
|
||||
let video_context1 = self.video_context1.clone();
|
||||
let context = self.context.clone();
|
||||
// (Re)build the ring + video processor on first use or a stream size change (the
|
||||
// hand-off is BGRA8 regardless of the stream's bit depth, so depth never rebuilds).
|
||||
// (Re)build the ring + video processor on first use, a stream size change, or a
|
||||
// flavor change (the host flips PQ in-band; SDR↔HDR swaps the slot format, so
|
||||
// it rebuilds like a resize — bit DEPTH alone still never rebuilds: an SDR
|
||||
// 10-bit stream and an 8-bit one share the same output flavor).
|
||||
let pq_out = self.hdr10_out && color.is_pq();
|
||||
let rebuild = self
|
||||
.ring
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.width != width || r.height != height);
|
||||
.is_none_or(|r| r.width != width || r.height != height || r.pq_out != pq_out);
|
||||
if rebuild {
|
||||
let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1);
|
||||
self.ring = Some(SharedRing::build(
|
||||
@@ -605,6 +635,7 @@ impl D3d11vaDecoder {
|
||||
width,
|
||||
height,
|
||||
generation,
|
||||
pq_out,
|
||||
)?);
|
||||
}
|
||||
let ring = self.ring.as_mut().expect("ring built above");
|
||||
@@ -647,10 +678,36 @@ impl D3d11vaDecoder {
|
||||
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
};
|
||||
// The DECODE surface is DXVA-aligned (height rounded up to the profile's
|
||||
// macroblock/tile alignment — 128 for HEVC/AV1), so it is TALLER than the
|
||||
// frame: a 2400-line stream decodes into a 2432-line texture. Without an
|
||||
// explicit source rect the processor blits the WHOLE surface — the padding
|
||||
// rows (uninitialized NV12: Y=0,U=V=0, which converts to vivid green) land at
|
||||
// the bottom of the output and the picture is squashed to fit. Clamp the
|
||||
// source to the real frame; the dest stays the whole (frame-sized) slot.
|
||||
// Live-hit on Intel 3840x2400 as a ~32 px green bar (2026-07-19).
|
||||
video_context1.VideoProcessorSetStreamSourceRect(
|
||||
&ring.vp,
|
||||
0,
|
||||
true,
|
||||
Some(&RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width as i32,
|
||||
bottom: height as i32,
|
||||
}),
|
||||
);
|
||||
video_context1.VideoProcessorSetStreamColorSpace1(&ring.vp, 0, in_cs);
|
||||
video_context1.VideoProcessorSetOutputColorSpace1(
|
||||
&ring.vp,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
// HDR ring: PQ in, PQ out — a pure colorspace conversion (YCbCr→RGB),
|
||||
// no tone mapping; the presenter passes the values through to its HDR10
|
||||
// swapchain. SDR ring: sRGB out (a PQ stream is tone-mapped here).
|
||||
if ring.pq_out {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
|
||||
} else {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
},
|
||||
);
|
||||
|
||||
let stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||
@@ -684,17 +741,38 @@ impl D3d11vaDecoder {
|
||||
// completion, and an unflushed deferred batch would add a driver-decided delay.
|
||||
context.Flush();
|
||||
|
||||
log_layout_once(width, height, index, color.is_pq());
|
||||
let mut src_desc = D3D11_TEXTURE2D_DESC::default();
|
||||
src.GetDesc(&mut src_desc);
|
||||
log_layout_once(
|
||||
width,
|
||||
height,
|
||||
src_desc.Width,
|
||||
src_desc.Height,
|
||||
index,
|
||||
color.is_pq(),
|
||||
);
|
||||
Ok(D3d11Frame {
|
||||
width,
|
||||
height,
|
||||
// What the slot now CONTAINS: sRGB BT.709 full-range RGB (PQ was tone-mapped).
|
||||
color: ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 13, // sRGB (H.273)
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
// What the slot now CONTAINS. HDR ring: PQ BT.2020 full-range RGB (the
|
||||
// presenter reads is_pq() and flips its HDR10 swapchain). SDR ring: sRGB
|
||||
// BT.709 full-range RGB (PQ was tone-mapped above).
|
||||
color: if ring.pq_out {
|
||||
ColorDesc {
|
||||
primaries: 9,
|
||||
transfer: 16, // PQ / SMPTE ST.2084
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
}
|
||||
} else {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 13, // sRGB (H.273)
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
}
|
||||
},
|
||||
rgb10: ring.pq_out,
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
||||
keyframe: crate::video::frame_is_keyframe(self.frame),
|
||||
handle,
|
||||
@@ -720,10 +798,20 @@ impl Drop for D3d11vaDecoder {
|
||||
}
|
||||
|
||||
/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver.
|
||||
fn log_layout_once(width: u32, height: u32, index: u32, pq: bool) {
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(width, height, slice = index, pq, "D3D11VA first frame");
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
tex_w,
|
||||
tex_h,
|
||||
slice = index,
|
||||
pq,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,6 +865,13 @@ 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(
|
||||
@@ -1195,6 +1202,12 @@ 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,
|
||||
@@ -1209,6 +1222,10 @@ 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;
|
||||
@@ -1254,7 +1271,11 @@ impl Drop for PyroWaveEncoder {
|
||||
// before the VkDevice they borrow (encoder before device, per pyrowave.h).
|
||||
unsafe {
|
||||
self.device.device_wait_idle().ok();
|
||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||
// 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_device_destroy(self.pw_dev);
|
||||
for (_, _, i, m, v) in self.import_cache.drain(..) {
|
||||
self.device.destroy_image_view(v, None);
|
||||
|
||||
@@ -2077,9 +2077,15 @@ 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() {
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
if self.ltr_slots[slot].is_some() {
|
||||
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
|
||||
@@ -2363,6 +2369,16 @@ 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
|
||||
@@ -2391,6 +2407,9 @@ 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,
|
||||
|
||||
@@ -417,6 +417,12 @@ 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)")
|
||||
};
|
||||
@@ -639,6 +645,10 @@ 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,
|
||||
@@ -655,6 +665,8 @@ 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;
|
||||
@@ -698,7 +710,11 @@ 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 {
|
||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||
// 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);
|
||||
}
|
||||
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
|
||||
pw::pyrowave_image_destroy(img);
|
||||
}
|
||||
|
||||
@@ -1017,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_slot: Option<usize> = None;
|
||||
let mut force_ltr: Option<(usize, i64)> = None;
|
||||
let mut recovery_anchor = false;
|
||||
if self.ltr_active {
|
||||
if forced {
|
||||
@@ -1035,10 +1035,16 @@ impl Encoder for QsvEncoder {
|
||||
);
|
||||
}
|
||||
if let Some(slot) = self.pending_force.take() {
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||
if force_ltr.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;
|
||||
@@ -1046,6 +1052,7 @@ 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.
|
||||
@@ -1130,7 +1137,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_slot.is_some() {
|
||||
if forced || mark_slot.is_some() || force_ltr.is_some() {
|
||||
let mut c = FrameCtrl::new();
|
||||
if forced {
|
||||
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
||||
@@ -1148,24 +1155,55 @@ impl Encoder for QsvEncoder {
|
||||
c.reflist.ApplyLongTermIdx = 1;
|
||||
use_reflist = true;
|
||||
}
|
||||
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)"
|
||||
);
|
||||
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;
|
||||
}
|
||||
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();
|
||||
@@ -1265,6 +1303,17 @@ 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 {
|
||||
@@ -1287,6 +1336,9 @@ 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,
|
||||
@@ -1366,15 +1418,21 @@ 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() {}
|
||||
inner.pending.clear();
|
||||
inner.ready.clear();
|
||||
inner.frames_submitted = 0;
|
||||
inner.first_au_logged = false;
|
||||
// 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.
|
||||
// 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) {
|
||||
@@ -1845,6 +1903,36 @@ 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]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! D3D11 shared-texture → Vulkan import (Windows): the presenter half of the D3D11VA
|
||||
//! decode path (`pf_client_core::video_d3d11`). Each decoded frame arrives as the NT
|
||||
//! handle of a shareable **BGRA8** texture (the decoder's VideoProcessor already did
|
||||
//! handle of a shareable single-plane RGB texture — **BGRA8** sRGB normally, **RGB10A2**
|
||||
//! PQ for the HDR pass-through flavor (the decoder's VideoProcessor already did
|
||||
//! YUV→RGB); we import it as a single-plane VkImage (`VK_KHR_external_memory_win32`,
|
||||
//! dedicated allocation) and the presenter blits it straight into its video image — no
|
||||
//! CSC pass. Single-plane RGBA is deliberate: importing the earlier multiplanar NV12
|
||||
@@ -28,31 +29,41 @@ pub const DEVICE_EXTENSIONS: [&std::ffi::CStr; 2] = [
|
||||
ash::khr::win32_keyed_mutex::NAME,
|
||||
];
|
||||
|
||||
/// Can this device import a D3D11 BGRA8 texture as a blit source? The spec-required
|
||||
/// Can this device import a D3D11 texture of `format` as a blit source? The spec-required
|
||||
/// capability probe for the exact image the import path creates — creating an external
|
||||
/// image the driver doesn't support is undefined behavior (observed as
|
||||
/// `VK_ERROR_DEVICE_LOST` at the first submits with the old NV12 hand-off).
|
||||
pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> bool {
|
||||
fn format_importable(
|
||||
instance: &ash::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
format: vk::Format,
|
||||
) -> bool {
|
||||
let mut ext_info = vk::PhysicalDeviceExternalImageFormatInfo::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE);
|
||||
let fmt_info = vk::PhysicalDeviceImageFormatInfo2::default()
|
||||
.format(vk::Format::B8G8R8A8_UNORM)
|
||||
.format(format)
|
||||
.ty(vk::ImageType::TYPE_2D)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::TRANSFER_SRC)
|
||||
.push_next(&mut ext_info);
|
||||
let mut ext_props = vk::ExternalImageFormatProperties::default();
|
||||
let mut props = vk::ImageFormatProperties2::default().push_next(&mut ext_props);
|
||||
let ok = unsafe {
|
||||
instance.get_physical_device_image_format_properties2(pdev, &fmt_info, &mut props)
|
||||
}
|
||||
.is_ok()
|
||||
unsafe { instance.get_physical_device_image_format_properties2(pdev, &fmt_info, &mut props) }
|
||||
.is_ok()
|
||||
&& ext_props
|
||||
.external_memory_properties
|
||||
.external_memory_features
|
||||
.contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE);
|
||||
tracing::info!(bgra8 = ok, "D3D11 texture → Vulkan import support");
|
||||
ok
|
||||
.contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE)
|
||||
}
|
||||
|
||||
/// The two hand-off flavors' import support: `.0` = BGRA8 (the SDR ring — gates the whole
|
||||
/// D3D11VA path), `.1` = RGB10A2 (the HDR PQ ring — gates only the pass-through flavor;
|
||||
/// without it a PQ stream keeps the decoder-side tonemap to BGRA8).
|
||||
pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> (bool, bool) {
|
||||
let bgra8 = format_importable(instance, pdev, vk::Format::B8G8R8A8_UNORM);
|
||||
let rgb10 = format_importable(instance, pdev, vk::Format::A2B10G10R10_UNORM_PACK32);
|
||||
tracing::info!(bgra8, rgb10, "D3D11 texture → Vulkan import support");
|
||||
(bgra8, rgb10)
|
||||
}
|
||||
|
||||
/// One imported frame: the BGRA8 image over the shared texture and its imported
|
||||
@@ -98,7 +109,13 @@ pub fn import(
|
||||
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
|
||||
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
|
||||
}
|
||||
let mp_format = vk::Format::B8G8R8A8_UNORM;
|
||||
// DXGI R10G10B10A2 and Vulkan A2B10G10R10_PACK32 are the same bit layout (R in the
|
||||
// low bits) — the standard interop pairing, same as BGRA8 ↔ B8G8R8A8.
|
||||
let mp_format = if frame.rgb10 {
|
||||
vk::Format::A2B10G10R10_UNORM_PACK32
|
||||
} else {
|
||||
vk::Format::B8G8R8A8_UNORM
|
||||
};
|
||||
let handle_type = vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE;
|
||||
|
||||
// One single-plane image over the whole texture, transfer-source only — the blit is
|
||||
|
||||
@@ -53,6 +53,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -68,7 +70,11 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
|
||||
pub fn new(
|
||||
connector: Arc<NativeClient>,
|
||||
touch_mode: TouchMode,
|
||||
invert_scroll: bool,
|
||||
) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
captured: false,
|
||||
@@ -79,6 +85,7 @@ impl Capture {
|
||||
scroll_acc: (0.0, 0.0),
|
||||
touch_slots: HashMap::new(),
|
||||
touch_mode,
|
||||
invert_scroll,
|
||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||
}
|
||||
}
|
||||
@@ -194,9 +201,10 @@ 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;
|
||||
ax += f64::from(dx) * 120.0;
|
||||
ay += f64::from(dy) * 120.0 * sign;
|
||||
ax += f64::from(dx) * 120.0 * sign;
|
||||
let vy = ay.trunc() as i32;
|
||||
if vy != 0 {
|
||||
ay -= f64::from(vy);
|
||||
|
||||
@@ -48,6 +48,8 @@ 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
|
||||
@@ -194,7 +196,7 @@ struct StreamState {
|
||||
/// PyroWave present has no demote rung (nothing else decodes the codec), so a
|
||||
/// persistent non-device-lost present failure would warn on every frame. Latch it:
|
||||
/// warn on the first failure of a streak, then stay quiet until a present succeeds.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_present_warned: bool,
|
||||
hw_fails: u32,
|
||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||
@@ -267,7 +269,7 @@ impl StreamState {
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
@@ -811,7 +813,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);
|
||||
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
st.capture = Some(cap);
|
||||
@@ -1007,7 +1009,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// PyroWave planar frames: already on the presenter's device and
|
||||
// fence-complete — a present failure has no demote rung (nothing
|
||||
// else decodes the codec); only device loss ends the session.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => {
|
||||
// The wavelet stream carries the negotiated ColorInfo (no VUI): an
|
||||
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||
|
||||
@@ -33,6 +33,8 @@ 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).
|
||||
@@ -47,7 +49,7 @@ pub enum FrameInput<'a> {
|
||||
D3d11(pf_client_core::video::D3d11Frame),
|
||||
/// PyroWave planar output — three R8 plane views already on THIS device, decode
|
||||
/// fence-complete, GENERAL layout (`pf_client_core::video_pyrowave`).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
PyroWave(pf_client_core::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ pub struct Presenter {
|
||||
csc: CscPass,
|
||||
/// The planar (3-plane) CSC variant for PyroWave frames; built only when the device
|
||||
/// passed the pyrowave probe.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
csc_planar: Option<CscPass>,
|
||||
/// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it.
|
||||
video_export: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
@@ -304,7 +306,7 @@ impl Drop for Presenter {
|
||||
#[cfg(target_os = "linux")]
|
||||
self.hw.take();
|
||||
self.csc.destroy(&self.device);
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if let Some(p) = &self.csc_planar {
|
||||
p.destroy(&self.device);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Presenter {
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
#[cfg(windows)]
|
||||
FrameInput::D3d11(d) => Some(d.color.is_pq()),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => Some(f.color.is_pq()),
|
||||
};
|
||||
if let Some(pq) = frame_pq {
|
||||
@@ -68,7 +68,7 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
let mut win_frame: Option<crate::d3d11::HwFrame> = None;
|
||||
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
let mut pyro_frame: Option<pf_client_core::video_pyrowave::PyroWavePlanarFrame> = None;
|
||||
let cpu_frame = match input {
|
||||
FrameInput::Redraw => None,
|
||||
@@ -96,7 +96,7 @@ impl Presenter {
|
||||
vk_frame = Some((v, views));
|
||||
None
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => {
|
||||
pyro_frame = Some(f);
|
||||
None
|
||||
@@ -155,7 +155,7 @@ impl Presenter {
|
||||
}
|
||||
self.csc.bind_planes(&self.device, views[0], views[1]);
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if let Some(f) = &pyro_frame {
|
||||
if self
|
||||
.video
|
||||
@@ -239,11 +239,12 @@ impl Presenter {
|
||||
);
|
||||
}
|
||||
|
||||
// D3D11 frame: acquire the imported BGRA texture from the external "queue
|
||||
// D3D11 frame: acquire the imported RGB texture from the external "queue
|
||||
// family" (the keyed mutex on the submit is the actual cross-API sync) and
|
||||
// blit it into the video image — the frame arrives as ready sRGB from the
|
||||
// decoder's VideoProcessor, so there is no CSC pass; the blit converts the
|
||||
// BGRA→RGBA component order. Same layout dance as the CPU staging path.
|
||||
// blit it into the video image — the frame arrives as ready RGB from the
|
||||
// decoder's VideoProcessor (sRGB BGRA8, or PQ RGB10A2 on the HDR ring —
|
||||
// matching the HDR-mode video image), so there is no CSC pass; the blit
|
||||
// converts component order. Same layout dance as the CPU staging path.
|
||||
#[cfg(windows)]
|
||||
if let (Some(f), Some(v)) = (&win_frame, &self.video) {
|
||||
external_acquire_barrier(&self.device, self.cmd_buf, f.image(), self.qfi);
|
||||
@@ -316,7 +317,7 @@ impl Presenter {
|
||||
// PyroWave frame: the planes are already on THIS device, decode
|
||||
// fence-complete and barriered to fragment sampling (GENERAL) by the
|
||||
// decoder — no acquire needed, just the planar CSC pass.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if let (Some(f), Some(v)) = (&pyro_frame, &self.video) {
|
||||
let extent = vk::Extent2D {
|
||||
width: v.width,
|
||||
@@ -694,7 +695,7 @@ impl Presenter {
|
||||
}
|
||||
|
||||
/// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
unsafe fn record_csc_planar(
|
||||
&self,
|
||||
framebuffer: vk::Framebuffer,
|
||||
|
||||
@@ -207,7 +207,7 @@ impl Presenter {
|
||||
// The planar (PyroWave) pass renders to the same intermediate — rebuild it at the
|
||||
// new format too (an HDR pyrowave session needs the 10-bit intermediate exactly
|
||||
// like the H.26x path; 8-bit PQ bands visibly).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if let Some(p) = self.csc_planar.take() {
|
||||
p.destroy(&self.device);
|
||||
self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?);
|
||||
|
||||
@@ -94,8 +94,9 @@ impl Presenter {
|
||||
// (vkGetPhysicalDeviceImageFormatProperties2 — creating an unsupported external
|
||||
// image is UB, observed as VK_ERROR_DEVICE_LOST at the first submits on NVIDIA).
|
||||
#[cfg(windows)]
|
||||
let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n))
|
||||
&& crate::d3d11::import_supported(&instance, pdev);
|
||||
let (import_bgra8, import_rgb10) = crate::d3d11::import_supported(&instance, pdev);
|
||||
#[cfg(windows)]
|
||||
let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n)) && import_bgra8;
|
||||
#[cfg(windows)]
|
||||
if win_capable {
|
||||
dev_exts.extend(crate::d3d11::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr()));
|
||||
@@ -317,7 +318,7 @@ impl Presenter {
|
||||
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
|
||||
// Starts SDR like `csc`; an HDR (PQ) pyrowave session rebuilds it at the 10-bit
|
||||
// intermediate via `set_hdr_mode`, exactly like the H.26x pass.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
let csc_planar = if pyrowave_ok {
|
||||
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
|
||||
} else {
|
||||
@@ -392,14 +393,25 @@ impl Presenter {
|
||||
d3d11_import: win_capable,
|
||||
#[cfg(not(windows))]
|
||||
d3d11_import: false,
|
||||
// Filled in below — the HDR10 surface facts arrive with pick_formats.
|
||||
d3d11_hdr10: false,
|
||||
adapter_luid,
|
||||
queue_lock: queue_lock.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut video_export = video_export;
|
||||
|
||||
let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
|
||||
// The D3D11VA backend may emit its HDR (RGB10 PQ) ring only when this device can
|
||||
// import the 10-bit texture AND the surface offers an HDR10 swapchain to pass it
|
||||
// through to; otherwise a PQ stream keeps the decoder-side tonemap to sRGB.
|
||||
#[cfg(windows)]
|
||||
if let Some(v) = video_export.as_mut() {
|
||||
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
|
||||
}
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
||||
tracing::info!(
|
||||
?format,
|
||||
@@ -451,7 +463,7 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
hw_win,
|
||||
csc,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
csc_planar,
|
||||
video_export,
|
||||
overlay_pipe,
|
||||
@@ -485,6 +497,52 @@ 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,7 +48,8 @@ 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 (caller continues without audio) rather than a silent dead thread.
|
||||
// endpoint surfaces as Err (the native plane then keeps retrying the open with backoff)
|
||||
// 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()
|
||||
@@ -134,6 +135,14 @@ 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>>,
|
||||
@@ -151,11 +160,14 @@ 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), 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).
|
||||
// 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.
|
||||
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,
|
||||
@@ -163,11 +175,21 @@ fn capture_thread(
|
||||
mode = m;
|
||||
failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(r) = ready.take() {
|
||||
let _ = r.send(Err(anyhow!("{e:#}")));
|
||||
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:#}")));
|
||||
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,
|
||||
@@ -197,7 +219,8 @@ fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
|
||||
}
|
||||
|
||||
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
|
||||
/// fatal via the `ready` handshake; later: reopen with backoff).
|
||||
/// retried [`FIRST_OPEN_ATTEMPTS`] times, then fatal via the `ready` handshake; later: reopen
|
||||
/// with backoff).
|
||||
fn capture_once(
|
||||
tx: &SyncSender<Vec<f32>>,
|
||||
stop: &AtomicBool,
|
||||
|
||||
@@ -26,24 +26,35 @@ 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() -> pf_capture::ZeroCopyPolicy {
|
||||
fn zero_copy_policy(pyrowave_session: bool) -> 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_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()
|
||||
};
|
||||
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()
|
||||
};
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +68,9 @@ 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;
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy())
|
||||
// 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))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -88,7 +101,7 @@ pub fn capture_virtual_output(
|
||||
vout.keepalive,
|
||||
want.gpu,
|
||||
want.chroma_444,
|
||||
zero_copy_policy(),
|
||||
zero_copy_policy(want.pyrowave),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,25 +61,28 @@ 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, 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),
|
||||
// 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,
|
||||
hdr: cfg.hdr,
|
||||
client: String::new(),
|
||||
app: app.as_ref().map(|a| a.title.clone()),
|
||||
launch: 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(),
|
||||
});
|
||||
@@ -103,9 +106,9 @@ pub fn start(
|
||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
crate::events::emit(crate::events::EventKind::StreamStopped {
|
||||
stream: event_stream,
|
||||
});
|
||||
// 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::ClientDisconnected {
|
||||
client: event_client,
|
||||
reason,
|
||||
|
||||
@@ -31,6 +31,7 @@ mod heroic;
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod lutris;
|
||||
mod scanners;
|
||||
mod steam;
|
||||
#[cfg(windows)]
|
||||
mod xbox;
|
||||
@@ -46,6 +47,7 @@ pub use heroic::*;
|
||||
pub use launch::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use lutris::*;
|
||||
pub use scanners::*;
|
||||
pub use steam::*;
|
||||
#[cfg(windows)]
|
||||
pub use xbox::*;
|
||||
@@ -161,22 +163,39 @@ impl ArtKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||
/// 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.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let mut games = SteamProvider.list();
|
||||
let off = disabled_scanners();
|
||||
let on = |id: &str| !off.contains(id);
|
||||
let mut games = Vec::new();
|
||||
if on("steam") {
|
||||
games.extend(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")]
|
||||
{
|
||||
games.extend(LutrisProvider.list());
|
||||
games.extend(HeroicProvider.list());
|
||||
if on("lutris") {
|
||||
games.extend(LutrisProvider.list());
|
||||
}
|
||||
if on("heroic") {
|
||||
games.extend(HeroicProvider.list());
|
||||
}
|
||||
}
|
||||
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
games.extend(EpicProvider.list());
|
||||
games.extend(GogProvider.list());
|
||||
games.extend(XboxProvider.list());
|
||||
if on("epic") {
|
||||
games.extend(EpicProvider.list());
|
||||
}
|
||||
if on("gog") {
|
||||
games.extend(GogProvider.list());
|
||||
}
|
||||
if on("xbox") {
|
||||
games.extend(XboxProvider.list());
|
||||
}
|
||||
}
|
||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ mod mgmt_token;
|
||||
mod native;
|
||||
mod native_pairing;
|
||||
mod pipeline;
|
||||
mod plugins;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
@@ -249,6 +250,10 @@ 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());
|
||||
@@ -623,6 +628,8 @@ 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)
|
||||
|
||||
@@ -203,6 +203,8 @@ 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,
|
||||
|
||||
@@ -45,6 +45,72 @@ 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
|
||||
|
||||
@@ -138,6 +138,13 @@ 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 [
|
||||
@@ -1309,6 +1316,45 @@ 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`
|
||||
|
||||
@@ -1173,6 +1173,7 @@ 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
|
||||
|
||||
@@ -71,18 +71,23 @@ 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).
|
||||
let mut capturer = match audio_cap.lock().unwrap().take() {
|
||||
// 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() {
|
||||
Some(mut c) if c.channels() == want as u32 => {
|
||||
c.drain(); // discard audio captured between sessions (also re-claims routing)
|
||||
c
|
||||
Some(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) => c,
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
|
||||
return;
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio failed to open — retrying in the background until it comes up");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,8 +96,10 @@ pub(super) fn audio_thread(
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
|
||||
capturer.idle(); // parked, not streaming — release the routing claim
|
||||
crate::audio::park_audio_capture(&audio_cap, capturer);
|
||||
if let Some(mut c) = capturer {
|
||||
c.idle(); // parked, not streaming — release the routing claim
|
||||
crate::audio::park_audio_capture(&audio_cap, c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -103,20 +110,22 @@ 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) 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;
|
||||
// (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;
|
||||
// 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;
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
if capturer.is_some() {
|
||||
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) {
|
||||
|
||||
@@ -1166,6 +1166,21 @@ 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();
|
||||
@@ -1536,11 +1551,12 @@ 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
|
||||
// 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());
|
||||
// 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());
|
||||
} else {
|
||||
want_kf = true; // range too old / no RFI backend → coalesced keyframe below
|
||||
}
|
||||
@@ -1562,19 +1578,46 @@ 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 suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window);
|
||||
if suppress {
|
||||
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 {
|
||||
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()),
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! `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")
|
||||
}
|
||||
@@ -155,36 +155,22 @@ impl SessionPlan {
|
||||
}
|
||||
gpu && !force_cpu_for_nvenc_444
|
||||
};
|
||||
// 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
|
||||
};
|
||||
// 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.
|
||||
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 (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: 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: self.codec == crate::encode::Codec::PyroWave,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
//!
|
||||
//! ## 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
|
||||
@@ -53,20 +58,25 @@ pub struct StreamInfo {
|
||||
pub hdr: bool,
|
||||
/// Client-supplied device name (may be empty); sanitized before it reaches the file.
|
||||
pub client: String,
|
||||
/// 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).
|
||||
/// 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).
|
||||
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) — only the native loop announces the marker today, hence the fixed plane.
|
||||
/// (RFC §4), for every plane that announces.
|
||||
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: crate::events::Plane::Native,
|
||||
plane: info.plane,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +281,7 @@ mod imp {
|
||||
hdr: true,
|
||||
client: "Couch'TV".to_string(),
|
||||
launch: None,
|
||||
plane: crate::events::Plane::Native,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).expect("marker exists while streaming");
|
||||
@@ -290,6 +301,7 @@ mod imp {
|
||||
hdr: false,
|
||||
client: "Phone".to_string(),
|
||||
launch: None,
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
@@ -311,3 +323,35 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ 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).
|
||||
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`.
|
||||
|
||||
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
|
||||
`pairing.pending`, send yourself a notification, and call
|
||||
|
||||
@@ -75,7 +75,9 @@ The app is in **Google Play Internal Testing** — request a tester invite on ou
|
||||
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure
|
||||
Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host
|
||||
list, settings, PIN pairing); the stream itself runs in punktfunk's Vulkan presenter. Its decoder
|
||||
tries **Vulkan Video, then D3D11VA, then software**, with 10-bit/HDR present, WASAPI audio + mic,
|
||||
order is per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
|
||||
**D3D11VA first** on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the
|
||||
proven path there), with 10-bit/HDR present, WASAPI audio + mic,
|
||||
SDL3 controllers (rumble, lightbar, DualSense), network discovery, and the full PIN-pairing trust
|
||||
surface. It builds for both `x86_64` and `aarch64` and ships as a **signed MSIX**. Launch it and
|
||||
pick a host from the list, just like the other native apps.
|
||||
|
||||
@@ -161,7 +161,7 @@ A few knobs are read by the native **clients**, not the host:
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck, D3D11VA/Vulkan on Windows) with a software fallback. |
|
||||
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware per vendor (Linux: VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck; Windows: Vulkan Video on NVIDIA/AMD, D3D11VA on Intel and others) with a software fallback. |
|
||||
|
||||
## Bitrate
|
||||
|
||||
|
||||
@@ -1,100 +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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
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.
|
||||
@@ -60,7 +60,7 @@ host is newer than the Linux host.)
|
||||
|---|---|
|
||||
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC + AV1 decode (AV1 on hardware that decodes it — M3-class Macs, A17 Pro-class iPhones), GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession` → `CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. |
|
||||
| **Linux** (`punktfunk-client`) | relm4/GTK4 shell; streams run in the spawned Vulkan session presenter. Hardware decode via Vulkan Video (all vendors, incl. NVIDIA) with VAAPI dmabuf and software fallbacks, PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test, Skia console UI + tiered stats overlay. Ships as Flatpak, apt, rpm, and Arch packages. |
|
||||
| **Windows** (`punktfunk-client`) | WinUI 3 shell; streams run in the Vulkan session presenter with a Vulkan Video → D3D11VA → software decode chain, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface. Ships as a signed MSIX (x86_64 + ARM64). **Hardware decode validated on NVIDIA and Intel; HDR present pending on-glass validation.** |
|
||||
| **Windows** (`punktfunk-client`) | WinUI 3 shell; streams run in the Vulkan session presenter with a per-vendor decode chain — Vulkan Video → D3D11VA → software on NVIDIA/AMD, D3D11VA → Vulkan Video → software on Intel and others — plus HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface. Ships as a signed MSIX (x86_64 + ARM64). **Hardware decode and HDR10 present validated on glass on NVIDIA and Intel (incl. HDR pass-through on the Intel D3D11VA path).** |
|
||||
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), tiered stats overlay (three-finger tap), custom resolutions, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
|
||||
|
||||
`punktfunk-probe` is a headless reference and measurement client (for testing and
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -7,6 +8,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ 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"
|
||||
@@ -236,6 +240,14 @@ 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
|
||||
@@ -518,6 +530,60 @@ 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
|
||||
|
||||
@@ -195,6 +195,24 @@ 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.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// `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;
|
||||
};
|
||||
+77
-4
@@ -1,10 +1,21 @@
|
||||
#!/usr/bin/env bun
|
||||
// `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).
|
||||
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
|
||||
//
|
||||
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
|
||||
// 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)
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
const arg = (flag: string): string | undefined => {
|
||||
@@ -17,6 +28,68 @@ 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);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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
|
||||
});
|
||||
});
|
||||
@@ -13,3 +13,25 @@ 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.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!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>
|
||||
@@ -170,6 +170,9 @@
|
||||
"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",
|
||||
|
||||
@@ -170,6 +170,9 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
);
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -37,6 +38,8 @@ export const SectionLibrary: FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourceTogglesSection />
|
||||
|
||||
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 = () => {};
|
||||
@@ -46,6 +47,21 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user