Files
punktfunk/clients/apple/Sources/PunktfunkShared/StoredHost.swift
T
enricobuehler 4cae1b8bb8 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>
2026-07-16 18:16:39 +02:00

58 lines
3.1 KiB
Swift

// 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 ?? [] }
}