ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m36s
The reserved punktfunk://browse/<host-ref> route now routes on Apple: it
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: a pin conflict
refuses, a live session is never preempted, an unsaved host gets a
notice (the library rides the paired mTLS identity, so there is nothing
to browse before the host is saved). browse ignores launch=/profile= —
nothing streams until a title is picked, and that connect resolves its
own profile.
On top of the route, the two new front doors:
- OpenLibraryIntent ("Open Game Library") beside Connect/Wake/End in
Shortcuts/Siri/Spotlight, host-parameterized like the others and
round-tripping through the URL — one router, no second path.
- A configurable library widget (kind "PunktfunkLibrary",
AppIntentConfiguration over HostEntity — the configuration the
HostEntity doc comment anticipated): pick a host, tap into its
library. Unconfigured it follows the most recent host; a configured
host that was removed shows the empty state rather than silently
following a different host. Same .never timeline + HostStore push as
the hosts widget, now reloading both kinds.
DeepLink.browse(host:) is the one emitter both doors share, covered by
a round-trip test beside connect's; the parse side was already in the
grammar and the vector file. Docs updated (clients, game-library,
profiles-and-links).
203 lines
6.9 KiB
Swift
203 lines
6.9 KiB
Swift
// 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)
|
|
}
|