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:
@@ -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<PunktfunkSessionAttributes>?
|
||||
/// 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<PunktfunkSessionAttributes>.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<PunktfunkSessionAttributes.ContentState> {
|
||||
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user