feat(host,clients/apple,console): the library round-trip — a quit game the host never noticed, and the loop back to the shelf
ci / bun-nix (pull_request) Successful in 1m28s
apple / swift (pull_request) Failing after 1m33s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m37s
ci / web (pull_request) Successful in 4m43s
ci / rust (pull_request) Successful in 6m0s
ci / rust-arm64 (pull_request) Successful in 6m19s
android / android (pull_request) Successful in 6m40s

From a field report (Discord, 2026-08-16, Windows host 0.29.0 + iPad 0.30.0): quitting a game
mid-stream left the session up and the web console showing it as "running" forever, with no
setting that made any difference.

The console was not merely out of date — it was asserting something the host had no way to know.
A lease with nothing to recognise its game by set its OWN state to `Running`, on the reasoning
that the host had just launched it. That made three different situations indistinguishable: a
game being watched, a game that quit and was never noticed, and a game the host cannot see at
all. `session_on_game_exit` can never fire for a lease nothing is watching, which is why no
setting helped.

Windows reached that state far more easily than Linux, and for a reason worth naming: the host
never holds a `Child` there, and the pid `CreateProcessAsUserW` hands back was logged and
discarded. So a title whose provider published no detect hint had *nothing* identifying it — its
exit went unseen and `POST /game/end` had no pid to signal, while the same title on Linux was
fully tracked through its child.

Host:
* `GameState::Untracked` — a lease that nothing is watching says so, in `/status`, the console
  card and the tray label. Keyed on "is anything watching this" rather than on the lease kind, so
  a nested gamescope lease (whose exit the capture loop catches) still correctly reads `running`.
* `LeaseRequest::spawned` carries the pid Windows already knew, pinned to its start time by a new
  `Scanner::resolve` so a recycled pid cannot impersonate it. It takes the same lifetime rules the
  owned child gets, shim reclassification included, and feeds the Windows terminate ladder.
* `game_on_new_launch` (keep|end, default keep): close this client's previous game before starting
  a different one. Its own axis rather than a fourth `game_on_session_end` value — wanting a game
  to survive a disconnect says nothing about wanting it kept when you deliberately pick another.
  Four safety rules, made pure and unit-tested: never another client's game, never one the player
  started themselves, never the title being launched, and never a record whose liveness is merely
  Unknown.

The same pid fix closes a second defect: a launch that adopted nothing answers `Unknown`, falls
back to the 90-second in-flight window, and past it starts a SECOND copy. That is why "click the
game that is already running" resumed on Linux and relaunched on Windows.

Apple client — the loop the report was really about (browse, play, quit, browse):
* tapping a paired host opens its library; "Stream the Desktop" moves to the card menu
* the catalog is cached per host and rendered immediately, marked stale, so a sleeping host still
  shows its titles (only art was cached before; the catalog was fetched live every visit)
* opening the library wakes the host and retries across the boot window, so it is warm by the time
  a title is picked — waking was bound to CONNECTING, which is too late to help
* titles already up are badged Resume and sorted first, read from `/status` (already on the
  paired-cert lane — no new host API)
* the grid returns to where you were, remembered as the last title opened rather than a pixel
  offset, which survives a rotation, a resize and a host gaining titles

