From 858cf0e5350d62aef64aa096d75c0268f84c7bb5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:14:48 +0200 Subject: [PATCH] feat(client/apple): profiles, resolved once per connect instead of ten times mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/PunktfunkClient/ContentView.swift | 202 ++++--- .../Session/SessionModel.swift | 68 ++- .../Session/StreamCommands.swift | 12 +- .../PunktfunkClient/Stores/HostStore.swift | 19 + .../PunktfunkClient/Stores/ProfileStore.swift | 107 ++++ .../Connection/PunktfunkConnection.swift | 14 + .../PunktfunkKit/Input/InputCapture.swift | 2 +- .../PunktfunkKit/Input/TouchMouse.swift | 4 +- .../PunktfunkKit/Support/StatsVerbosity.swift | 17 + .../PunktfunkKit/Video/SessionPresenter.swift | 9 +- .../PunktfunkKit/Video/Stage2Pipeline.swift | 2 +- .../PunktfunkKit/Views/StreamView.swift | 10 +- .../PunktfunkKit/Views/StreamViewIOS.swift | 13 +- .../Sources/PunktfunkShared/DeepLink.swift | 497 ++++++++++++++++-- .../PunktfunkShared/DefaultsKeys.swift | 5 + .../PunktfunkShared/EffectiveSettings.swift | 253 +++++++++ .../PunktfunkShared/ModifierLayout.swift | 3 +- .../Sources/PunktfunkShared/StoredHost.swift | 15 +- .../PunktfunkShared/StreamProfile.swift | 461 ++++++++++++++++ .../SharedFoundationTests.swift | 385 ++++++++++++-- 20 files changed, 1896 insertions(+), 202 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift create mode 100644 clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift create mode 100644 clients/apple/Sources/PunktfunkShared/StreamProfile.swift diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index b5398679..49ac4a5d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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) } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 0f57fa0d..8971f0e3 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index fc927b08..a9085f1d 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 07eaf23e..2da19a73 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -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). diff --git a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift new file mode 100644 index 00000000..aa1ee041 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift @@ -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( + _ id: String, _ keyPath: WritableKeyPath, _ 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) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index e0d7631e..d66f8203 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -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.) diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index b62f7585..7c1abf70 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.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 diff --git a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift index 75fbeec5..ffe263f4 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift index 88d4d41b..1ab6d8d3 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift @@ -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()) + } } diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index a834e1e2..0f0609e2 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index bee768ab..a8a3d579 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -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`. diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 840ecf93..c568b905 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -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() diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 4c3e13e9..f275cb7d 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkShared/DeepLink.swift b/clients/apple/Sources/PunktfunkShared/DeepLink.swift index 581b36f7..99fc1744 100644 --- a/clients/apple/Sources/PunktfunkShared/DeepLink.swift +++ b/clients/apple/Sources/PunktfunkShared/DeepLink.swift @@ -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/ — connect to a stored host -// punktfunk://connect/?launch= — connect and ask the host to launch it +// punktfunk://connect/[?fp=<64-hex>][&host=][&launch=] +// [&profile=][&name=