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
@@ -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