Files
punktfunk/clients/apple/Sources/PunktfunkWidgets/HostsWidget.swift
T
enricobuehler df6a5325d8 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>
2026-07-16 18:51:41 +02:00

187 lines
6.2 KiB
Swift

// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
// connect path (trust policy / WoL / approval all apply).
//
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
// (HostStore → WidgetCenter.reloadTimelines).
import SwiftUI
import WidgetKit
import PunktfunkShared
// MARK: - Timeline
struct HostsEntry: TimelineEntry {
let date: Date
let hosts: [StoredHost]
}
struct HostsProvider: TimelineProvider {
func placeholder(in context: Context) -> HostsEntry {
HostsEntry(date: .now, hosts: [])
}
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
// changes (a new host, a fresh connect reordering by recency).
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
completion(Timeline(entries: [entry], policy: .never))
}
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
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)
}
}
}
// MARK: - Widget
struct HostsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
HostsWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Punktfunk Hosts")
.description("Quick-launch your recent streaming hosts.")
.supportedFamilies([
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
])
}
}
// MARK: - Views
struct HostsWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: HostsEntry
var body: some View {
switch family {
case .systemMedium:
MediumHostsView(hosts: entry.hosts)
case .accessoryCircular:
CircularHostView(host: entry.hosts.first)
case .accessoryRectangular:
RectangularHostView(host: entry.hosts.first)
default: // systemSmall + fallback
SmallHostView(host: entry.hosts.first)
}
}
}
/// Deep link that connects to a stored host.
private func connectURL(_ host: StoredHost) -> URL {
DeepLink.connect(host: host.id, launchID: nil).url
}
private struct SmallHostView: View {
let host: StoredHost?
var body: some View {
if let host {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "play.tv.fill")
.font(.title2)
.foregroundStyle(.tint)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
.lineLimit(2)
if let last = host.lastConnected {
Text(last, format: .relative(presentation: .named))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(connectURL(host))
} else {
EmptyHostView()
}
}
}
private struct MediumHostsView: View {
let hosts: [StoredHost]
var body: some View {
if hosts.isEmpty {
EmptyHostView()
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Punktfunk")
.font(.caption).bold()
.foregroundStyle(.tint)
ForEach(hosts.prefix(4)) { host in
Link(destination: connectURL(host)) {
HStack {
Image(systemName: "play.tv.fill")
.foregroundStyle(.tint)
Text(host.displayName)
.font(.subheadline)
.lineLimit(1)
Spacer()
if let last = host.lastConnected {
Text(last, format: .relative(presentation: .named))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
private struct CircularHostView: View {
let host: StoredHost?
var body: some View {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "play.tv.fill")
}
.widgetURL(host.map(connectURL))
}
}
private struct RectangularHostView: View {
let host: StoredHost?
var body: some View {
HStack {
Image(systemName: "play.tv.fill")
Text(host?.displayName ?? "Punktfunk")
.lineLimit(1)
}
.widgetURL(host.map(connectURL))
}
}
private struct EmptyHostView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "play.tv")
.font(.title2)
.foregroundStyle(.secondary)
Text("Open Punktfunk to add a host.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}