Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
107ad09
wip: generalization checkpoint (catalog, Steam sources, shared presen…
Bium23 Nov 13, 2026
0a46af7
feat: Steam squares use the app's own icon, never a cropped cover
Bium23 Nov 13, 2026
6fe3177
feat: Steam games use the shared Selene head and hero
Bium23 Nov 13, 2026
feb7742
fix: keep game art between Steam and nxnative games
Bium23 Nov 13, 2026
b92b531
feat: Steam games get the Spotlight page below the head
Bium23 Nov 13, 2026
95582ee
feat: Steam backgrounds prefer screen-shaped art
Bium23 Nov 13, 2026
3471549
feat: Steam Play with real sessions
Bium23 Nov 13, 2026
ff52c65
feat: Steam Options replace the separate Steam page
Bium23 Nov 13, 2026
554b57a
fix: Steam sessions survive an NX Library restart
Bium23 Nov 13, 2026
b814538
feat: Steam art shows at once and LaunchBox finds PC art
Bium23 Nov 14, 2026
c4f134a
feat: Keep Steam in the background while a game starts
Bium23 Nov 14, 2026
20494a2
feat: Settings groups keep Switch and Steam apart
Bium23 Nov 14, 2026
db29089
fix: Settings sidebar follows the selected section
Bium23 Nov 14, 2026
2f73234
fix: Settings sidebar scrolls to the selection at once
Bium23 Nov 14, 2026
a63bd78
fix: Settings Search waits for the player to open its field
Bium23 Nov 14, 2026
2ba7b20
fix: Esc in Settings Search closes the field, not Settings
Bium23 Nov 14, 2026
d5440ff
fix: moonlight keeps 60 fps on ProMotion while nothing else moves
Bium23 Nov 14, 2026
7a8f284
fix: Steam heads arrive exactly like Switch ones
Bium23 Nov 14, 2026
c4e18ff
feat: Steam backgrounds also look for LaunchBox fan art
Bium23 Nov 14, 2026
e07686e
fix: a Steam session follows the process it saw start
Bium23 Nov 14, 2026
324451b
feat: Switch-only settings move into the Switch group
Bium23 Nov 14, 2026
4ab1e83
chore: drop the unused Steam backdrop view and orphan strings
Bium23 Nov 14, 2026
0300f09
fix: app data roots are folders before they exist
Bium23 Nov 14, 2026
81fda68
fix: Steam art decodes off the main thread like nxnative art
Bium23 Nov 14, 2026
64fbf7f
fix: Collection's head fades with Spotlight's curve
Bium23 Nov 14, 2026
3150129
fix: drop two concurrency warnings and an unused notification
Bium23 Nov 14, 2026
e70772c
fix: Steam Play checks processes cheaply; drop unused capability rules
Bium23 Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ jobs:
run: python3 -m unittest discover -s Tests/PublicationChecks
- name: Swift tests
run: swift test
- name: Catalog notice regressions without UI
run: python3 Tests/CatalogAppChecks/run_notices.py
- name: Shared game header regression without UI
run: python3 Tests/CatalogAppChecks/run_game_header.py
- name: Release compilation
run: swift build -c release --product NXLibrary
- name: Package and verify development app
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Discovery includes subfolders of Game apps. It stays inside the chosen folders,

Import your `prod.keys` when prompted. NX Library keeps a private copy with mode `0600` and leaves the original file unchanged. Key values are not displayed or logged.

Reopen both setup steps from **Settings → Start-up → Open Setup** whenever needed; this keeps your library and completion state. NX Library never runs `sudo`, accepts the Xcode licence for you or changes `xcode-select`.
Reopen both setup steps from **Settings → Switch · Engine → Open Setup** whenever needed; this keeps your library and completion state. NX Library never runs `sudo`, accepts the Xcode licence for you or changes `xcode-select`.

Already have a built nxnative game app? Use **Settings → Library → Scan game apps → Scan**. The same action finds existing builds and refreshes the library. Play remains available without the original sources; rebuilding requires attaching them again.

Expand Down
18 changes: 8 additions & 10 deletions Sources/NXLibrary/NXLibraryApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension Notification.Name {
static let nxSetupCheckGamepadAction = Notification.Name("NXSetupCheckGamepadAction")
static let nxThemePickerGamepadAction = Notification.Name("NXThemePickerGamepadAction")
static let nxLibraryOpenSettings = Notification.Name("NXLibraryOpenSettings")
static let nxLibraryRetryCatalogSave = Notification.Name("NXLibraryRetryCatalogSave")
static let nxLibraryOpenConsole = Notification.Name("NXLibraryOpenConsole")
static let nxLibraryOpenLibrary = Notification.Name("NXLibraryOpenLibrary")
static let nxLibraryOpenAddGame = Notification.Name("NXLibraryOpenAddGame")
Expand All @@ -28,18 +29,9 @@ struct NXLibraryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@FocusedValue(\.nxMenuAvailability) private var availability
@ObservedObject private var localization = AppLocalization.shared
private let settingsStore: AppSettingsStore

init() {
let store = AppSettingsStore()
settingsStore = store
// Publish the initial locale before SwiftUI starts constructing the window graph.
AppLocalization.shared.configure(store.settings)
}

var body: some Scene {
Window("NX Library", id: "main") {
MainView(settingsStore: settingsStore)
DevelopmentStartupView()
}
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1440, height: 900)
Expand All @@ -50,6 +42,12 @@ struct NXLibraryApp: App {
.keyboardShortcut("n", modifiers: [.command])
.disabled(availability?.general != true)
}
CommandGroup(after: .newItem) {
if availability?.catalogRecovery == true {
Button(L10n.text("Retry saving library")) { NativeMenuCommands.shared.post(.nxLibraryRetryCatalogSave) }
.keyboardShortcut("s", modifiers: [.command])
}
}
CommandGroup(after: .appInfo) {
Button(L10n.text("Check for Updates…")) { NativeMenuCommands.shared.post(.nxLibraryCheckAppUpdates) }
.disabled(availability?.general != true)
Expand Down
5 changes: 2 additions & 3 deletions Sources/NXLibrary/Services/AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class AppUpdater: ObservableObject {

private let repository: String?
private let runningVersion: String
private let defaults = UserDefaults.standard
private let defaults = AppData.preferences
private var downloadSession: URLSession?
private var lastRelease: AppUpdate.Release?
private var shouldOfferResult = false
Expand Down Expand Up @@ -276,8 +276,7 @@ final class AppUpdater: ObservableObject {
}

private static func workFolder() -> URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("NXLibrary/Temp/Update")
AppData.supportRoot.appendingPathComponent("Temp/Update")
}

nonisolated private static func run(_ tool: String, _ arguments: [String]) -> Bool {
Expand Down
6 changes: 3 additions & 3 deletions Sources/NXLibrary/Services/GameControllerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public final class GameControllerService: ObservableObject {
@Published public var deadzone: Float = 0.35 {
didSet {
let clamped = min(max(deadzone, 0.10), 0.80)
UserDefaults.standard.set(Double(clamped), forKey: deadzoneKey)
AppData.preferences.set(Double(clamped), forKey: deadzoneKey)
}
}

Expand All @@ -28,8 +28,8 @@ public final class GameControllerService: ObservableObject {
private var lastNavTime: TimeInterval = 0

private init() {
if UserDefaults.standard.object(forKey: deadzoneKey) != nil {
deadzone = min(max(Float(UserDefaults.standard.double(forKey: deadzoneKey)), 0.10), 0.80)
if AppData.preferences.object(forKey: deadzoneKey) != nil {
deadzone = min(max(Float(AppData.preferences.double(forKey: deadzoneKey)), 0.10), 0.80)
}
setupObservers()
refreshControllers()
Expand Down
1 change: 1 addition & 0 deletions Sources/NXLibrary/Services/NativeMenuCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct NativeMenuAvailability {
var play = false
var details = false
var options = false
var catalogRecovery = false
}

private struct NativeMenuAvailabilityKey: FocusedValueKey {
Expand Down
245 changes: 245 additions & 0 deletions Sources/NXLibrary/Services/SteamPlayCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import AppKit
import Combine
import NXLibraryCore

/// Play for Steam games and their real sessions (`SteamPlayState`). Play sends `steam://run/<AppID>`
/// to the registered Steam client only; it never opens the game's app itself. The process list is
/// read on launches, quits and the app list changing, and every 30 s for waits and heartbeats: the
/// installation's own app starts a session, its exit ends it, Steam staying open does not count.
/// The catalog is not written here; only `PlaySessionStore` is.
///
/// With "Keep Steam in the background" (`SteamBackgroundLaunch`), a Steam that Play starts opens
/// with `-silent -nofriendsui`, and an open Steam is hidden while the game is on its way. Past
/// `attentionDelay` Steam is shown again and the head says to check it; once Play is over,
/// Steam comes back only if it was in view before.
@MainActor
final class SteamPlayCoordinator: ObservableObject {
static let shared = SteamPlayCoordinator()
nonisolated static let steamBundleID = "com.valvesoftware.steam"
private static let tick: TimeInterval = 30

@Published private(set) var states: [UUID: SteamPlayState] = [:]
/// Waits past `attentionDelay` of a background launch: Steam is back in view and may need the player.
@Published private(set) var late: Set<UUID> = []

private var games: [CatalogGame] = []
private var steamEnabled = false
private var sessions: PlaySessionStore?
private var settings: AppSettingsStore?
/// Background launches in progress, with whether Steam was in view before Play.
private var backgrounds: [UUID: Bool] = [:]
private var tokens: [NSObjectProtocol] = []
/// Each running app's paths as the system gives them and resolved, by pid: resolved once while it lives.
private var canonicalProcesses: [Int32: (observed: NativeProcessObservation, canonical: NativeProcessObservation)] = [:]
private var listObservation: NSKeyValueObservation?
private var timer: Timer?

func phase(of game: CatalogGame) -> SteamPlayState.Phase { states[game.id]?.phase ?? .idle }
func isRunning(_ game: CatalogGame) -> Bool { if case .running = phase(of: game) { return true }; return false }
/// One available, recognized installation in an enabled source: what Play needs.
func canPlay(_ game: CatalogGame) -> Bool {
CatalogGamePresentation(game: game, sourceEnabled: steamEnabled).launchTarget != nil
}

/// The A hint for a Steam game, matching the Play pill; none while Play cannot do anything.
func hint(for game: CatalogGame) -> [HintItem] {
switch phase(of: game) {
case .running: return [HintItem(.a, L10n.text("Resume"))]
case .starting: return [HintItem(.a, L10n.text("Cancel"))]
case .failed: return [HintItem(.a, L10n.text("Try again"))]
case .idle: return canPlay(game) ? [HintItem(.a, L10n.text("Play"))] : []
}
}

/// The pill and A press: waiting cancels, anything else plays (or brings a running game forward).
func press(_ game: CatalogGame) {
if case .starting = phase(of: game) { cancel(game) } else { play(game) }
}

/// Steam is coming back for the player: the game is late and Steam may be asking for something.
func mayNeedSteam(_ game: CatalogGame) -> Bool { late.contains(game.id) }

func start(sessions: PlaySessionStore, settings: AppSettingsStore) {
guard tokens.isEmpty else { return }
self.sessions = sessions
self.settings = settings
let center = NSWorkspace.shared.notificationCenter
// Launches and quits arrive through the `runningApplications` observation below, once each.
// Steam brings itself forward when it gets steam://run; during a background launch it goes away again.
tokens = [NSWorkspace.didActivateApplicationNotification, NSWorkspace.didUnhideApplicationNotification].map {
center.addObserver(forName: $0, object: nil, queue: .main) { [weak self] note in
let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
guard app?.bundleIdentifier == Self.steamBundleID else { return }
Task { @MainActor in self?.followBackground() }
}
}
listObservation = NSWorkspace.shared.observe(\.runningApplications) { [weak self] _, _ in
Task { @MainActor in self?.refresh() }
}
timer = Timer.scheduledTimer(withTimeInterval: Self.tick, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refresh(heartbeat: true) }
}
refresh()
}

func stop() {
let center = NSWorkspace.shared.notificationCenter
for token in tokens { center.removeObserver(token) }
tokens = []
listObservation?.invalidate(); listObservation = nil
timer?.invalidate(); timer = nil
// A later start re-reads the processes: `RunningGameMonitor.start` closes open Steam sessions
// at their last heartbeat, and a game still running must start a new one, not stay "running".
states = [:]
canonicalProcesses = [:]
sessions = nil
settings = nil
if backgrounds.values.contains(true) { Self.showSteam() }
backgrounds = [:]
late = []
}

/// The catalog changed. A game no longer in it keeps its session until its process ends.
func setGames(_ all: [CatalogGame], sources: [CatalogSourceConfiguration]) {
games = all.filter { $0.integration == .steam }
steamEnabled = sources.contains { $0.integration == .steam && $0.isEnabled }
refresh()
}

/// The registered handler of steam:// URLs, when it is Valve's Steam client.
static var steamClientURL: URL? {
guard let probe = URL(string: "steam://"),
let handler = NSWorkspace.shared.urlForApplication(toOpen: probe),
Bundle(url: handler)?.bundleIdentifier == steamBundleID else { return nil }
return handler
}

func play(_ game: CatalogGame) {
// A game that quit since the last notification is not resumed: read the processes first.
refresh()
let presentation = CatalogGamePresentation(game: game, sourceEnabled: steamEnabled)
let client = Self.steamClientURL
var state = states[game.id] ?? SteamPlayState()
let request = state.requestPlay(target: presentation.launchTarget, clientAvailable: client != nil, now: Date())
states[game.id] = state
switch request {
case .resume(let pid):
if let app = NSRunningApplication(processIdentifier: pid) { RunningGameMonitor.activate(app) }
case .handOff(let url, let generation):
guard let client else { return }
// Steam gets the request in the background: NX Library stays in front until the game's
// own window appears, instead of Steam's library flashing up in between.
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = false
let id = game.id
let background = settings?.settings.steamInBackground ?? false
let steam = Self.steamApplication
// Launch arguments reach only a Steam that this request starts.
configuration.arguments = SteamBackgroundLaunch.arguments(enabled: background, steamRunning: steam != nil)
late.remove(id)
if background {
backgrounds[id] = backgrounds[id] ?? (steam.map { !$0.isHidden } ?? false)
Timer.scheduledTimer(withTimeInterval: SteamBackgroundLaunch.attentionDelay + 0.5, repeats: false) { [weak self] _ in
Task { @MainActor in self?.followBackground() }
}
followBackground()
}
NSWorkspace.shared.open([url], withApplicationAt: client, configuration: configuration) { [weak self] _, error in
let accepted = error == nil
Task { @MainActor in self?.finishHandOff(id, accepted: accepted, generation: generation) }
}
case .wait, .refuse:
break
}
}

/// Stop waiting, or dismiss a failure. A game already running is not touched.
func cancel(_ game: CatalogGame) {
guard var state = states[game.id] else { return }
state.cancel(); states[game.id] = state
followBackground()
}

private func finishHandOff(_ id: UUID, accepted: Bool, generation: Int) {
guard var state = states[id] else { return }
state.handoffFinished(accepted: accepted, generation: generation)
states[id] = state
refresh()
}

private func refresh(heartbeat: Bool = false) {
// Before `start` there is no session store: a game seen now would turn "running" with no session.
guard sessions != nil else { return }
// The list drops an app when it quits (no `isTerminated` round trip to LaunchServices per app), and a pid's
// paths are resolved once while it lives.
var resolved: [Int32: NativeProcessObservation] = [:]
let processes = NSWorkspace.shared.runningApplications.map { app -> NativeProcessObservation in
let pid = app.processIdentifier
let observed = NativeProcessObservation(pid: pid, bundleURL: app.bundleURL, executableURL: app.executableURL)
let process = canonicalProcesses[pid].flatMap { $0.observed == observed ? $0.canonical : nil } ?? observed.canonical()
canonicalProcesses[pid] = (observed, process)
resolved[pid] = process
return process
}
canonicalProcesses = canonicalProcesses.filter { resolved[$0.key] != nil }
let now = Date()
var next = states
let followed = Set(games.map(\.id)).union(states.keys)
for id in followed {
let game = games.first { $0.id == id }
let app = game?.steamGame?.installations.first { $0.availability == .available }?.primaryApplication
var state = next[id] ?? SteamPlayState()
switch state.observe(processes, application: app, now: now) {
case .started:
if let appID = game?.steamGame?.appID { sessions?.start(gameID: id, sourceID: "steam-\(appID)") }
case .ended:
sessions?.end(gameID: id)
case nil:
if heartbeat, case .running = state.phase { sessions?.heartbeat(for: id) }
}
if state.phase == .idle, game == nil { next[id] = nil } else { next[id] = state }
}
if next != states { states = next }
followBackground()
}

// MARK: - Keep Steam in the background

private static var steamApplication: NSRunningApplication? {
NSRunningApplication.runningApplications(withBundleIdentifier: steamBundleID).first { !$0.isTerminated }
}

private static func showSteam() {
// Shown, not brought forward: NX Library or the game keeps the focus.
if let steam = steamApplication, steam.isHidden { steam.unhide() }
}

/// Hides Steam while a background launch is on its way; shows it again when the game is late,
/// and once Play is over if it was in view before.
private func followBackground(now: Date = Date()) {
var keepAway = false
for (id, wasVisible) in backgrounds {
let phase = states[id]?.phase ?? .idle
switch phase {
case .starting:
if SteamBackgroundLaunch.mayNeedPlayer(enabled: true, phase: phase, now: now) {
backgrounds[id] = nil
late.insert(id)
Self.showSteam()
} else if SteamBackgroundLaunch.keepsSteamAway(enabled: true, phase: phase, now: now) {
keepAway = true
}
case .running:
break
case .idle, .failed:
backgrounds[id] = nil
if wasVisible { Self.showSteam() }
}
}
let waiting = Set(states.compactMap { id, state -> UUID? in
if case .starting = state.phase { return id } else { return nil }
})
if !late.isSubset(of: waiting) { late.formIntersection(waiting) }
if keepAway, let steam = Self.steamApplication, !steam.isHidden { steam.hide() }
}
}
Loading