feat(apple/library): launch a picked title (step 4 client side)
apple / swift (push) Successful in 1m17s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 30s
ci / rust (push) Successful in 2m2s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m13s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m17s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 30s
ci / rust (push) Successful in 2m2s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m13s
docker / deploy-docs (push) Successful in 17s
Tapping a game in the (flagged) library now starts a session that asks the host to launch it — the picked GameEntry id rides the connect down to the host, which resolves it against its own library (27e5865). - PunktfunkConnection.init gains `launchID` and calls the new punktfunk_connect_ex4 (wrapping it in withOptionalCString; nil = host default). - Threaded SessionModel.connect(launchID:) → ContentView.connect(_:launchID:) → a `launchTitle(host, id)` helper that dismisses the browser and connects. - LibraryView gains `onLaunch`; cards become buttons that fire it. Wired on every platform (ContentView sheet on macOS/iOS, HomeView destination on tvOS) via a new `onLaunchTitle` closure on HomeView. Settings footer updated (launch is live now). Can't compile Swift on the Linux box; CI (apple.yml) verifies. The host side of this chain is live-validated on the dev box: a client `--launch custom:<id>` made the host resolve the id and spawn gamescope running the title (see27e5865). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,9 @@ struct ContentView: View {
|
|||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
.sheet(item: $libraryTarget) { host in
|
.sheet(item: $libraryTarget) { host in
|
||||||
NavigationStack { LibraryView(store: store, host: host) }
|
NavigationStack {
|
||||||
|
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -80,14 +82,16 @@ struct ContentView: View {
|
|||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
|
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||||
#else
|
#else
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
showSettings: $showSettings,
|
showSettings: $showSettings,
|
||||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
|
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +175,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Connect
|
// 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
|
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
||||||
// controller): the host's virtual pad backend is fixed per session.
|
// controller): the host's virtual pad backend is fixed per session.
|
||||||
model.connect(
|
model.connect(
|
||||||
@@ -183,7 +187,15 @@ struct ContentView: View {
|
|||||||
gamepad: GamepadManager.shared.resolveType(
|
gamepad: GamepadManager.shared.resolveType(
|
||||||
setting: PunktfunkConnection.GamepadType(
|
setting: PunktfunkConnection.GamepadType(
|
||||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
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
|
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ struct HomeView: View {
|
|||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||||
let onPaired: (StoredHost, Data) -> Void
|
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.
|
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ struct HomeView: View {
|
|||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $libraryTarget) { host in
|
.navigationDestination(item: $libraryTarget) { host in
|
||||||
LibraryView(store: store, host: host)
|
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import SwiftUI
|
|||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@ObservedObject var store: HostStore
|
@ObservedObject var store: HostStore
|
||||||
let host: StoredHost
|
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 games: [GameEntry] = []
|
||||||
@State private var loading = false
|
@State private var loading = false
|
||||||
@@ -59,7 +62,12 @@ struct LibraryView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 18) {
|
LazyVGrid(columns: columns, spacing: 18) {
|
||||||
ForEach(games) { game in
|
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()
|
.padding()
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ final class SessionModel: ObservableObject {
|
|||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
launchID: String? = nil,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
@@ -103,7 +104,7 @@ final class SessionModel: ObservableObject {
|
|||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps) }
|
gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
|
|||||||
@@ -412,9 +412,9 @@ struct SettingsView: View {
|
|||||||
Text("Experimental")
|
Text("Experimental")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
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 "
|
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||||
+ "API on the LAN with a token (serve --mgmt-bind 0.0.0.0 --mgmt-token …). "
|
+ "The host must expose that API on the LAN with a token "
|
||||||
+ "Browsing only for now — launching a title comes later.")
|
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,26 +242,31 @@ public final class PunktfunkConnection {
|
|||||||
compositor: Compositor = .auto,
|
compositor: Compositor = .auto,
|
||||||
gamepad: GamepadType = .auto,
|
gamepad: GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
launchID: String? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
var observed = [UInt8](repeating: 0, count: 32)
|
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
|
handle = host.withCString { cs in
|
||||||
withOptionalCString(identity?.certPEM) { cert in
|
withOptionalCString(identity?.certPEM) { cert in
|
||||||
withOptionalCString(identity?.keyPEM) { key in
|
withOptionalCString(identity?.keyPEM) { key in
|
||||||
if let pin = pinSHA256 {
|
withOptionalCString(launchID) { launch in
|
||||||
return pin.withUnsafeBytes { p in
|
if let pin = pinSHA256 {
|
||||||
punktfunk_connect_ex3(
|
return pin.withUnsafeBytes { p in
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
punktfunk_connect_ex4(
|
||||||
gamepad.rawValue, bitrateKbps,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
gamepad.rawValue, bitrateKbps, launch,
|
||||||
cert, key, timeoutMs)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user