Files
punktfunk/clients/apple/Sources/PunktfunkKit/LibraryClient.swift
T
enricobuehler ba39b08e09
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s
feat(web): consolidate paired devices, self-contained sections, docs + lint
Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:05:22 +02:00

119 lines
5.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 serves HTTPS on a port distinct from the punktfunk/1 data plane (default
// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
// read-only library route by its **mTLS certificate** no bearer token. The host binds this read
// surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired
// client browses a host's library with no operator step. 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 "not paired yet").
public enum LibraryError: LocalizedError {
case unauthorized
case http(Int)
case unreachable(String)
public var errorDescription: String? {
switch self {
case .unauthorized:
return "The host didn't recognize this device. Pair with the host first — it "
+ "authorizes paired clients by their certificate (no token needed)."
case .http(let code):
return "The management API returned HTTP \(code)."
case .unreachable(let why):
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
+ "so check the host is updated and reachable (a host pinned to "
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
}
}
}
/// 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 https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
/// presents `identity` (its persistent cert/key PEM the same identity the host paired over
/// QUIC), and the host's self-signed cert is pinned by `hostFingerprint` (SHA-256 of its DER,
/// the value the client already trusts). No bearer token a paired client is authorized by
/// its certificate. `hostFingerprint == nil` TOFU (accept the presented host cert).
public static func fetch(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) async throws -> [GameEntry] {
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
throw LibraryError.unreachable("invalid host address")
}
let identity: SecIdentity
do {
identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
} catch {
throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let req = URLRequest(url: url, timeoutInterval: 10)
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.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)
}
}
}