Verified: Linux container gate over punktfunk-host — fmt, clippy --all-targets -D warnings, a
plain build and 597 tests green, openapi regenerated and its drift test passing. Web console tsc
clean, 740 messages en+de. Apple swift build + 347 tests on macOS, and iOS + tvOS typechecked
(CI compiles neither).
This commit is contained in:
2026-08-17 01:18:24 +02:00
parent d7b6b7e7cd
commit 22fdea66ff
23 changed files with 1261 additions and 81 deletions
+13 -1
View File
@@ -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`]."
@@ -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)
@@ -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)
@@ -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))
}
}
@@ -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 2060 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..<attempts {
if Task.isCancelled { break }
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid
// and the gamepad coverflow both inherit the D4 ordering.
let fetched = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256
).launchersFirst
games = fetched
servedFromCacheAt = nil
errorText = nil
await LibraryCache.shared?.store(fetched, hostID: current.id.uuidString)
break
} catch {
// Anything other than "can't reach it" is settled a rejected certificate does not
// become acceptable by waiting, and retrying an unpaired host twelve times just
// delays telling the user what is actually wrong.
let unreachable: Bool
if case .unreachable = error as? LibraryError { unreachable = true } else {
unreachable = false
}
let more = unreachable && attempt + 1 < attempts
if !more {
// A cached catalog outranks the error: the titles on screen are still the right
// ones to choose from, and replacing them with a red message because the host
// is asleep is precisely what this cache exists to prevent. The staleness note
// carries the situation instead.
if games.isEmpty {
errorText = (error as? LibraryError)?.errorDescription
?? error.localizedDescription
}
break
}
try? await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC)
}
}
// What's up on the host right now never fatal, and deliberately after the catalog so a
// slow `/status` can't hold the titles back.
let live = await LibraryClient.running(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
running = Dictionary(
live.filter(\.isUp).compactMap { g in g.appID.map { ($0, g) } },
// Two sessions can have the same title up (the host admits concurrent sessions); for a
// Resume badge either one is the same answer.
uniquingKeysWith: { first, _ in first })
loading = false
}
/// Every launch from this shelf goes through here, so the player's position is recorded on
/// exactly one path however they picked the title a tap, the keyboard, or the coverflow.
/// `nil` in browse-only mode, which is what keeps the tiles untappable there.
private var launchAndRemember: ((String) -> 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)
@@ -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)
@@ -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")
}
}
@@ -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://<address>:<port>/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 sessiongame 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("]")
+386 -13
View File
@@ -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<Option<OwnedChild>>,
/// The process the host spawned for this launch on a platform with no `Child` to hold
/// ([`LeaseRequest::spawned`]), pinned to its start time.
///
/// Never cleared: unlike a `Child` there is no handle to reap, and every read re-verifies the
/// pair through [`crate::procscan::Scanner::alive`] before counting it live or signalling it —
/// so a stale entry answers "gone", which is the correct answer, rather than a recycled pid.
spawned: Option<crate::procscan::ProcRef>,
/// Whether this lease's game has been asked to end (so a second request is a no-op, and the
/// watcher's exit doesn't look like the player quitting).
terminating: AtomicBool,
@@ -283,6 +309,19 @@ pub struct LeaseRequest {
/// The child the host spawned for this launch, when it spawned one directly, and whether it
/// leads its own process group (see [`OwnedChild::group_leader`]).
pub child: Option<(std::process::Child, bool)>,
/// The pid the host spawned for this launch on a platform where it gets a **pid instead of a
/// child** — Windows, where a launch goes through `CreateProcessAsUserW` into the interactive
/// session and there is no `std::process::Child` to hold.
///
/// Tracked for exactly the same reason [`Self::child`] is, and it closes the gap that made
/// Windows strictly worse than Linux at this: a title whose provider supplied no detect hint
/// has an empty [`DetectSpec`], and with no child either the lease had *nothing* — so it went
/// [`LeaseKind::Untracked`], its exit was never noticed, and `POST /game/end` had no pid to
/// signal. The same title on Linux was fully tracked, because the host held its child there.
///
/// Resolved to a (pid, start time) pair at [`open`] time so a recycled pid can never be
/// mistaken for it; see [`crate::procscan::Scanner::resolve`].
pub spawned: Option<u32>,
/// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a
/// process, which is what keeps a copy of the game the player already had open from being
/// mistaken for this session's. `None` disables the filter (no readable uptime clock).
@@ -323,10 +362,16 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
nested,
launcher,
child,
spawned,
launch_stamp,
procs,
} = req;
// Pin the spawned pid to its start time before anything else can recycle it. A pid we cannot
// resolve (it already exited, or it is not queryable) is simply dropped: a bare number is never
// allowed further in, which is procscan's rule 2.
let spawned = spawned.and_then(crate::procscan::resolve);
// A launcher tile is untracked FIRST, before anything else is considered — see
// `LeaseRequest::launcher`. Checking it ahead of `child` is the whole point: a launcher the host
// just started leaves a live child behind, and tracking that child is exactly the inconsistency
@@ -335,7 +380,10 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
LeaseKind::Untracked
} else if nested {
LeaseKind::Nested
} else if child.is_some() {
} else if child.is_some() || spawned.is_some() {
// A pid we spawned is our own child in every sense that matters here — the only difference
// is that this platform hands back a number instead of a handle — so it takes the same
// lifetime rules, shim reclassification included.
LeaseKind::Child
} else if !spec.is_empty() {
LeaseKind::Matched
@@ -358,6 +406,7 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
spec,
launch_stamp,
child: Mutex::new(owned),
spawned,
terminating: AtomicBool::new(false),
created_ms: now_ms(),
was_running: AtomicBool::new(false),
@@ -389,11 +438,27 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
let watcher = spawn_watcher(shared.clone(), child, procs, on_exit);
if watcher.is_none() {
// Nothing is polling this lease (no signals to poll, or a platform without a matcher yet), so
// its state will never advance on its own. Report it as running rather than leaving the
// console showing "launching" forever: the host did just launch it, and with no watcher it is
// making no claim about noticing the exit. The row disappears when the session ends.
shared.set_state(GameState::Running);
// Nothing is polling this lease, so its state will never advance on its own — and which
// state to leave it in depends on WHY, because the two cases make opposite promises.
//
// * An UNTRACKED lease has no signal at all: nothing will ever notice this game starting or
// stopping. `Untracked` says so. It used to report `Running`, on the reasoning that the
// host had just launched it and a row stuck at "launching" reads as broken — but that
// made a console row assert liveness the host cannot back up, and it is what a
// 2026-08-16 field report ran into (see [`GameState::Untracked`]).
// * A NESTED lease with no store signals is genuinely being watched, just not from here:
// a bare-spawn gamescope dies with its app, so the capture loop's node-death check is its
// exit detection. Its game really is running, and its exit really will end the session,
// so `Running` stays the truthful answer for it.
//
// Keyed on `Nested` rather than on `Untracked` so the two remaining ways to reach here —
// a platform with no process matcher, and a watcher thread that failed to spawn — land on
// the honest answer too: in both, nothing is watching, whatever the lease's kind says.
shared.set_state(if matches!(shared.kind, LeaseKind::Nested) {
GameState::Running
} else {
GameState::Untracked
});
}
GameLease { shared, watcher }
}
@@ -475,12 +540,57 @@ fn watch(
// assigns it, so an initial value would be dead.
let mut known: Vec<crate::procscan::ProcRef>;
// The process the host spawned on a platform that hands back a pid rather than a `Child`
// (Windows). Cleared once it is seen gone, so `is_some()` reads "ours is still up" — the same
// one-way transition `child` makes when it is reaped, which is what lets both phases treat the
// two the same way.
let mut spawned = shared.spawned;
// Re-verified every time rather than remembered: `alive` checks the (pid, start) pair, so a
// recycled pid answers "gone" instead of impersonating our launch.
let spawned_up = |s: &Option<crate::procscan::ProcRef>| -> bool {
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
};
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
loop {
if cancelled() {
return;
}
// The pid-shaped twin of the `try_wait` arm below: our own process is gone, and the same
// two questions decide what that means. There is no exit status to read (no handle), so
// "quick" alone stands in for "a launcher handing off" — which is the right reading on the
// platform this applies to, where every launch goes through a launcher or the shell.
if matches!(kind, LeaseKind::Child)
&& child.is_none()
&& spawned.is_some()
&& !spawned_up(&spawned)
{
spawned = None;
if spawned_at.elapsed() < SHIM_WINDOW {
if shared.spec.is_empty() {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and this \
title has no detect signals stopping game tracking for it"
);
return;
}
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by its store \
signals instead"
);
kind = LeaseKind::Matched;
} else if shared.spec.is_empty() {
// It ran long enough to have BEEN the game, and nothing else identifies it.
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
return;
}
// With signals available, let the scan below decide — it may have been a wrapper whose
// game is still up.
}
// A `Child` lease's own child is the primary signal; a shim exit re-resolves the lease.
if matches!(kind, LeaseKind::Child) {
match child.as_mut().map(|c| c.try_wait()) {
@@ -553,7 +663,7 @@ fn watch(
// With no signals the child is all we have, so it still counts immediately: a custom command
// is tracked exactly as before.
let child_alive = matches!(kind, LeaseKind::Child)
&& child.is_some()
&& (child.is_some() || spawned.is_some())
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
@@ -600,8 +710,19 @@ fn watch(
return;
}
}
// Same conclusion for a pid-shaped launch: past the start phase there is no shim
// question left to ask, so our process going away IS the game going away when nothing
// else identifies it.
if spawned.is_some() && !spawned_up(&spawned) {
spawned = None;
if shared.spec.is_empty() {
finish(&shared, &on_exit, "the launched process exited");
return;
}
}
}
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
let child_alive =
matches!(kind, LeaseKind::Child) && (child.is_some() || spawned.is_some());
// Cheap first: are the processes we already know about still there? Only when none of them is
// do we pay for a full scan — which is also what notices a game that re-exec'd into a new pid
// (a launcher stub becoming the real binary, an engine relaunching itself).
@@ -851,14 +972,28 @@ fn unix_term_ladder(shared: &LeaseShared) {
/// Windows: ask the game's windows to close, wait, then terminate what's left.
///
/// Same shape as the Unix ladder, different primitives — and one structural difference: the host never
/// holds a child here. Every Windows launch goes through a launcher or the shell
/// (`steam://`, `com.epicgames.launcher://`, `shell:AppsFolder\…`), so the game is always recognized
/// rather than owned, and the pid set comes entirely from the matcher.
/// Same shape as the Unix ladder, different primitives — and one structural difference: the host
/// holds no `Child` here. Every Windows launch goes through a launcher or the shell (`steam://`,
/// `com.epicgames.launcher://`, `shell:AppsFolder\…`), so the game is normally *recognized* rather
/// than owned and the pid set comes from the matcher.
///
/// It does, however, know the pid it spawned ([`LeaseShared::spawned`]), and that is folded in here
/// — otherwise a title with no detect signals could not be ended at all: the matcher returns
/// nothing for an empty spec, so "End" found no pids and silently did nothing.
#[cfg(windows)]
fn windows_term_ladder(shared: &LeaseShared) {
let scanner = crate::procscan::Scanner::system();
let live = || scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
let live = || {
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
// de-duplicated: the matcher may well have found this same process by its image.
if let Some(p) = shared.spawned {
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
}
procs
};
let pids: Vec<u32> = live().into_iter().map(|p| p.pid).collect();
if pids.is_empty() {
@@ -895,6 +1030,121 @@ fn windows_term_ladder(shared: &LeaseShared) {
);
}
/// End the processes an **earlier** launch adopted — the action behind
/// [`crate::session_settings::GameOnNewLaunch::End`].
///
/// Its own ladder rather than a call into [`terminate`] because the input is different in kind. That
/// one ends a game a *live lease* is tracking, and can therefore re-scan by [`DetectSpec`] and
/// signal a child it owns. By the time a player picks a new title the previous game usually has no
/// lease at all — its session ended, the lease was dropped, and all that survives is the pid set its
/// watcher published to [`crate::launchreg`]. So this signals exactly that set, and nothing else.
///
/// Blocking: the caller is a launch about to spawn, and starting the new title *before* the old one
/// has let go of the display, the audio device and the gamepad is precisely the mess this exists to
/// avoid. Bounded by [`TERM_GRACE`].
///
/// Returns how many processes were still alive when asked.
pub fn end_previous_launch(title: &str, procs: &[crate::procscan::ProcRef], why: &str) -> usize {
let scanner = crate::procscan::Scanner::system();
// Re-verified before every signal, so a pid recycled since the watcher last published it is
// never hit (procscan rule 2). This is the whole safety of signalling a remembered pid.
let live = || scanner.alive(procs);
let first = live();
if first.is_empty() {
return 0;
}
tracing::info!(
title,
procs = first.len(),
reason = why,
"ending the previous game before launching the new one"
);
ask_to_close(&first);
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
if live().is_empty() {
tracing::info!(title, "the previous game closed when asked");
return first.len();
}
}
let remaining = live();
tracing::warn!(
title,
remaining = remaining.len(),
grace_s = TERM_GRACE.as_secs(),
"the previous game did not close when asked — killing it"
);
force_close(&remaining);
first.len()
}
/// Ask these processes to close the way a user would: `WM_CLOSE` on Windows, `SIGTERM` on Unix. The
/// game runs its own shutdown and can save.
fn ask_to_close(procs: &[crate::procscan::ProcRef]) {
#[cfg(windows)]
{
let pids: Vec<u32> = procs.iter().map(|p| p.pid).collect();
crate::game_term::request_close(&pids);
}
#[cfg(target_os = "linux")]
for p in procs {
// SAFETY: `kill` returns a status code and touches no memory of ours. Always a POSITIVE pid
// — these are processes the matcher adopted, not a group this host leads, so a negative
// target would signal an unrelated process group.
unsafe {
libc::kill(p.pid as i32, libc::SIGTERM);
}
}
#[cfg(not(any(windows, target_os = "linux")))]
let _ = procs;
}
/// Insist, for whatever ignored [`ask_to_close`].
fn force_close(procs: &[crate::procscan::ProcRef]) {
#[cfg(windows)]
{
let pids: Vec<u32> = procs.iter().map(|p| p.pid).collect();
crate::game_term::kill(&pids);
}
#[cfg(target_os = "linux")]
for p in procs {
// SAFETY: as above.
unsafe {
libc::kill(p.pid as i32, libc::SIGKILL);
}
}
#[cfg(not(any(windows, target_os = "linux")))]
let _ = procs;
}
/// Apply [`crate::session_settings::GameOnNewLaunch`] for a session about to launch `game_id`.
///
/// Called immediately **before** the spawn, so the old game is gone (or has had its grace) by the
/// time the new one starts. A no-op on the shipped default, and for a client the host holds no
/// launch records for.
///
/// ⚠ One path is ordered the other way round: a Linux **bare-spawn gamescope** launch is nested by
/// the display layer when the source opens, which is well before this point — so there the previous
/// game is closed just *after* the new one starts rather than just before. The policy still holds
/// (the old game does not linger), and the contention this ordering exists to avoid is largely moot
/// there anyway, since a nested launch brings up its own display rather than sharing one.
pub fn end_others_for_new_launch(fingerprint: Option<&str>, game_id: Option<&str>) {
if crate::session_settings::get().game_on_new_launch
!= crate::session_settings::GameOnNewLaunch::End
{
return;
}
for other in crate::launchreg::others_still_running(fingerprint, game_id) {
end_previous_launch(
&other.game_id,
&other.procs,
"the player launched a different title",
);
}
}
// ---------------------------------------------------------------------------------------------
// The grace registry: leases whose session is gone but whose game is on probation
// ---------------------------------------------------------------------------------------------
@@ -1191,6 +1441,7 @@ mod tests {
nested,
launcher: false,
child: None,
spawned: None,
// No start-time floor: these leases are never matched against real processes.
launch_stamp: None,
// Not a recorded launch — nothing here spawns anything (`crate::launchreg`).
@@ -1429,6 +1680,7 @@ mod tests {
nested: false,
launcher: false,
child: Some((child, false)),
spawned: None,
launch_stamp: None,
procs: None,
},
@@ -1451,6 +1703,93 @@ mod tests {
);
}
/// A launch the host knows only as a **pid** — no `Child`, no detect signals — is still its
/// own child, and must classify as one.
///
/// This is the Windows shape (`CreateProcessAsUserW` returns a pid) reproduced on Linux, where
/// it can actually be driven. Before the pid was carried, this exact combination was
/// [`LeaseKind::Untracked`]: nothing watched the game, nothing could end it, and the console
/// reported it running forever.
#[cfg(target_os = "linux")]
#[test]
fn a_pid_only_launch_is_tracked_like_a_child() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn the fake game");
let pid = child.id();
let lease = open(
LeaseRequest {
spawned: Some(pid),
// The field-report case: the provider gave nothing to recognize the game by.
spec: DetectSpec::default(),
launch_stamp: launch_clock(),
..req("custom:pid-only", DetectSpec::default(), false)
},
Box::new(|| {}),
);
assert!(
matches!(lease.shared().kind(), LeaseKind::Child),
"a pid we spawned is our own child, whatever the spec says"
);
assert!(
lease.shared().is_trackable(),
"and therefore endable — `POST /game/end` had no pid to signal before this"
);
drop(lease);
let _ = child.kill();
let _ = child.wait();
}
/// The same launch, driven to its exit: the pid dying is the game exiting, and that fires the
/// action that ends the session — which is precisely what never happened in the field report.
///
/// Ignored by default: it waits out [`EXIT_CONFIRM`] after a real process ends, ~10 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~10s (exit confirmation)"]
fn a_pid_only_launch_reports_its_exit() {
use std::sync::atomic::AtomicUsize;
// Reaped on its own thread: an unwaited child becomes a zombie, and a zombie keeps its
// `/proc/<pid>` entry with an unchanged start time — so the scan would call it alive
// forever and the exit under test could never be observed.
let mut child = std::process::Command::new("sleep")
.arg("4")
.spawn()
.expect("spawn the fake game");
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
static PID_EXITS: AtomicUsize = AtomicUsize::new(0);
PID_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
spawned: Some(pid),
spec: DetectSpec::default(),
launch_stamp: launch_clock(),
..req("custom:pid-only-exit", DetectSpec::default(), false)
},
Box::new(|| {
PID_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline && shared.state() != GameState::Exited {
std::thread::sleep(Duration::from_millis(250));
}
assert_eq!(shared.state(), GameState::Exited, "the game should be gone");
assert_eq!(
PID_EXITS.load(Ordering::SeqCst),
1,
"the player quitting must end the session exactly once"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
@@ -1489,6 +1828,7 @@ mod tests {
nested: false,
launcher: false,
child: Some((child, true)),
spawned: None,
launch_stamp,
procs: None,
},
@@ -1550,7 +1890,40 @@ mod tests {
assert_eq!(GameState::Launching.as_str(), "launching");
assert_eq!(GameState::Running.as_str(), "running");
assert_eq!(GameState::Exited.as_str(), "exited");
assert_eq!(GameState::Untracked.as_str(), "untracked");
assert_eq!(GameState::from_u8(1), GameState::Running);
assert_eq!(GameState::from_u8(3), GameState::Untracked);
assert_eq!(GameState::from_u8(99), GameState::Launching);
}
/// The 2026-08-16 field report in one assertion: a title with nothing to recognize it by must
/// not report `running`. It used to, which made the console assert liveness the host had no way
/// to back up — and left a user who had just quit the game watching a row that would never
/// change, with no setting that could affect it.
#[test]
fn a_lease_with_nothing_to_watch_says_so_instead_of_claiming_to_run() {
let lease = open(
req("custom:no-signals", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(!lease.shared().is_trackable());
assert_eq!(
lease.shared().state(),
GameState::Untracked,
"an unwatchable lease must not report `running`"
);
}
/// The other half of that rule: a nested lease with no store signals IS watched, just from the
/// capture loop rather than from here (a bare-spawn gamescope dies with its app). Its game
/// really is running and its exit really does end the session, so it keeps saying `running` —
/// the fix must not flatten the two cases together.
#[test]
fn a_nested_lease_still_reports_running_because_something_else_watches_it() {
let lease = open(
req("steam:nested-no-signals", DetectSpec::default(), true),
Box::new(|| {}),
);
assert_eq!(lease.shared().state(), GameState::Running);
}
}
+25 -4
View File
@@ -331,6 +331,22 @@ fn run(
// `spawned_now` is what actually happened — the record is settled from it below.
#[allow(unused_mut)]
let mut spawned_now = false;
// Windows hands back a pid rather than a child; kept for the lease (see the native plane
// and `gamelease::LeaseRequest::spawned`). `None` elsewhere and when nothing was spawned.
#[allow(unused_mut)]
let mut spawned_pid: Option<u32> = None;
// Close this client's previous game first, when the operator asked for that — the compat
// plane's half of `GameOnNewLaunch`. Moonlight clients are cert-paired, so they carry the
// fingerprint the launch records are keyed by and the policy applies to them exactly as it
// does on the native plane.
if !adopt_launch {
if let Some(t) = target.as_ref() {
crate::gamelease::end_others_for_new_launch(
life.fingerprint.as_deref(),
t.game.id.as_deref(),
);
}
}
#[cfg(windows)]
if let Some(t) = target.as_ref() {
if adopt_launch {
@@ -343,12 +359,16 @@ fn run(
// A library title launches by its store-qualified id (the interactive-session spawner
// resolves the store's own recipe); an operator-typed command runs as itself.
let launched = match (t.game.id.as_deref(), t.command.as_deref()) {
(Some(id), _) => crate::library::launch_gamestream_library(id),
(None, Some(cmd)) => crate::library::launch_gamestream_command(cmd),
(None, None) => Ok(()),
(Some(id), _) => crate::library::launch_gamestream_library(id).map(Some),
(None, Some(cmd)) => crate::library::launch_gamestream_command(cmd).map(Some),
// Nothing to start (a target that names neither) — not a spawn, so no pid.
(None, None) => Ok(None),
};
match launched {
Ok(()) => spawned_now = true,
Ok(pid) => {
spawned_pid = pid;
spawned_now = true;
}
Err(e) => {
tracing::warn!(title = %t.game.title, error = %e, "gamestream: could not launch app")
}
@@ -450,6 +470,7 @@ fn run(
nested,
launcher: t.launcher,
child,
spawned: spawned_pid,
launch_stamp,
// For an adopted launch this is the ORIGINAL launch's slot, so the record keeps
// tracking the same processes across the handover.
+164
View File
@@ -264,6 +264,73 @@ fn reg() -> &'static Reg {
})
}
/// One of this client's earlier launches that is still running — the input to
/// [`crate::session_settings::GameOnNewLaunch::End`].
pub struct StillRunning {
/// The store-qualified library id it was launched as.
pub game_id: String,
/// The processes its lease adopted, as last published. Re-verified by the caller immediately
/// before anything is signalled, so a pid recycled since is never hit (rule 2).
pub procs: Vec<crate::procscan::ProcRef>,
}
/// Every **other** title this client has running on this host from a launch the host performed.
///
/// The safety of ending-on-new-launch rests entirely on where this list comes from, so it is worth
/// being explicit about what it can never contain:
///
/// * **another client's game.** Records are keyed by cert fingerprint, and this filters on it — so
/// one device picking a new title can never close a game somebody else is mid-way through.
/// * **a game the player started themselves.** Only the host's own launches are recorded here at
/// all; a copy started at the machine was never written, so it cannot be read back out (rule 1,
/// preserved by construction rather than by a check).
/// * **the title being launched now.** Filtered by `keep_game_id`, so relaunching what is already
/// running still resolves to [`Plan::Adopt`] rather than closing the game and starting it again.
/// * **anything already gone.** Liveness is re-verified per record, and only [`Liveness::Running`]
/// qualifies — `Unknown` is deliberately excluded, because "no opinion" must never authorize
/// signalling a process.
pub fn others_still_running(
fingerprint: Option<&str>,
keep_game_id: Option<&str>,
) -> Vec<StillRunning> {
let Some(fp) = fingerprint else {
// An anonymous client (TOFU/`--open`, and the whole GameStream plane) owns no records, and
// must not be able to reach anyone else's.
return Vec::new();
};
reg()
.records
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|r| is_other_running(r, fp, keep_game_id, liveness(r)))
.map(|r| StillRunning {
game_id: r.key.game_id.clone(),
procs: r
.procs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.copied()
.collect(),
})
.collect()
}
/// The filter behind [`others_still_running`] — pure, so the four rules that make ending-on-launch
/// safe can be tested without a registry, a process table or a clock.
///
/// The caller supplies the liveness verdict for the same reason [`covers`] takes one.
fn is_other_running(rec: &Record, fp: &str, keep_game_id: Option<&str>, live: Liveness) -> bool {
rec.key.fingerprint == fp
&& Some(rec.key.game_id.as_str()) != keep_game_id
&& rec.launched
// `Unknown` deliberately does NOT qualify. It is the verdict for a launch that adopted
// nothing — a title with no detect signals, or a platform with no matcher — and "no opinion
// about what is running" must never authorize signalling a process.
&& live == Liveness::Running
}
/// Decide what this session must do about its launch, and claim the answer.
///
/// `fresh_stamp` is this session's own [`crate::gamelease::launch_clock`] reading, taken before
@@ -463,6 +530,66 @@ mod tests {
}
}
/// The four rules that keep `GameOnNewLaunch::End` from closing something it must not.
///
/// Each line here is a game somebody could otherwise lose mid-play, so they are asserted
/// individually rather than through one composite case.
#[test]
fn ending_on_a_new_launch_only_ever_reaches_this_clients_other_live_games() {
let mut r = rec(true, 0, None);
r.key.game_id = "steam:1".into();
// The case it exists for: same client, a different title, still up.
assert!(is_other_running(
&r,
"fp",
Some("steam:2"),
Liveness::Running
));
// Another device's game — one client picking a new title must never close someone else's.
assert!(!is_other_running(
&r,
"other-fp",
Some("steam:2"),
Liveness::Running
));
// The title being launched right now: that is a relaunch, which `Plan::Adopt` handles by
// keeping the game. Closing and restarting it would be the opposite of the intent.
assert!(!is_other_running(
&r,
"fp",
Some("steam:1"),
Liveness::Running
));
// A launch that never actually happened has nothing running behind it.
let never = rec(false, 0, None);
assert!(!is_other_running(
&never,
"fp",
Some("steam:2"),
Liveness::Running
));
// Already gone, and — the one that matters most — NO OPINION. `Unknown` means nothing was
// ever adopted, so there is no verified pid set to signal.
assert!(!is_other_running(&r, "fp", Some("steam:2"), Liveness::Gone));
assert!(!is_other_running(
&r,
"fp",
Some("steam:2"),
Liveness::Unknown
));
}
/// An anonymous client owns no records, and must not be able to reach anybody else's.
#[test]
fn an_anonymous_client_can_never_end_another_clients_game() {
assert!(others_still_running(None, Some("steam:2")).is_empty());
}
/// Identity is the record's key, and a launch that can't be keyed is never reclaimed.
#[test]
fn a_launch_is_keyed_by_both_the_client_and_the_title() {
@@ -513,6 +640,43 @@ mod tests {
assert!(!covers(&old, Liveness::Unknown, outside, window));
}
/// Why "click the game that is already running" resumes on some hosts and started a **second
/// copy** on others — and what fixed it.
///
/// The rule above is authoritative only where liveness has an opinion, and liveness comes from
/// the processes a launch's lease actually adopted. A lease that adopts nothing publishes
/// nothing, answers `Unknown`, and so falls back to the 90-second window — past which the same
/// title launches again.
///
/// That is precisely the state a Windows launch used to be stuck in for any title whose
/// provider published no detect signals: no child, no matched processes, nothing to publish.
/// Carrying the spawned pid (`gamelease::LeaseRequest::spawned`) is what moves such a launch
/// from the left column to the right one here.
#[cfg(any(target_os = "linux", windows))]
#[test]
fn only_a_launch_that_adopted_something_stays_adoptable_past_the_window() {
let t0 = Instant::now();
let window = Duration::from_secs(90);
let outside = t0 + Duration::from_secs(600);
// Adopted nothing: no opinion, so past the window this launches a second copy.
let blind = rec(true, 0, Some(t0));
assert_eq!(liveness(&blind), Liveness::Unknown);
assert!(!covers(&blind, liveness(&blind), outside, window));
// Adopted a process that is demonstrably alive — this very test binary — so the record
// answers `Running` and stays adoptable however long ago its session let go.
let seeing = rec(true, 0, Some(t0));
let self_ref = crate::procscan::resolve(std::process::id())
.expect("this process must be resolvable by the scanner");
seeing.procs.lock().unwrap().push(self_ref);
assert_eq!(liveness(&seeing), Liveness::Running);
assert!(
covers(&seeing, liveness(&seeing), outside, window),
"a launch whose game is still up must resume, not start a second copy"
);
}
/// The sweep drops what nobody can reclaim and keeps what somebody can.
#[test]
fn the_sweep_keeps_only_reclaimable_records() {
+12 -5
View File
@@ -183,8 +183,13 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
///
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
/// desktop and grabs foreground.
///
/// Returns the **pid of the process it started**, which is what the caller hands to
/// [`crate::gamelease::LeaseRequest::spawned`]. It used to be logged and discarded, and that was the
/// whole of Windows' disadvantage against Linux here: with no `Child` to hold and no pid kept, a
/// title whose provider supplied no detect hint left the lease nothing to watch or signal.
#[cfg(windows)]
pub fn launch_title(id: &str) -> Result<()> {
pub fn launch_title(id: &str) -> Result<u32> {
let entry = all_games()
.into_iter()
.find(|g| g.id == id)
@@ -206,7 +211,7 @@ pub fn launch_title(id: &str) -> Result<()> {
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
.with_context(|| format!("launch '{id}' in the interactive session"))?;
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
Ok(())
Ok(pid)
}
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
@@ -739,7 +744,7 @@ pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
pub fn launch_gamestream_command(cmd: &str) -> Result<u32> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
@@ -747,7 +752,9 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
.context("spawn gamestream command in the interactive session")?;
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
Ok(())
// The `cmd.exe` shim's own pid: it exits the moment it has started the real program, which the
// lease reads as a hand-off (inside its shim window) rather than as the game exiting.
Ok(pid)
}
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
@@ -756,7 +763,7 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
pub fn launch_gamestream_library(id: &str) -> Result<u32> {
launch_title(id)
}
+12 -5
View File
@@ -199,8 +199,9 @@ pub(crate) struct ActiveGame {
store: Option<String>,
/// `native` or `gamestream`.
plane: crate::events::Plane,
/// `launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is
/// gone and it will be ended when the reconnect window closes).
/// `launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title
/// exposes nothing the host can recognize its process by, so its exit will never be noticed),
/// or `grace` (its session is gone and it will be ended when the reconnect window closes).
#[schema(example = "running")]
state: String,
/// Seconds until this game is ended — only present on a `grace` row.
@@ -592,9 +593,15 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
games: crate::session_status::games()
.into_iter()
.map(|g| match g.grace_remaining_s {
Some(left) => format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60),
None => g.title,
.map(|g| match (g.grace_remaining_s, g.state) {
(Some(left), _) => {
format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60)
}
// Say so here too: the tray is the surface someone at the machine reads, and
// "Hades" beside a game the host cannot actually follow reads as a promise it
// never made.
(None, "untracked") => format!("{} (not tracked)", g.title),
(None, _) => g.title,
})
.collect(),
})
+30 -3
View File
@@ -1906,6 +1906,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// below, so a launch that did not happen can never be inherited by a later session.
#[allow(unused_mut)]
let mut spawned_now = false;
// The pid Windows hands back for the process it started, kept so the lease has something of its
// own to watch and to signal even when the title carries no detect signals at all (see
// `gamelease::LeaseRequest::spawned`). `None` on every other platform and whenever nothing was
// spawned.
#[allow(unused_mut)]
let mut spawned_pid: Option<u32> = None;
// Close whatever this client had running before, if the operator asked for that
// (`GameOnNewLaunch`). Before the spawn, and blocking, so the old game has released the display,
// the audio device and the gamepad before the new one asks for them. A no-op on the default
// policy, on an adopted launch (same title — nothing to close), and for an anonymous client.
if !adopt_launch {
if let Some(t) = launch_target.as_ref() {
crate::gamelease::end_others_for_new_launch(
endpoint::peer_fingerprint(&conn)
.map(hex::encode)
.as_deref(),
t.game.id.as_deref(),
);
}
}
#[cfg(target_os = "windows")]
if let Some(id) = launch.as_deref() {
if adopt_launch {
@@ -1914,10 +1934,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
"this client's copy of this title is already running from an earlier session — not \
starting a second one"
);
} else if let Err(e) = crate::library::launch_title(id) {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
} else {
spawned_now = true;
match crate::library::launch_title(id) {
Ok(pid) => {
spawned_pid = Some(pid);
spawned_now = true;
}
Err(e) => {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title")
}
}
}
}
#[cfg(target_os = "linux")]
@@ -2020,6 +2046,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
nested,
launcher: target.launcher,
child,
spawned: spawned_pid,
launch_stamp,
// For an adopted launch this is the ORIGINAL launch's slot, so the record keeps
// tracking the same processes across the handover.
+17
View File
@@ -70,6 +70,23 @@ pub fn launch_stamp() -> Option<f64> {
}
}
/// Pin a pid the host itself just spawned to *this* process, so it can be tracked and signalled
/// under rule 2 — see [`Scanner::resolve`]. `None` on a platform with no matcher, and for a pid that
/// has already gone or cannot be queried.
///
/// The platform-neutral wrapper, so [`crate::gamelease`] stays free of `cfg`s.
pub fn resolve(pid: u32) -> Option<ProcRef> {
#[cfg(any(target_os = "linux", windows))]
{
Scanner::system().resolve(pid)
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = pid;
None
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
@@ -115,6 +115,17 @@ impl Scanner {
out
}
/// Pin a pid the host itself just spawned to *this* process, by reading its start time.
///
/// The one legitimate way into rule 2 from a bare pid: it is safe here, and only here, because
/// the caller spawned the process and is resolving it immediately, so there is no window in
/// which the number could have been recycled. Everything downstream then re-verifies the pair
/// through [`Self::alive`] like any other adopted process.
pub fn resolve(&self, pid: u32) -> Option<ProcRef> {
let start = self.start_ticks(&self.root.join(pid.to_string()))?;
Some(ProcRef { pid, start })
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
@@ -105,6 +105,21 @@ impl Scanner {
out
}
/// Pin a pid the host itself just spawned to *this* process, by reading its creation time.
///
/// This is what lets a Windows launch be tracked at all when the title carries no detect
/// signals. `CreateProcessAsUserW` into the interactive session hands back a bare pid rather
/// than a `std::process::Child`, and that pid used to be logged and dropped — so a title whose
/// provider gave no `install_dir`/`exe` had **nothing** identifying it, the lease degraded to
/// [`crate::gamelease::LeaseKind::Untracked`], and neither its exit nor a request to end it
/// could be acted on. Resolving the pid immediately after the spawn is the one safe entry into
/// rule 2 from a bare pid: the number cannot have been recycled in that window, and everything
/// downstream re-verifies the (pid, creation time) pair through [`Self::alive`].
pub fn resolve(&self, pid: u32) -> Option<ProcRef> {
let (start, _image) = process_start_and_image(pid)?;
Some(ProcRef { pid, start })
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
+44 -1
View File
@@ -52,6 +52,40 @@ impl GameOnSessionEnd {
}
}
/// What to do with a title this client already has running when it launches a **different** one.
///
/// The third axis rather than a fourth value on [`GameOnSessionEnd`], because it answers a different
/// question at a different moment: that one is "this session is over, what about its game", this one
/// is "the player asked for something else, what about the last thing". Folding them together would
/// tie two unrelated choices to one switch — an operator who wants a game to survive a disconnect
/// very plausibly still wants it closed when they pick another title.
///
/// Scoped to the **same client's own launches**, and only ever to launches this host performed
/// itself ([`crate::launchreg`]). A game the player started at the machine was never recorded there,
/// so it can never be closed by this; nor can another client's game, which would otherwise let one
/// device end someone else's session mid-play.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GameOnNewLaunch {
/// Leave it running — the shipped default, and the same posture as [`GameOnSessionEnd::Keep`]:
/// ending a game can cost unsaved progress, so nothing is ended unless the operator asked.
#[default]
Keep,
/// Close it, politely first (`WM_CLOSE` / `SIGTERM`), before starting the new title. What a
/// player coming from Moonlight expects, and the top request from the 2026-08-16 field report:
/// "auto-closing the active game when launching a new one".
End,
}
impl GameOnNewLaunch {
pub fn as_str(self) -> &'static str {
match self {
Self::Keep => "keep",
Self::End => "end",
}
}
}
/// The default reconnect window before `Always` ends a game — long enough to cover a Wi-Fi
/// roam, a client crash-and-restart, or a walk to another room, because the cost of being wrong is
/// the player's unsaved progress.
@@ -84,6 +118,9 @@ pub struct SessionSettings {
/// End the streaming session when the launched game exits.
#[serde(default = "default_true")]
pub session_on_game_exit: bool,
/// End this client's previous game when it launches a different one. See [`GameOnNewLaunch`].
#[serde(default)]
pub game_on_new_launch: GameOnNewLaunch,
/// How long a vanished client has to reconnect before `Always` ends its game. Ignored by the
/// other two policies.
#[serde(default = "default_grace")]
@@ -96,6 +133,7 @@ impl Default for SessionSettings {
version: 1,
game_on_session_end: GameOnSessionEnd::default(),
session_on_game_exit: true,
game_on_new_launch: GameOnNewLaunch::default(),
disconnect_grace_seconds: DEFAULT_GRACE_SECS,
}
}
@@ -191,6 +229,7 @@ pub fn enforced() -> Vec<String> {
vec![
"session_on_game_exit".to_string(),
"game_on_session_end".to_string(),
"game_on_new_launch".to_string(),
"disconnect_grace_seconds".to_string(),
]
}
@@ -207,9 +246,11 @@ mod tests {
#[test]
fn defaults_are_the_documented_ones() {
let d = SessionSettings::default();
// End-session-on-game-exit ships ON; ending games ships OFF.
// End-session-on-game-exit ships ON; ending games ships OFF — on BOTH the axes that can
// end one, because both of them cost unsaved progress when they are wrong.
assert!(d.session_on_game_exit);
assert_eq!(d.game_on_session_end, GameOnSessionEnd::Keep);
assert_eq!(d.game_on_new_launch, GameOnNewLaunch::Keep);
assert_eq!(d.disconnect_grace_seconds, 300);
}
@@ -277,6 +318,7 @@ mod tests {
version: 99,
game_on_session_end: GameOnSessionEnd::Always,
session_on_game_exit: false,
game_on_new_launch: GameOnNewLaunch::End,
disconnect_grace_seconds: 5, // below the floor
})
.expect("write");
@@ -284,6 +326,7 @@ mod tests {
let got = store.get();
assert_eq!(got.game_on_session_end, GameOnSessionEnd::Always);
assert!(!got.session_on_game_exit);
assert_eq!(got.game_on_new_launch, GameOnNewLaunch::End);
assert_eq!(got.disconnect_grace_seconds, MIN_GRACE_SECS);
assert_eq!(got.version, 1, "version is normalized, not echoed");
// A fresh load sees the same thing, and no temp file is left behind.
+3 -1
View File
@@ -225,7 +225,8 @@ pub struct GameSnapshot {
pub title: String,
pub store: Option<String>,
pub plane: crate::events::Plane,
/// `launching` / `running` / `exited`, or `grace` for a game on its reconnect window.
/// `launching` / `running` / `exited` / `untracked`, or `grace` for a game on its reconnect
/// window.
pub state: &'static str,
/// Seconds left before the game is ended, for a `grace` row.
pub grace_remaining_s: Option<u64>,
@@ -437,6 +438,7 @@ mod tests {
nested: false,
launcher: false,
child: None,
spawned: None,
launch_stamp: None,
procs: None,
},
+7
View File
@@ -651,6 +651,8 @@
"games_state_running": "Läuft",
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_state_untracked": "Nicht verfolgt",
"games_untracked_note": "Dieser Titel bietet nichts, woran der Host seinen Prozess erkennen kann — das Beenden des Spiels beendet die Sitzung deshalb nicht",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele.",
"games_end_all_waiting_title": "Alle {count} wartenden Spiele beenden?",
@@ -670,6 +672,11 @@
"session_game_end_on_quit": "Beim Stoppen schließen",
"session_game_end_always": "Immer schließen",
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
"session_game_new_launch": "Wenn du ein anderes Spiel startest",
"session_game_new_launch_help": "Das laufende Spiel zu schließen gibt Anzeige, Tongerät und Controller für das neue frei. Es kostet aber auch alles, was dieses Spiel noch nicht gespeichert hatte.",
"session_game_new_launch_keep": "Weiterlaufen lassen",
"session_game_new_launch_end": "Vorher schließen",
"session_game_new_launch_scope": "Betrifft nur Spiele, die dieser Host für dasselbe Gerät gestartet hat, und nie den Titel, den du gerade startest — ein bereits laufendes Spiel erneut zu wählen bringt dich weiterhin dorthin zurück. Ein Spiel, das du an diesem Rechner selbst gestartet hast, oder eines, das ein anderes Gerät spielt, wird nie angefasst.",
"session_game_grace": "Zeitfenster für die Rückkehr",
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
+7
View File
@@ -651,6 +651,8 @@
"games_state_running": "Running",
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_state_untracked": "Not tracked",
"games_untracked_note": "This title exposes nothing the host can recognize its process by, so quitting it won't end the session",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close.",
"games_end_all_waiting_title": "End all {count} waiting games?",
@@ -670,6 +672,11 @@
"session_game_end_on_quit": "Close it on Stop",
"session_game_end_always": "Always close it",
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
"session_game_new_launch": "When you launch another game",
"session_game_new_launch_help": "Closing the game you were playing frees the display, the sound device and the controller for the new one. It also costs whatever that game had not saved.",
"session_game_new_launch_keep": "Leave it running",
"session_game_new_launch_end": "Close it first",
"session_game_new_launch_scope": "Only reaches games this host launched for the same device, and never the title you are starting — picking a game that is already running still takes you back to it. A game you started at this machine, or one another device is playing, is never touched.",
"session_game_grace": "Reconnect window",
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
"session_game_saved": "Session and game settings saved",
+13 -1
View File
@@ -56,6 +56,11 @@ const GameRow: FC<{
isEnding: boolean;
}> = ({ game, art, onEnd, isEnding }) => {
const waiting = game.state === "grace";
// A title the host cannot recognize the process of. Worth its own line rather than just a badge:
// the whole point is that the session settings do not apply to this row, and a user who has just
// quit the game and is watching the console wondering why nothing happened deserves the answer
// here rather than in the host log.
const untracked = game.state === "untracked";
return (
<div className="flex items-center gap-3">
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an
@@ -86,7 +91,9 @@ const GameRow: FC<{
? m.games_closing_in({
time: formatCountdown(game.grace_remaining_s ?? 0),
})
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
: untracked
? m.games_untracked_note()
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
</p>
</div>
<Button
@@ -129,6 +136,8 @@ function stateLabel(state: string): string {
return m.games_state_exited();
case "grace":
return m.games_state_grace();
case "untracked":
return m.games_state_untracked();
default:
return state;
}
@@ -137,6 +146,9 @@ function stateLabel(state: string): string {
function stateVariant(state: string): "success" | "secondary" | "outline" {
if (state === "running") return "success";
if (state === "launching") return "secondary";
// `untracked` falls through to `outline` with the rest: it is not an error — plenty of
// legitimate titles (a launcher tile, an AUMID activation) can never be tracked — so it must not
// wear the destructive styling that `grace` uses to mean "about to lose your progress".
return "outline";
}
+39 -1
View File
@@ -2,7 +2,11 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import type {
GameOnNewLaunch,
GameOnSessionEnd,
SessionSettings,
} from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
@@ -18,6 +22,7 @@ import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
const NEW_LAUNCH_POLICIES: GameOnNewLaunch[] = ["keep", "end"];
/**
* Whether a launched game and its streaming session share a fate
@@ -135,6 +140,34 @@ export const SessionGameCard: FC = () => {
</p>
</Field>
{/* 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. */}
<Field
label={m.session_game_new_launch()}
help={m.session_game_new_launch_help()}
group
>
<div className="flex flex-wrap gap-2">
{NEW_LAUNCH_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_new_launch ?? "keep") === p}
disabled={busy || !acts("game_on_new_launch")}
onClick={() => apply({ game_on_new_launch: p })}
>
{NEW_LAUNCH_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_new_launch ?? "keep") === "end" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_new_launch_scope()}
</p>
)}
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
@@ -201,6 +234,11 @@ const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
always: () => m.session_game_end_always(),
};
const NEW_LAUNCH_LABEL: Record<GameOnNewLaunch, () => string> = {
keep: () => m.session_game_new_launch_keep(),
end: () => m.session_game_new_launch_end(),
};
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*