diff --git a/api/openapi.json b/api/openapi.json
index 080d4cc3..ee503874 100644
--- a/api/openapi.json
+++ b/api/openapi.json
@@ -4242,7 +4242,7 @@
},
"state": {
"type": "string",
- "description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
+ "description": "`launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title\nexposes nothing the host can recognize its process by, so its exit will never be noticed),\nor `grace` (its session is gone and it will be ended when the reconnect window closes).",
"example": "running"
},
"store": {
@@ -6194,6 +6194,14 @@
}
}
},
+ "GameOnNewLaunch": {
+ "type": "string",
+ "description": "What to do with a title this client already has running when it launches a **different** one.\n\nThe third axis rather than a fourth value on [`GameOnSessionEnd`], because it answers a different\nquestion at a different moment: that one is \"this session is over, what about its game\", this one\nis \"the player asked for something else, what about the last thing\". Folding them together would\ntie two unrelated choices to one switch — an operator who wants a game to survive a disconnect\nvery plausibly still wants it closed when they pick another title.\n\nScoped to the **same client's own launches**, and only ever to launches this host performed\nitself ([`crate::launchreg`]). A game the player started at the machine was never recorded there,\nso it can never be closed by this; nor can another client's game, which would otherwise let one\ndevice end someone else's session mid-play.",
+ "enum": [
+ "keep",
+ "end"
+ ]
+ },
"GameOnSessionEnd": {
"type": "string",
"description": "What to do with the launched game when its session ends.",
@@ -8095,6 +8103,10 @@
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
"minimum": 0
},
+ "game_on_new_launch": {
+ "$ref": "#/components/schemas/GameOnNewLaunch",
+ "description": "End this client's previous game when it launches a different one. See [`GameOnNewLaunch`]."
+ },
"game_on_session_end": {
"$ref": "#/components/schemas/GameOnSessionEnd",
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
index 3ed8d658..a31a8175 100644
--- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
+++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
@@ -126,7 +126,13 @@ struct HostCardView: View {
let onSpeedTest: () -> Void
let onForget: () -> Void
let onRemove: () -> Void
- /// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
+ /// Open this host's game library. `nil` — no library affordance at all — when the setting is
+ /// off or the host is unpaired (the library plane needs the pinned identity).
+ ///
+ /// When present this is the card's **primary** action: tapping a machine you play games on
+ /// should offer the games, not drop you on its desktop. Streaming the desktop is still one tap
+ /// away, in the menu, and remains primary for a host with no library. Field note, 2026-08-16:
+ /// "clicking the PC opening the library directly".
var onBrowseLibrary: (() -> Void)? = nil
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
@@ -146,9 +152,13 @@ struct HostCardView: View {
}
}
+ /// What tapping the card does: open the library where the host has one, else connect. The
+ /// menu carries whichever of the two this isn't, so neither is ever more than one press away.
+ private var primaryAction: () -> Void { onBrowseLibrary ?? onConnect }
+
var body: some View {
let m = CardMetrics.current
- return Button(action: onConnect) {
+ return Button(action: primaryAction) {
HStack(spacing: m.spacing) {
monogramTile(monogram(host.displayName), osChain: host.osChain,
m: m, connecting: isConnecting, filled: true)
@@ -220,11 +230,12 @@ struct HostCardView: View {
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action — it is this card's connect with a title picked
- // first — so a pinned card offers it and opens its own shelf, whose launches carry the
- // pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
- // are about the machine, and a shortcut has no business claiming them.)
- if let onBrowseLibrary {
- Button("Browse Library…", action: onBrowseLibrary)
+ // first — and it is now what TAPPING the card does, so the menu carries the other
+ // half instead: streaming the machine itself. (Pair / speed test / wake / forget stay
+ // on the host's card: those are about the machine, and a shortcut has no business
+ // claiming them.)
+ if onBrowseLibrary != nil {
+ Button("Stream the Desktop", systemImage: "display", action: onConnect)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
@@ -245,8 +256,11 @@ struct HostCardView: View {
}
Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest)
- if let onBrowseLibrary {
- Button("Browse Library…", action: onBrowseLibrary)
+ // The inverse of the card's primary tap — see `onBrowseLibrary`. Absent for a host
+ // with no library, where connecting IS the primary tap and a menu row for it would
+ // just be the same action twice.
+ if onBrowseLibrary != nil {
+ Button("Stream the Desktop", systemImage: "display", action: onConnect)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift
index 538e4a81..a421887b 100644
--- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift
+++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift
@@ -26,6 +26,9 @@ struct LibraryCoverflowView: View {
let games: [GameEntry]
let artLoader: (any LibraryArtSource)?
var onLaunch: ((String) -> Void)?
+ /// Which titles the host already has up, keyed by library id — so a card the player can return
+ /// to says `Resume` rather than looking like every other one. Empty on an older host.
+ var running: [String: RunningGame] = [:]
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
var onDismiss: (() -> Void)?
@@ -155,6 +158,10 @@ struct LibraryCoverflowView: View {
// composited transform, so it would only show up on the centred card.
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
}
+ // Opposite corner from the store chip, and `solid` for the same compositing reason.
+ .overlay(alignment: .topTrailing) {
+ if running[game.id] != nil { RunningBadge(solid: true) }
+ }
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryScrollMemory.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryScrollMemory.swift
new file mode 100644
index 00000000..1b926d30
--- /dev/null
+++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryScrollMemory.swift
@@ -0,0 +1,43 @@
+// Where the player was in a host's library, so the round trip back from a stream doesn't lose it.
+//
+// Leaving a stream re-presents `LibraryView` from scratch — new `@State`, new ScrollView — so a
+// library of any size came back at the top every time. For the loop this screen exists to serve
+// (browse → play → quit → browse), that means re-scrolling to the same place on every lap. Field
+// note, 2026-08-16: "when leaving the stream the client should stay at the last scroll position in
+// the library view instead of jumping back".
+//
+// The position is remembered as the ID OF THE TITLE the player last opened, not as a pixel offset.
+// An offset is meaningless across the things that legitimately change between visits — a rotation,
+// a window resize, a Split View, a host that gained or lost titles, or the running-first ordering
+// this view now applies. A title id survives all of them, and `scrollTo` turns it back into a
+// position at whatever the current layout is.
+
+import Foundation
+
+/// Per-host "last title opened", in `UserDefaults`.
+///
+/// Small, non-sensitive and worth surviving a relaunch — the app being killed in the background
+/// while a stream is up is exactly when this is most useful — so `UserDefaults` rather than an
+/// in-memory cache. One key per host, namespaced so nothing else can collide with it.
+enum LibraryScrollMemory {
+ private static func key(forHost hostID: String) -> String {
+ "punktfunk.library.lastTitle.\(hostID)"
+ }
+
+ /// The title last opened from this host's library, if any is remembered.
+ static func last(forHost hostID: String) -> String? {
+ UserDefaults.standard.string(forKey: key(forHost: hostID))
+ }
+
+ /// Remember a title as this host's position. Called when one is launched, which is the only
+ /// moment the player is definitely leaving the grid for it.
+ static func remember(_ gameID: String, forHost hostID: String) {
+ UserDefaults.standard.set(gameID, forKey: key(forHost: hostID))
+ }
+
+ /// Forget a host's position — part of removing the host, so a forgotten host leaves no trace
+ /// of what somebody was playing behind on the device.
+ static func forget(hostID: String) {
+ UserDefaults.standard.removeObject(forKey: key(forHost: hostID))
+ }
+}
diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift
index 84114c4e..2e44cc30 100644
--- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift
+++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift
@@ -1,7 +1,14 @@
-// Experimental game-library browser (plan step 3, gated behind DefaultsKey.libraryEnabled).
-// Renders a poster grid of the host's library fetched over the management API. Read-only:
-// launching a chosen title is a later step. Reached from a host card's "Browse Library…"
-// context-menu action, which only appears when the feature flag is on.
+// The host's game library: a poster grid fetched over the management API, from which titles are
+// launched. Reached by TAPPING a paired host's card — this is the primary destination for a host
+// that has a library, with streaming the desktop as the card's menu action.
+//
+// Three behaviours here exist to make the round trip (browse → play → quit → browse) hold together,
+// all from a 2026-08-16 field report: the catalog is cached so a sleeping host still shows its
+// titles, opening the screen wakes that host so it is warm by the time one is picked, and the
+// position in the grid survives the stream. Titles already running are marked and sorted first.
+//
+// Still gated behind `DefaultsKey.libraryEnabled` (default on) and, separately, on the host being
+// PAIRED — see `HomeView.hostCard` for why the pin is load-bearing rather than cosmetic.
import PunktfunkKit
import SwiftUI
@@ -72,6 +79,16 @@ struct LibraryView: View {
@State private var games: [GameEntry] = []
@State private var loading = false
@State private var errorText: String?
+ /// What the host has launched right now, keyed by library id — the `Resume` affordance. Empty
+ /// on an older host, an unreachable one, or while the catalog is being served from cache.
+ @State private var running: [String: RunningGame] = [:]
+ /// When the catalog on screen was fetched, if it came from disk rather than from the host.
+ /// Non-nil ⇒ these titles are a memory, not an observation, and the view says so.
+ @State private var servedFromCacheAt: Date?
+ /// Guards the one-shot scroll restore. `onAppear` fires again on every re-layout (and once per
+ /// section), and re-scrolling after the player has started browsing would yank the grid out
+ /// from under them — which is a worse bug than the one being fixed.
+ @State private var restoredScroll = false
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: (any LibraryArtSource)?
@@ -151,6 +168,23 @@ struct LibraryView: View {
#endif
}
+ /// Says the titles below are remembered rather than observed — shown only while that is true,
+ /// and never as an error: a cached library is a working library, and a host that is still
+ /// waking is the case this whole path exists to serve.
+ @ViewBuilder private var staleNote: some View {
+ if servedFromCacheAt != nil {
+ HStack(spacing: 6) {
+ Image(systemName: loading ? "arrow.clockwise" : "wifi.slash")
+ Text(loading ? "Waking the host…" : "Showing this host's last known library")
+ }
+ .font(.geist(12, relativeTo: .caption))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal)
+ .padding(.top, 8)
+ }
+ }
+
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
consoleField(
@@ -163,14 +197,20 @@ struct LibraryView: View {
} else {
if gamepadUIActive {
LibraryCoverflowView(
- games: games, artLoader: artLoader, onLaunch: onLaunch,
+ games: ordered, artLoader: artLoader, onLaunch: launchAndRemember,
+ running: running,
onDismiss: { (onClose ?? { dismiss() })() },
// Nil where there is nothing to copy into (tvOS), which is what drops the
// hint from the legend rather than leaving a button that does nothing.
onCopyLink: LinkClipboard.isAvailable ? { copyLink($0) } : nil,
controllerActive: controllerActive)
} else {
- grid
+ // Above the grid rather than over it: the coverflow owns its whole surface and has
+ // its own legend row, so the note rides the plain-grid presentation only.
+ VStack(spacing: 0) {
+ staleNote
+ grid
+ }
}
}
}
@@ -197,8 +237,8 @@ struct LibraryView: View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
// renders exactly as it did before.
- let launchers = games.filter(\.isLauncher)
- let titles = games.filter { !$0.isLauncher }
+ let launchers = ordered.filter(\.isLauncher)
+ let titles = ordered.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollViewReader { proxy in
ScrollView {
@@ -213,6 +253,18 @@ struct LibraryView: View {
}
}
.padding()
+ // Put the player back where they were. Leaving a stream re-presents this view from
+ // scratch, so a long library always came back at the top — meaning the round trip
+ // "browse → play → quit → browse" lost your place every single time. The last title
+ // opened from this shelf is remembered per host and scrolled back to once, without
+ // animation, so it is simply already there rather than visibly moving.
+ .onAppear {
+ guard !restoredScroll, let last = LibraryScrollMemory.last(forHost: host.id.uuidString),
+ ordered.contains(where: { $0.id == last })
+ else { return }
+ restoredScroll = true
+ proxy.scrollTo(last, anchor: .center)
+ }
#if os(iOS) || os(macOS)
// The grid's own width, reported without affecting layout — a GeometryReader
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
@@ -241,8 +293,8 @@ struct LibraryView: View {
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
},
onConfirm: {
- guard let onLaunch, let id = keyCursor else { return }
- onLaunch(id)
+ guard let launch = launchAndRemember, let id = keyCursor else { return }
+ launch(id)
})
#endif
}
@@ -274,13 +326,17 @@ struct LibraryView: View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
Group {
- if let onLaunch {
- Button { onLaunch(game.id) } label: {
- GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
+ if let launch = launchAndRemember {
+ Button { launch(game.id) } label: {
+ GameCard(
+ game: game, artLoader: artLoader, selected: isKeyCursor(game),
+ isRunning: running[game.id] != nil)
}
.buttonStyle(.plain)
} else {
- GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
+ GameCard(
+ game: game, artLoader: artLoader, selected: isKeyCursor(game),
+ isRunning: running[game.id] != nil)
}
}
.id(game.id)
@@ -395,28 +451,118 @@ struct LibraryView: View {
loading = false
return
}
- do {
- // `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
- // the gamepad coverflow both inherit the D4 ordering.
- games = try await LibraryClient.fetch(
- address: current.address,
- port: current.effectiveMgmtPort,
- certPEM: identity.certPEM,
- keyPEM: identity.keyPEM,
- hostFingerprint: current.pinnedSHA256
- ).launchersFirst
- artLoader = try LibraryArtLoader(
- address: current.address,
- port: current.effectiveMgmtPort,
- certPEM: identity.certPEM,
- keyPEM: identity.keyPEM,
- hostFingerprint: current.pinnedSHA256)
- } catch {
- games = []
- errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
+ // Show the catalog we already have BEFORE talking to the host. A library is the screen a
+ // player uses to decide what to play, and an empty one while a sleeping box boots is the
+ // opposite of useful — so the last-known titles go up immediately, marked as remembered,
+ // and are replaced the moment the host answers.
+ if let cached = await LibraryCache.shared?.load(hostID: current.id.uuidString) {
+ games = cached.games.launchersFirst
+ servedFromCacheAt = cached.fetchedAt
}
+ // ...and wake the box while the player is still choosing. Waking has always been bound to
+ // CONNECTING, which is too late to help: by then they have picked a title and are waiting
+ // out a cold boot. Opening the library is the earliest honest signal that someone intends
+ // to play.
+ //
+ // Sent up front and unconditionally rather than only when the host looks offline — the
+ // same shape as the client core's own `orchestrate` path, and for the same reason: a magic
+ // packet is a single fire-and-forget datagram that an already-awake machine ignores, so
+ // waiting to find out whether it is needed costs more than sending it.
+ let waking = !current.wakeMacs.isEmpty && PunktfunkConnection.wakeOnLANAvailable
+ if waking {
+ _ = PunktfunkConnection.wakeOnLAN(macs: current.wakeMacs, lastKnownIP: current.address)
+ }
+
+ // The art loader is built from the same identity whether or not the catalog fetch
+ // succeeds, so cached posters render behind a cached catalog with the host still down.
+ artLoader = try? LibraryArtLoader(
+ address: current.address,
+ port: current.effectiveMgmtPort,
+ certPEM: identity.certPEM,
+ keyPEM: identity.keyPEM,
+ hostFingerprint: current.pinnedSHA256)
+
+ // A woken box takes 20–60 s to answer, so one attempt would almost always land on a host
+ // that is still POSTing. Retry across that window when we sent a packet; without one, ask
+ // exactly once and report what happened, as before.
+ let attempts = waking ? 12 : 1
+ for attempt in 0.. Void)? {
+ guard let onLaunch else { return nil }
+ return { id in
+ LibraryScrollMemory.remember(id, forHost: host.id.uuidString)
+ onLaunch(id)
+ }
+ }
+
+ /// The catalog in display order: anything already running first, so getting back into it is the
+ /// first thing on the screen rather than something to scroll for.
+ ///
+ /// Applied on top of `launchersFirst` rather than instead of it — a launcher that is up still
+ /// belongs with the launchers.
+ private var ordered: [GameEntry] {
+ guard !running.isEmpty else { return games }
+ return games.filter { running[$0.id] != nil } + games.filter { running[$0.id] == nil }
+ }
}
#if os(iOS) || os(macOS)
@@ -451,6 +597,11 @@ private struct GameCard: View {
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
+ /// This title is already up on the host, so picking it resumes rather than starts. Worth
+ /// saying on the tile: the host has quietly adopted a running launch instead of starting a
+ /// second copy for a while now, but nothing ever told the player that — so choosing a game
+ /// they were already playing looked identical to starting one, and read as a relaunch.
+ var isRunning = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -469,6 +620,10 @@ private struct GameCard: View {
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
+ // Opposite corner from the store badge so the two never collide on a narrow tile.
+ .overlay(alignment: .topTrailing) {
+ if isRunning { RunningBadge(compact: true) }
+ }
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift
index f5dae289..75671bdb 100644
--- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift
+++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift
@@ -44,6 +44,42 @@ struct StoreBadge: View {
}
}
+/// "This one is already running on the host" — the Resume affordance, overlaid on a poster.
+///
+/// A badge rather than a changed button title because the grid's tiles have no titles to change:
+/// the poster *is* the control. It says `Resume` rather than `Running` on purpose — the player
+/// does not need a status report, they need to know what tapping it will do.
+///
+/// Flat-filled for the same reason `StoreBadge(solid:)` exists: the coverflow composites its cards
+/// offscreen, where a material has no backdrop to sample.
+struct RunningBadge: View {
+ var solid: Bool = false
+ /// Glyph only, no word. The grid's tiles go down to ~130 pt wide and already carry the store
+ /// chip in the opposite corner; at that size "Resume" plus an icon leaves the two badges
+ /// touching in the middle. The coverflow's cards are several times wider and take the word.
+ var compact: Bool = false
+
+ var body: some View {
+ Group {
+ if compact {
+ Image(systemName: "play.fill")
+ } else {
+ Label("Resume", systemImage: "play.fill").labelStyle(.titleAndIcon)
+ }
+ }
+ .font(.geist(11, .semibold, relativeTo: .caption2))
+ .foregroundStyle(.white)
+ // Semantic green rather than the brand violet: this is a state the host reports, not a
+ // Punktfunk surface, and it has to stay distinguishable from the launcher badge — which
+ // already owns the brand fill one corner away.
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(Color.green.opacity(solid ? 0.92 : 0.85), in: Capsule())
+ .padding(6)
+ .accessibilityLabel("Running on the host — resume")
+ }
+}
+
#if canImport(UIKit)
private typealias PlatformImage = UIImage
#elseif canImport(AppKit)
diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryCache.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryCache.swift
new file mode 100644
index 00000000..be2414d2
--- /dev/null
+++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryCache.swift
@@ -0,0 +1,97 @@
+// On-disk cache for a host's library CATALOG — the list of titles, not their art.
+//
+// Cover art has been cached since `ArtCache`, but the catalog behind it never was: every visit to a
+// library refetched `GET /api/v1/library` and showed nothing until that call returned. A host that
+// is asleep, or simply not reachable yet, therefore had an EMPTY library — which is the opposite of
+// what a player wants from the screen they use to decide what to play, and it makes waking a host
+// on library entry pointless: there would be nothing to look at while it boots.
+//
+// So the catalog is cached per host and rendered immediately, marked stale, and reconciled when the
+// host answers. Field request, 2026-08-16: "it would add a nice seamless experience if offline
+// library would be added".
+//
+// Caches directory, like `ArtCache`, for the same reason: every byte is re-derivable from the host,
+// so the system is welcome to evict it. Unlike art, a catalog is small (a few hundred KB for a big
+// library), so there is no size budget here — one file per host, replaced wholesale.
+//
+// Deliberately free of any Network.framework / PunktfunkCore dependency, so it can be unit-tested
+// against a temporary directory.
+
+import CryptoKit
+import Foundation
+
+/// A host's library as last seen, with when that was.
+public struct CachedLibrary: Codable, Sendable {
+ public var games: [GameEntry]
+ public var fetchedAt: Date
+
+ public init(games: [GameEntry], fetchedAt: Date) {
+ self.games = games
+ self.fetchedAt = fetchedAt
+ }
+
+ /// How old this snapshot is. The UI uses it to word the staleness note, never to decide whether
+ /// to show the catalog: a year-old library is still a far better answer than an empty one, and
+ /// the live fetch is always in flight behind it anyway.
+ public var age: TimeInterval { Date().timeIntervalSince(fetchedAt) }
+}
+
+/// Per-host catalog storage. An actor so disk work stays off the SwiftUI thread and a read can
+/// never race the write that follows a fetch.
+public actor LibraryCache {
+ private let directory: URL
+ private let fileManager = FileManager.default
+
+ public init(directory: URL) {
+ self.directory = directory
+ }
+
+ /// The app's standard location, or nil if the caches directory is unavailable — in which case
+ /// callers simply run without a cache rather than failing.
+ public static func standard() -> LibraryCache? {
+ guard let caches = FileManager.default.urls(
+ for: .cachesDirectory, in: .userDomainMask).first
+ else { return nil }
+ return LibraryCache(
+ directory: caches.appendingPathComponent("PunktfunkLibrary", isDirectory: true))
+ }
+
+ /// The shared instance, or nil where there is no caches directory.
+ public static let shared = standard()
+
+ public func load(hostID: String) -> CachedLibrary? {
+ guard let data = try? Data(contentsOf: path(for: hostID)) else { return nil }
+ // A catalog written by an older build whose `GameEntry` had different fields decodes to
+ // nothing rather than failing: a miss costs one fetch, which is what would have happened
+ // anyway. Never surfaced as an error.
+ return try? JSONDecoder().decode(CachedLibrary.self, from: data)
+ }
+
+ public func store(_ games: [GameEntry], hostID: String) {
+ // An empty catalog is not worth remembering: it is indistinguishable from "never fetched"
+ // when read back, and caching it would pin a blank library over a host that has titles.
+ guard !games.isEmpty else { return }
+ let snapshot = CachedLibrary(games: games, fetchedAt: Date())
+ guard let data = try? JSONEncoder().encode(snapshot) else { return }
+ do {
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ try data.write(to: path(for: hostID), options: .atomic)
+ } catch {
+ return // a cache that can't write is a slower app, not a broken one
+ }
+ }
+
+ /// Drop a host's catalog — used when its identity is forgotten, so a removed host leaves no
+ /// list of what somebody plays behind on disk.
+ public func forget(hostID: String) {
+ try? fileManager.removeItem(at: path(for: hostID))
+ }
+
+ /// Hashed rather than used verbatim: a host id is user-controlled text and must never be able
+ /// to reach out of this directory (`../`) or exceed a filename length limit.
+ private func path(for hostID: String) -> URL {
+ let digest = SHA256.hash(data: Data(hostID.utf8))
+ let name = digest.map { String(format: "%02x", $0) }.joined()
+ return directory.appendingPathComponent("\(name).json")
+ }
+}
diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift
index 84aa57df..d2957a60 100644
--- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift
+++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift
@@ -1,7 +1,7 @@
-// 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.
+// Game library client. 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 — and what it
+// currently has running (`GET /api/v1/status`), so a title the player can return to can be marked
+// as such. 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
@@ -131,6 +131,37 @@ public enum LibraryError: LocalizedError {
}
}
+/// One game the host currently has launched, from `/api/v1/status`.
+///
+/// A deliberately partial mirror of the host's `ActiveGame`: only the fields a client can act on.
+/// The console's own view of this payload carries more (which session, which plane, the grace
+/// countdown), and none of that is a player's business from the library screen.
+public struct RunningGame: Codable, Hashable, Sendable {
+ /// Store-qualified library id (`steam:570`) — the key that lines this up with a `GameEntry`.
+ /// Absent for an operator-typed GameStream command, which has no catalog entry behind it.
+ public var appID: String?
+ public var title: String
+ /// `launching` | `running` | `exited` | `untracked` | `grace`. A plain String on purpose: the
+ /// host owns the vocabulary and adds to it (`untracked` arrived in 0.30), so an unknown value
+ /// must never fail the decode of the whole list.
+ public var state: String
+
+ private enum CodingKeys: String, CodingKey {
+ case appID = "app_id"
+ case title
+ case state
+ }
+
+ /// Is this title *up on the host right now* — i.e. would picking it take the player back into
+ /// it rather than start it?
+ ///
+ /// `untracked` counts: the host cannot follow that process, but it did launch it and has no
+ /// evidence it stopped. `grace` counts too — its session is gone but the game is still running,
+ /// which is precisely the case where getting back in promptly matters most. Only a confirmed
+ /// `exited` does not.
+ public var isUp: Bool { state != "exited" }
+}
+
/// Stateless fetcher for a host's library.
public enum LibraryClient {
/// `GET https://:/api/v1/library`, authenticated by **mTLS**: the client
@@ -171,6 +202,40 @@ public enum LibraryClient {
}
}
+ /// What the host currently has running, from `GET /api/v1/status`.
+ ///
+ /// Same lane, same identity, no new host work: `/status` is already on the paired-certificate
+ /// allowlist (the host's `mgmt::auth::cert_may_access`) alongside `/library`, and has carried a
+ /// `games[]` array since the session⇄game lifetime work. The client simply never read it — so a
+ /// player had no way to see, from the device they browse on, that something was already up.
+ ///
+ /// Best-effort by contract: an older host, an unreachable one, or a shape we don't recognize
+ /// yields an empty list rather than an error. Nothing here is worth failing a library screen
+ /// over — the worst case is a Resume badge that doesn't appear.
+ public static func running(
+ address: String,
+ port: UInt16 = punktfunkDefaultMgmtPort,
+ certPEM: String,
+ keyPEM: String,
+ hostFingerprint: Data?
+ ) async -> [RunningGame] {
+ guard let identity = try? clientIdentity(certPEM: certPEM, keyPEM: keyPEM),
+ let response = try? await send(
+ path: "/api/v1/status", address: address, port: port,
+ identity: identity, hostFingerprint: hostFingerprint),
+ response.status == 200,
+ let status = try? JSONDecoder().decode(HostStatus.self, from: response.body)
+ else { return [] }
+ return status.games ?? []
+ }
+
+ /// Just the slice of `/status` this client reads. Everything else on that payload is the
+ /// operator console's business, and decoding only what we use keeps an unrelated schema change
+ /// on the host from breaking the library screen.
+ private struct HostStatus: Decodable {
+ var games: [RunningGame]?
+ }
+
/// `https://addr:port`, IPv6 literals bracketed — the mirror of the Rust client's `base_url`.
static func baseURL(address: String, port: UInt16) -> String {
let bare = address.hasPrefix("[") && address.hasSuffix("]")
diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs
index d25d649e..9a2ef2d4 100644
--- a/crates/punktfunk-host/src/gamelease.rs
+++ b/crates/punktfunk-host/src/gamelease.rs
@@ -17,6 +17,12 @@
//! ([`crate::library::DetectSpec`]), not by a pid we happen to hold — and a lease we do hold a child
//! for ([`LeaseKind::Child`]) falls back to recognition the moment that child turns out to be a shim.
//!
+//! Where the host *did* start the process itself it keeps that too, as a second signal rather than
+//! the definition: a `Child` on Linux, and on Windows the bare pid `CreateProcessAsUserW` hands back
+//! ([`LeaseRequest::spawned`]). That pid used to be dropped, which left Windows strictly worse off
+//! than Linux — a title whose provider published no detect signals had *nothing* identifying it, so
+//! its exit was never noticed and it could not be ended (field report 2026-08-16, Windows 0.29.0).
+//!
//! ### Safety posture
//!
//! Ending a game is destructive: it can cost unsaved progress. Three rules bound it.
@@ -135,6 +141,17 @@ pub enum GameState {
Running = 1,
/// Confirmed gone.
Exited = 2,
+ /// The host launched this title but has no way to recognize its process
+ /// ([`LeaseKind::Untracked`]), so it will never observe the game starting *or* stopping.
+ ///
+ /// A terminal state, and the honest answer to "what is this game doing?" — which
+ /// [`Running`](Self::Running) was not. Reporting `Running` here (the shipped behavior until
+ /// 0.30) made three separate things indistinguishable in the console: a game being watched, a
+ /// game that quit and was never noticed, and a game the host cannot see at all. It is also
+ /// exactly what a 2026-08-16 field report hit — a Windows title quit mid-stream, the console
+ /// stayed on "running" forever, and no session setting made any difference, because
+ /// `session_on_game_exit` can never fire for a lease nothing is watching.
+ Untracked = 3,
}
impl GameState {
@@ -142,6 +159,7 @@ impl GameState {
match v {
1 => Self::Running,
2 => Self::Exited,
+ 3 => Self::Untracked,
_ => Self::Launching,
}
}
@@ -151,6 +169,7 @@ impl GameState {
Self::Launching => "launching",
Self::Running => "running",
Self::Exited => "exited",
+ Self::Untracked => "untracked",
}
}
}
@@ -178,6 +197,13 @@ pub struct LeaseShared {
/// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]).
/// Cleared once that child is reaped.
child: Mutex
+ {/* Its own axis rather than a fourth end-policy: that one asks what a
+ session owes its game, this one asks what a new launch owes the last
+ one — and wanting a game to survive a disconnect says nothing about
+ wanting it kept when you deliberately pick something else. */}
+
+