Apple: a Shortcut or a home-screen widget jumps straight into a host's game library #155
@@ -0,0 +1,202 @@
|
||||
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
|
||||
// a saved host in the widget's configuration (long-press → Edit Widget — the picker is
|
||||
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
|
||||
// deep-links into that host's game library via `punktfunk://browse/<uuid>` — the app's onOpenURL
|
||||
// routes it to the same library presentation every internal surface drives. No session starts
|
||||
// until a title is picked there.
|
||||
//
|
||||
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
|
||||
// leads with). A configured host that no longer exists shows the empty state rather than silently
|
||||
// following a different host — a widget that says "Studio" must never open someone else's library.
|
||||
//
|
||||
// Timeline is a single `.never` entry — the app pushes reloads on store changes (HostStore →
|
||||
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Configuration intent
|
||||
|
||||
/// The widget's per-instance configuration. Executes in the EXTENSION process — which is why
|
||||
/// `HostEntity` and its query live in PunktfunkShared, not the app.
|
||||
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Choose Host"
|
||||
static let description = IntentDescription("Pick whose game library this widget opens.")
|
||||
|
||||
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
|
||||
var host: HostEntity?
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct LibraryEntry: TimelineEntry {
|
||||
let date: Date
|
||||
/// The resolved target: the configured host if it still exists, the most recent one when
|
||||
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
|
||||
let host: StoredHost?
|
||||
}
|
||||
|
||||
struct LibraryProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> LibraryEntry {
|
||||
LibraryEntry(date: .now, host: nil)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||
-> LibraryEntry {
|
||||
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
|
||||
}
|
||||
|
||||
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||
-> Timeline<LibraryEntry> {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
|
||||
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
|
||||
policy: .never)
|
||||
}
|
||||
|
||||
/// The configured host by id — nil (NOT a fallback) when it's gone; most-recent when nothing
|
||||
/// was configured.
|
||||
static func resolve(_ configured: HostEntity?) -> StoredHost? {
|
||||
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
|
||||
guard let configured else { return hosts.first }
|
||||
return hosts.first { $0.id == configured.id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct LibraryWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
|
||||
provider: LibraryProvider()
|
||||
) { entry in
|
||||
LibraryWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Game Library")
|
||||
.description("Jump straight into a host's game library.")
|
||||
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
/// Deep link that opens a stored host's library.
|
||||
private func browseURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.browse(host: host.id).url
|
||||
}
|
||||
|
||||
struct LibraryWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: LibraryEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
CircularLibraryView(host: entry.host)
|
||||
case .accessoryRectangular:
|
||||
RectangularLibraryView(host: entry.host)
|
||||
default: // systemSmall + fallback
|
||||
SmallLibraryView(host: entry.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SmallLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.brand)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
Text("Game Library")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(browseURL(host))
|
||||
} else {
|
||||
EmptyLibraryView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
}
|
||||
.widgetURL(host.map(browseURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
VStack(alignment: .leading) {
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
Text("Library")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.widgetURL(host.map(browseURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyLibraryView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to pick a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews (Xcode canvas)
|
||||
//
|
||||
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
|
||||
// so the canvas works without a paired device or saved hosts. The small preview's second entry
|
||||
// shows the empty state one timeline click away.
|
||||
|
||||
private let previewHost = StoredHost(
|
||||
name: "Studio", address: "192.168.1.20",
|
||||
lastConnected: .now.addingTimeInterval(-40 * 60))
|
||||
|
||||
#Preview("Small", as: .systemSmall) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
LibraryEntry(date: .now, host: nil)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import WidgetKit
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
LibraryWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +487,20 @@ struct ContentView: View {
|
||||
?? "That link is malformed and was ignored."
|
||||
return
|
||||
}
|
||||
guard link.route == .connect else {
|
||||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
||||
// neither, and saying so beats silently connecting instead.
|
||||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
||||
switch link.route {
|
||||
case .connect:
|
||||
break
|
||||
case .browse:
|
||||
// The reserved library route, now real: open the host's game library without starting
|
||||
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
|
||||
// title is picked, and that connect resolves its own profile) — ignored, not refused,
|
||||
// per the unknown-parameter rule.
|
||||
openLibrary(from: link)
|
||||
return
|
||||
case .wake:
|
||||
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
|
||||
// the Wake Host intent, which never round-trips through a URL.)
|
||||
deepLinkNotice = "Punktfunk links can't do “wake” yet."
|
||||
return
|
||||
}
|
||||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||||
@@ -544,6 +554,38 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// `punktfunk://browse/<host-ref>` — jump into a host's game library. Drives the SAME
|
||||
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
|
||||
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
|
||||
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
|
||||
/// refuses, a live session is never preempted, and an unsaved host can't be browsed — the
|
||||
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
|
||||
/// is saved (the notice says what to do instead).
|
||||
private func openLibrary(from link: DeepLink) {
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
return
|
||||
}
|
||||
guard model.phase == .idle else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
libraryTarget = host
|
||||
case .unknown(let address, _, let name, _):
|
||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||
+ "Add it with the + button first — a library can only be browsed on a saved host."
|
||||
case .ambiguous:
|
||||
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
|
||||
+ "Rename one, or link to it by its address."
|
||||
case .unresolvable:
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
}
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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 / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
|
||||
// library), 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
|
||||
@@ -51,6 +52,28 @@ struct ConnectToHostIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump straight into a host's game library — no session. Foregrounds the app and routes the
|
||||
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
|
||||
/// `libraryTarget` every surface shares — so the shortcut lands in whichever library presentation
|
||||
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
|
||||
/// touch/desktop library otherwise. A session starts only when a title is picked there.
|
||||
struct OpenLibraryIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Game Library"
|
||||
static let description = IntentDescription(
|
||||
"Open a host's game library in Punktfunk, without starting a stream.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.browse(host: host.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 {
|
||||
@@ -97,6 +120,13 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: OpenLibraryIntent(),
|
||||
phrases: [
|
||||
"Open \(\.$host) library in \(.applicationName)",
|
||||
"Show \(\.$host) games in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
|
||||
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
||||
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
|
||||
}
|
||||
|
||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
||||
/// `.never`-refresh entries and relies on this push.
|
||||
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
|
||||
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
|
||||
/// `.never`-refresh entries and rely on this push.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,13 @@ public struct DeepLink: Equatable, Sendable {
|
||||
profile: (profile?.isEmpty ?? true) ? nil : profile)
|
||||
}
|
||||
|
||||
/// A library link for a saved host — the shape the library widget and the Open Library intent
|
||||
/// emit. Opens the host's game library without starting a session; a session begins only when
|
||||
/// the user picks a title there, through the normal connect path.
|
||||
public static func browse(host: UUID) -> DeepLink {
|
||||
DeepLink(route: .browse, hostRef: host.uuidString)
|
||||
}
|
||||
|
||||
/// The self-emitted form for a saved host: id first (address-independent), with the address
|
||||
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
|
||||
/// the record is gone ("Copy link", and any shortcut written from a card).
|
||||
|
||||
@@ -178,6 +178,18 @@ final class SharedFoundationTests: XCTestCase {
|
||||
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
|
||||
}
|
||||
|
||||
/// The library widget's and the Open Library intent's emitter — the reserved `browse` route
|
||||
/// with a bare UUID path. Same backward-compatibility stakes as connect: a Home-Screen widget
|
||||
/// keeps sending yesterday's URL.
|
||||
func testDeepLinkBrowseRoundTrips() throws {
|
||||
let id = UUID(uuidString: "11111111-2222-4333-8444-555555555555")!
|
||||
let link = DeepLink.browse(host: id)
|
||||
XCTAssertEqual(link.route, .browse)
|
||||
XCTAssertEqual(
|
||||
link.urlString, "punktfunk://browse/11111111-2222-4333-8444-555555555555")
|
||||
XCTAssertEqual(try DeepLink(url: link.url), link)
|
||||
}
|
||||
|
||||
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
|
||||
/// both a re-addressed host and a wiped store.
|
||||
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
|
||||
|
||||
@@ -28,8 +28,10 @@ protocol — the lowest-latency, most resilient path, with the full feature set:
|
||||
launch one straight into the stream.
|
||||
- A live **stats overlay** (resolution, fps, bitrate, latency) and a built-in **network speed test**
|
||||
to pick a bitrate for your link.
|
||||
- **Widgets, Live Activities and Shortcuts** — a hosts widget for the home screen, a Live Activity
|
||||
while a session runs, and App Intents so Siri and the Shortcuts app can start a stream.
|
||||
- **Widgets, Live Activities and Shortcuts** — a hosts widget and a game-library widget for the
|
||||
home screen (the library one opens a host you pick straight into its library), a Live Activity
|
||||
while a session runs, and App Intents so Siri and the Shortcuts app can start a stream or jump
|
||||
into a host's game library.
|
||||
|
||||
Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds from the
|
||||
`clients/apple` directory in the repo (Swift / VideoToolbox / Metal).
|
||||
|
||||
@@ -142,7 +142,10 @@ and runs what it already knows about the title, so a client can never hand the h
|
||||
See [Moonlight](/docs/moonlight).
|
||||
- **A link** — a [`punktfunk://` link](/docs/profiles-and-links) carries the id in a `launch=`
|
||||
parameter, so a desktop shortcut, a browser bookmark or a home-automation rule starts the stream
|
||||
with the title already launching: `punktfunk://connect/couch-pc?launch=steam:570`.
|
||||
with the title already launching: `punktfunk://connect/couch-pc?launch=steam:570`. On the Apple
|
||||
apps, `punktfunk://browse/couch-pc` opens the library itself instead — that route backs their
|
||||
home-screen library widget and the **Open Game Library** shortcut, so a tap lands you in a
|
||||
picked host's library with nothing streaming yet.
|
||||
- **The command line** — the client's own [`punktfunk`](/docs/host-cli#punktfunk-on-the-client-machine)
|
||||
command, which ships with the Linux and Windows clients. `punktfunk library <host-ref>` prints `id`,
|
||||
`store` and `title` as tab-separated lines, then a count (`--json` for tools);
|
||||
|
||||
@@ -124,8 +124,10 @@ dropped, unknown parameters are ignored, an empty value means "not given", and i
|
||||
appears twice the first one wins. `pf://` parses as an input alias, but nothing emits it and no app
|
||||
registers it with the operating system, so write `punktfunk://`.
|
||||
|
||||
`connect` is the only route any client acts on today; `wake` and `browse` parse, but every client
|
||||
answers them with a notice. Values are capped (2048 for the whole URL, 128 for the host reference
|
||||
`connect` works everywhere. `browse` — the same grammar with no parameters acted on — opens the
|
||||
host's game library instead of streaming, on the Apple apps today (it backs their library widget
|
||||
and the Open Game Library shortcut); every other client answers it with a notice, as they all do
|
||||
for `wake`. Values are capped (2048 for the whole URL, 128 for the host reference
|
||||
and `launch`, 64 for `profile` and `name`), and `launch` must be printable ASCII with no spaces,
|
||||
quotes, backslashes, `$` or backticks.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user