The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as its last item for the users who do want to rebind from there — plus "Pin as Card ▸" and "Copy Link". A pinned host+profile combo becomes its own card next to its host: same record, same live status, the profile as the prominent subtitle, one click to connect. It is an extra grid ENTRY, never a duplicated host record — duplicating would fork pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions; edit, pair, forget and remove stay on the primary card, where the thing they act on lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole point: focus-and-press is what those surfaces do well, and menus are not. The edit sheet binds and pins; both rows vanish when no profiles exist, so a user who never makes one sees exactly today's sheet. A dangling binding renders as "Default settings (profile deleted)" and is cleaned up on save. The speed test finally writes where the tested host reads. Unbound: the global, as before. Bound to a profile that overrides bitrate: that override. Bound to one that inherits it: both are offered, because either is defensible and guessing would silently pick one. Every button names its target, and the probe now runs at the mode that host would actually stream — the measurement is the streaming path. Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter on Connect, still round-tripping through the URL router rather than opening a second connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link observer moved out with them: an intent that posts to nobody is a shortcut that silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
5.1 KiB
Swift
116 lines
5.1 KiB
Swift
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
|
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
|
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
|
|
// the existing Wake-on-LAN path — so these intents only wrap them.
|
|
//
|
|
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
|
|
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
|
|
// ask. Only the AppShortcutsProvider stays iOS-gated, because it bundles `EndStreamIntent` — a
|
|
// LiveActivityIntent, and ActivityKit is iPhone/iPad only.
|
|
//
|
|
// `HostEntity` and `ProfileEntity` (the parameter types) live in PunktfunkShared so a widget's
|
|
// configuration intent, which executes in the EXTENSION process, can share them.
|
|
|
|
#if canImport(AppIntents)
|
|
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, optionally with a settings
|
|
/// profile). 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, ambiguous or
|
|
/// unknown profile, already-streaming) hold. One router, not a second connect path.
|
|
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?
|
|
@Parameter(
|
|
title: "Profile",
|
|
description: "Which settings profile to use — leave empty for the host's default")
|
|
var profile: ProfileEntity?
|
|
|
|
func perform() async throws -> some IntentResult {
|
|
let url = DeepLink.connect(host: host.id, launchID: launchID, profile: profile?.id).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:
|
|
// One string LITERAL — LocalizedStringResource is ExpressibleByStringLiteral, but a
|
|
// `"…" + "…"` concatenation is a runtime String it can't convert.
|
|
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
|
/// well under the 10-shortcut cap. Phrases stay host-parameterized — App Shortcut phrases can't
|
|
/// embed a second arbitrary `AppEntity`, so the profile is picked in the Shortcuts editor. No
|
|
/// phrase regression.
|
|
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
|
|
#endif
|