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/<uuid>[?launch=<GameEntry.id>] 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) <noreply@anthropic.com>
This commit is contained in:
@@ -19,5 +19,19 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
|
||||
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
|
||||
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.unom.punktfunk.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -73,5 +73,15 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
|
||||
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
|
||||
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
|
||||
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
|
||||
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
|
||||
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -20,5 +20,14 @@
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
|
||||
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
|
||||
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
|
||||
developer portal and enabled in the provisioning profile for BOTH app ids
|
||||
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<StoredHost.ID> = []
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||
|
||||
@@ -23,6 +23,7 @@ import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
@MainActor
|
||||
public final class GamepadManager: ObservableObject {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import AVFoundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/<host-uuid> — connect to a stored host
|
||||
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> — 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 "/<uuid>"; strip the leading slash and any trailing one.
|
||||
let raw = comps.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let host = UUID(uuidString: raw) else { return nil }
|
||||
let launch = comps.queryItems?
|
||||
.first(where: { $0.name == "launch" })?.value
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self = .connect(host: host, launchID: launch)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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 ?? [] }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user