feat(apple/library): experimental game-library browser (flagged off)
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
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 4s
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 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
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 4s
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 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s
Plan step 3 — the Apple client surfaces the host's game library, behind a feature flag (`DefaultsKey.libraryEnabled`, default OFF). Browsing only; launching a chosen title is step 4. - PunktfunkKit `LibraryClient`: Codable GameEntry/Artwork/LaunchSpec mirroring crates/punktfunk-host/src/library.rs, and an async fetch of GET /api/v1/library with a bearer token. Typed LibraryError guides setup (the common case is "needs a --mgmt-token"). `Artwork.posterCandidates` = portrait → header → hero. - `LibraryView`: cross-platform poster grid (LazyVGrid, AsyncImage that walks the art candidates past load failures to a text placeholder), a store badge, and an inline Connection form (mgmt port + token) that surfaces when the API is unreachable / 401 / no token set. Read-only. - StoredHost gains `mgmtPort`/`mgmtToken` (the mgmt API is a distinct port from the data plane and needs a token off-loopback). Both OPTIONAL — synthesized Decodable ignores property defaults but treats a missing Optional as nil, so older saved hosts decode unchanged (a defaulted non-optional would wipe the list). HostStore.setMgmt. - Entry point: a flag-gated "Browse Library…" host-card context action → LibraryView (sheet on macOS/iOS, pushed on tvOS), mirroring the pair/speed-test plumbing. Plus a Settings "Experimental" toggle. Can't compile Swift on the Linux dev box; CI (apple.yml: swift build + swift test on the mac mini) verifies the macOS path. Added LibraryClientTests (decode + art order) for `swift test`. iOS/tvOS-only branches mirror existing patterns. Live-verify on the Mac pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Game library client (experimental, plan step 3). Fetches the host's unified game library
|
||||
// from the management REST API (`GET /api/v1/library`) — the same payload the web console's
|
||||
// /library page renders. Read-only on the client for now; launching a chosen title is a later
|
||||
// step. Gated behind `DefaultsKey.libraryEnabled` in the UI.
|
||||
//
|
||||
// The management API is HTTP on a port distinct from the punktfunk/1 data plane (default 47990),
|
||||
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback
|
||||
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with
|
||||
// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||
public struct Artwork: Codable, Hashable, Sendable {
|
||||
public var portrait: String?
|
||||
public var hero: String?
|
||||
public var logo: String?
|
||||
public var header: String?
|
||||
|
||||
/// Preferred order for a poster grid: the 600×900 capsule, falling back to the header
|
||||
/// (which is near-universal — many older titles lack a portrait capsule).
|
||||
public var posterCandidates: [URL] {
|
||||
[portrait, header, hero].compactMap { $0 }.compactMap { URL(string: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the host would launch a title (carried for a later step; the client only displays it).
|
||||
public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
public var kind: String // "steam_appid" | "command"
|
||||
public var value: String
|
||||
}
|
||||
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "needs a token").
|
||||
public enum LibraryError: LocalizedError {
|
||||
case unauthorized
|
||||
case http(Int)
|
||||
case unreachable(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized:
|
||||
return "The host's management API rejected the token. Start the host with "
|
||||
+ "--mgmt-token and enter the same token here."
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the management API: \(why). The host must expose it on the "
|
||||
+ "LAN (serve --mgmt-bind 0.0.0.0 --mgmt-token …)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The management API's default port — adjacent to the GameStream block; matches
|
||||
/// `mgmt::DEFAULT_PORT` on the host.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
/// Stateless fetcher for a host's library.
|
||||
public enum LibraryClient {
|
||||
/// `GET http://<address>:<port>/api/v1/library` with an optional bearer token.
|
||||
public static func fetch(
|
||||
address: String, port: UInt16 = punktfunkDefaultMgmtPort, token: String? = nil
|
||||
) async throws -> [GameEntry] {
|
||||
guard let url = URL(string: "http://\(address):\(port)/api/v1/library") else {
|
||||
throw LibraryError.unreachable("invalid host address")
|
||||
}
|
||||
var req = URLRequest(url: url, timeoutInterval: 10)
|
||||
if let token, !token.isEmpty {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: req)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LibraryError.unreachable("not an HTTP response")
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
return try JSONDecoder().decode([GameEntry].self, from: data)
|
||||
case 401:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
throw LibraryError.http(http.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user