diff --git a/clients/apple/PunktfunkWidgets/LibraryWidget.swift b/clients/apple/PunktfunkWidgets/LibraryWidget.swift new file mode 100644 index 00000000..066746fa --- /dev/null +++ b/clients/apple/PunktfunkWidgets/LibraryWidget.swift @@ -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/` — 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 { + // 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) +} diff --git a/clients/apple/PunktfunkWidgets/PunktfunkWidgetBundle.swift b/clients/apple/PunktfunkWidgets/PunktfunkWidgetBundle.swift index 4ee4e8d6..dff6ee22 100644 --- a/clients/apple/PunktfunkWidgets/PunktfunkWidgetBundle.swift +++ b/clients/apple/PunktfunkWidgets/PunktfunkWidgetBundle.swift @@ -15,6 +15,7 @@ import WidgetKit struct PunktfunkWidgetBundle: WidgetBundle { var body: some Widget { HostsWidget() + LibraryWidget() PunktfunkSessionLiveActivity() } } diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 5966760f..f07fc0ae 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -668,10 +668,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 @@ -725,6 +735,38 @@ struct ContentView: View { } } + /// `punktfunk://browse/` — 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 diff --git a/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift index 7c23c643..c0ea204f 100644 --- a/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift +++ b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift @@ -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: [ diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 2c47eeaa..2c5f754e 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -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 } } diff --git a/clients/apple/Sources/PunktfunkShared/DeepLink.swift b/clients/apple/Sources/PunktfunkShared/DeepLink.swift index 99fc1744..0037f9df 100644 --- a/clients/apple/Sources/PunktfunkShared/DeepLink.swift +++ b/clients/apple/Sources/PunktfunkShared/DeepLink.swift @@ -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). diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index 8926b995..3f79ec54 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -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 { diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index 7bc527a3..631a462f 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -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). diff --git a/docs-site/content/docs/game-library.md b/docs-site/content/docs/game-library.md index 631880a2..f10d7dd1 100644 --- a/docs-site/content/docs/game-library.md +++ b/docs-site/content/docs/game-library.md @@ -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 ` prints `id`, `store` and `title` as tab-separated lines, then a count (`--json` for tools); diff --git a/docs-site/content/docs/profiles-and-links.md b/docs-site/content/docs/profiles-and-links.md index 8beac305..b6203d46 100644 --- a/docs-site/content/docs/profiles-and-links.md +++ b/docs-site/content/docs/profiles-and-links.md @@ -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.