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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user