df6a5325d8
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>
102 lines
4.2 KiB
Swift
102 lines
4.2 KiB
Swift
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
|
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
|
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
|
//
|
|
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
|
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
|
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
|
// PunktfunkShared so the widget's configuration intent can share it.
|
|
|
|
#if os(iOS)
|
|
import AppIntents
|
|
import Foundation
|
|
import PunktfunkKit
|
|
|
|
/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only
|
|
/// carries id + name.
|
|
private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
|
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
|
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
|
else { return nil }
|
|
return hosts.first { $0.id == id }
|
|
}
|
|
|
|
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
|
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
|
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
|
struct ConnectToHostIntent: AppIntent {
|
|
static let title: LocalizedStringResource = "Connect to Host"
|
|
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
|
static let openAppWhenRun = true
|
|
|
|
@Parameter(title: "Host") var host: HostEntity
|
|
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
|
var launchID: String?
|
|
|
|
func perform() async throws -> some IntentResult {
|
|
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
|
await MainActor.run {
|
|
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
|
}
|
|
return .result()
|
|
}
|
|
}
|
|
|
|
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
|
/// home, wake the tower") without foregrounding the app.
|
|
struct WakeHostIntent: AppIntent {
|
|
static let title: LocalizedStringResource = "Wake Host"
|
|
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
|
|
|
|
@Parameter(title: "Host") var host: HostEntity
|
|
|
|
func perform() async throws -> some IntentResult {
|
|
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
|
|
throw IntentError.noWakeAddress
|
|
}
|
|
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
|
|
return .result()
|
|
}
|
|
}
|
|
|
|
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
|
|
/// show as the intent's failure text.
|
|
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
|
case noWakeAddress
|
|
|
|
var localizedStringResource: LocalizedStringResource {
|
|
switch self {
|
|
case .noWakeAddress:
|
|
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk "
|
|
+ "can learn it."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
|
/// well under the 10-shortcut cap.
|
|
struct PunktfunkShortcuts: AppShortcutsProvider {
|
|
static var appShortcuts: [AppShortcut] {
|
|
AppShortcut(
|
|
intent: ConnectToHostIntent(),
|
|
phrases: [
|
|
"Connect to \(\.$host) in \(.applicationName)",
|
|
"Stream \(\.$host) with \(.applicationName)",
|
|
],
|
|
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
|
AppShortcut(
|
|
intent: WakeHostIntent(),
|
|
phrases: [
|
|
"Wake \(\.$host) with \(.applicationName)",
|
|
],
|
|
shortTitle: "Wake Host", systemImageName: "power")
|
|
AppShortcut(
|
|
intent: EndStreamIntent(),
|
|
phrases: [
|
|
"End the \(.applicationName) stream",
|
|
],
|
|
shortTitle: "End Stream", systemImageName: "stop.fill")
|
|
}
|
|
}
|
|
#endif
|