From 4cae1b8bb86c6be15a27dc1e8f38442ae63489f3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 18:16:39 +0200 Subject: [PATCH] feat(apple/M0): App Group + PunktfunkShared + punktfunk:// deep links Foundation milestone for Live Activities & Widgets (design/apple-live- activities-and-widgets.md). No user-visible change beyond the URL scheme. - New dependency-free PunktfunkShared SwiftPM target (+ library product) so a future widget extension can link it WITHOUT PunktfunkKit (Rust staticlib + presentation layer). Moves StoredHost (model + JSON codec), DefaultsKeys, and punktfunkDefaultMgmtPort there; adds AppGroup.suiteName and the punktfunk:// DeepLink builder/parser. PunktfunkKit @_exported-imports it (no call-site churn for consumers; intra-Kit files import it explicitly since imports are file-scoped). - HostStore reads/writes the shared App-Group suite (group.io.unom.punktfunk) with a one-time migration from UserDefaults.standard (old value left in place for staged rollout); reloads the "PunktfunkHosts" widget timeline on change. - App Group entitlement on iOS/tvOS + macOS. - CFBundleURLTypes scheme `punktfunk`; ContentView.onOpenURL routes connect/[?launch=] into the existing connect() path (unknown host / already-streaming guards; never tears down a live session). - Round-trip tests: StoredHost JSON codec (+ legacy missing-optional decode), DeepLink grammar. `swift build` + `swift test` green (142 tests, 0 failures). Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/apple/Config/Info.plist | 14 +++ .../apple/Config/Punktfunk-macOS.entitlements | 10 +++ clients/apple/Config/Punktfunk.entitlements | 9 ++ clients/apple/Package.swift | 12 ++- .../Sources/PunktfunkClient/ContentView.swift | 40 +++++++++ .../PunktfunkClient/Stores/HostStore.swift | 67 ++++++++------ .../Connection/LibraryClient.swift | 7 +- .../Gamepad/GamepadFeedback.swift | 1 + .../PunktfunkKit/Gamepad/GamepadManager.swift | 1 + .../Gamepad/GamepadUIEnvironment.swift | 1 + .../PunktfunkKit/Input/TouchMouse.swift | 1 + .../Sources/PunktfunkKit/SharedReexport.swift | 9 ++ .../PunktfunkKit/Support/StatsVerbosity.swift | 1 + .../PunktfunkKit/Video/SessionPresenter.swift | 1 + .../PunktfunkKit/Video/Stage2Pipeline.swift | 1 + .../PunktfunkKit/Views/StreamView.swift | 1 + .../PunktfunkKit/Views/StreamViewIOS.swift | 1 + .../Sources/PunktfunkShared/AppGroup.swift | 23 +++++ .../Sources/PunktfunkShared/DeepLink.swift | 57 ++++++++++++ .../DefaultsKeys.swift | 3 +- .../Sources/PunktfunkShared/StoredHost.swift | 57 ++++++++++++ .../SharedFoundationTests.swift | 89 +++++++++++++++++++ 22 files changed, 371 insertions(+), 35 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/SharedReexport.swift create mode 100644 clients/apple/Sources/PunktfunkShared/AppGroup.swift create mode 100644 clients/apple/Sources/PunktfunkShared/DeepLink.swift rename clients/apple/Sources/{PunktfunkKit/Support => PunktfunkShared}/DefaultsKeys.swift (97%) create mode 100644 clients/apple/Sources/PunktfunkShared/StoredHost.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift diff --git a/clients/apple/Config/Info.plist b/clients/apple/Config/Info.plist index d5293e31..5c135bc5 100644 --- a/clients/apple/Config/Info.plist +++ b/clients/apple/Config/Info.plist @@ -19,5 +19,19 @@ _punktfunk._udp + + CFBundleURLTypes + + + CFBundleURLName + io.unom.punktfunk.deeplink + CFBundleURLSchemes + + punktfunk + + + diff --git a/clients/apple/Config/Punktfunk-macOS.entitlements b/clients/apple/Config/Punktfunk-macOS.entitlements index 877cc42c..a92c6af1 100644 --- a/clients/apple/Config/Punktfunk-macOS.entitlements +++ b/clients/apple/Config/Punktfunk-macOS.entitlements @@ -73,5 +73,15 @@ $(AppIdentifierPrefix)io.unom.punktfunk + + + com.apple.security.application-groups + + group.io.unom.punktfunk + diff --git a/clients/apple/Config/Punktfunk.entitlements b/clients/apple/Config/Punktfunk.entitlements index bee84560..ac45c932 100644 --- a/clients/apple/Config/Punktfunk.entitlements +++ b/clients/apple/Config/Punktfunk.entitlements @@ -20,5 +20,14 @@ is true on iOS/tvOS too. --> com.apple.developer.networking.multicast + + com.apple.security.application-groups + + group.io.unom.punktfunk + diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index fe75d522..3fd28e27 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -9,13 +9,20 @@ let package = Package( platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)], products: [ .library(name: "PunktfunkKit", targets: ["PunktfunkKit"]), + // Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group + // constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget + // extension — which must never link PunktfunkKit (Rust staticlib + presentation layer) — + // can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift). + .library(name: "PunktfunkShared", targets: ["PunktfunkShared"]), .executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]), ], targets: [ .binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"), + // No dependencies by design — an extension process links this alone. + .target(name: "PunktfunkShared"), .target( name: "PunktfunkKit", - dependencies: ["PunktfunkCore"], + dependencies: ["PunktfunkCore", "PunktfunkShared"], // OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the // app target) so it rides along via Bundle.module in both `swift build` and the Xcode // app, which links the PunktfunkKit product. Refresh with @@ -43,7 +50,8 @@ let package = Package( // PunktfunkCore is a direct dep too so the wire tests can name the C ABI's // `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout. .testTarget( - name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"], + name: "PunktfunkKitTests", + dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"], resources: [ // PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference // planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 32121f58..0cd4a21a 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -51,6 +51,10 @@ struct ContentView: View { } } @State private var showAddHost = false + /// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or + /// a live session is already up. Surfaced as an informational alert (distinct from the + /// "Connection failed" one, which is for actual connect errors). + @State private var deepLinkNotice: String? @State private var pairingTarget: StoredHost? /// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN /// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b). @@ -114,6 +118,10 @@ struct ContentView: View { seedDefaultModeIfNeeded() autoConnectIfAsked() } + // Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card + // 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) } .onChange(of: model.phase) { _, phase in switch phase { case .streaming: @@ -262,6 +270,38 @@ struct ContentView: View { + "console (port 3000 → Pairing). This device connects automatically once you " + "approve it — no need to reconnect.") } + // Informational deep-link outcome (unknown host / already streaming). Not an error. + .alert( + "Can't open", + isPresented: Binding( + get: { deepLinkNotice != nil }, + set: { if !$0 { deepLinkNotice = nil } }) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(deepLinkNotice ?? "") + } + } + + /// 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. + 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." + 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." + return + } + return // deep-linked to the host we're already on — nothing to do + } + connect(host, launchID: launchID) } private var home: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 5a87026b..07eaf23e 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -11,32 +11,13 @@ import Foundation import PunktfunkKit import SwiftUI +#if canImport(WidgetKit) +import WidgetKit +#endif -struct StoredHost: Identifiable, Codable, Hashable { - var id = UUID() - var name: String - var address: String - var port: UInt16 = 9777 - /// SHA-256 of the host's certificate, set after the user explicitly trusted it. - var pinnedSHA256: Data? - /// Last time a streaming session actually started (nil until the first one). - var lastConnected: Date? - /// Management-API port for the library browser (distinct from the data-plane `port`). Optional - /// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still - /// decode: synthesized Decodable ignores property defaults but treats a missing Optional as - /// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.) - var mgmtPort: UInt16? - /// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`. - /// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the - /// client can send a magic packet to wake the host later (when it's asleep and no longer - /// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned. - var macAddresses: [String]? - - var displayName: String { name.isEmpty ? address : name } - var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort } - /// Wake-capable, in a form the wake helper accepts (empty when none learned yet). - var wakeMacs: [String] { macAddresses ?? [] } -} +// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension +// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here +// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`. extension StoredHost { /// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the @@ -86,8 +67,14 @@ final class HostStore: ObservableObject { /// never advertises still reads Online. Not persisted (it's live reachability, not config). @Published var probedOnline: Set = [] + /// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget + /// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see + /// `AppGroup.defaults`). + private let defaults = AppGroup.defaults + init() { - if let data = UserDefaults.standard.data(forKey: Self.key), + Self.migrateToAppGroupIfNeeded() + if let data = defaults.data(forKey: Self.key), let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) { hosts = decoded } else { @@ -95,6 +82,20 @@ final class HostStore: ObservableObject { } } + /// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before + /// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no + /// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight + /// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from + /// the not-yet-updated app. Remove the standard copy a release later. + private static func migrateToAppGroupIfNeeded() { + let suite = AppGroup.defaults + let standard = UserDefaults.standard + guard suite !== standard else { return } // un-entitled fallback: nothing to migrate + guard suite.data(forKey: key) == nil, + let legacy = standard.data(forKey: key) else { return } + suite.set(legacy, forKey: key) + } + func add(_ host: StoredHost) { hosts.append(host) } @@ -112,7 +113,7 @@ final class HostStore: ObservableObject { func markConnected(_ hostID: UUID) { guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } - hosts[i].lastConnected = Date() + hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget } /// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently @@ -158,7 +159,17 @@ final class HostStore: ObservableObject { private func persist() { if let data = try? JSONEncoder().encode(hosts) { - UserDefaults.standard.set(data, forKey: Self.key) + defaults.set(data, forKey: Self.key) } + reloadHostsWidget() // the widget reads this store; any change refreshes its timeline + } + + /// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/ + /// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses + /// `.never`-refresh entries and relies on this push. + private func reloadHostsWidget() { + #if canImport(WidgetKit) && os(iOS) + WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts") + #endif } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index d02e0d3b..6a7f459a 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -11,6 +11,9 @@ // LaunchSpec schema in `crates/punktfunk-host/src/library.rs`. import Foundation +// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the +// dependency-free widget extension can share them; PunktfunkKit re-exports the module. +import PunktfunkShared /// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries). public struct Artwork: Codable, Hashable, Sendable { @@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError { } } -/// The management API's default port — adjacent to the GameStream block; matches -/// `mgmt::DEFAULT_PORT` on the host. -public let punktfunkDefaultMgmtPort: UInt16 = 47990 - /// Stateless fetcher for a host's library. public enum LibraryClient { /// `GET https://
:/api/v1/library`, authenticated by **mTLS**: the client diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 3ef2c5c7..5cf9a77a 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -23,6 +23,7 @@ import Combine import CoreHaptics import Foundation import GameController +import PunktfunkShared public final class GamepadFeedback { private let connection: PunktfunkConnection diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index 41b81234..dc161caf 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -20,6 +20,7 @@ import Combine import Foundation import GameController +import PunktfunkShared @MainActor public final class GamepadManager: ObservableObject { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift index b76cf4bf..97ad42cb 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift @@ -6,6 +6,7 @@ // the two combine without adding a second ObservableObject or an environment key nobody else needs. import Foundation +import PunktfunkShared public enum GamepadUIEnvironment { /// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`); diff --git a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift index 7367a8ed..75fbeec5 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift @@ -19,6 +19,7 @@ #if os(iOS) import Foundation import PunktfunkCore +import PunktfunkShared import UIKit /// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched diff --git a/clients/apple/Sources/PunktfunkKit/SharedReexport.swift b/clients/apple/Sources/PunktfunkKit/SharedReexport.swift new file mode 100644 index 00000000..8696bc9a --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/SharedReexport.swift @@ -0,0 +1,9 @@ +// PunktfunkShared holds what the app AND the widget extension both need — the stored-host model, +// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity +// attributes — in a module that links neither the Rust core nor the presentation layer. +// +// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing +// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn. +// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly — Swift imports are +// file-scoped; the re-export only reaches downstream modules.) +@_exported import PunktfunkShared diff --git a/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift index 0bcf90cf..88d4d41b 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift @@ -8,6 +8,7 @@ // tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly. import Foundation +import PunktfunkShared /// How much of the streaming statistics overlay to show. The raw values are stable on disk — /// rename the cases freely, never the strings. diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index c285c0a8..93a1cadc 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -9,6 +9,7 @@ #if canImport(Metal) && canImport(QuartzCore) import AVFoundation import Foundation +import PunktfunkShared import QuartzCore #if os(tvOS) import UIKit diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index b829cdb0..674e1aac 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -38,6 +38,7 @@ import AVFoundation import Foundation import Metal +import PunktfunkShared import QuartzCore /// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 8487ca0f..a92d3335 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -19,6 +19,7 @@ #if os(macOS) import AppKit import AVFoundation +import PunktfunkShared import SwiftUI import os diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 5273fe37..3afad18e 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -35,6 +35,7 @@ import AVFoundation import GameController import PunktfunkCore +import PunktfunkShared import SwiftUI import UIKit import os diff --git a/clients/apple/Sources/PunktfunkShared/AppGroup.swift b/clients/apple/Sources/PunktfunkShared/AppGroup.swift new file mode 100644 index 00000000..25400904 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/AppGroup.swift @@ -0,0 +1,23 @@ +// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity). +// +// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in +// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB, +// so everything an extension needs — the stored-host model + its JSON codec, the settings-key names, +// the deep-link grammar, and (later) the Live Activity attributes — lives here and here only. + +import Foundation + +/// The one App-Group identifier, matched by `Config/*.entitlements` +/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app +/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`). +public enum AppGroup { + public static let suiteName = "group.io.unom.punktfunk" + + /// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to + /// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned + /// build) so the app still functions single-process rather than crashing — the widget just + /// won't see the same store there. + public static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } +} diff --git a/clients/apple/Sources/PunktfunkShared/DeepLink.swift b/clients/apple/Sources/PunktfunkShared/DeepLink.swift new file mode 100644 index 00000000..581b36f7 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/DeepLink.swift @@ -0,0 +1,57 @@ +// 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. +// +// Grammar (v1): +// punktfunk://connect/ — connect to a stored host +// punktfunk://connect/?launch= — connect and ask the host to launch it +// +// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded +// on parse, so ids with reserved characters survive the round trip. + +import Foundation + +public enum DeepLink: Equatable { + /// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any. + case connect(host: UUID, launchID: String?) + + public static let scheme = "punktfunk" + + /// Build the canonical URL for a route. Non-optional: every route is representable. + public var url: URL { + switch self { + case let .connect(host, launchID): + var comps = URLComponents() + comps.scheme = Self.scheme + comps.host = "connect" + comps.path = "/\(host.uuidString)" + if let launchID, !launchID.isEmpty { + comps.queryItems = [URLQueryItem(name: "launch", value: launchID)] + } + // URLComponents percent-encodes the query value; force-unwrap is safe for a URL we + // fully control (scheme/host/path are all valid). + return comps.url! + } + } + + /// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of + /// case in the scheme and of a trailing slash on the path. + public init?(_ url: URL) { + guard url.scheme?.lowercased() == Self.scheme else { return nil } + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + + switch comps.host?.lowercased() { + case "connect": + // Path is "/"; strip the leading slash and any trailing one. + let raw = comps.path + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let host = UUID(uuidString: raw) else { return nil } + let launch = comps.queryItems? + .first(where: { $0.name == "launch" })?.value + .flatMap { $0.isEmpty ? nil : $0 } + self = .connect(host: host, launchID: launch) + default: + return nil + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift similarity index 97% rename from clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift rename to clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 5b94cbba..6278bb8c 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -1,7 +1,8 @@ // One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key // duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view // reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These -// live in PunktfunkKit because both the app and the kit's views read them. +// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app, +// the kit's views, AND the widget extension all read them — the widget needs `DefaultsKey.hosts`. import Foundation diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift new file mode 100644 index 00000000..91bf9c9d --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -0,0 +1,57 @@ +// The saved-host model + its on-disk JSON wire format — the widget/extension depends on BOTH, so +// they live in the dependency-free shared module. The `ObservableObject` store that wraps them +// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers +// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`. +// +// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app +// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it — do not +// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still +// decode; synthesized Decodable treats a missing Optional as nil). + +import Foundation + +/// The management-API port default (distinct from the data-plane `port`). Lives here (not in +/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve +/// it without the shared module taking a dependency on the kit. +public let punktfunkDefaultMgmtPort: UInt16 = 47990 + +public struct StoredHost: Identifiable, Codable, Hashable { + public var id = UUID() + public var name: String + public var address: String + public var port: UInt16 = 9777 + /// SHA-256 of the host's certificate, set after the user explicitly trusted it. + public var pinnedSHA256: Data? + /// Last time a streaming session actually started (nil until the first one). + public var lastConnected: Date? + /// Management-API port for the library browser (distinct from the data-plane `port`). Optional + /// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still + /// decode: synthesized Decodable ignores property defaults but treats a missing Optional as + /// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.) + public var mgmtPort: UInt16? + /// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`. + /// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the + /// client can send a magic packet to wake the host later (when it's asleep and no longer + /// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned. + public var macAddresses: [String]? + + public init( + id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777, + pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil, + macAddresses: [String]? = nil + ) { + self.id = id + self.name = name + self.address = address + self.port = port + self.pinnedSHA256 = pinnedSHA256 + self.lastConnected = lastConnected + self.mgmtPort = mgmtPort + self.macAddresses = macAddresses + } + + public var displayName: String { name.isEmpty ? address : name } + public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort } + /// Wake-capable, in a form the wake helper accepts (empty when none learned yet). + public var wakeMacs: [String] { macAddresses ?? [] } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift new file mode 100644 index 00000000..a6ef0ceb --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -0,0 +1,89 @@ +// PunktfunkShared is now a wire contract between the app (writer) and the widget extension + +// deep-link senders (readers). These pin the two formats that cross that boundary: +// • the `StoredHost` JSON codec — the widget decodes the exact bytes the app persisted, and +// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode; +// • the `punktfunk://` deep-link grammar — the widget builds URLs the app parses. + +import XCTest + +@testable import PunktfunkKit +import PunktfunkShared + +final class SharedFoundationTests: XCTestCase { + // MARK: - StoredHost JSON codec + + func testStoredHostRoundTrips() throws { + let host = StoredHost( + id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + name: "Tower", address: "192.168.1.173", port: 9777, + pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]), + lastConnected: Date(timeIntervalSince1970: 1_700_000_000), + mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"]) + + let data = try JSONEncoder().encode(host) + let decoded = try JSONDecoder().decode(StoredHost.self, from: data) + XCTAssertEqual(decoded, host) + } + + /// Older saved hosts predate `mgmtPort`/`macAddresses` — a missing key must decode to nil, not + /// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat + /// guarantee the widget depends on when reading a store written by any prior build. + func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws { + let json = """ + {"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777} + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(StoredHost.self, from: json) + XCTAssertEqual(decoded.name, "Old") + XCTAssertNil(decoded.mgmtPort) + XCTAssertNil(decoded.macAddresses) + XCTAssertNil(decoded.pinnedSHA256) + XCTAssertNil(decoded.lastConnected) + // Resolvers fall back cleanly. + XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort) + XCTAssertEqual(decoded.wakeMacs, []) + XCTAssertEqual(decoded.displayName, "Old") + } + + func testStoredHostDisplayNameFallsBackToAddress() { + let host = StoredHost(name: "", address: "10.0.0.9") + XCTAssertEqual(host.displayName, "10.0.0.9") + } + + // MARK: - DeepLink grammar + + func testDeepLinkConnectRoundTrips() { + let id = UUID() + let link = DeepLink.connect(host: id, launchID: nil) + let parsed = DeepLink(link.url) + XCTAssertEqual(parsed, link) + XCTAssertEqual(parsed, .connect(host: id, launchID: nil)) + } + + func testDeepLinkConnectWithLaunchRoundTrips() { + let id = UUID() + // A store-qualified GameEntry.id with a reserved char must survive percent-encoding. + let launch = "steam:570" + let link = DeepLink.connect(host: id, launchID: launch) + XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch)) + } + + func testDeepLinkParsesCanonicalString() throws { + let id = UUID() + let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)")) + XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil)) + } + + func testDeepLinkRejectsForeignSchemeAndBadHost() throws { + let id = UUID() + XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)")))) + XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid")))) + XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)")))) + } + + func testDeepLinkSchemeIsCaseInsensitive() throws { + let id = UUID() + let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)")) + XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil)) + } +}