ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.
Two shapes of bug, one per client:
**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.
**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.
So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:
* a game that exits returns to the shelf it was launched from, pin and all,
rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
`ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
drops a pin that was unpinned while the game ran, rather than reopening a card
that no longer exists.
* Android's stats overlay finally names the profile a library launch used
(`profileName` was never set on that path).
* Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
ignored — and refuses an unknown or ambiguous reference exactly as the connect
route does (§10.6) rather than degrading to the binding.
* every shelf says which one it is, in the card's own `host · profile` shape:
the console's title, GTK's page title, Android's ConsoleHeader, Apple's
navigation title and its console heading.
Verified per platform, none of it on trust:
* console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
* Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
pass alone caught `navigationDestination(item:)` needing Hashable, and an
iOS-only screen was confirmed genuinely compiled by planting a type error.
* Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
expectation flipped to match.
* Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
cold (3m10s) — that client cannot compile on a Mac.
302 lines
14 KiB
Swift
302 lines
14 KiB
Swift
// 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 echoCancel = true
|
|
public var touchMode = "trackpad"
|
|
public var mouseMode = "capture"
|
|
public var invertScroll = false
|
|
public var gamepadType = 0
|
|
public var gamepadForwarding = true
|
|
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
|
public var systemButtons = "auto"
|
|
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
|
public var guideGesture = "auto"
|
|
/// 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?
|
|
/// The profile's `#RRGGBB` chip colour, so the HUD names it in the same colour the card that
|
|
/// launched it wore — the colour is an identifier, and it only works if it's the same one.
|
|
public var profileAccent: 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)
|
|
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
|
|
touchMode = str(DefaultsKey.touchMode, touchMode)
|
|
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
|
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
|
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
|
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
|
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
|
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
|
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 `system_buttons` policy resolved for this platform: forward the raw guide (and
|
|
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
|
|
/// its own overlay for the press that is the OS's business, and suppressing our send
|
|
/// would only break users who handed the button to the app (iOS 27's Home-button
|
|
/// setting; macOS with the gestures claimed).
|
|
public var systemButtonsForward: Bool {
|
|
switch systemButtons {
|
|
case "local": return false
|
|
default: return true
|
|
}
|
|
}
|
|
|
|
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
|
|
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
|
|
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
|
|
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
|
|
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
|
|
public var guideGestureEnabled: Bool {
|
|
switch guideGesture {
|
|
case "on": return true
|
|
case "off": return false
|
|
default:
|
|
#if os(macOS)
|
|
return false
|
|
#else
|
|
return true
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 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.echoCancel { s.echoCancel = 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.gamepadForwarding { s.gamepadForwarding = v }
|
|
if let v = overlay.systemButtons { s.systemButtons = v }
|
|
if let v = overlay.guideGesture { s.guideGesture = 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
|
|
out.profileAccent = profile.accent
|
|
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: Hashable, 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()
|
|
}
|
|
}
|