Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e00037a89 | |||
| 46b9aa8cf0 | |||
| 372b27540b | |||
| db4d15bf8b | |||
| 8e24ea9ed7 | |||
| 73c0125843 |
@@ -207,10 +207,20 @@ jobs:
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
@@ -218,11 +228,10 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -252,35 +261,27 @@ jobs:
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
||||
# signing permission error"). The profile must be installed on the runner under
|
||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||
# manually-installed App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
|
||||
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
|
||||
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
|
||||
# in an xcconfig lands it on the app/framework slices only.
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -312,33 +313,24 @@ jobs:
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign).
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
|
||||
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) which build for the macOS host and reject a provisioning profile
|
||||
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
|
||||
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
|
||||
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
|
||||
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
|
||||
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
|
||||
# is ignored there.)
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
"version": "0.3.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -24,6 +24,9 @@ let package = Package(
|
||||
.copy("Resources/THIRD-PARTY-NOTICES.txt"),
|
||||
.copy("Resources/LICENSE-MIT.txt"),
|
||||
.copy("Resources/LICENSE-APACHE.txt"),
|
||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||
// Registered with Core Text at first use; see BrandFont.swift.
|
||||
.copy("Resources/Fonts"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -10,32 +10,59 @@ struct AcknowledgementsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.title2).bold()
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.caption)
|
||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||
// ~885 KB total) load lazily as they scroll into view — a single Text that large overshoots
|
||||
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
|
||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Licenses.thirdPartyNotices)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
|
||||
Text(Licenses.thirdPartyNoticesChunks[i])
|
||||
.font(.caption2.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -81,6 +81,11 @@ struct AddHostSheet: View {
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||
.scrollDisabled(true)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
@@ -120,8 +125,8 @@ struct AddHostSheet: View {
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
|
||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
|
||||
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
|
||||
// (set once at launch). Backgrounds are left at the system defaults — transparent at the scroll
|
||||
// edge (the large title floats on the content), blurred once scrolled — so only the typeface
|
||||
// changes: Geist, matching the cards and the website.
|
||||
|
||||
#if os(iOS)
|
||||
import PunktfunkKit
|
||||
import UIKit
|
||||
|
||||
enum BrandTheme {
|
||||
static func apply() {
|
||||
BrandFont.registerIfNeeded()
|
||||
|
||||
let scrollEdge = UINavigationBarAppearance()
|
||||
scrollEdge.configureWithTransparentBackground()
|
||||
applyFonts(to: scrollEdge)
|
||||
|
||||
let standard = UINavigationBarAppearance()
|
||||
standard.configureWithDefaultBackground()
|
||||
applyFonts(to: standard)
|
||||
|
||||
let proxy = UINavigationBar.appearance()
|
||||
proxy.scrollEdgeAppearance = scrollEdge
|
||||
proxy.standardAppearance = standard
|
||||
proxy.compactAppearance = standard
|
||||
}
|
||||
|
||||
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
|
||||
private static func applyFonts(to appearance: UINavigationBarAppearance) {
|
||||
if let large = UIFont(name: "Geist-Bold", size: 34) {
|
||||
appearance.largeTitleTextAttributes[.font] = large
|
||||
}
|
||||
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
|
||||
appearance.titleTextAttributes[.font] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +28,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@@ -68,15 +69,19 @@ struct ContentView: View {
|
||||
// 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 }
|
||||
store.markConnected(host.id)
|
||||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||
// host's observed fingerprint and remember it as paired — future connects are then
|
||||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||
if awaitingApproval?.host.id == host.id {
|
||||
if let fp = model.connection?.hostFingerprint {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
awaitingApproval = nil
|
||||
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||||
? model.connection?.hostFingerprint : nil
|
||||
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||
@@ -333,6 +338,7 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
@@ -475,6 +481,7 @@ struct ContentView: View {
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||
// two-column layout.
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
SettingsView()
|
||||
.settingsSheetSizing()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
@@ -249,8 +248,10 @@ struct HomeView: View {
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||
// it needs room for a monospaced "IP:port" without truncating.
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let iconSize: CGFloat
|
||||
let iconBox: CGFloat
|
||||
let cardPadding: CGFloat
|
||||
let nameFont: Font
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||
Circle()
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||
Text(host.displayName)
|
||||
.font(m.nameFont)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if host.pinnedSHA256 != nil {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let last = host.lastConnected {
|
||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(m.nameFont)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -146,7 +146,7 @@ private struct GameCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { storeBadge }
|
||||
Text(game.title)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||
// screenshot harness too, so captured screens are on-brand.
|
||||
Group {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
.tint(.brand)
|
||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||
.font(.geist(17, relativeTo: .body))
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
// A separate scene — `.tint` does not cross scene boundaries, so re-apply the brand
|
||||
// tint here or the Preferences window falls back to the (unreliable) asset accent.
|
||||
SettingsView()
|
||||
.tint(.brand)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||||
// outgrown one scrolling pane); iOS uses a single grouped Form; 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.
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -21,7 +23,8 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@@ -32,6 +35,21 @@ struct SettingsView: View {
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var settingsSelection: SettingsCategory?
|
||||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State private var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -39,6 +57,15 @@ struct SettingsView: View {
|
||||
@State private var inputDevices: [AudioDevice] = []
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||
init(initialCategory: SettingsCategory? = nil) {
|
||||
_settingsSelection = State(initialValue: initialCategory)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||
@@ -66,6 +93,7 @@ struct SettingsView: View {
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
@@ -106,29 +134,115 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS: one grouped Form
|
||||
// MARK: - iOS / iPadOS: adaptive split view
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
Form {
|
||||
streamModeSection
|
||||
audioSection
|
||||
compositorSection
|
||||
presenterSection
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
Section {
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
// On iPhone the split view collapses to a push list, but a selection List
|
||||
// draws no disclosure indicator of its own — add one in compact width for the
|
||||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||
// the chevron is omitted there.
|
||||
HStack {
|
||||
Label(category.title, systemImage: category.symbol)
|
||||
if horizontalSizeClass == .compact {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// Purely a drill-in affordance — the row's button trait already
|
||||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.tag(category)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||
settingsDetail(settingsSelection ?? .general)
|
||||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||
.toolbar {
|
||||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||
if newValue == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .controllers:
|
||||
Form { controllersSection }
|
||||
.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
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
@@ -156,6 +270,10 @@ struct SettingsView: View {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -186,20 +304,25 @@ struct SettingsView: View {
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: compositors, selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
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.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -219,7 +342,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -243,6 +366,63 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder private var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
@@ -253,6 +433,7 @@ struct SettingsView: View {
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
@@ -267,7 +448,7 @@ struct SettingsView: View {
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +458,85 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
|
||||
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
("720p", 1280, 720),
|
||||
("1080p", 1920, 1080),
|
||||
("1440p", 2560, 1440),
|
||||
("4K", 3840, 2160),
|
||||
("Ultrawide 1080p", 2560, 1080),
|
||||
("Ultrawide 1440p", 3440, 1440),
|
||||
("Super ultrawide", 5120, 1440),
|
||||
]
|
||||
|
||||
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
|
||||
/// dimensions (native wins a tie).
|
||||
private var resolutionModes: [(name: String, w: Int, h: Int)] {
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
|
||||
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
|
||||
var seen = Set<String>()
|
||||
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
/// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields.
|
||||
private var resolutionChoices: [(label: String, tag: String)] {
|
||||
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||
}
|
||||
|
||||
private var presetResolutionTags: Set<String> {
|
||||
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
|
||||
}
|
||||
|
||||
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
}
|
||||
|
||||
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||
/// sentinel toggles `customMode` instead of writing a size.
|
||||
private var resolutionSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
return
|
||||
}
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames the
|
||||
/// screen can't show), plus any stored custom value so it stays selectable.
|
||||
private var refreshChoices: [Int] {
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||
if rates.isEmpty { rates = [maxHz] }
|
||||
if !rates.contains(hz) { rates.append(hz) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -321,7 +576,7 @@ struct SettingsView: View {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +596,7 @@ struct SettingsView: View {
|
||||
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(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -355,26 +610,47 @@ struct SettingsView: View {
|
||||
} 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(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
@ViewBuilder private var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var hdrSection: some View {
|
||||
Section {
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
} header: {
|
||||
Text("HDR")
|
||||
} footer: {
|
||||
Text("Request a 10-bit BT.2020 PQ (HDR10) stream. It only engages when the host is "
|
||||
+ "sending HDR content AND this display supports HDR — otherwise the stream stays "
|
||||
+ "8-bit SDR. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +668,7 @@ struct SettingsView: View {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -407,7 +683,7 @@ struct SettingsView: View {
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -441,7 +717,7 @@ struct SettingsView: View {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -593,13 +869,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.green.opacity(0.2)))
|
||||
@@ -621,6 +897,10 @@ struct SettingsView: View {
|
||||
width = Int(max(bounds.width, bounds.height))
|
||||
height = Int(min(bounds.width, bounds.height))
|
||||
hz = UIScreen.main.maximumFramesPerSecond
|
||||
#if os(iOS)
|
||||
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||
customMode = false
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -631,3 +911,52 @@ extension Double {
|
||||
Swift.min(Swift.max(self, lo), hi)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||
/// 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
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||
/// to the push list).
|
||||
@ViewBuilder
|
||||
func settingsSheetSizing() -> some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||
presentationSizing(.page)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
switch phase {
|
||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
||||
resultView(result)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
||||
if let rec = Self.recommendedKbps(result) {
|
||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||
+ "(~70% of measured, headroom for encoder bursts).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Too little data made it through to recommend a bitrate — "
|
||||
+ "check the network and retry.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
||||
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
||||
Text("⌘⇧C toggles the on-screen cursor")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
||||
// this button is the in-overlay, click-to-disconnect affordance.
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// or drops this and runs the PIN pairing ceremony instead.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct TrustCardView: View {
|
||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(hostName)")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
||||
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
||||
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
||||
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
||||
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
||||
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum BrandFont {
|
||||
public enum Weight {
|
||||
case regular, medium, semibold, bold
|
||||
}
|
||||
|
||||
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
||||
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
||||
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
||||
|
||||
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
||||
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
||||
private static let registered: Void = {
|
||||
for face in sansFaces {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
||||
#if DEBUG
|
||||
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
var error: Unmanaged<CFError>?
|
||||
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
||||
#if DEBUG
|
||||
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
||||
print("BrandFont: failed to register \(face): \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
||||
public static func registerIfNeeded() { _ = registered }
|
||||
|
||||
fileprivate static func sansFace(_ weight: Weight) -> String {
|
||||
switch weight {
|
||||
case .regular: return "Geist-Regular"
|
||||
case .medium: return "Geist-Medium"
|
||||
case .semibold: return "Geist-SemiBold"
|
||||
case .bold: return "Geist-Bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||
static let brand: Color = {
|
||||
#if canImport(UIKit)
|
||||
return Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#elseif canImport(AppKit)
|
||||
return Color(NSColor(name: nil) { appearance in
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#else
|
||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
static func geist(
|
||||
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
||||
relativeTo textStyle: TextStyle = .body
|
||||
) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
||||
}
|
||||
|
||||
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
||||
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
||||
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public enum DefaultsKey {
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
|
||||
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
private var broken = false
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
|
||||
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that
|
||||
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
|
||||
// update immediately rebuilds into the same dead connection, flooding the log and never
|
||||
// recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
|
||||
// the moment a player runs cleanly (or the controller changes).
|
||||
private var retryAfter = Date.distantPast
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
@@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
@@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil {
|
||||
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
@@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to.
|
||||
if !ok { self.teardown() }
|
||||
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||
if !ok {
|
||||
self.teardown()
|
||||
self.scheduleRetryBackoff()
|
||||
} else if self.low?.player != nil || self.high?.player != nil {
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
low = makeMotor(haptics, .default)
|
||||
}
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / a transient
|
||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||
scheduleRetryBackoff()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
|
||||
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
|
||||
/// every rumble update.
|
||||
private func scheduleRetryBackoff() {
|
||||
consecutiveFailures += 1
|
||||
let shift = min(consecutiveFailures - 1, 4)
|
||||
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
|
||||
engine.playsHapticsOnly = true
|
||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
|
||||
@@ -27,10 +27,35 @@ public enum Licenses {
|
||||
+ apache
|
||||
}
|
||||
|
||||
/// The bundled brand typeface (Geist Sans + Geist Mono) — SIL Open Font License 1.1. The
|
||||
/// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's
|
||||
/// distribution requirement; this surfaces it in the Acknowledgements screen too.
|
||||
public static var fontLicense: String {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// `scripts/gen-third-party-notices.sh`).
|
||||
public static var thirdPartyNotices: String {
|
||||
let text = resource("THIRD-PARTY-NOTICES")
|
||||
return text.isEmpty ? "Third-party notices unavailable." : text
|
||||
}
|
||||
|
||||
/// `thirdPartyNotices` pre-split into render-sized line chunks. The full notices are ~885 KB /
|
||||
/// 16k lines; a single SwiftUI `Text` that large overshoots CoreText/CoreAnimation's max
|
||||
/// renderable height — it lays out for ages and draws blank past the limit — so the
|
||||
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||
public static let thirdPartyNoticesChunks: [String] = {
|
||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let chunkSize = 200
|
||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import CoreGraphics
|
||||
import CoreVideo
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
||||
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
||||
@@ -30,11 +33,44 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||
return o;
|
||||
}
|
||||
|
||||
// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger
|
||||
// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale
|
||||
// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1)
|
||||
// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact.
|
||||
// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear
|
||||
// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear.
|
||||
float catmullRomLuma(texture2d<float> tex, sampler s, float2 uv) {
|
||||
float2 texSize = float2(tex.get_width(), tex.get_height());
|
||||
float2 samplePos = uv * texSize;
|
||||
float2 tc1 = floor(samplePos - 0.5) + 0.5;
|
||||
float2 f = samplePos - tc1;
|
||||
float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
float2 w3 = f * f * (-0.5 + 0.5 * f);
|
||||
float2 w12 = w1 + w2;
|
||||
float2 off12 = w2 / w12;
|
||||
float2 tc0 = (tc1 - 1.0) / texSize;
|
||||
float2 tc3 = (tc1 + 2.0) / texSize;
|
||||
float2 tc12 = (tc1 + off12) / texSize;
|
||||
float r = 0.0;
|
||||
r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y);
|
||||
return r;
|
||||
}
|
||||
|
||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
||||
@@ -55,7 +91,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
|
||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||
@@ -81,6 +117,11 @@ public final class MetalVideoPresenter {
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
||||
private var hdrActive = false
|
||||
#if DEBUG
|
||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only when a size changes
|
||||
/// (on first frame, a resize, or a host Reconfigure) instead of every frame.
|
||||
private var lastSizeSig = ""
|
||||
#endif
|
||||
|
||||
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
||||
public init?() {
|
||||
@@ -113,6 +154,12 @@ public final class MetalVideoPresenter {
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.framebufferOnly = true
|
||||
layer.isOpaque = true
|
||||
// Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let
|
||||
// the system compositor scale it to the layer's bounds — the same `.resizeAspect` path
|
||||
// stage-1's AVSampleBufferDisplayLayer (videoGravity) uses, so stage-2 matches its sharpness.
|
||||
// A native-resolution present is then pixel-exact (1:1, no shader scaling), and any display
|
||||
// scaling uses the system's high-quality scaler rather than the in-shader bicubic.
|
||||
layer.contentsGravity = .resizeAspect
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
||||
// display-link / MAIN thread) has to block waiting for one to free.
|
||||
layer.maximumDrawableCount = 3
|
||||
@@ -129,12 +176,6 @@ public final class MetalVideoPresenter {
|
||||
self.layer = layer
|
||||
}
|
||||
|
||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
if layer.drawableSize != size { layer.drawableSize = size }
|
||||
}
|
||||
|
||||
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
|
||||
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
|
||||
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
||||
@@ -171,13 +212,33 @@ public final class MetalVideoPresenter {
|
||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
||||
else { return false }
|
||||
|
||||
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid
|
||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
||||
let drawable = layer.nextDrawable(),
|
||||
// Size the drawable to the decoded frame so the fullscreen triangle samples the texture 1:1
|
||||
// (pixel-exact); the layer's contentsGravity then scales it to the on-screen bounds via the
|
||||
// system compositor (matching stage-1). Re-set only on a change (first frame / Reconfigure).
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
#if DEBUG
|
||||
// Diagnose sharpness: decoded should equal the drawable (the shader is 1:1); the layer's
|
||||
// bounds may differ (the system scales). Logged only when a size changes.
|
||||
let decodedW = Int(decodedSize.width)
|
||||
let decodedH = Int(decodedSize.height)
|
||||
let sig = "\(decodedW)x\(decodedH)|\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))"
|
||||
if sig != lastSizeSig {
|
||||
lastSizeSig = sig
|
||||
let msg = "stage2: decoded \(decodedW)x\(decodedH) → drawable "
|
||||
+ "\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height)) "
|
||||
+ "(texture \(drawable.texture.width)x\(drawable.texture.height), "
|
||||
+ "contentsScale \(layer.contentsScale), "
|
||||
+ "layerBounds \(Int(layer.bounds.width))x\(Int(layer.bounds.height)))"
|
||||
presenterLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
#endif
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = drawable.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -177,6 +177,16 @@ public final class SessionAudio {
|
||||
private var playbackEngine: AVAudioEngine?
|
||||
private var captureEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
/// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process
|
||||
/// singleton, and across a reconnect the old session's deactivate must be ordered before the
|
||||
/// new session's activate (a per-instance queue would let them race and leave the new session's
|
||||
/// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
@@ -189,37 +199,60 @@ public final class SessionAudio {
|
||||
flag.stop()
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
||||
/// default device; on iOS the UIDs are ignored entirely (routes are
|
||||
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
||||
/// start — the mic may start slightly later if the permission prompt is pending.
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
#if os(iOS)
|
||||
// Route + policy live in the session, not per-engine: stereo playback, mic
|
||||
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
/// Route + policy live in the session, not per-engine: stereo playback, mic capture when
|
||||
/// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`.
|
||||
private func activateAudioSession(micEnabled: Bool) {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
#if os(iOS)
|
||||
if micEnabled {
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
||||
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||
// affects the built-in route (headphones/BT still win).
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
}
|
||||
#else // tvOS — no app-accessible mic
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
@@ -258,19 +291,24 @@ public final class SessionAudio {
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
||||
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
||||
// queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget.
|
||||
Self.sessionQueue.async {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if wasDraining {
|
||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||
}
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its
|
||||
// resume cue.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
//
|
||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick`
|
||||
// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||
// + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||
// Only the ring + decoder cross threads and both are internally locked.
|
||||
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
|
||||
func request() {
|
||||
lock.lock()
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request
|
||||
let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request (matches Android)
|
||||
if due { lastNs = now }
|
||||
let conn = due ? connection : nil
|
||||
lock.unlock()
|
||||
@@ -114,20 +114,24 @@ public final class Stage2Pipeline {
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||
// the old code advanced lastFramesDropped on the same edge it called recovery.request(),
|
||||
// so a request swallowed by the throttle (the lost recovery IDR being pruned within the
|
||||
// window) was never re-sent and the picture stayed frozen. Keep asking until an IDR lands.
|
||||
var awaitingIDR = false
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
||||
// resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs
|
||||
// (framesDropped) and the decoder conceals the reference-missing deltas that
|
||||
// follow — often WITHOUT an error callback — so key off the drop count climbing,
|
||||
// then keep asking (awaitingIDR) until a fresh IDR re-anchors decode. Polled every
|
||||
// iteration so a total-loss drought recovers the moment packets resume.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
@@ -136,15 +140,16 @@ public final class Stage2Pipeline {
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, token.isLive else { continue }
|
||||
if !decoder.decode(au: au, format: f) {
|
||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy —
|
||||
// and keep asking for that IDR (infinite GOP) until one re-anchors decode.
|
||||
decoder.reset()
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
}
|
||||
} catch {
|
||||
if token.isLive { onSessionEnd?() }
|
||||
@@ -166,11 +171,6 @@ public final class Stage2Pipeline {
|
||||
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
presenter.setDrawableSize(size)
|
||||
}
|
||||
|
||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the
|
||||
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||
public func stop() {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||
|
||||
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
||||
/// its own token, so it can never be revived by a newer start().
|
||||
@@ -47,44 +50,74 @@ final class StreamPump {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastKeyframeRequest = Date.distantPast
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||
// fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the
|
||||
// same edge it fired the throttled request — so a request swallowed by the throttle (a
|
||||
// second drop within the window, e.g. the lost recovery IDR itself being pruned) was
|
||||
// never re-sent: the counter went flat, the climb never re-fired, and the picture stayed
|
||||
// frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the
|
||||
// Mac's Ethernet never does.
|
||||
var awaitingIDR = false
|
||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||
var wasFailed = false
|
||||
// Coalesced host keyframe request. 100 ms throttle (matches the working Android path):
|
||||
// fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained
|
||||
// freeze can't flood the control stream.
|
||||
func requestKeyframeThrottled() {
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
}
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
||||
// packets resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
// frozen / garbage picture that never flips the layer to .failed — so key off the
|
||||
// drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
|
||||
// every iteration so a total-loss drought still recovers when packets resume.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
// Log only on the false→true transition (once per recovery cycle), not per
|
||||
// dropped AU, so heavy loss doesn't spam the log.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.notice(
|
||||
"video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR")
|
||||
}
|
||||
lastFramesDropped = dropped
|
||||
requestKeyframeThrottled()
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { requestKeyframeThrottled() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
||||
if let f = idrFormat {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
if awaitingIDR {
|
||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if layer.status == .failed {
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||
// the layer stays .failed across several polls until the IDR lands.
|
||||
// IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
|
||||
// re-gate on the next in-band parameter sets and keep asking — enqueuing a
|
||||
// delta into a failed layer can't recover it.
|
||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||
layer.flush()
|
||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||
requestKeyframeThrottled()
|
||||
if idrFormat == nil {
|
||||
format = nil
|
||||
awaitingIDR = true
|
||||
}
|
||||
}
|
||||
wasFailed = failed
|
||||
guard let f = format,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||
token.isLive // don't enqueue a stale frame after a restart
|
||||
|
||||
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
|
||||
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
||||
}
|
||||
|
||||
public override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
// `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the
|
||||
// stage-2 metal sublayer's drawableSize could stay at the old size while the view grows —
|
||||
// the compositor then upscales a too-small drawable and the video turns blocky. Resize the
|
||||
// drawable here too so it always tracks the window's pixel size (no stale upscale).
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
// MARK: - Capture state machine
|
||||
|
||||
/// Clicking into the video engages capture; that click is local (engagement), so
|
||||
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
|
||||
cursorVisible = false
|
||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// 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-1 is
|
||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||
// pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
|
||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the
|
||||
/// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it.
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
|
||||
/// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's
|
||||
/// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a
|
||||
/// resized window rescales through the system's filter (matching stage-1) instead of the shader.
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = window?.backingScaleFactor ?? 1
|
||||
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = window?.backingScaleFactor ?? 1
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
|
||||
@@ -136,6 +136,13 @@ public final class StreamViewController: UIViewController {
|
||||
|
||||
public override func loadView() {
|
||||
view = StreamLayerUIView()
|
||||
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||
// doesn't capture self (no retain cycle with the registration).
|
||||
registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in
|
||||
vc.layoutMetalLayer()
|
||||
}
|
||||
#if os(iOS)
|
||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
||||
// cursor from our deltas, so the local one only diverges from it. This hides the
|
||||
@@ -219,10 +226,17 @@ public final class StreamViewController: UIViewController {
|
||||
inputCapture = capture
|
||||
#endif
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// Presenter choice — stage-2 is the DEFAULT (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 frame with no
|
||||
// way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always
|
||||
// takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -300,8 +314,8 @@ public final class StreamViewController: UIViewController {
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
let metal = pipeline.layer
|
||||
metal.contentsScale = streamView.contentScaleFactor
|
||||
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||
// (contentsScale + frame + drawableSize are all set by layoutMetalLayer() just below.)
|
||||
streamView.layer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
stage2 = pipeline
|
||||
@@ -325,9 +339,20 @@ public final class StreamViewController: UIViewController {
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's
|
||||
/// fullscreen triangle scales the decoded texture to fill it.
|
||||
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||
/// would size the drawable at point resolution → a pixelated, upscaled mess. Falls back to the
|
||||
/// main screen scale if the trait is still unspecified.
|
||||
private var renderScale: CGFloat {
|
||||
let s = traitCollection.displayScale
|
||||
return s > 0 ? s : UIScreen.main.scale
|
||||
}
|
||||
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter
|
||||
/// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect)
|
||||
/// scales it to this frame via the system compositor (matching stage-1's videoGravity).
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -337,13 +362,11 @@ public final class StreamViewController: UIViewController {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = streamView.contentScaleFactor
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true) // don't animate the resize
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = renderScale
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
private func teardownStage2() {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
import Metal
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class MetalPresenterTests: XCTestCase {
|
||||
/// `MetalVideoPresenter.init?()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUV→RGB
|
||||
/// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host
|
||||
/// means a shader failed to compile — this catches a malformed shader before it silently
|
||||
/// degrades stage-2 to a stage-1 fallback on device.
|
||||
func testPresenterInitCompilesShaders() throws {
|
||||
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||
throw XCTSkip("no Metal device available in this environment")
|
||||
}
|
||||
XCTAssertNotNil(
|
||||
MetalVideoPresenter(),
|
||||
"stage-2 Metal shaders failed to compile (presenter init returned nil)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -327,17 +327,17 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Windows defaults a new IddCx monitor into CLONE mode when a physical display is already
|
||||
// active (a laptop panel, an attached monitor): the cloned IDD shares that display's source, so
|
||||
// the OS never commits a distinct path for it and capture sees no frames. Force EXTEND first so
|
||||
// the IDD comes up as its OWN active path; the resolve loop below then finds it. Idempotent /
|
||||
// no-op on a sole-display box, so it's safe on the headless single-GPU path too.
|
||||
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (a CCD topology apply) with no
|
||||
// borrowed caller memory; it runs under the manager `state` lock, the sole topology mutator.
|
||||
unsafe { force_extend_topology() };
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its
|
||||
// OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode`
|
||||
// below then promotes it to primary and `isolate_displays_ccd` makes it the sole composited
|
||||
// desktop — the proven flow). May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// the capture backend re-resolves once a GPU is present.
|
||||
//
|
||||
// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED
|
||||
// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed
|
||||
// the headless path — the IDD then never gets its own path → "not an active display path" → black).
|
||||
// force-EXTEND is only the FALLBACK below, for an integrated-screen box where a fresh IDD is CLONED
|
||||
// onto the panel (shares its source) instead of getting its own path.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
@@ -349,6 +349,32 @@ impl VirtualDisplayManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for an integrated-screen box (e.g. a laptop panel): Windows CLONES a freshly-added
|
||||
// IDD onto the existing display, sharing its source, so it never gets its own committed path. On
|
||||
// the IddCx clone behaviour observed live (commit 8e87e61, an Intel-iGPU + NVIDIA-Optimus laptop)
|
||||
// `resolve_gdi_name` then stays None — so this `is_none()` fallback fires, force-EXTENDs to
|
||||
// de-clone, and the second resolve finds the now-committed path. Headless/extended boxes already
|
||||
// resolved above (the IDD auto-activates with its OWN source) and skip this — which is the whole
|
||||
// point, since force-EXTEND's bare preset is ACCESS_DENIED from our service context there.
|
||||
//
|
||||
// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone
|
||||
// appear as a *shared-source ACTIVE* path (resolve → Some), which this `is_none()` gate would NOT
|
||||
// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
|
||||
// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
|
||||
if gdi_name.is_none() {
|
||||
// SAFETY: as above — `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no
|
||||
// borrowed caller memory, under the `state` lock.
|
||||
unsafe { force_extend_topology() };
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: as the resolve loop above.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
|
||||
Reference in New Issue
Block a user