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))
+ }
+}