feat(client/apple): profiles, resolved once per connect instead of ten times mid-session

The Apple half of settings profiles (design/client-settings-profiles.md §4) starts
with the thing the desktop shells got for free from the shared Rust core: a model,
and one place that resolves it.

`SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles`
field for field, unknown-key carry-through included — an older build that opens a
profile a newer one wrote must not gut it on save. The catalog lives in the App
Group suite beside the saved hosts, because the things that point at it are fields
on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both
optional and appended last, because that JSON is a widget contract.

`EffectiveSettings` is the resolution — globals with the session's overlay on top,
computed once at connect and latched in `SessionSettings` for the rest of it. That
latch is the point. Ten sites across the app AND the kit read `UserDefaults`
directly mid-session (the presenter's priority and VRR, the vsync flip, the
match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4,
the audio endpoints); a profile that reached some of them and not others would be
worse than no feature at all. They now read the latch, which off-session is the
plain globals — byte for byte what they saw before.

The deep-link grammar grows up with it: `DeepLink` becomes a full port of
`deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`,
and every refusal code — and the Swift suite now runs
`clients/shared/deeplink-vectors.json` itself, read from the source tree so there
is no second copy to drift. All 44 cases green. The router refuses what it can't
honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that
contradicts the pin. A shortcut that streams with the wrong settings is worse than
one that explains itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:06:41 +02:00
co-authored by Claude Opus 5
parent 9b00ad6658
commit 858cf0e535
20 changed files with 1896 additions and 202 deletions
@@ -20,36 +20,32 @@ import SwiftUI
struct ContentView: View {
@StateObject private var model = SessionModel()
@StateObject private var store = HostStore()
/// The settings-profile catalog (design/client-settings-profiles.md §4.2) read at every
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
@StateObject private var profiles = ProfileStore()
@StateObject private var discovery = HostDiscovery()
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
/// The tier the overlay actually shows: the live session's (its profile's, then whatever the
/// S/three-finger cycle moved it to) while streaming, the persisted global otherwise.
private var statsVerbosity: StatsVerbosity {
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
model.connection != nil
? model.statsVerbosity
: (StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal)
}
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
private var preferredCodecByte: UInt8 {
switch codec {
case "h264": return PunktfunkConnection.codecH264
case "hevc": return PunktfunkConnection.codecHEVC
case "av1": return PunktfunkConnection.codecAV1
case "pyrowave": return PunktfunkConnection.codecPyroWave
default: return 0
}
/// Fullscreen-while-streaming is profileable (a Game profile goes fullscreen, a Work one
/// doesn't), so a live session obeys ITS value and the host list obeys the global.
private var fullscreenForSession: Bool {
model.connection != nil ? model.settings.fullscreenWhileStreaming : fullscreenWhileStreaming
}
@State private var showAddHost = false
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored unknown host, or
@@ -144,6 +140,12 @@ struct ContentView: View {
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
// parallel session this drives the one `model` ContentView owns.
.onOpenURL { handleDeepLink($0) }
// A live stats-overlay cycle from ANY surface (S, the three-finger tap, the Stream
// menu) writes the global; push it into the session so the overlay follows it from there
// on, whatever tier the session's profile started on.
.onChange(of: statsVerbosityRaw) { _, raw in
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
}
#if os(iOS) || os(tvOS)
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
// ignored so neither branch fires for a Control-Center pull.
@@ -260,7 +262,7 @@ struct ContentView: View {
// `isFullscreen` (the user can toggle it manually), which drives the session view's
// safe-area handling below.
.background(FullscreenController(
active: fullscreenWhileStreaming && model.connection != nil,
active: fullscreenForSession && model.connection != nil,
isFullscreen: $isFullscreen))
#endif
// On the outer Group so the sheet survives the trust-prompt home transition
@@ -335,7 +337,7 @@ struct ContentView: View {
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenWhileStreaming && isFullscreen { return false }
if fullscreenForSession && isFullscreen { return false }
#endif
return true
},
@@ -396,25 +398,81 @@ struct ContentView: View {
}
#endif
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
/// unknown host notice + no-op; a live session is up ignore if it's the same host, else
/// tell the user to end the current one first (NEVER tear down a live session on a background
/// tap); otherwise the normal `connect` trust policy, WoL and the approval sheet all apply.
/// Route a `punktfunk://` deep link into the existing connect path the whole §2 grammar
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
/// `fp`/`host` recovery parameters and a one-off `profile`.
///
/// The security posture is the parser's plus three rules that live here, and none of them
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
/// confirmation, not a connect), never preempts a live session (same host focus, different
/// host say so; NEVER tear one down on a background tap), and carries only references a
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
private func handleDeepLink(_ url: URL) {
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
deepLinkNotice = "That host isn't saved on this device."
let link: DeepLink
do {
link = try DeepLink(url: url)
} catch DeepLinkError.notOurScheme {
return // not ours ignore it silently rather than warning about someone else's URL
} catch {
deepLinkNotice = (error as? DeepLinkError)?.message
?? "That link is malformed and was ignored."
return
}
if model.phase != .idle {
guard model.activeHost?.id == hostID else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
guard link.route == .connect else {
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
// neither, and saying so beats silently connecting instead.
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
return
}
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
// must refuse, not degrade to the host's binding (§10.6).
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
return // deep-linked to the host we're already on nothing to do
}
connect(host, launchID: launchID)
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
return
}
guard model.phase == .idle else {
guard model.activeHost?.id == host.id else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
return // deep-linked to the host we're already on nothing to do
}
connect(host, launchID: link.launch, profile: selection)
case .unknown(let address, let port, let name, let fp):
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
// the user makes the trust decision with their eyes on it.
guard model.phase == .idle else {
deepLinkNotice = "Already streaming. End that session first."
return
}
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button — the link points at \(address):\(String(port))"
+ (fp == nil ? "." : ", and carries a fingerprint to verify it against.")
case .ambiguous:
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
+ "Rename one, or link to it by its address."
case .unresolvable:
deepLinkNotice = "That host isn't saved on this device."
}
}
private var home: some View {
@@ -731,7 +789,14 @@ struct ContentView: View {
// MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
/// `profile` is this connect's one-off pick ("Connect with ", a pinned card, a link's
/// `profile=`). `.inherit` the default, and what a plain card tap passes falls through to
/// the host's binding. A one-off NEVER rebinds the host: rebinding is always an explicit act
/// in the edit sheet (design §5.2).
private func connect(
_ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit, allowTofu: Bool? = nil
) {
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
@@ -750,20 +815,22 @@ struct ContentView: View {
return
}
}
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
startSession(
host, launchID: launchID, profile: profile, allowTofu: host.pinnedSHA256 == nil)
}
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
/// gamepad-type setting resolves NOW (Automatic match the active physical controller): the
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
/// delegated-approval connect (host parks it until the operator approves).
/// Resolve the stream mode + input prefs and hand off to the session model. The gamepad-type
/// setting resolves NOW (Automatic match the active physical controller): the host's virtual
/// pad backend is fixed per session. `requestAccess` opens the no-PIN delegated-approval
/// connect (host parks it until the operator approves).
private func startSession(
_ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
) {
let go = {
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq)
}
// Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean
@@ -778,7 +845,7 @@ struct ContentView: View {
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: {
waker.start(
@@ -794,19 +861,9 @@ struct ContentView: View {
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
private func scaledMode() -> (width: UInt32, height: UInt32) {
RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
}
private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
onUnreachable: (@MainActor () -> Void)? = nil
) {
@@ -814,19 +871,17 @@ struct ContentView: View {
// The delegated-approval wait prompt only makes sense once we're actually dialing set it
// here (after any wake), not before, so it never stacks under the "Waking" overlay.
if let approvalReq { awaitingApproval = approvalReq }
// THE resolution point (design §4.4): the globals plus this connect's profile, once, here.
// The model latches the result for the whole session, so nothing downstream can end up
// applying a profile to half of it.
let effective = EffectiveSettings.resolve(
host: host, selection: profile, catalog: profiles.catalog)
model.connect(
to: host,
width: scaledMode().width, height: scaledMode().height,
hz: UInt32(clamping: hz),
compositor: PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto,
effective: effective,
gamepad: GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
bitrateKbps: UInt32(clamping: bitrateKbps),
audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
preferredCodec: preferredCodecByte,
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
launchID: launchID,
allowTofu: allowTofu,
requestAccess: requestAccess,
@@ -979,36 +1034,25 @@ struct ContentView: View {
hz = dims[2]
}
}
var pref = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto
// The dev levers layer over the globals (no host record, so no binding to resolve).
var effective = EffectiveSettings(defaults: .standard)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
let c = PunktfunkConnection.Compositor(name: name) {
pref = c
effective.compositor = Int(c.rawValue)
}
var pad = GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
// Back through resolveType so the lever is adopted as the session's setting: the
// per-pad arrivals declare it too, which is what the host actually builds from.
pad = GamepadManager.shared.resolveType(setting: g)
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
let v = UInt32(kbps) {
bitrate = v
let v = Int(kbps) {
effective.bitrateKbps = v
}
model.connect(
to: host,
width: scaledMode().width, height: scaledMode().height,
hz: UInt32(clamping: hz),
compositor: pref,
gamepad: pad,
bitrateKbps: bitrate,
audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
preferredCodec: preferredCodecByte,
autoTrust: true)
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
}
}
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
@Published private(set) var connection: PunktfunkConnection?
/// The host this session is for (a value copy; identity = id).
@Published private(set) var activeHost: StoredHost?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@Published private(set) var settings = EffectiveSettings()
/// The stats-overlay tier for this session: the resolved one at connect, then whatever the
/// live cycle surfaces (S, the three-finger tap) move it to. Separate from the @AppStorage
/// global so a profile that overrides the tier actually gets it, without the cycle breaking.
@Published var statsVerbosity: StatsVerbosity = .normal
@Published var errorMessage: String?
@Published var fps = 0
@Published var mbps = 0.0
@@ -218,13 +226,13 @@ final class SessionModel: ObservableObject {
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
/// advertising). It never fires for the delegated-approval path, whose failure text carries
/// its own instructions.
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto,
/// `effective` is the whole stream mode + input/audio configuration for this session, already
/// resolved from the globals and the session's profile by the caller the ONE place that
/// resolution happens (§4.4). It is latched into `SessionSettings` here so the kit-side
/// readers (the presenter, the input paths, the match-window follower) see the same values
/// this connect asked the host for, instead of re-reading the globals mid-session.
func connect(to host: StoredHost, effective: EffectiveSettings,
gamepad: PunktfunkConnection.GamepadType = .auto,
bitrateKbps: UInt32 = 0,
audioChannels: UInt8 = 2,
hdrEnabled: Bool = true,
preferredCodec: UInt8 = 0,
launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
@@ -234,6 +242,21 @@ final class SessionModel: ObservableObject {
phase = .connecting
activeHost = host
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
SessionSettings.begin(effective)
let mode = RenderScale.apply(
baseWidth: effective.width, baseHeight: effective.height,
scale: effective.renderScale,
maxDimension: RenderScale.maxDimension(codec: effective.codec))
let (width, height) = (mode.width, mode.height)
let hz = UInt32(clamping: effective.refreshHz)
let compositor = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
let audioChannels = UInt8(clamping: effective.audioChannels)
let hdrEnabled = effective.hdrEnabled
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
let pin = host.pinnedSHA256
// Capability gate (main-actor screen APIs): only advertise HDR when this display can
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
@@ -266,7 +289,7 @@ final class SessionModel: ObservableObject {
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
// hardware-decode probe below still gates what can actually be advertised.
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? false
let want444 = effective.enable444
Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired
@@ -313,9 +336,7 @@ final class SessionModel: ObservableObject {
// NSCursor. Capture-mode sessions keep today's composited pointer.
#if os(macOS)
let clientCaps: UInt8 =
(MouseInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
?? .capture) == .desktop ? 0x01 : 0
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
let clientCaps: UInt8 = 0
#endif
@@ -443,6 +464,16 @@ final class SessionModel: ObservableObject {
}
}
/// Follow a live stats-overlay cycle (S, the three-finger tap, the Stream menu). Those
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
/// with it, so cycling still works in a session a profile put on a different tier.
func setStatsVerbosity(_ tier: StatsVerbosity) {
guard statsVerbosity != tier else { return }
statsVerbosity = tier
settings.statsVerbosity = tier.rawValue
SessionSettings.setStatsVerbosity(tier.rawValue)
}
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
func confirmTrust() -> Data? {
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
@@ -460,6 +491,9 @@ final class SessionModel: ObservableObject {
func disconnect(deliberate: Bool = true) {
statsTimer?.invalidate()
statsTimer = nil
// Release the session's resolved settings: from here every reader falls back to the plain
// globals, which is exactly what they saw before profiles existed.
SessionSettings.end()
// No-op when this session never reached `.streaming` (a refused/aborted connect).
displaySleepGuard.release()
// Drop any armed background keep-alive (incl. the timeout that just fired us).
@@ -559,15 +593,15 @@ final class SessionModel: ObservableObject {
phase = .streaming
displaySleepGuard.acquire()
// Audio starts with streaming, not during the trust prompt no host sound (or
// mic uplink!) before the user trusted the host. Devices come from Settings;
// "" = system default.
let defaults = UserDefaults.standard
// mic uplink!) before the user trusted the host. Devices and the mic switch come from the
// session's resolved settings ("" = system default), so a profile that turns the mic on
// for work calls applies to the uplink too.
let audio = SessionAudio(connection: conn)
audio.start(
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
speakerUID: settings.speakerUID,
micUID: settings.micUID,
micChannel: settings.micChannel,
micEnabled: settings.micEnabled)
self.audio = audio
// Gamepads: forward every controller GamepadManager selected each on its own wire pad
// index (a pin forwards only one, Automatic forwards all) and render the host's feedback
@@ -43,17 +43,13 @@ extension FocusedValues {
struct StreamCommands: Commands {
@FocusedValue(\.sessionFocus) private var session
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
var body: some Commands {
CommandMenu("Stream") {
Button("Cycle Statistics") {
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
statsVerbosityRaw = current.next().rawValue
}
// Through the shared cycle so it advances from the LIVE session's tier a profile
// that starts a session on Detailed must cycle to Off from here, not from whatever
// the global default happens to be.
Button("Cycle Statistics") { StatsVerbosity.cycle() }
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
// Reaches the key window's stream view via NotificationCenter capture is view
// state the Scene can't touch directly. (Captured, the combo is handled by
@@ -148,6 +148,25 @@ final class HostStore: ObservableObject {
hosts[i].macAddresses = macs
}
/// Bind this host to a settings profile, or to "Default settings" (nil) the ONLY way the
/// default changes. A one-off "Connect with " deliberately never lands here (§5.2:
/// predictable, not sticky).
func setProfile(_ hostID: UUID, profileID: String?) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].profileID = profileID
}
/// Pin or unpin a host+profile combo as its own card (§5.2a). Presentation only: it never
/// touches the default binding or the profile itself. nil stays out of the saved JSON when
/// nothing is pinned, so the widget contract sees no new key for the common case.
func setPinned(_ hostID: UUID, profileID: String, pinned: Bool) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
var pins = hosts[i].pinnedProfileIDs ?? []
pins.removeAll { $0 == profileID }
if pinned { pins.append(profileID) }
hosts[i].pinnedProfileIDs = pins.isEmpty ? nil : pins
}
/// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
/// `pair=optional` (the only case the connect path still offers the trust prompt).
@@ -0,0 +1,107 @@
// The settings-profile catalog as an observable store the app-side wrapper around
// `ProfileCatalog` (design/client-settings-profiles.md §4.2), matching what `HostStore` is to
// `[StoredHost]`.
//
// The catalog lives in the App Group suite beside the saved hosts, because that is where the
// things that POINT at it live: a binding is `StoredHost.profileID` and a pin is an entry in
// `StoredHost.pinnedProfileIDs`. Nothing here is keyed by host "Work" applied to three hosts is
// one profile, and the per-host part is only the binding (§4.1).
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class ProfileStore: ObservableObject {
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
}
var profiles: [StreamProfile] { catalog.profiles }
init(catalog: ProfileCatalog? = nil) {
self.catalog = catalog ?? ProfileCatalog.load()
}
func profile(id: String?) -> StreamProfile? {
id.flatMap { catalog.profile(id: $0) }
}
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
/// This host's pinned profiles in card order, duplicates and dangling ids dropped.
func pinned(for host: StoredHost) -> [StreamProfile] { catalog.pinned(for: host) }
func nameTaken(_ name: String, except: String? = nil) -> Bool {
catalog.nameTaken(name, except: except)
}
// MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete)
/// Create an EMPTY profile it inherits everything, which is the right creation default under
/// inherit-by-exception. "Duplicate" covers starting from another profile.
@discardableResult
func create(name: String) -> StreamProfile {
let profile = StreamProfile(name: name)
catalog.profiles.append(profile)
return profile
}
@discardableResult
func duplicate(_ id: String, name: String) -> StreamProfile? {
guard let source = catalog.profile(id: id) else { return nil }
var copy = source
copy.id = newProfileID()
copy.name = name
catalog.profiles.append(copy)
return copy
}
func rename(_ id: String, to name: String) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].name = name
}
func setAccent(_ id: String, to accent: String?) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].accent = accent
}
/// Delete a profile. Bindings and pins pointing at it are left alone deliberately: they
/// degrade to "Default settings" / a dropped card at read time (§6), so a delete never has to
/// walk the host store and a host record saved by an older build can't resurrect a stale id.
func delete(_ id: String) {
catalog.profiles.removeAll { $0.id == id }
}
/// How the delete warning counts what it is about to change: hosts bound to this profile and
/// pinned cards that will disappear.
func usage(of id: String, hosts: [StoredHost]) -> (bound: Int, pinned: Int) {
(
hosts.filter { $0.profileID == id }.count,
hosts.filter { ($0.pinnedProfileIDs ?? []).contains(id) }.count
)
}
// MARK: - Overrides
/// Record an override, always by explicit write never by comparing the new value against
/// today's global. A value that happens to equal the global is a legitimate PIN: the profile
/// keeps it when the global later moves, and that is the whole difference between this feature
/// and "copy the settings" (§4.1).
func setOverride<Value>(
_ id: String, _ keyPath: WritableKeyPath<SettingsOverlay, Value?>, _ value: Value
) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].overrides[keyPath: keyPath] = value
}
/// The only way back to inheriting: an explicit per-row reset. `field` is the overlay's own
/// serialized name, with `resolution` covering the width/height/match-window tri-state.
func clearOverride(_ id: String, field: String) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
OverlayField.clear(field, in: &catalog.profiles[i].overrides)
}
}
@@ -1020,6 +1020,20 @@ public final class PunktfunkConnection {
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
/// The `codec` SETTING (a `DefaultsKey.codec` / profile-overlay string) as a soft-preference
/// byte; `0` = Automatic, i.e. the host decides. Lives here beside the bits so the settings
/// string is mapped to the wire in exactly one place a session and a speed test that
/// disagreed on what "pyrowave" means would be a silent mismatch.
public static func codecByte(_ setting: String) -> UInt8 {
switch setting {
case "h264": return codecH264
case "hevc": return codecHEVC
case "av1": return codecAV1
case "pyrowave": return codecPyroWave
default: return 0
}
}
/// `AccessUnit.flags` bit: the AU is shard-aligned self-delimiting chunks (the wire's
/// `USER_FLAG_CHUNK_ALIGNED`, PyroWave datagram-aligned mode §4.4) walk it
/// window-by-window at `shardPayload`. (The C `#define` doesn't import into Swift.)
@@ -670,7 +670,7 @@ public final class InputCapture {
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here so the toggle
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
// between events doesn't strand a fractional remainder of the old sign.
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
let invert = SessionSettings.current.invertScroll
let dx = invert ? -rawDx : rawDx
let dy = invert ? -rawDy : rawDy
let fy = dy + residualScrollY
@@ -34,7 +34,7 @@ public enum TouchInputMode: String, CaseIterable, Sendable {
/// The persisted setting, defaulting to trackpad when unset/unknown.
public static var current: TouchInputMode {
TouchInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
rawValue: SessionSettings.current.touchMode
) ?? .trackpad
}
}
@@ -331,7 +331,7 @@ final class TouchMouse {
/// through the shared `statsVerbosity` default, which the app's HUD views observe via
/// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
private static func cycleStats() {
StatsVerbosity.store(StatsVerbosity.current.next())
StatsVerbosity.cycle()
}
}
#endif
@@ -56,4 +56,21 @@ public enum StatsVerbosity: String, CaseIterable, Sendable {
public static func store(_ tier: StatsVerbosity) {
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
}
/// The tier the LIVE session is showing its profile's, if one overrode it falling back to
/// the persisted global while idle. What the in-stream cycle advances FROM: cycling in a
/// session a profile put on Detailed must go to Off, not to whatever the global happens to be.
public static var session: StatsVerbosity {
StatsVerbosity(rawValue: SessionSettings.current.statsVerbosity) ?? .normal
}
/// Advance the in-stream overlay one tier (S, the three-finger tap, the Stream menu).
///
/// It writes the GLOBAL, as every client's cycle always has, and the app pushes that back into
/// the live session so from the moment the user cycles, the session follows the global
/// rather than the profile's start-of-session tier. That is the honest reading of an explicit
/// live override, and it keeps one observable source (`@AppStorage`) driving the overlay.
public static func cycle() {
store(session.next())
}
}
@@ -275,8 +275,8 @@ final class SessionPresenter {
let choice = explicit ?? PresenterChoice.platformDefault
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
let priority = PresentPriority.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
setting: SessionSettings.current.presentPriority,
bufferSetting: SessionSettings.current.smoothBuffer)
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
// display, so the FIFO alone provides the buffering.
@@ -305,8 +305,7 @@ final class SessionPresenter {
// Resolve THIS session's windowed mechanism once (setting + dev env lever)
// `setComposited` routes between it and fullscreen-async from every layout.
windowedMode = Self.windowedPresentMode(
setting: UserDefaults.standard.object(
forKey: DefaultsKey.windowedSafePresent) as? Bool,
setting: SessionSettings.current.windowedSafePresent,
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
// unless the surface mechanism actually presents, covering it while it does.
@@ -364,7 +363,7 @@ final class SessionPresenter {
stage2?.setFrameRateHint(hz: Float(hz))
guard let link = stage2Link else { return }
let hzF = Float(hz)
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
let allowVRR = SessionSettings.current.allowVRR
#if os(macOS)
// Off: `.default` = the link free-runs at the display's native rate (pre-VRR behavior).
// On: request the content rate with a 24 Hz floor capped at the display, never at the
@@ -848,7 +848,7 @@ public final class Stage2Pipeline {
let vsyncPaced = vsyncPaced
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
&& SessionSettings.current.vsync)
let vsyncClock = vsyncClock
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
@@ -886,7 +886,7 @@ public final class StreamLayerView: NSView {
// Advance the shared tier setting directly every @AppStorage reader (the HUD's
// visibility/content, the Settings pickers) observes UserDefaults, so this is the
// same as the menu path.
StatsVerbosity.store(StatsVerbosity.current.next())
StatsVerbosity.cycle()
}
capture.start()
inputCapture = capture
@@ -896,7 +896,7 @@ public final class StreamLayerView: NSView {
// only a relative pointer, so absolute sends would be silently dropped there
// (pointer stuck = "all input dead") pinned to capture. M flips it live.
let mode = MouseInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? ""
rawValue: SessionSettings.current.mouseMode
) ?? .capture
let absOK = connection.resolvedCompositor != .gamescope
desktopMouse = mode == .desktop && absOK
@@ -943,10 +943,10 @@ public final class StreamLayerView: NSView {
// default keeps the explicit mode.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
enabled: SessionSettings.current.matchWindow,
renderScale: SessionSettings.current.renderScale,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
codec: SessionSettings.current.codec))
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower
layoutPresenter()
@@ -242,10 +242,11 @@ public final class StreamViewController: StreamViewControllerBase {
#if os(iOS)
/// Whether the user wants the mouse/trackpad pointer CAPTURED (pointer lock relative
/// movement, the gaming default) rather than forwarded as an absolute position (desktop
/// use). Read live from UserDefaults so it tracks the Settings toggle; defaults to on when
/// use). Read from the session's resolved settings so it tracks the Settings toggle (it is
/// tier G this device's input hardware so no profile can move it); defaults to on when
/// unset. iPad-only gated again in `prefersPointerLocked`.
private var pointerCaptureEnabled: Bool {
UserDefaults.standard.object(forKey: DefaultsKey.pointerCapture) as? Bool ?? true
SessionSettings.current.pointerCapture
}
/// Whether the pointer should be CAPTURED right now: iPad, capture engaged, and the user
@@ -403,10 +404,10 @@ public final class StreamViewController: StreamViewControllerBase {
// default keeps the explicit mode.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
enabled: SessionSettings.current.matchWindow,
renderScale: SessionSettings.current.renderScale,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
codec: SessionSettings.current.codec))
follower.onResizeTarget = onResizeTarget
matchFollower = follower
#endif
@@ -560,7 +561,7 @@ public final class StreamViewController: StreamViewControllerBase {
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
SessionSettings.current.hdrEnabled
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
@@ -1,57 +1,468 @@
// The `punktfunk://` deep-link grammar the single builder/parser shared by the widget (which
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
// The `punktfunk://` URL grammar the single builder/parser shared by the widget (which emits
// links via `widgetURL`/`Link`), the App Intents (which round-trip through it rather than opening
// a second connect path) and the app (`ContentView.onOpenURL`, which routes them into the existing
// connect path). Keeping every side on one type means the wire format can't drift.
//
// Grammar (v1):
// punktfunk://connect/<host-uuid> connect to a stored host
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> connect and ask the host to launch it
// punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
// [&profile=<ref>][&name=<label>]
//
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
// on parse, so ids with reserved characters survive the round trip.
// The invariant the grammar exists to keep: **a URL may only ever do what a click on an existing
// card could do, minus trust decisions.** So it carries *references* to things that already exist
// on this device a host record, a settings profile, a library id and never values: no
// resolution, no bitrate, no codec. A web page must not be able to shape a session beyond picking
// among the user's own configurations. `pair` is deliberately not a route and never will be;
// pairing stays an interactive ceremony.
//
// `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever *emits*
// or registers it (design/client-deep-links.md §2).
//
// This is a port of `crates/pf-client-core/src/deeplink.rs`, and the two are held together by
// `clients/shared/deeplink-vectors.json` 44 cases including every refusal code, run verbatim by
// both test suites. Change one parser and the other's suite fails, which is the point: three
// parsers that drift are three different security postures.
import Foundation
public enum DeepLink: Equatable {
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
case connect(host: UUID, launchID: String?)
/// Hostile-input caps (design §8). The total is generous for a real link and small enough that a
/// pasted megabyte never reaches the decoder.
public enum DeepLinkLimits {
public static let url = 2048
public static let hostRef = 128
public static let launch = 128
public static let profile = 64
public static let name = 64
}
public static let scheme = "punktfunk"
/// The default native port, as everywhere else in the clients.
public let punktfunkDefaultPort: UInt16 = 9777
/// Build the canonical URL for a route. Non-optional: every route is representable.
public var url: URL {
/// What the URL asks for. `wake`/`browse` are reserved in the grammar and parse today; a front-end
/// that hasn't implemented them refuses with a notice rather than silently connecting the
/// grammar is the contract, per-platform support is not.
public enum DeepLinkRoute: String, Equatable, Sendable {
case connect, wake, browse
}
/// An `addr[:port]` pair carried by a link (the `host=` recovery parameter, or an address-shaped
/// reference).
public struct DeepLinkAddress: Equatable, Sendable {
public var address: String
public var port: UInt16
public init(_ address: String, _ port: UInt16) {
self.address = address
self.port = port
}
}
/// Why a URL was rejected. The `code` strings are the cross-language contract (the vector file
/// names them) Rust and Kotlin report the same code for the same input.
public enum DeepLinkError: Error, Equatable, Sendable {
/// Not a `punktfunk://` (or `pf://`) URL at all the caller should ignore it, not warn.
case notOurScheme
case tooLong
/// A route this grammar doesn't define.
case unknownRoute(String)
/// `punktfunk://pair/` pairing is an interactive ceremony, never a link.
case pairRefused
case missingHostRef
/// A `%` escape that isn't two hex digits, or a decode that isn't UTF-8.
case badEscape
/// A control character survived decoding no legitimate field contains one.
case controlChar
/// A parameter past its cap; carries the parameter name.
case paramTooLong(String)
/// `fp=` that isn't 64 hex characters.
case badFingerprint
/// `host=` that isn't `addr[:port]` with a parsable port.
case badHostParam
/// `launch=` outside the printable, shell-safe id charset the host and Decky agree on.
case badLaunchID
/// The stable code shared with the Rust/Kotlin ports and the vector file.
public var code: String {
switch self {
case let .connect(host, launchID):
var comps = URLComponents()
comps.scheme = Self.scheme
comps.host = "connect"
comps.path = "/\(host.uuidString)"
if let launchID, !launchID.isEmpty {
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
}
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
// fully control (scheme/host/path are all valid).
return comps.url!
case .notOurScheme: return "not-our-scheme"
case .tooLong: return "too-long"
case .unknownRoute: return "unknown-route"
case .pairRefused: return "pair-refused"
case .missingHostRef: return "missing-host-ref"
case .badEscape: return "bad-escape"
case .controlChar: return "control-char"
case .paramTooLong: return "param-too-long"
case .badFingerprint: return "bad-fingerprint"
case .badHostParam: return "bad-host-param"
case .badLaunchID: return "bad-launch-id"
}
}
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
/// case in the scheme and of a trailing slash on the path.
public init?(_ url: URL) {
guard url.scheme?.lowercased() == Self.scheme else { return nil }
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
switch comps.host?.lowercased() {
case "connect":
// Path is "/<uuid>"; strip the leading slash and any trailing one.
let raw = comps.path
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let host = UUID(uuidString: raw) else { return nil }
let launch = comps.queryItems?
.first(where: { $0.name == "launch" })?.value
.flatMap { $0.isEmpty ? nil : $0 }
self = .connect(host: host, launchID: launch)
default:
return nil
/// A sentence for the notice a refusing front-end shows. Deliberately names the failing
/// reference: a shortcut that can't honor what it asked for says so instead of streaming with
/// the wrong settings.
public var message: String {
switch self {
case .notOurScheme: return "That isn't a Punktfunk link."
case .tooLong: return "That link is too long to be genuine."
case .unknownRoute(let r): return "Punktfunk links can't do “\(r)”."
case .pairRefused:
return "Pairing can't be done from a link — pair the host in Punktfunk first."
case .missingHostRef: return "That link doesn't say which host to use."
case .badEscape, .controlChar: return "That link is malformed and was ignored."
case .paramTooLong(let p): return "That link's “\(p)” value is too long."
case .badFingerprint: return "That link's host fingerprint isn't a valid one."
case .badHostParam: return "That link's host address isn't valid."
case .badLaunchID: return "That link's game id isn't a valid one."
}
}
}
/// A parsed, validated link. Every field is already length- and charset-checked, so a consumer
/// never has to re-validate hostile input; what it still has to do is *resolve* the references
/// may name things that don't exist here.
public struct DeepLink: Equatable, Sendable {
public var route: DeepLinkRoute = .connect
/// The host reference as written: a stable record id, a host name, or `addr[:port]`.
public var hostRef: String
/// Expected host certificate fingerprint, lowercase hex (64 chars).
public var fp: String?
/// Recovery address for a stable id that no longer resolves (store wiped, reinstall).
public var host: DeepLinkAddress?
/// A store-qualified library id (`steam:570`) for the host to launch on arrival.
public var launch: String?
/// A settings-profile reference (id, or a unique name) one-off, never rebinding.
public var profile: String?
/// Display label for the unknown-host confirmation sheet (external emitters).
public var name: String?
public static let scheme = "punktfunk"
public init(
route: DeepLinkRoute = .connect, hostRef: String, fp: String? = nil,
host: DeepLinkAddress? = nil, launch: String? = nil, profile: String? = nil,
name: String? = nil
) {
self.route = route
self.hostRef = hostRef
self.fp = fp
self.host = host
self.launch = launch
self.profile = profile
self.name = name
}
// MARK: - Emission
/// The canonical URL for this link always `punktfunk://`, never the `pf://` alias.
public var urlString: String {
var s = "punktfunk://\(route.rawValue)/\(Self.encode(hostRef))"
var separator = "?"
func push(_ key: String, _ value: String) {
s += separator + key + "=" + Self.encode(value)
separator = "&"
}
if let fp { push("fp", fp) }
if let host {
let text: String
if host.port == punktfunkDefaultPort {
text = host.address
} else if host.address.contains(":") {
text = "[\(host.address)]:\(host.port)" // literal IPv6 needs its brackets back
} else {
text = "\(host.address):\(host.port)"
}
push("host", text)
}
if let launch { push("launch", launch) }
if let profile { push("profile", profile) }
if let name { push("name", name) }
return s
}
/// Non-optional: every segment is percent-encoded on the way out, so the emitted string is
/// always a valid URL force-unwrapping a value we fully control.
public var url: URL { URL(string: urlString)! }
/// A plain connect link for a saved host the shape the widget and the Connect intent emit.
public static func connect(host: UUID, launchID: String? = nil, profile: String? = nil)
-> DeepLink {
DeepLink(
hostRef: host.uuidString,
launch: (launchID?.isEmpty ?? true) ? nil : launchID,
profile: (profile?.isEmpty ?? true) ? nil : profile)
}
/// The self-emitted form for a saved host: id first (address-independent), with the address
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
/// the record is gone ("Copy link", and any shortcut written from a card).
public static func forHost(
_ host: StoredHost, launch: String? = nil, profile: String? = nil
) -> DeepLink {
DeepLink(
hostRef: host.id.uuidString,
fp: host.pinnedSHA256.map(Self.hexLower),
host: DeepLinkAddress(host.address, host.port),
launch: (launch?.isEmpty ?? true) ? nil : launch,
profile: (profile?.isEmpty ?? true) ? nil : profile)
}
// MARK: - Parsing
/// Parse an incoming URL. Everything hostile is rejected here, once, for every front-end.
public init(url: URL) throws {
self = try DeepLink.parse(url.absoluteString)
}
public static func parse(_ text: String) throws -> DeepLink {
guard text.utf8.count <= DeepLinkLimits.url else { throw DeepLinkError.tooLong }
guard let separator = text.range(of: "://") else { throw DeepLinkError.notOurScheme }
let scheme = text[text.startIndex..<separator.lowerBound].lowercased()
guard scheme == "punktfunk" || scheme == "pf" else { throw DeepLinkError.notOurScheme }
// A fragment is never part of this grammar; drop it rather than folding it into the last
// parameter (where it would smuggle unvalidated text past the caps).
let rest = String(text[separator.upperBound...].prefix { $0 != "#" })
let path: Substring
let query: Substring
if let q = rest.firstIndex(of: "?") {
path = rest[rest.startIndex..<q]
query = rest[rest.index(after: q)...]
} else {
path = rest[...]
query = ""
}
var trimmed = String(path)
while trimmed.hasSuffix("/") { trimmed.removeLast() }
let routeWord: String
let hostRefRaw: String
if let slash = trimmed.firstIndex(of: "/") {
routeWord = String(trimmed[trimmed.startIndex..<slash])
hostRefRaw = String(trimmed[trimmed.index(after: slash)...])
} else if isRouteWord(trimmed) {
// A single segment: a bare reference is unambiguous as long as it isn't one of the
// route words those stay routes (with a missing reference), so `punktfunk://pair`
// refuses instead of hunting for a host called "pair".
routeWord = trimmed
hostRefRaw = ""
} else {
routeWord = "connect"
hostRefRaw = trimmed
}
let lowered = routeWord.lowercased()
if lowered == "pair" { throw DeepLinkError.pairRefused }
guard let route = DeepLinkRoute(rawValue: lowered) else {
throw DeepLinkError.unknownRoute(lowered)
}
let hostRef = try decode(hostRefRaw)
guard !hostRef.isEmpty else { throw DeepLinkError.missingHostRef }
guard hostRef.unicodeScalars.count <= DeepLinkLimits.hostRef else {
throw DeepLinkError.paramTooLong("host-ref")
}
var link = DeepLink(route: route, hostRef: hostRef)
for pair in query.split(separator: "&", omittingEmptySubsequences: true) {
let rawKey: Substring
let rawValue: Substring
if let eq = pair.firstIndex(of: "=") {
rawKey = pair[pair.startIndex..<eq]
rawValue = pair[pair.index(after: eq)...]
} else {
rawKey = pair
rawValue = ""
}
let key = try decode(String(rawKey)).lowercased()
let value = try decode(String(rawValue))
// `?launch=` with nothing after it is "not given", not an error.
if value.isEmpty { continue }
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter
// must not turn an otherwise valid link into a refusal, and appending a second `fp=`
// must not be able to override the first.
switch key {
case "fp" where link.fp == nil:
let fp = value.lowercased()
guard fp.count == 64, fp.allSatisfy(\.isHexDigit) else {
throw DeepLinkError.badFingerprint
}
link.fp = fp
case "host" where link.host == nil:
guard let parsed = parseAddressPort(value) else { throw DeepLinkError.badHostParam }
link.host = parsed
case "launch" where link.launch == nil:
guard value.utf8.count <= DeepLinkLimits.launch else {
throw DeepLinkError.paramTooLong("launch")
}
guard isSafeLaunchID(value) else { throw DeepLinkError.badLaunchID }
link.launch = value
case "profile" where link.profile == nil:
guard value.unicodeScalars.count <= DeepLinkLimits.profile else {
throw DeepLinkError.paramTooLong("profile")
}
link.profile = value
case "name" where link.name == nil:
guard value.unicodeScalars.count <= DeepLinkLimits.name else {
throw DeepLinkError.paramTooLong("name")
}
link.name = value
default:
break
}
}
return link
}
// MARK: - Resolution
/// True when this link's `fp` contradicts what we have pinned for that host the link is
/// stale or lying, and the only safe answer is a hard refusal (design §3.1).
public func pinConflict(with host: StoredHost) -> Bool {
guard let fp, let pin = host.pinnedSHA256 else { return false }
return fp.lowercased() != Self.hexLower(pin)
}
/// Resolve this link's host reference against the local store, in the documented order:
/// stable record id unique case-insensitive name `addr[:port]` literal. The `host=`
/// parameter is the recovery path a self-emitted shortcut that outlived the record it was
/// written from still lands on the right box (degraded to the confirmation sheet).
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
let reference = hostRef.lowercased()
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
return .known(match)
}
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
if byName.count == 1 { return .known(byName[0]) }
if byName.count > 1 { return .ambiguous }
// `addr[:port]` literal, then the `host=` recovery parameter both matched the way every
// other per-host lookup in the client matches. The literal is only considered when the
// reference could BE an address: a stale record id must fall through to `host=` (or to a
// refusal), never be offered as a box to dial.
let literal = Self.looksLikeAddress(hostRef) ? Self.parseAddressPort(hostRef) : nil
for candidate in [literal, host].compactMap({ $0 }) {
if let match = hosts.first(where: {
$0.address == candidate.address && $0.port == candidate.port
}) {
return .known(match)
}
}
guard let target = literal ?? host else { return .unresolvable }
return .unknown(address: target.address, port: target.port, name: name, fp: fp)
}
/// What the local host store made of a link's references.
public enum HostResolution: Equatable, Sendable {
/// A record we already have (subject to `pinConflict`).
case known(StoredHost)
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
case unknown(address: String, port: UInt16, name: String?, fp: String?)
/// The name matched more than one saved host refuse with a notice, never guess.
case ambiguous
/// A reference that resolves to nothing and carries no address to fall back on.
case unresolvable
}
// MARK: - Grammar helpers (ports of the Rust originals keep them in step)
/// Could this reference be a network address (an IP literal or a host name) rather than a
/// record id or a display name? Only then may an unmatched reference become "an unknown host
/// at this address". A stable id that no longer resolves is NOT an address: offering to dial a
/// UUID as a hostname would turn a wiped store into a dead end instead of `host=` recovery.
static func looksLikeAddress(_ s: String) -> Bool {
let chars = Array(s)
let uuidShaped = chars.count == 36 && chars.enumerated().allSatisfy { i, c in
[8, 13, 18, 23].contains(i) ? c == "-" : c.isHexDigit
}
guard !uuidShaped, !s.isEmpty else { return false }
return s.allSatisfy { c in
c.isASCII && (c.isLetter || c.isNumber || ".-_:[]".contains(c))
}
}
/// The reserved first path segments everything the grammar routes on, plus `pair`, which is
/// reserved precisely so it can be refused rather than mistaken for a host name.
private static func isRouteWord(_ s: String) -> Bool {
["connect", "wake", "browse", "pair"].contains(s.lowercased())
}
/// `addr`, `addr:port`, `[v6]`, `[v6]:port` nil when the port isn't a number. A bare IPv6
/// literal (`::1`) keeps its colons and takes the default port; anything else splits at the
/// last colon, like every other host-parsing site in the clients.
static func parseAddressPort(_ s: String) -> DeepLinkAddress? {
guard !s.isEmpty else { return nil }
if s.hasPrefix("[") {
let body = s.dropFirst()
guard let close = body.firstIndex(of: "]") else { return nil }
let address = String(body[body.startIndex..<close])
guard !address.isEmpty else { return nil }
let tail = String(body[body.index(after: close)...])
if tail.isEmpty { return DeepLinkAddress(address, punktfunkDefaultPort) }
guard tail.hasPrefix(":"), let port = UInt16(tail.dropFirst()) else { return nil }
return DeepLinkAddress(address, port)
}
guard let colon = s.lastIndex(of: ":") else {
return DeepLinkAddress(s, punktfunkDefaultPort)
}
let head = String(s[s.startIndex..<colon])
// `::1` and friends: the head still has a colon, so this isn't a port separator.
if head.contains(":") { return DeepLinkAddress(s, punktfunkDefaultPort) }
guard !head.isEmpty, let port = UInt16(s[s.index(after: colon)...]) else { return nil }
return DeepLinkAddress(head, port)
}
/// The launch-id charset the whole product already agrees on: printable, non-space ASCII with
/// no shell metacharacters (Decky rides ids through Steam launch options as an env token, so a
/// quote or a backtick genuinely breaks something downstream). Validation only the id is
/// opaque and the host matches it verbatim against its own library.
static func isSafeLaunchID(_ id: String) -> Bool {
// `"`, `'`, `\`, `$`, backtick spelled as bytes because a Swift literal holding all five
// is its own little escaping puzzle, and this list must read exactly like the Rust one.
let forbidden: Set<UInt8> = [0x22, 0x27, 0x5C, 0x24, 0x60]
return !id.isEmpty && id.utf8.allSatisfy { (0x21...0x7e).contains($0) && !forbidden.contains($0) }
}
/// Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
/// UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray `\n`
/// or a half-escape end up inside a filename or a log line.
static func decode(_ s: String) throws -> String {
let bytes = Array(s.utf8)
var out: [UInt8] = []
out.reserveCapacity(bytes.count)
var i = 0
while i < bytes.count {
guard bytes[i] == UInt8(ascii: "%") else {
out.append(bytes[i])
i += 1
continue
}
guard i + 2 < bytes.count,
let hi = Character(UnicodeScalar(bytes[i + 1])).hexDigitValue,
let lo = Character(UnicodeScalar(bytes[i + 2])).hexDigitValue
else { throw DeepLinkError.badEscape }
out.append(UInt8(hi << 4 | lo))
i += 3
}
guard let text = String(bytes: out, encoding: .utf8) else { throw DeepLinkError.badEscape }
if text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) {
throw DeepLinkError.controlChar
}
return text
}
/// Percent-encode for emission: unreserved characters plus `:` (legal in a query value and
/// left alone by `URLComponents`, so the three emitters agree on `steam:570`).
static func encode(_ s: String) -> String {
var out = ""
for b in s.utf8 {
let c = Character(UnicodeScalar(b))
if c.isASCII, c.isLetter || c.isNumber || "-._~:".contains(c) {
out.append(c)
} else {
out += String(format: "%%%02X", b)
}
}
return out
}
static func hexLower(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
@@ -94,6 +94,11 @@ public enum DefaultsKey {
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
public static let enable444 = "punktfunk.enable444"
public static let hosts = "punktfunk.hosts"
/// The settings-profile catalog (`ProfileCatalog`, one JSON blob) design
/// client-settings-profiles.md §4.2. Lives in the APP GROUP suite with `hosts`, not with the
/// settings: bindings and pins are fields on the host record, and an extension that can read
/// the hosts should be able to read what they point at.
public static let profiles = "punktfunk.profiles"
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or
/// "desktop" (uncaptured absolute pointer) the cross-client `mouse_mode`. Replaces the
/// never-shipped "punktfunk.cursorMode" (auto/always/never client-side-cursor setting,
@@ -0,0 +1,253 @@
// The settings ONE session runs on the global defaults with the session's profile overlaid,
// resolved once at connect and read from there on (design/client-settings-profiles.md §4.2/§4.4):
//
// effective = overlay(profile).apply(globals)
// profile = one-off pick (Connect with ) ?? host.profileID ?? none
//
// Before this existed, ~10 sites scattered across the app AND the kit read `UserDefaults` directly
// mid-session a per-host profile would have applied to some of them and not others, which is
// worse than not shipping the feature. They now read `SessionSettings.current`: the live session's
// resolution while one is up, the plain globals otherwise (byte-for-byte today's behaviour when no
// profile is involved).
//
// Only SESSION-CONSUMED values live here. Pure app-level preferences the library toggle, the
// gamepad-UI switch, HUD placement, auto-wake, background keep-alive stay plain `@AppStorage`
// where they are read: they are about the app, not about a stream.
import Foundation
public struct EffectiveSettings: Equatable, Sendable {
// Tier P profileable (design §3).
public var width = 1920
public var height = 1080
public var refreshHz = 60
public var matchWindow = false
public var bitrateKbps = 0
public var renderScale = 1.0
public var codec = "auto"
public var hdrEnabled = true
public var compositor = 0
public var audioChannels = 2
public var micEnabled = true
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
public var enable444 = false
public var presentPriority = "latency"
public var smoothBuffer = 0
public var vsync = false
public var allowVRR = true
public var windowedSafePresent = true
public var modifierLayout = "mac"
// Tier G this device's endpoints and hardware. Session-consumed, so they ride along, but
// never profileable: a profile is about how a host is streamed, not about which speaker this
// Mac uses.
public var speakerUID = ""
public var micUID = ""
public var micChannel = 0
public var pointerCapture = true
/// The profile this resolution came from, when one applied the HUD names it so "which
/// profile am I on?" is answerable mid-session, and the one-off/binding distinction never has
/// to be guessed from the settings themselves.
public var profileID: String?
public var profileName: String?
public init() {}
/// The global defaults, as every `@AppStorage` in the settings surface sees them. `.standard`
/// by default settings are per-device, unlike the App-Group-shared host store.
public init(defaults: UserDefaults) {
func int(_ key: String, _ fallback: Int) -> Int {
defaults.object(forKey: key) as? Int ?? fallback
}
func dbl(_ key: String, _ fallback: Double) -> Double {
defaults.object(forKey: key) as? Double ?? fallback
}
func bool(_ key: String, _ fallback: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? fallback
}
func str(_ key: String, _ fallback: String) -> String {
defaults.string(forKey: key) ?? fallback
}
width = int(DefaultsKey.streamWidth, width)
height = int(DefaultsKey.streamHeight, height)
refreshHz = int(DefaultsKey.streamHz, refreshHz)
matchWindow = bool(DefaultsKey.matchWindow, matchWindow)
bitrateKbps = int(DefaultsKey.bitrateKbps, bitrateKbps)
renderScale = dbl(DefaultsKey.renderScale, renderScale)
codec = str(DefaultsKey.codec, codec)
hdrEnabled = bool(DefaultsKey.hdrEnabled, hdrEnabled)
compositor = int(DefaultsKey.compositor, compositor)
audioChannels = int(DefaultsKey.audioChannels, audioChannels)
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
enable444 = bool(DefaultsKey.enable444, enable444)
presentPriority = str(DefaultsKey.presentPriority, presentPriority)
smoothBuffer = int(DefaultsKey.smoothBuffer, smoothBuffer)
vsync = bool(DefaultsKey.vsync, vsync)
allowVRR = bool(DefaultsKey.allowVRR, allowVRR)
windowedSafePresent = bool(DefaultsKey.windowedSafePresent, windowedSafePresent)
modifierLayout = str(DefaultsKey.modifierLayout, modifierLayout)
speakerUID = str(DefaultsKey.speakerUID, speakerUID)
micUID = str(DefaultsKey.micUID, micUID)
micChannel = int(DefaultsKey.micChannel, micChannel)
pointerCapture = bool(DefaultsKey.pointerCapture, pointerCapture)
}
/// The stats tier as stored, with the pre-tier `hudEnabled` migration `StatsVerbosity.current`
/// performs duplicated in one line here so this module needn't reach into PunktfunkKit.
private static func storedStatsVerbosity(_ defaults: UserDefaults) -> String {
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity) { return raw }
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
return "off"
}
return "normal"
}
/// The one resolution seam: this overlay on top of these settings. Pure no store reads, no
/// clock so it is testable field by field. A `.some` that happens to equal the base is a
/// legitimate PIN: it keeps its value when the global later moves.
public func applying(_ overlay: SettingsOverlay) -> EffectiveSettings {
var s = self
if let v = overlay.width { s.width = v }
if let v = overlay.height { s.height = v }
if let v = overlay.refreshHz { s.refreshHz = v }
if let v = overlay.matchWindow { s.matchWindow = v }
if let v = overlay.bitrateKbps { s.bitrateKbps = v }
if let v = overlay.renderScale { s.renderScale = v }
if let v = overlay.codec { s.codec = v }
if let v = overlay.hdrEnabled { s.hdrEnabled = v }
if let v = overlay.compositor { s.compositor = v }
if let v = overlay.audioChannels { s.audioChannels = v }
if let v = overlay.micEnabled { s.micEnabled = v }
if let v = overlay.touchMode { s.touchMode = v }
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
if let v = overlay.presentPriority { s.presentPriority = v }
if let v = overlay.smoothBuffer { s.smoothBuffer = v }
if let v = overlay.vsync { s.vsync = v }
if let v = overlay.allowVRR { s.allowVRR = v }
if let v = overlay.windowedSafePresent { s.windowedSafePresent = v }
if let v = overlay.modifierLayout { s.modifierLayout = v }
return s
}
/// The whole resolution, in one call, in the precedence every client shares:
/// **one-off pick ?? host binding ?? none**. A dangling id a profile deleted out from under
/// a binding, a link naming one that no longer exists resolves as none: never an error,
/// never a blocked connect (§4.4).
public static func resolve(
host: StoredHost?, selection: ProfileSelection = .inherit,
catalog: ProfileCatalog? = nil, defaults: UserDefaults = .standard
) -> EffectiveSettings {
let base = EffectiveSettings(defaults: defaults)
let profile: StreamProfile? = {
switch selection {
case .defaults:
return nil
case .profile(let id):
return (catalog ?? ProfileCatalog.load()).profile(id: id)
case .inherit:
guard let host else { return nil }
return (catalog ?? ProfileCatalog.load()).binding(for: host)
}
}()
guard let profile else { return base }
var out = base.applying(profile.overrides)
out.profileID = profile.id
out.profileName = profile.name
return out
}
}
/// What a single connect was told to use, before any store is consulted.
///
/// The third case is why this is an enum rather than an `Optional<StreamProfile>`: "Connect with
/// Default settings" on a BOUND host has to force the globals, and "no pick at all" has to fall
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
/// No pick the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
case defaults
/// This profile, for this one connect. NEVER rebinds the host (§5.2).
case profile(String)
public init(profileID: String?) {
self = profileID.map(ProfileSelection.profile) ?? .inherit
}
}
// MARK: - The live session's resolution
/// What a session-scoped reader should read instead of `UserDefaults`.
///
/// A plain `static var` would be the obvious shape, but these are read from the decode pump and
/// the presenter's display-link callback as well as the main actor, so the value sits behind a
/// lock the `FrameMeter` pattern used elsewhere in the client.
public enum SessionSettings {
private final class Box: @unchecked Sendable {
private let lock = NSLock()
private var value: EffectiveSettings?
var active: EffectiveSettings? {
get { lock.withLock { value } }
set { lock.withLock { value = newValue } }
}
}
private static let box = Box()
/// The live session's resolution, or nil while idle.
public static var active: EffectiveSettings? { box.active }
/// What every session-scoped reader uses: the live session's resolution while one is up, the
/// plain globals otherwise. Reading it off-session is exactly what those sites did before.
public static var current: EffectiveSettings {
box.active ?? EffectiveSettings(defaults: .standard)
}
/// Latch the settings a starting session resolved. Called once per connect, before the
/// connection exists, so nothing reads a half-applied mix.
public static func begin(_ settings: EffectiveSettings) {
box.active = settings
}
/// Release the latch when the session ends later reads fall back to the globals.
public static func end() {
box.active = nil
}
/// The one value that legitimately moves mid-session: the stats tier, which every client
/// cycles live (S, the three-finger tap). The cycle writes the global as before AND moves
/// the session's own value, so cycling works in a profile-driven session too.
public static func setStatsVerbosity(_ raw: String) {
guard var s = box.active else { return }
s.statsVerbosity = raw
box.active = s
}
}
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
lock()
defer { unlock() }
return body()
}
}
@@ -44,7 +44,8 @@ public enum ModifierLayout: String, CaseIterable, Sendable {
/// The persisted layout (default `.mac` when unset).
public static var current: ModifierLayout {
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
guard let raw = SessionSettings.active?.modifierLayout
?? UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
return .mac
}
return ModifierLayout(rawValue: raw) ?? .mac
@@ -39,11 +39,22 @@ public struct StoredHost: Identifiable, Codable, Hashable {
/// forward-compat reason as `mgmtPort`). Honored only when the host advertises
/// `HOST_CAP_CLIPBOARD`.
public var clipboardSync: Bool?
/// This host's default settings profile (`StreamProfile.id`) what a plain click/tap uses.
/// nil, or an id whose profile was deleted, resolves as "Default settings", i.e. exactly
/// today's behaviour: a dangling binding is never an error and never blocks a connect
/// (design/client-settings-profiles.md §4.4). Optional and appended last for the same
/// widget-contract reason as `mgmtPort`.
public var profileID: String?
/// Profiles pinned as additional cards for this host (design §5.2a), in card order. NOT the
/// default that is `profileID`; a pin is presentation only, and duplicates and dangling ids
/// are dropped when the cards are built. Optional for the same forward-compat reason.
public var pinnedProfileIDs: [String]?
public init(
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
macAddresses: [String]? = nil, clipboardSync: Bool? = nil
macAddresses: [String]? = nil, clipboardSync: Bool? = nil,
profileID: String? = nil, pinnedProfileIDs: [String]? = nil
) {
self.id = id
self.name = name
@@ -54,6 +65,8 @@ public struct StoredHost: Identifiable, Codable, Hashable {
self.mgmtPort = mgmtPort
self.macAddresses = macAddresses
self.clipboardSync = clipboardSync
self.profileID = profileID
self.pinnedProfileIDs = pinnedProfileIDs
}
public var displayName: String { name.isEmpty ? address : name }
@@ -0,0 +1,461 @@
// Client settings profiles named bundles of setting overrides applied on top of the global
// defaults (design/client-settings-profiles.md §4). The Swift half of the model whose Rust
// original is `crates/pf-client-core/src/profiles.rs`; the two are mirrored field for field so a
// future export/import has one shape to speak.
//
// A profile overrides only the fields the user touched; everything else keeps following the
// global defaults *live*, so fixing a global once fixes it everywhere. That is why an overlay is
// sparse `Optional`s rather than a snapshot copy, and why `.some(x)` is written on touch and nil
// on an explicit "reset to default" never by diffing against the current global (a `.some`
// equal to today's global is a legitimate *pin*: the profile keeps `x` when the global later
// moves).
//
// The catalog lives in the APP GROUP suite, with the saved hosts rather than with the settings:
// per-host data is already shared there so the widget can read it, and bindings/pins live on the
// host record. It is a dependency-free part of PunktfunkShared for the same reason `StoredHost`
// is an extension process can read it without linking PunktfunkKit.
import Foundation
/// The catalog file's schema version. Bumped only for a breaking shape change additive fields
/// ride the unknown-key preservation below.
public let punktfunkProfilesVersion = 1
// MARK: - Unknown-key carry-through
/// A decoded-but-unmodelled JSON value. Exists only so an overlay (or a profile) written by a
/// NEWER build survives an opensave round-trip on an older one: the don't-clobber rule from the
/// design's §4.1, and the reason the Rust overlay carries a `#[serde(flatten)] extra` map.
public enum JSONValue: Codable, Equatable, Sendable {
case null
case bool(Bool)
case number(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() {
self = .null
} else if let v = try? c.decode(Bool.self) {
self = .bool(v)
} else if let v = try? c.decode(Double.self) {
self = .number(v)
} else if let v = try? c.decode(String.self) {
self = .string(v)
} else if let v = try? c.decode([JSONValue].self) {
self = .array(v)
} else {
self = .object(try c.decode([String: JSONValue].self))
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let v): try c.encode(v)
case .number(let v):
// Whole numbers encode as integers so a carried-through `"width": 3840` doesn't come
// back as `3840.0` and read as a different value to the next parser.
if v == v.rounded(), abs(v) < 9_007_199_254_740_992 {
try c.encode(Int(v))
} else {
try c.encode(v)
}
case .string(let v): try c.encode(v)
case .array(let v): try c.encode(v)
case .object(let v): try c.encode(v)
}
}
}
/// A `CodingKey` for keys only known at runtime what the unknown-key sweep needs.
private struct AnyKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ s: String) { stringValue = s }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
// MARK: - The overlay
/// Every profileable ("tier P") setting, as an `Optional`: nil = inherit the global value, live.
///
/// Tier-H fields (host properties like the per-host clipboard toggle) and tier-G ones (this
/// device's hardware and audio endpoints) are deliberately absent see the design's §3 curation.
/// The last seven are the Apple-only additions §3 lists: presentation and modifier choices that
/// are host-OS- and display-specific by nature.
///
/// The coding keys are the Rust overlay's serialized names. Two of them carry a different
/// REPRESENTATION here `compositor` and `gamepad` are the wire enum's ordinals on Apple and
/// strings in the Rust core because those are the shapes the two clients already persist; a
/// cross-platform importer has to map them either way.
public struct SettingsOverlay: Codable, Equatable, Sendable {
public var width: Int?
public var height: Int?
public var refreshHz: Int?
public var matchWindow: Bool?
public var bitrateKbps: Int?
public var renderScale: Double?
public var codec: String?
public var hdrEnabled: Bool?
public var compositor: Int?
public var audioChannels: Int?
public var micEnabled: Bool?
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
public var fullscreenWhileStreaming: Bool?
// Apple-only additions (design §3).
public var enable444: Bool?
public var presentPriority: String?
public var smoothBuffer: Int?
public var vsync: Bool?
public var allowVRR: Bool?
public var windowedSafePresent: Bool?
public var modifierLayout: String?
/// Overlay keys a newer build wrote and this one doesn't model carried through a
/// loadsave round-trip untouched.
public var extra: [String: JSONValue] = [:]
public init() {}
/// True when the profile overrides nothing "inherits everything", the state a freshly
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds a
/// newer build's field is not empty.
public var isEmpty: Bool { self == SettingsOverlay() }
// MARK: Codable
private enum Key: String, CaseIterable {
case width, height
case refreshHz = "refresh_hz"
case matchWindow = "match_window"
case bitrateKbps = "bitrate_kbps"
case renderScale = "render_scale"
case codec
case hdrEnabled = "hdr_enabled"
case compositor
case audioChannels = "audio_channels"
case micEnabled = "mic_enabled"
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
case presentPriority = "present_priority"
case smoothBuffer = "smooth_buffer"
case vsync
case allowVRR = "allow_vrr"
case windowedSafePresent = "windowed_safe_present"
case modifierLayout = "modifier_layout"
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
func int(_ k: Key) -> Int? { try? c.decodeIfPresent(Int.self, forKey: AnyKey(k.rawValue)) }
func dbl(_ k: Key) -> Double? { try? c.decodeIfPresent(Double.self, forKey: AnyKey(k.rawValue)) }
func bool(_ k: Key) -> Bool? { try? c.decodeIfPresent(Bool.self, forKey: AnyKey(k.rawValue)) }
func str(_ k: Key) -> String? { try? c.decodeIfPresent(String.self, forKey: AnyKey(k.rawValue)) }
width = int(.width)
height = int(.height)
refreshHz = int(.refreshHz)
matchWindow = bool(.matchWindow)
bitrateKbps = int(.bitrateKbps)
renderScale = dbl(.renderScale)
codec = str(.codec)
hdrEnabled = bool(.hdrEnabled)
compositor = int(.compositor)
audioChannels = int(.audioChannels)
micEnabled = bool(.micEnabled)
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
presentPriority = str(.presentPriority)
smoothBuffer = int(.smoothBuffer)
vsync = bool(.vsync)
allowVRR = bool(.allowVRR)
windowedSafePresent = bool(.windowedSafePresent)
modifierLayout = str(.modifierLayout)
let known = Set(Key.allCases.map(\.rawValue))
for key in c.allKeys where !known.contains(key.stringValue) {
extra[key.stringValue] = try c.decode(JSONValue.self, forKey: key)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: AnyKey.self)
// Absent overrides serialize away entirely (no `null`s) the file stays readable, and
// "not overridden" has exactly one representation on disk.
try c.encodeIfPresent(width, forKey: AnyKey(Key.width.rawValue))
try c.encodeIfPresent(height, forKey: AnyKey(Key.height.rawValue))
try c.encodeIfPresent(refreshHz, forKey: AnyKey(Key.refreshHz.rawValue))
try c.encodeIfPresent(matchWindow, forKey: AnyKey(Key.matchWindow.rawValue))
try c.encodeIfPresent(bitrateKbps, forKey: AnyKey(Key.bitrateKbps.rawValue))
try c.encodeIfPresent(renderScale, forKey: AnyKey(Key.renderScale.rawValue))
try c.encodeIfPresent(codec, forKey: AnyKey(Key.codec.rawValue))
try c.encodeIfPresent(hdrEnabled, forKey: AnyKey(Key.hdrEnabled.rawValue))
try c.encodeIfPresent(compositor, forKey: AnyKey(Key.compositor.rawValue))
try c.encodeIfPresent(audioChannels, forKey: AnyKey(Key.audioChannels.rawValue))
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
try c.encodeIfPresent(enable444, forKey: AnyKey(Key.enable444.rawValue))
try c.encodeIfPresent(presentPriority, forKey: AnyKey(Key.presentPriority.rawValue))
try c.encodeIfPresent(smoothBuffer, forKey: AnyKey(Key.smoothBuffer.rawValue))
try c.encodeIfPresent(vsync, forKey: AnyKey(Key.vsync.rawValue))
try c.encodeIfPresent(allowVRR, forKey: AnyKey(Key.allowVRR.rawValue))
try c.encodeIfPresent(
windowedSafePresent, forKey: AnyKey(Key.windowedSafePresent.rawValue))
try c.encodeIfPresent(modifierLayout, forKey: AnyKey(Key.modifierLayout.rawValue))
let known = Set(Key.allCases.map(\.rawValue))
for (key, value) in extra where !known.contains(key) {
try c.encode(value, forKey: AnyKey(key))
}
}
}
// MARK: - Field descriptors
/// The overlay's fields, named the way a UI can carry them as plain strings: the serialized key,
/// with `resolution` as the one alias covering the width/height/match-window tri-state a single
/// control drives on every client. Kept as one list so "what can be reset" lives in one place
/// two copies of it is exactly how a reset button and a commit path drift.
public enum OverlayField {
public static let resolution = "resolution"
/// Drop one override by name, putting the row back to inheriting. Returns false for a name
/// this build doesn't know.
@discardableResult
public static func clear(_ field: String, in overlay: inout SettingsOverlay) -> Bool {
switch field {
case resolution:
overlay.width = nil
overlay.height = nil
overlay.matchWindow = nil
case "width": overlay.width = nil
case "height": overlay.height = nil
case "refresh_hz": overlay.refreshHz = nil
case "match_window": overlay.matchWindow = nil
case "bitrate_kbps": overlay.bitrateKbps = nil
case "render_scale": overlay.renderScale = nil
case "codec": overlay.codec = nil
case "hdr_enabled": overlay.hdrEnabled = nil
case "compositor": overlay.compositor = nil
case "audio_channels": overlay.audioChannels = nil
case "mic_enabled": overlay.micEnabled = nil
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
case "present_priority": overlay.presentPriority = nil
case "smooth_buffer": overlay.smoothBuffer = nil
case "vsync": overlay.vsync = nil
case "allow_vrr": overlay.allowVRR = nil
case "windowed_safe_present": overlay.windowedSafePresent = nil
case "modifier_layout": overlay.modifierLayout = nil
default: return false
}
return true
}
/// Does this overlay override `field`? Drives the per-row override marker; `resolution` is
/// overridden when any leg of its tri-state is.
public static func isOverridden(_ field: String, in o: SettingsOverlay) -> Bool {
switch field {
case resolution: return o.width != nil || o.height != nil || o.matchWindow != nil
case "width": return o.width != nil
case "height": return o.height != nil
case "refresh_hz": return o.refreshHz != nil
case "match_window": return o.matchWindow != nil
case "bitrate_kbps": return o.bitrateKbps != nil
case "render_scale": return o.renderScale != nil
case "codec": return o.codec != nil
case "hdr_enabled": return o.hdrEnabled != nil
case "compositor": return o.compositor != nil
case "audio_channels": return o.audioChannels != nil
case "mic_enabled": return o.micEnabled != nil
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
case "present_priority": return o.presentPriority != nil
case "smooth_buffer": return o.smoothBuffer != nil
case "vsync": return o.vsync != nil
case "allow_vrr": return o.allowVRR != nil
case "windowed_safe_present": return o.windowedSafePresent != nil
case "modifier_layout": return o.modifierLayout != nil
default: return false
}
}
}
// MARK: - The profile
/// One named bundle of overrides. `id` is stable across renames bindings, pins and deep links
/// all point at it, never at the name.
public struct StreamProfile: Codable, Equatable, Identifiable, Sendable {
public var id: String
/// User-facing and editable; unique case-insensitively (menus are ambiguous otherwise
/// enforced by the editing UIs via `ProfileCatalog.nameTaken`).
public var name: String
/// `#RRGGBB` chip color. The UI may ignore it; the schema reserves it (pinned cards use it
/// to tint their subtitle).
public var accent: String?
public var overrides: SettingsOverlay
/// Profile keys a newer build wrote preserved across a loadsave round-trip.
public var extra: [String: JSONValue] = [:]
/// A new, empty profile: inherits everything (the right creation default under
/// inherit-by-exception "Duplicate" covers starting from another profile).
public init(name: String, id: String = newProfileID(), accent: String? = nil,
overrides: SettingsOverlay = SettingsOverlay()) {
self.id = id
self.name = name
self.accent = accent
self.overrides = overrides
}
private enum Key: String, CaseIterable {
case id, name, accent, overrides
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
id = (try? c.decode(String.self, forKey: AnyKey(Key.id.rawValue))) ?? newProfileID()
name = (try? c.decode(String.self, forKey: AnyKey(Key.name.rawValue))) ?? ""
accent = try? c.decodeIfPresent(String.self, forKey: AnyKey(Key.accent.rawValue))
overrides = (try? c.decodeIfPresent(
SettingsOverlay.self, forKey: AnyKey(Key.overrides.rawValue))) ?? SettingsOverlay()
let known = Set(Key.allCases.map(\.rawValue))
for key in c.allKeys where !known.contains(key.stringValue) {
extra[key.stringValue] = try c.decode(JSONValue.self, forKey: key)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: AnyKey.self)
try c.encode(id, forKey: AnyKey(Key.id.rawValue))
try c.encode(name, forKey: AnyKey(Key.name.rawValue))
try c.encodeIfPresent(accent, forKey: AnyKey(Key.accent.rawValue))
try c.encode(overrides, forKey: AnyKey(Key.overrides.rawValue))
let known = Set(Key.allCases.map(\.rawValue))
for (key, value) in extra where !known.contains(key) {
try c.encode(value, forKey: AnyKey(key))
}
}
}
/// 12 lowercase hex characters the shape the Rust `new_profile_id` mints, so the two catalogs
/// speak one id format.
public func newProfileID() -> String {
(0..<6).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined()
}
// MARK: - The catalog
/// What a `profile=` / `Connect with ` reference resolved to. Ambiguity is reported rather than
/// guessed: a link naming two profiles must refuse, not pick one.
public enum ProfileResolution: Equatable, Sendable {
case found
case notFound
/// More than one profile carries this name (case-insensitively).
case ambiguous
}
/// The profile catalog client-wide, not per host: "Work" applied to three hosts is one
/// profile, and the per-host part is only the binding on the host record.
public struct ProfileCatalog: Codable, Equatable, Sendable {
public var version: Int
public var profiles: [StreamProfile]
public init(version: Int = punktfunkProfilesVersion, profiles: [StreamProfile] = []) {
self.version = version
self.profiles = profiles
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
version = (try? c.decodeIfPresent(Int.self, forKey: .version)) ?? 0
profiles = (try? c.decodeIfPresent([StreamProfile].self, forKey: .profiles)) ?? []
}
/// The stored catalog, or an empty one a missing or unreadable blob is "no profiles", never
/// an error: nothing about streaming may hinge on this key existing.
public static func load(from defaults: UserDefaults = AppGroup.defaults) -> ProfileCatalog {
guard let data = defaults.data(forKey: DefaultsKey.profiles),
let catalog = try? JSONDecoder().decode(ProfileCatalog.self, from: data)
else { return ProfileCatalog(profiles: []) }
return catalog
}
public func save(to defaults: UserDefaults = AppGroup.defaults) {
var out = self
out.version = punktfunkProfilesVersion
guard let data = try? JSONEncoder().encode(out) else { return }
defaults.set(data, forKey: DefaultsKey.profiles)
}
public func profile(id: String) -> StreamProfile? {
profiles.first { $0.id == id }
}
/// The binding a host resolves to, dropping a dangling id: a profile deleted out from under a
/// host is "no profile" (today's behaviour), never an error and never a blocked connect.
public func binding(for host: StoredHost) -> StreamProfile? {
host.profileID.flatMap { profile(id: $0) }
}
/// This host's pinned profiles, in card order, with duplicates and dangling ids dropped
/// a pin is presentation only, so a deleted profile just loses its card.
public func pinned(for host: StoredHost) -> [StreamProfile] {
var seen = Set<String>()
return (host.pinnedProfileIDs ?? [])
.filter { seen.insert($0).inserted }
.compactMap { profile(id: $0) }
}
/// Resolve a reference the way every surface must: exact id first, then a unique
/// case-insensitive name. Ambiguous names resolve to `.ambiguous`, never to the first match.
public func resolve(_ reference: String) -> (StreamProfile?, ProfileResolution) {
if let p = profile(id: reference) { return (p, .found) }
let hits = profiles.filter { $0.name.lowercased() == reference.lowercased() }
switch hits.count {
case 1: return (hits[0], .found)
case 0: return (nil, .notFound)
default: return (nil, .ambiguous)
}
}
/// Is this name already used (case-insensitively) by a *different* profile? The create/rename
/// guard `except` is the profile being renamed, so renaming "Work" to "work" is allowed.
public func nameTaken(_ name: String, except: String? = nil) -> Bool {
profiles.contains {
$0.name.lowercased() == name.lowercased() && $0.id != except
}
}
}
@@ -1,8 +1,12 @@
// PunktfunkShared is now a wire contract between the app (writer) and the widget extension +
// deep-link senders (readers). These pin the two formats that cross that boundary:
// PunktfunkShared is a wire contract between the app (writer) and the widget extension +
// deep-link senders (readers). These pin the formats that cross that boundary:
// the `StoredHost` JSON codec the widget decodes the exact bytes the app persisted, and
// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode;
// the `punktfunk://` deep-link grammar the widget builds URLs the app parses.
// older saved JSON (missing `mgmtPort` / `macAddresses` / `profileID`) must still decode;
// the `punktfunk://` deep-link grammar driven by `clients/shared/deeplink-vectors.json`,
// the SAME file the Rust suite runs, so the two parsers cannot drift into two security
// postures;
// the settings-profile catalog including the don't-clobber rule that keeps an older build
// from erasing a newer one's overlay fields just by opening a profile.
import XCTest
@@ -18,16 +22,18 @@ final class SharedFoundationTests: XCTestCase {
name: "Tower", address: "192.168.1.173", port: 9777,
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true)
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true,
profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"])
let data = try JSONEncoder().encode(host)
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
XCTAssertEqual(decoded, host)
}
/// Older saved hosts predate `mgmtPort`/`macAddresses` a missing key must decode to nil, not
/// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat
/// guarantee the widget depends on when reading a store written by any prior build.
/// Older saved hosts predate `mgmtPort`/`macAddresses` and now `profileID`/
/// `pinnedProfileIDs` too. A missing key must decode to nil, not throw (synthesized Decodable
/// treats a missing Optional as nil). This is the forward-compat guarantee the widget depends
/// on when reading a store written by any prior build.
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
let json = """
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
@@ -40,6 +46,8 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertNil(decoded.pinnedSHA256)
XCTAssertNil(decoded.lastConnected)
XCTAssertNil(decoded.clipboardSync)
XCTAssertNil(decoded.profileID)
XCTAssertNil(decoded.pinnedProfileIDs)
// Resolvers fall back cleanly.
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
XCTAssertEqual(decoded.wakeMacs, [])
@@ -51,40 +59,351 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertEqual(host.displayName, "10.0.0.9")
}
// MARK: - DeepLink grammar
// MARK: - DeepLink grammar (the cross-language vector file)
func testDeepLinkConnectRoundTrips() {
let id = UUID()
let link = DeepLink.connect(host: id, launchID: nil)
let parsed = DeepLink(link.url)
XCTAssertEqual(parsed, link)
XCTAssertEqual(parsed, .connect(host: id, launchID: nil))
private struct VectorFile: Decodable {
struct Case: Decodable {
let name: String
let url: String
let error: String?
let emit: String?
let expect: Expect?
}
struct Expect: Decodable {
let route: String
// swiftlint:disable:next identifier_name
let host_ref: String
let fp: String?
let launch: String?
let profile: String?
let name: String?
let host_addr: String?
let host_port: Int?
}
let cases: [Case]
}
func testDeepLinkConnectWithLaunchRoundTrips() {
let id = UUID()
// A store-qualified GameEntry.id with a reserved char must survive percent-encoding.
let launch = "steam:570"
let link = DeepLink.connect(host: id, launchID: launch)
XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch))
/// `clients/shared/deeplink-vectors.json`, read from the source tree rather than copied into
/// the test bundle a copy is a second file, and a second file drifts. Resolved from
/// `#filePath` (this file sits at `clients/apple/Tests/PunktfunkKitTests/`).
private static var vectorFileURL: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // PunktfunkKitTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // apple
.deletingLastPathComponent() // clients
.appendingPathComponent("shared/deeplink-vectors.json")
}
func testDeepLinkParsesCanonicalString() throws {
let id = UUID()
let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)"))
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
/// Every case in the shared vector file the same 44 the Rust suite's `shared_vectors` test
/// consumes. Refusal CODES are part of the contract, not just the happy path: a parser that
/// rejects the right inputs for the wrong reason has already drifted from the other one.
func testDeepLinkSharedVectors() throws {
let url = Self.vectorFileURL
XCTAssertTrue(
FileManager.default.fileExists(atPath: url.path),
"the cross-language vector file must be readable at \(url.path)")
let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url))
XCTAssertGreaterThan(file.cases.count, 20, "the vector file is the contract; keep it rich")
for testCase in file.cases {
if let code = testCase.error {
do {
let link = try DeepLink.parse(testCase.url)
XCTFail("\(testCase.name): expected \(code), parsed \(link)")
} catch let error as DeepLinkError {
XCTAssertEqual(error.code, code, testCase.name)
}
continue
}
let want = try XCTUnwrap(testCase.expect, testCase.name)
let link = try DeepLink.parse(testCase.url)
XCTAssertEqual(link.route.rawValue, want.route, testCase.name)
XCTAssertEqual(link.hostRef, want.host_ref, testCase.name)
XCTAssertEqual(link.fp, want.fp, "\(testCase.name) fp")
XCTAssertEqual(link.launch, want.launch, "\(testCase.name) launch")
XCTAssertEqual(link.profile, want.profile, "\(testCase.name) profile")
XCTAssertEqual(link.name, want.name, "\(testCase.name) name")
XCTAssertEqual(link.host?.address, want.host_addr, "\(testCase.name) host_addr")
XCTAssertEqual(
link.host.map { Int($0.port) }, want.host_port, "\(testCase.name) host_port")
if let emit = testCase.emit {
XCTAssertEqual(link.urlString, emit, "\(testCase.name) emit")
}
}
}
func testDeepLinkRejectsForeignSchemeAndBadHost() throws {
/// The widget's and the Connect intent's emitter a bare UUID path, which is the grammar the
/// shipped links use. Backward compatibility is not optional here: a widget on the Home Screen
/// keeps sending yesterday's URL.
func testDeepLinkConnectRoundTrips() throws {
let id = UUID()
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)"))))
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid"))))
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)"))))
let link = DeepLink.connect(host: id)
XCTAssertEqual(try DeepLink(url: link.url), link)
XCTAssertEqual(link.hostRef, id.uuidString)
XCTAssertNil(link.launch)
let launched = DeepLink.connect(host: id, launchID: "steam:570")
XCTAssertEqual(try DeepLink(url: launched.url).launch, "steam:570")
let profiled = DeepLink.connect(host: id, launchID: nil, profile: "a1b2c3d4e5f6")
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
}
func testDeepLinkSchemeIsCaseInsensitive() throws {
let id = UUID()
let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)"))
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
/// both a re-addressed host and a wiped store.
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
var host = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
name: "Desk", address: "192.168.1.50", port: 7777)
host.pinnedSHA256 = Data(repeating: 0xCC, count: 32)
let link = DeepLink.forHost(host, launch: "steam:570", profile: "aaaaaaaaaaaa")
XCTAssertEqual(
link.urlString,
"punktfunk://connect/11111111-2222-4333-8444-555555555555"
+ "?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ "&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa")
XCTAssertEqual(try DeepLink.parse(link.urlString), link)
}
/// Resolution order id beats a unique name beats an address plus the two refusals a
/// front-end must surface rather than guess through.
func testDeepLinkHostResolution() throws {
let desk = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
name: "Desk", address: "192.168.1.50",
pinnedSHA256: Data(repeating: 0xAA, count: 32))
let couchA = StoredHost(name: "Couch", address: "192.168.1.60")
let couchB = StoredHost(name: "Couch", address: "192.168.1.61")
let hosts = [desk, couchA, couchB]
func resolve(_ url: String) throws -> DeepLink.HostResolution {
try DeepLink.parse(url).resolveHost(in: hosts)
}
XCTAssertEqual(
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
// A stale id with the recovery parameter: the address finds the record anyway.
XCTAssertEqual(
try resolve(
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
.known(desk))
// Nothing local matches: the sheet gets the address, the claimed name and the pin which
// is what makes a first connect verified rather than blind trust-on-first-use.
XCTAssertEqual(
try resolve("punktfunk://connect/10.0.0.9:7000?name=Studio"),
.unknown(address: "10.0.0.9", port: 7000, name: "Studio", fp: nil))
// But a stale record id is not a hostname: without `host=` there is nothing to dial.
XCTAssertEqual(
try resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000"), .unresolvable)
XCTAssertEqual(try resolve("punktfunk://connect/Basement%20PC"), .unresolvable)
// A pin that contradicts the stored one is the link lying the caller hard-refuses.
let lying = try DeepLink.parse("punktfunk://connect/desk?fp=\(String(repeating: "b", count: 64))")
XCTAssertTrue(lying.pinConflict(with: desk))
let honest = try DeepLink.parse("punktfunk://connect/desk?fp=\(String(repeating: "a", count: 64))")
XCTAssertFalse(honest.pinConflict(with: desk))
// No pin stored nothing to contradict; the trust flow runs as usual.
XCTAssertFalse(lying.pinConflict(with: couchA))
}
// MARK: - Settings profiles
/// The overlay applies field by field: a value wins, an absent one keeps the base's live
/// value including a value that happens to equal the base (an explicit pin).
func testOverlayAppliesOnlyWhatItOverrides() {
var base = EffectiveSettings()
base.width = 1920
base.height = 1080
base.bitrateKbps = 20_000
base.codec = "hevc"
XCTAssertTrue(SettingsOverlay().isEmpty)
XCTAssertEqual(base.applying(SettingsOverlay()), base)
var overlay = SettingsOverlay()
overlay.width = 3840
overlay.height = 2160
overlay.refreshHz = 120
overlay.codec = "av1"
overlay.enable444 = true
overlay.modifierLayout = "windows"
XCTAssertFalse(overlay.isEmpty)
let out = base.applying(overlay)
XCTAssertEqual([out.width, out.height, out.refreshHz], [3840, 2160, 120])
XCTAssertEqual(out.codec, "av1")
XCTAssertTrue(out.enable444)
XCTAssertEqual(out.modifierLayout, "windows")
// Untouched fields keep following the base.
XCTAssertEqual(out.bitrateKbps, 20_000)
// Tier-G endpoints are not in the overlay at all no profile can move this device's
// speaker or microphone.
XCTAssertEqual(out.speakerUID, base.speakerUID)
// An overlay carrying a value equal to the base is still an override: the profile PINS it,
// so a later change to the global doesn't move it.
var pin = SettingsOverlay()
pin.bitrateKbps = 20_000
XCTAssertFalse(pin.isEmpty)
var moved = base
moved.bitrateKbps = 50_000
XCTAssertEqual(moved.applying(pin).bitrateKbps, 20_000)
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state one row
/// drives.
func testOverlayClearDropsOneOverride() {
var overlay = SettingsOverlay()
overlay.width = 3840
overlay.height = 2160
overlay.matchWindow = false
overlay.codec = "av1"
XCTAssertTrue(OverlayField.isOverridden("resolution", in: overlay))
XCTAssertTrue(OverlayField.clear("codec", in: &overlay))
XCTAssertNil(overlay.codec)
XCTAssertTrue(OverlayField.clear(OverlayField.resolution, in: &overlay))
XCTAssertNil(overlay.width)
XCTAssertNil(overlay.height)
XCTAssertNil(overlay.matchWindow)
XCTAssertTrue(overlay.isEmpty)
XCTAssertFalse(OverlayField.clear("no_such_field", in: &overlay))
}
/// A catalog round-trips, and what this build can't represent survives it: an unknown overlay
/// KEY is carried through untouched rather than erased the don't-clobber rule, which is what
/// keeps an older build from silently gutting a profile a newer one wrote.
func testCatalogRoundTripsAndPreservesUnknownKeys() throws {
let stored = """
{
"version": 1,
"profiles": [
{
"id": "a1b2c3d4e5f6", "name": "Game", "accent": "#ff8800",
"overrides": {
"width": 3840, "height": 2160, "refresh_hz": 120,
"codec": "vvc-from-the-future", "stats_verbosity": "compact",
"some_new_axis": {"nested": true}
},
"future_profile_key": 7
},
{ "id": "0f0f0f0f0f0f", "name": "Work" }
]
}
""".data(using: .utf8)!
let catalog = try JSONDecoder().decode(ProfileCatalog.self, from: stored)
XCTAssertEqual(catalog.profiles.count, 2)
let game = try XCTUnwrap(catalog.profile(id: "a1b2c3d4e5f6"))
XCTAssertEqual(game.accent, "#ff8800")
XCTAssertEqual(game.overrides.codec, "vvc-from-the-future")
XCTAssertEqual(game.overrides.statsVerbosity, "compact")
// A profile with no `overrides` key at all is the empty (inherit-everything) one.
XCTAssertTrue(try XCTUnwrap(catalog.profile(id: "0f0f0f0f0f0f")).overrides.isEmpty)
let text = try XCTUnwrap(String(data: JSONEncoder().encode(catalog), encoding: .utf8))
XCTAssertTrue(text.contains("some_new_axis"))
XCTAssertTrue(text.contains("future_profile_key"))
// Absent overrides serialize away entirely "not overridden" has one representation.
XCTAssertFalse(text.contains("null"))
let round = try JSONDecoder().decode(ProfileCatalog.self, from: Data(text.utf8))
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.overrides.width, 3840)
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.overrides.extra.count, 1)
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.extra.count, 1)
}
/// Reference resolution: id first, then a unique case-insensitive name; two profiles sharing a
/// name resolve to `.ambiguous` (the caller refuses) rather than to whichever came first.
func testCatalogResolvesIDsFirstAndRefusesAmbiguity() {
let catalog = ProfileCatalog(profiles: [
StreamProfile(name: "Work", id: "111111111111"),
StreamProfile(name: "work", id: "222222222222"),
StreamProfile(name: "Game", id: "333333333333"),
])
XCTAssertEqual(catalog.resolve("111111111111").1, .found)
XCTAssertEqual(catalog.resolve("Work").1, .ambiguous)
XCTAssertEqual(catalog.resolve("game").1, .found)
XCTAssertEqual(catalog.resolve("GAME").0?.id, "333333333333")
XCTAssertEqual(catalog.resolve("nope").1, .notFound)
XCTAssertTrue(catalog.nameTaken("GAME"))
XCTAssertFalse(catalog.nameTaken("GAME", except: "333333333333"))
XCTAssertTrue(catalog.nameTaken("GAME", except: "111111111111"))
XCTAssertFalse(catalog.nameTaken("Travel"))
}
/// Bindings and pins live ON the host record, and both degrade rather than error: a deleted
/// profile means "Default settings" for a binding and a vanished card for a pin.
func testBindingsAndPinsDropDanglingIDs() {
let catalog = ProfileCatalog(profiles: [
StreamProfile(name: "Game", id: "111111111111"),
StreamProfile(name: "Work", id: "222222222222"),
])
var host = StoredHost(name: "Desk", address: "10.0.0.1")
XCTAssertNil(catalog.binding(for: host))
host.profileID = "111111111111"
XCTAssertEqual(catalog.binding(for: host)?.name, "Game")
host.profileID = "deadbeefdead"
XCTAssertNil(catalog.binding(for: host), "a deleted profile is Default settings, not an error")
host.pinnedProfileIDs = ["222222222222", "deadbeefdead", "222222222222", "111111111111"]
XCTAssertEqual(catalog.pinned(for: host).map(\.id), ["222222222222", "111111111111"])
}
/// The whole per-connect resolution, in the precedence every client shares:
/// one-off pick ?? host binding ?? none.
func testEffectiveSettingsResolutionPrecedence() {
let defaults = UserDefaults(suiteName: "io.unom.punktfunk.tests.effective")!
defaults.removePersistentDomain(forName: "io.unom.punktfunk.tests.effective")
defaults.set(2_560, forKey: DefaultsKey.streamWidth)
defaults.set(30_000, forKey: DefaultsKey.bitrateKbps)
var gameOverrides = SettingsOverlay()
gameOverrides.bitrateKbps = 80_000
var workOverrides = SettingsOverlay()
workOverrides.bitrateKbps = 8_000
let catalog = ProfileCatalog(profiles: [
StreamProfile(name: "Game", id: "111111111111", overrides: gameOverrides),
StreamProfile(name: "Work", id: "222222222222", overrides: workOverrides),
])
var host = StoredHost(name: "Desk", address: "10.0.0.1")
host.profileID = "111111111111"
let unbound = EffectiveSettings.resolve(
host: StoredHost(name: "Plain", address: "10.0.0.2"),
catalog: catalog, defaults: defaults)
XCTAssertEqual(unbound.bitrateKbps, 30_000)
XCTAssertNil(unbound.profileID)
XCTAssertEqual(unbound.width, 2_560, "globals still flow through in every case")
let bound = EffectiveSettings.resolve(host: host, catalog: catalog, defaults: defaults)
XCTAssertEqual(bound.bitrateKbps, 80_000)
XCTAssertEqual(bound.profileName, "Game")
// A one-off pick wins over the binding and does not rebind anything.
let oneOff = EffectiveSettings.resolve(
host: host, selection: .profile("222222222222"),
catalog: catalog, defaults: defaults)
XCTAssertEqual(oneOff.bitrateKbps, 8_000)
XCTAssertEqual(host.profileID, "111111111111")
// "Connect with Default settings" on a BOUND host forces the globals the case that
// makes this a three-way selection rather than an optional profile.
XCTAssertEqual(
EffectiveSettings.resolve(host: host, selection: .defaults, catalog: catalog,
defaults: defaults).bitrateKbps,
30_000)
// A one-off naming a profile that no longer exists degrades to the globals rather than
// erroring same rule as a dangling binding.
XCTAssertEqual(
EffectiveSettings.resolve(host: host, selection: .profile("gone"), catalog: catalog,
defaults: defaults).bitrateKbps,
30_000)
defaults.removePersistentDomain(forName: "io.unom.punktfunk.tests.effective")
}
}