diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 21ba1f2..ace9a27 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -69,7 +69,9 @@ struct ContentView: View { SpeedTestSheet(host: host) } .sheet(item: $libraryTarget) { host in - NavigationStack { LibraryView(store: store, host: host) } + NavigationStack { + LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) + } } #endif } @@ -80,14 +82,16 @@ struct ContentView: View { store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, - connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired) + connect: { connect($0) }, connectDiscovered: connectDiscovered, + onPaired: handlePaired, onLaunchTitle: launchTitle) #else HomeView( store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, showSettings: $showSettings, - connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired) + connect: { connect($0) }, connectDiscovered: connectDiscovered, + onPaired: handlePaired, onLaunchTitle: launchTitle) #endif } @@ -171,7 +175,7 @@ struct ContentView: View { // MARK: - Connect - private func connect(_ host: StoredHost) { + private func connect(_ host: StoredHost, launchID: String? = nil) { // The gamepad-type setting resolves NOW (Automatic → match the active physical // controller): the host's virtual pad backend is fixed per session. model.connect( @@ -183,7 +187,15 @@ struct ContentView: View { gamepad: GamepadManager.shared.resolveType( setting: PunktfunkConnection.GamepadType( rawValue: UInt32(clamping: gamepadType)) ?? .auto), - bitrateKbps: UInt32(clamping: bitrateKbps)) + bitrateKbps: UInt32(clamping: bitrateKbps), + launchID: launchID) + } + + /// Picked a title in the (experimental) library: dismiss the browser and start a session that + /// asks the host to launch it. + private func launchTitle(_ host: StoredHost, _ id: String) { + libraryTarget = nil + connect(host, launchID: id) } /// Tap a discovered host: save it (so the session has a stored identity and the trust pin diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/HomeView.swift index 02d9970..df35537 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/HomeView.swift @@ -24,6 +24,8 @@ struct HomeView: View { let connectDiscovered: (DiscoveredHost) -> Void /// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness). let onPaired: (StoredHost, Data) -> Void + /// Picked a title in the (experimental) library — start a session that launches it. + let onLaunchTitle: (StoredHost, String) -> Void /// Experimental game-library browser (gated) — the host-card "Browse Library…" action. @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @@ -85,7 +87,7 @@ struct HomeView: View { SpeedTestSheet(host: host) } .navigationDestination(item: $libraryTarget) { host in - LibraryView(store: store, host: host) + LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) }) } #endif #if !os(tvOS) diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift index b21658c..eb6eb26 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -9,6 +9,9 @@ import SwiftUI struct LibraryView: View { @ObservedObject var store: HostStore let host: StoredHost + /// Tapping a title starts a session that asks the host to launch it (the library id is passed + /// through). `nil` ⇒ browse-only (cards aren't tappable). + var onLaunch: ((String) -> Void)? = nil @State private var games: [GameEntry] = [] @State private var loading = false @@ -59,7 +62,12 @@ struct LibraryView: View { ScrollView { LazyVGrid(columns: columns, spacing: 18) { ForEach(games) { game in - GameCard(game: game) + if let onLaunch { + Button { onLaunch(game.id) } label: { GameCard(game: game) } + .buttonStyle(.plain) + } else { + GameCard(game: game) + } } } .padding() diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index 9768859..f22b8ee 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -87,6 +87,7 @@ final class SessionModel: ObservableObject { compositor: PunktfunkConnection.Compositor = .auto, gamepad: PunktfunkConnection.GamepadType = .auto, bitrateKbps: UInt32 = 0, + launchID: String? = nil, autoTrust: Bool = false) { guard phase == .idle else { return } phase = .connecting @@ -103,7 +104,7 @@ final class SessionModel: ObservableObject { host: host.address, port: host.port, width: width, height: height, refreshHz: hz, pinSHA256: pin, identity: identity, compositor: compositor, - gamepad: gamepad, bitrateKbps: bitrateKbps) } + gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) } await MainActor.run { [weak self] in guard let self else { return } // The user may have abandoned this attempt (window closed, another host diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index ae7c13a..9f0bac9 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -412,9 +412,9 @@ struct SettingsView: View { Text("Experimental") } footer: { Text("Adds a “Browse Library…” action to each host that lists its games " - + "(Steam + custom) via the host's management API. The host must expose that " - + "API on the LAN with a token (serve --mgmt-bind 0.0.0.0 --mgmt-token …). " - + "Browsing only for now — launching a title comes later.") + + "(Steam + custom) via the host's management API; tap a title to launch it. " + + "The host must expose that API on the LAN with a token " + + "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).") .font(.caption) .foregroundStyle(.secondary) } diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index d22d6e3..eb7fc12 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -242,26 +242,31 @@ public final class PunktfunkConnection { compositor: Compositor = .auto, gamepad: GamepadType = .auto, bitrateKbps: UInt32 = 0, + launchID: String? = nil, timeoutMs: UInt32 = 10_000 ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } var observed = [UInt8](repeating: 0, count: 32) + // `launchID` (a host library id like "steam:570") asks the host to launch that title in + // the session; the host resolves it against its own library — nil = the host's default. handle = host.withCString { cs in withOptionalCString(identity?.certPEM) { cert in withOptionalCString(identity?.keyPEM) { key in - if let pin = pinSHA256 { - return pin.withUnsafeBytes { p in - punktfunk_connect_ex3( - cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, bitrateKbps, - p.bindMemory(to: UInt8.self).baseAddress, &observed, - cert, key, timeoutMs) + withOptionalCString(launchID) { launch in + if let pin = pinSHA256 { + return pin.withUnsafeBytes { p in + punktfunk_connect_ex4( + cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, bitrateKbps, launch, + p.bindMemory(to: UInt8.self).baseAddress, &observed, + cert, key, timeoutMs) + } } + return punktfunk_connect_ex4( + cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, bitrateKbps, launch, + nil, &observed, cert, key, timeoutMs) } - return punktfunk_connect_ex3( - cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, bitrateKbps, - nil, &observed, cert, key, timeoutMs) } } }