feat(apple/M1+M3+M4): widgets, Live Activity, and Siri/Shortcuts intents
The extension-side + App Intents surface for design/apple-live-activities-and- widgets.md. The iOS-framework code (WidgetKit/ActivityKit/AppIntents) can't be compiled by the macOS `swift build` CI target and needs the Xcode widget- extension target that only exists once created in the GUI — see the checklist in the memory note. What macOS DID verify: HostEntity (AppIntents is available on macOS), the shared attribute/notification plumbing, and that nothing regressed (142 tests green). Shared (PunktfunkShared): - PunktfunkSessionAttributes (ActivityAttributes) — the one type app + extension share; gated os(iOS) (ActivityKit imports on macOS but its types are unavailable, so canImport would wrongly admit it). - EndStreamIntent (LiveActivityIntent) — posts .punktfunkEndActiveSession. - HostEntity + HostEntityQuery (AppEntity over the shared store) — the intent / widget-config parameter type; canImport(AppIntents), so macOS type-checks it. - New notifications: end-active-session, open-deep-link. M1 widget extension sources (Sources/PunktfunkWidgets/, NOT a SwiftPM target — `swift build` ignores the dir): - PunktfunkWidgetBundle (@main): HostsWidget + PunktfunkSessionLiveActivity. - HostsWidget (kind "PunktfunkHosts"): reads the shared-suite store, sorts by recency, deep-links each host; small/medium/accessory families; empty state. - SessionLiveActivity: Lock-Screen banner + Dynamic Island (elapsed timer, mode line, background countdown, End button). M3 controller (app, iOS): SessionActivityController owns the Activity lifecycle (request/update/end + launch orphan-sweep + staleDate); ContentView drives it from the model's phase/isBackgrounded/backgroundDeadline (which SessionModel now publishes), keeping ActivityKit out of the cross-platform model. M4 (app, iOS): ConnectToHost/WakeHost intents + AppShortcutsProvider; Connect routes via .punktfunkOpenDeepLink into the same onOpenURL router (one set of guards); Wake reuses the WoL path; End surfaced to Shortcuts too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user