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=