diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 7d3029fb..a672880d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -55,6 +55,11 @@ struct ContentView: View { /// 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? + #if os(iOS) + /// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from + /// the session model's published state below; iPhone/iPad only. + @State private var liveActivity = SessionActivityController() + #endif @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). @@ -125,6 +130,9 @@ struct ContentView: View { .onAppear { seedDefaultModeIfNeeded() autoConnectIfAsked() + #if os(iOS) + SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left + #endif } // 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 @@ -145,6 +153,38 @@ struct ContentView: View { break } } + // Live Activity lifecycle, driven from the model's published state. + .onChange(of: model.phase) { _, phase in + switch phase { + case .streaming: + if let host = model.activeHost { + liveActivity.begin( + hostID: host.id, hostName: host.displayName, + launchTitle: nil, // no live foreground-app title mid-session (v1) + modeLine: currentModeLine(), startedAt: Date()) + } + case .idle: + liveActivity.end() + default: + break + } + } + .onChange(of: model.isBackgrounded) { _, backgrounded in + liveActivity.update { + $0.stage = backgrounded ? .background : .streaming + $0.backgroundDeadline = model.backgroundDeadline + } + } + // The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts + // this — tear the session down deliberately (quit-close the host). + .onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in + model.disconnect(deliberate: true) + } + // Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler + // as a widget tap. + .onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in + if let url = note.object as? URL { handleDeepLink(url) } + } #endif .onChange(of: model.phase) { _, phase in switch phase { @@ -307,6 +347,25 @@ struct ContentView: View { } } + #if os(iOS) + /// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection. + private func currentModeLine() -> String { + guard let c = model.connection else { return "" } + let codec: String + switch c.videoCodec { + case .h264: codec = "H.264" + case .hevc: codec = "HEVC" + case .av1: codec = "AV1" + case .pyrowave: codec = "PyroWave" + } + var line = "\(c.width)×\(c.height)" + if c.refreshHz > 0 { line += " @\(c.refreshHz)" } + line += " · \(codec)" + if c.isHDR { line += " · HDR" } + return line + } + #endif + /// 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 diff --git a/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift new file mode 100644 index 00000000..47c4899c --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift @@ -0,0 +1,101 @@ +// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an +// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process +// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them. +// +// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent +// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the +// provider rather than being split across platforms. `HostEntity` (the parameter type) is in +// PunktfunkShared so the widget's configuration intent can share it. + +#if os(iOS) +import AppIntents +import Foundation +import PunktfunkKit + +/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only +/// carries id + name. +private func loadStoredHost(_ id: UUID) -> StoredHost? { + guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts), + let hosts = try? JSONDecoder().decode([StoredHost].self, from: data) + else { return nil } + return hosts.first { $0.id == id } +} + +/// Start a session with a stored host (optionally launching a title). Foregrounds the app and +/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval +/// sheet all apply, and its guards (unknown host, already-streaming) hold. +struct ConnectToHostIntent: AppIntent { + static let title: LocalizedStringResource = "Connect to Host" + static let description = IntentDescription("Start a Punktfunk streaming session with a host.") + static let openAppWhenRun = true + + @Parameter(title: "Host") var host: HostEntity + @Parameter(title: "Game ID", description: "Optional store id like steam:570") + var launchID: String? + + func perform() async throws -> some IntentResult { + let url = DeepLink.connect(host: host.id, launchID: launchID).url + await MainActor.run { + NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url) + } + return .result() + } +} + +/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get +/// home, wake the tower") without foregrounding the app. +struct WakeHostIntent: AppIntent { + static let title: LocalizedStringResource = "Wake Host" + static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.") + + @Parameter(title: "Host") var host: HostEntity + + func perform() async throws -> some IntentResult { + guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else { + throw IntentError.noWakeAddress + } + PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address) + return .result() + } +} + +/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message +/// show as the intent's failure text. +enum IntentError: Error, CustomLocalizedStringResourceConvertible { + case noWakeAddress + + var localizedStringResource: LocalizedStringResource { + switch self { + case .noWakeAddress: + return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk " + + "can learn it." + } + } +} + +/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays +/// well under the 10-shortcut cap. +struct PunktfunkShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ConnectToHostIntent(), + phrases: [ + "Connect to \(\.$host) in \(.applicationName)", + "Stream \(\.$host) with \(.applicationName)", + ], + shortTitle: "Connect", systemImageName: "play.tv.fill") + AppShortcut( + intent: WakeHostIntent(), + phrases: [ + "Wake \(\.$host) with \(.applicationName)", + ], + shortTitle: "Wake Host", systemImageName: "power") + AppShortcut( + intent: EndStreamIntent(), + phrases: [ + "End the \(.applicationName) stream", + ], + shortTitle: "End Stream", systemImageName: "stop.fill") + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionActivityController.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionActivityController.swift new file mode 100644 index 00000000..85c9cef4 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionActivityController.swift @@ -0,0 +1,87 @@ +// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven +// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so +// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`) — +// the app process is alive whenever there's a session to report, so there's no push token plumbing. +// +// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available +// guards are needed (Activity has existed since 16.1). + +#if os(iOS) +import ActivityKit +import Foundation +import PunktfunkShared + +@MainActor +final class SessionActivityController { + private var activity: Activity? + /// The last pushed state, so an update can mutate one field and keep the rest (notably + /// `startedAt`, which the Lock-Screen timer ticks from). + private var state: PunktfunkSessionAttributes.ContentState? + + /// How far past the next expected update to mark the content stale — a frozen opt-out session + /// then greys out instead of showing a lying clock. + private static let staleWindow: TimeInterval = 90 + + var isActive: Bool { activity != nil } + + /// End any Activity left over from a previous launch that was killed mid-session. Call once at + /// app start (ContentView.onAppear). + static func sweepOrphans() { + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + } + + /// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live + /// Activities for the app, or one is already up. + func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) { + guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return } + let attributes = PunktfunkSessionAttributes( + hostID: hostID, hostName: hostName, launchTitle: launchTitle) + let initial = PunktfunkSessionAttributes.ContentState( + stage: .streaming, startedAt: startedAt, modeLine: modeLine) + state = initial + do { + activity = try Activity.request( + attributes: attributes, + content: content(initial), + pushType: nil) + } catch { + activity = nil + state = nil + } + } + + /// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once. + /// No-op when there's no live Activity. + func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) { + guard let activity, var next = state else { return } + mutate(&next) + state = next + Task { await activity.update(content(next)) } + } + + /// End with a final "ended" state, dismissed a few seconds later. + func end() { + guard let activity, var final = state else { + self.activity = nil + state = nil + return + } + self.activity = nil + state = nil + final.stage = .ending + final.backgroundDeadline = nil + Task { + await activity.end(content(final), dismissalPolicy: .after(.now + 4)) + } + } + + private func content(_ s: PunktfunkSessionAttributes.ContentState) + -> ActivityContent { + ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow)) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 66c103e0..3181f2e6 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -152,6 +152,9 @@ final class SessionModel: ObservableObject { /// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3) /// and is cleared on foreground or teardown. iOS/iPadOS only in practice. @Published private(set) var isBackgrounded = false + /// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the + /// Live Activity countdown. Set alongside `backgroundTimer`. + @Published private(set) var backgroundDeadline: Date? /// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`. private var backgroundTimer: DispatchSourceTimer? @@ -353,6 +356,7 @@ final class SessionModel: ObservableObject { // Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast, // exactly like today's network-drop path. min 1 minute guards a nonsense setting. let minutes = max(1, timeoutMinutes) + backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60)) let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + .seconds(minutes * 60)) timer.setEventHandler { [weak self] in @@ -370,6 +374,7 @@ final class SessionModel: ObservableObject { func exitBackground() { guard isBackgrounded else { return } isBackgrounded = false + backgroundDeadline = nil backgroundTimer?.cancel() backgroundTimer = nil audio?.setMicMuted(false) @@ -400,6 +405,7 @@ final class SessionModel: ObservableObject { backgroundTimer?.cancel() backgroundTimer = nil isBackgrounded = false + backgroundDeadline = nil let audio = self.audio self.audio = nil // Gamepad capture is main-actor (releases held buttons on the wire while the diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 76de54cd..ae3a9d40 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -131,4 +131,15 @@ extension Notification.Name { /// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's /// discoverable menu-bar surface. public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture") + + /// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`, + /// which runs in the app's process): the app tears the active session down deliberately + /// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` — + /// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly. + public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session") + + /// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`: + /// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one + /// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it. + public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link") } diff --git a/clients/apple/Sources/PunktfunkShared/HostEntity.swift b/clients/apple/Sources/PunktfunkShared/HostEntity.swift new file mode 100644 index 00000000..747f9989 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/HostEntity.swift @@ -0,0 +1,56 @@ +// The saved-host as an App Intents entity — the parameter type for the Connect/Wake intents and +// the configurable single-host widget. Lives in the shared module (not the app) because widget +// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only. +// +// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)` +// (unlike ActivityKit, whose macOS types are unavailable) — it compiles on every platform and the +// entity query reads the same shared App-Group store the widget does. + +#if canImport(AppIntents) +import AppIntents +import Foundation + +public struct HostEntity: AppEntity, Identifiable { + public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host") + public static let defaultQuery = HostEntityQuery() + + public let id: UUID + public let name: String + + public init(id: UUID, name: String) { + self.id = id + self.name = name + } + + public init(_ host: StoredHost) { + self.id = host.id + self.name = host.displayName + } + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +public struct HostEntityQuery: EntityQuery { + public init() {} + + public func entities(for identifiers: [UUID]) async throws -> [HostEntity] { + Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init) + } + + /// Sorted most-recent first — Siri/Shortcuts and the widget config picker suggest recent hosts. + public func suggestedEntities() async throws -> [HostEntity] { + Self.loadHosts().map(HostEntity.init) + } + + static func loadHosts() -> [StoredHost] { + guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts), + let hosts = try? JSONDecoder().decode([StoredHost].self, from: data) + else { return [] } + return hosts.sorted { + ($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast) + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkShared/PunktfunkSessionAttributes.swift b/clients/apple/Sources/PunktfunkShared/PunktfunkSessionAttributes.swift new file mode 100644 index 00000000..30d1ad20 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/PunktfunkSessionAttributes.swift @@ -0,0 +1,68 @@ +// The Live Activity's attributes — the ONE type that must be identical in the app (which starts +// and updates the Activity) and the widget extension (which renders it). Hence it lives in the +// dependency-free shared module. +// +// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types +// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build. +// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)). +// +// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension +// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is. + +#if os(iOS) +import ActivityKit +import Foundation + +public struct PunktfunkSessionAttributes: ActivityAttributes { + // Static for the Activity's whole life (set at request time). + public let hostID: UUID + public let hostName: String + /// The title of the launched game, if the session started from the library; nil for a plain + /// host connect (nothing tracks the live foreground app mid-session). + public let launchTitle: String? + + public init(hostID: UUID, hostName: String, launchTitle: String?) { + self.hostID = hostID + self.hostName = hostName + self.launchTitle = launchTitle + } + + public struct ContentState: Codable, Hashable { + public enum Stage: String, Codable, Hashable { + case streaming // foreground, live + case background // backgrounded keep-alive (countdown running) + case reconnecting // post-loss re-anchor hold + case ending // torn down — final state before dismissal + } + + public var stage: Stage + /// Session start — drives `Text(timerInterval:)` for a free client-side ticking clock (no + /// per-second push needed). + public var startedAt: Date + /// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes. + public var modeLine: String + /// Coarse, updated sparsely (every ~30 s) — never the 1 Hz stats firehose. + public var latencyMs: Int? + public var mbps: Double? + /// While backgrounded: when the keep-alive auto-disconnect fires — drives the countdown. + public var backgroundDeadline: Date? + + public init( + stage: Stage, startedAt: Date, modeLine: String, + latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil + ) { + self.stage = stage + self.startedAt = startedAt + self.modeLine = modeLine + self.latencyMs = latencyMs + self.mbps = mbps + self.backgroundDeadline = backgroundDeadline + } + } +} + +/// Kind string for the Live Activity — kept next to the attributes so app + extension agree. +public enum PunktfunkActivity { + public static let kind = "PunktfunkSession" +} +#endif diff --git a/clients/apple/Sources/PunktfunkShared/SessionIntents.swift b/clients/apple/Sources/PunktfunkShared/SessionIntents.swift new file mode 100644 index 00000000..f18c1378 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/SessionIntents.swift @@ -0,0 +1,30 @@ +// App Intents that must compile into BOTH the app and the widget extension live here in the shared +// module. Today that's `EndStreamIntent` — the Live Activity's "End stream" button (a +// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts. +// +// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4 +// Connect/Wake intents that need the app's router live in the app target, not here. + +#if os(iOS) +import AppIntents +import Foundation + +/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts / +/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent) +/// — it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns +/// into `disconnect(deliberate: true)` (the user explicitly ended it → quit-close the host). +@available(iOS 17.0, *) +public struct EndStreamIntent: LiveActivityIntent { + public static let title: LocalizedStringResource = "End Punktfunk Stream" + public static let description = IntentDescription("Ends the active Punktfunk streaming session.") + + public init() {} + + public func perform() async throws -> some IntentResult { + await MainActor.run { + NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil) + } + return .result() + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkWidgets/HostsWidget.swift b/clients/apple/Sources/PunktfunkWidgets/HostsWidget.swift new file mode 100644 index 00000000..de02ea41 --- /dev/null +++ b/clients/apple/Sources/PunktfunkWidgets/HostsWidget.swift @@ -0,0 +1,186 @@ +// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host +// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a +// session via `punktfunk://connect/` — the app's onOpenURL routes it through the normal +// connect path (trust policy / WoL / approval all apply). +// +// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline +// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes +// (HostStore → WidgetCenter.reloadTimelines). + +import SwiftUI +import WidgetKit + +import PunktfunkShared + +// MARK: - Timeline + +struct HostsEntry: TimelineEntry { + let date: Date + let hosts: [StoredHost] +} + +struct HostsProvider: TimelineProvider { + func placeholder(in context: Context) -> HostsEntry { + HostsEntry(date: .now, hosts: []) + } + + func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) { + completion(HostsEntry(date: .now, hosts: Self.loadHosts())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // Single entry, never auto-refresh: the app reloads this timeline whenever the store + // changes (a new host, a fresh connect reordering by recency). + let entry = HostsEntry(date: .now, hosts: Self.loadHosts()) + completion(Timeline(entries: [entry], policy: .never)) + } + + /// Decode the shared-suite host JSON (same wire format the app writes), most-recent first. + static func loadHosts() -> [StoredHost] { + guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts), + let hosts = try? JSONDecoder().decode([StoredHost].self, from: data) + else { return [] } + return hosts.sorted { + ($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast) + } + } +} + +// MARK: - Widget + +struct HostsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in + HostsWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Punktfunk Hosts") + .description("Quick-launch your recent streaming hosts.") + .supportedFamilies([ + .systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, + ]) + } +} + +// MARK: - Views + +struct HostsWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: HostsEntry + + var body: some View { + switch family { + case .systemMedium: + MediumHostsView(hosts: entry.hosts) + case .accessoryCircular: + CircularHostView(host: entry.hosts.first) + case .accessoryRectangular: + RectangularHostView(host: entry.hosts.first) + default: // systemSmall + fallback + SmallHostView(host: entry.hosts.first) + } + } +} + +/// Deep link that connects to a stored host. +private func connectURL(_ host: StoredHost) -> URL { + DeepLink.connect(host: host.id, launchID: nil).url +} + +private struct SmallHostView: View { + let host: StoredHost? + var body: some View { + if let host { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: "play.tv.fill") + .font(.title2) + .foregroundStyle(.tint) + Spacer(minLength: 0) + Text(host.displayName) + .font(.headline) + .lineLimit(2) + if let last = host.lastConnected { + Text(last, format: .relative(presentation: .named)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(connectURL(host)) + } else { + EmptyHostView() + } + } +} + +private struct MediumHostsView: View { + let hosts: [StoredHost] + var body: some View { + if hosts.isEmpty { + EmptyHostView() + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Punktfunk") + .font(.caption).bold() + .foregroundStyle(.tint) + ForEach(hosts.prefix(4)) { host in + Link(destination: connectURL(host)) { + HStack { + Image(systemName: "play.tv.fill") + .foregroundStyle(.tint) + Text(host.displayName) + .font(.subheadline) + .lineLimit(1) + Spacer() + if let last = host.lastConnected { + Text(last, format: .relative(presentation: .named)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +private struct CircularHostView: View { + let host: StoredHost? + var body: some View { + ZStack { + AccessoryWidgetBackground() + Image(systemName: "play.tv.fill") + } + .widgetURL(host.map(connectURL)) + } +} + +private struct RectangularHostView: View { + let host: StoredHost? + var body: some View { + HStack { + Image(systemName: "play.tv.fill") + Text(host?.displayName ?? "Punktfunk") + .lineLimit(1) + } + .widgetURL(host.map(connectURL)) + } +} + +private struct EmptyHostView: View { + var body: some View { + VStack(spacing: 6) { + Image(systemName: "play.tv") + .font(.title2) + .foregroundStyle(.secondary) + Text("Open Punktfunk to add a host.") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/clients/apple/Sources/PunktfunkWidgets/PunktfunkWidgetBundle.swift b/clients/apple/Sources/PunktfunkWidgets/PunktfunkWidgetBundle.swift new file mode 100644 index 00000000..4ee4e8d6 --- /dev/null +++ b/clients/apple/Sources/PunktfunkWidgets/PunktfunkWidgetBundle.swift @@ -0,0 +1,20 @@ +// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets, +// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and +// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget +// process's ~30 MB budget). +// +// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets +// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension +// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and +// the GUI checklist. + +import SwiftUI +import WidgetKit + +@main +struct PunktfunkWidgetBundle: WidgetBundle { + var body: some Widget { + HostsWidget() + PunktfunkSessionLiveActivity() + } +} diff --git a/clients/apple/Sources/PunktfunkWidgets/SessionLiveActivity.swift b/clients/apple/Sources/PunktfunkWidgets/SessionLiveActivity.swift new file mode 100644 index 00000000..d2ea7ed5 --- /dev/null +++ b/clients/apple/Sources/PunktfunkWidgets/SessionLiveActivity.swift @@ -0,0 +1,136 @@ +// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns +// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in +// the widget-extension process from the shared `PunktfunkSessionAttributes`. +// +// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts +// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via +// Text(timerInterval:) — no per-second push. + +import ActivityKit +import SwiftUI +import WidgetKit + +import PunktfunkShared + +struct PunktfunkSessionLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in + LockScreenView(context: context) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label { + Text(context.attributes.hostName).font(.caption).lineLimit(1) + } icon: { + Image(systemName: "play.tv.fill") + } + .foregroundStyle(.tint) + } + DynamicIslandExpandedRegion(.trailing) { + Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false) + .font(.caption).monospacedDigit() + .frame(maxWidth: 56) + .foregroundStyle(.secondary) + } + DynamicIslandExpandedRegion(.center) { + if let title = context.attributes.launchTitle { + Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + VStack(spacing: 6) { + Text(context.state.modeLine) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + StageLine(state: context.state) + EndButton() + } + } + } compactLeading: { + Image(systemName: "play.tv.fill").foregroundStyle(.tint) + } compactTrailing: { + Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false) + .monospacedDigit() + .frame(maxWidth: 44) + } minimal: { + Image(systemName: "play.tv.fill").foregroundStyle(.tint) + } + } + } +} + +// MARK: - Lock Screen banner + +private struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "play.tv.fill") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(context.attributes.hostName).font(.headline).lineLimit(1) + Spacer() + Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false) + .font(.subheadline).monospacedDigit() + .foregroundStyle(.secondary) + } + if let title = context.attributes.launchTitle { + Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Text(context.state.modeLine) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + StageLine(state: context.state) + } + if context.state.stage == .background { + EndButton() + } + } + .padding() + } +} + +// MARK: - Shared pieces + +/// The stage badge + (while backgrounded) the auto-disconnect countdown. +private struct StageLine: View { + let state: PunktfunkSessionAttributes.ContentState + + var body: some View { + switch state.stage { + case .streaming: + EmptyView() + case .background: + if let deadline = state.backgroundDeadline { + Text("Keeps running for ") + .font(.caption2).foregroundStyle(.secondary) + + Text(timerInterval: Date()...deadline, countsDown: true) + .font(.caption2).monospacedDigit() + } else { + badge("Running in background", .orange) + } + case .reconnecting: + badge("Reconnecting…", .yellow) + case .ending: + badge("Session ended", .secondary) + } + } + + private func badge(_ text: String, _ color: Color) -> some View { + Text(text).font(.caption2).foregroundStyle(color) + } +} + +/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent). +private struct EndButton: View { + var body: some View { + Button(intent: EndStreamIntent()) { + Label("End", systemImage: "stop.fill") + .font(.caption).bold() + } + .tint(.red) + .buttonStyle(.bordered) + } +}