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:
2026-07-16 18:51:41 +02:00
parent 14c5e7c11c
commit df6a5325d8
11 changed files with 760 additions and 0 deletions
@@ -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