Skip to content
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

User-visible changes to NX Library are recorded here. Entries are grouped by release, with the newest first. Engine changes also have their own [upgrade notes](docs/upstream-upgrades/README.md).

## 0.1.4.1 — unreleased

### Added

- **Session card.** When you close a game, a card in the top right says how long you played this time and in total, over the game's faint art with its logo, as in Selene. It waits until NX Library is in front, stays 8 seconds and takes no input. Sessions under 10 seconds show none. Switch: Settings → Games → **Session card** (on by default).
- **Back to NX Library when a game closes.** A game you quit while playing it brings NX Library back to the front, in its own Space, instead of leaving you wherever macOS chooses. If you had moved to another app while the game ran, or another game is still open, NX Library stays where it is.

### Changed

- **Mods that need a loader are left out.** Some GameBanana file mods list requirements on their page: a loader or a PC tool (exlaunch, Skyline, ARCropolis, TKMM, a mod manager, TotK Recomp, ReShade). The engine runs none of them, so such a mod would install and then not work, or break the game. NX Library now reads those requirements and doesn't list the mod. A tool the author only recommends doesn't hide it.
- **A mod's panel shows its requirements,** such as the game version it was made for or another mod it needs, under its version line, so a mismatch shows before the download. Notes about a modded Switch or an emulator are left out.
- The DMG is made with `diskutil image`, as macOS 26 asks.
- Updated the pinned nxnative engine to `bd979ced`. Ocarina of Time (Switch 2) loses the dotted rows across fog and light, the holes in the ground, Kokiri Forest's white blotches and the wrong colours of the Poe Sisters' and Phantom Ganon's paintings; its first start now prepares its pipelines before the first frame, it uses less memory, and its foliage draws without flashing white. Upstream also reports fixes for Donkey Kong Bananza's ambient occlusion, Mario Kart World, Mario Kart 8 Deluxe's shader build time, Super Smash Bros. Ultimate's first start, Hyrule Warriors: Age of Calamity's cutscenes, The Great Ace Attorney Chronicles, Monster Jam Steel Titans 2, Little Nightmares II, Ori and the Will of the Wisps, Tetris 99, Pokémon Friends, Splatoon Raiders and Cuphead 1.3.7; DRAGON BALL: Sparking! ZERO now plays, and Pikmin 4 runs at 60 fps (up to 120 on the Mac, in **Game options → Frame rate** after a rebuild). Two identical controllers over USB are two players, 30 fps in full screen is steadier, and a NUP is no longer unpacked again when it hasn't changed. Rebuilt games get **Press a button to join** in **Game options → Controls**. The Metal compiler is the same; every game translates its shaders again at its next build, and installed apps keep their engine until rebuilt.
- **Game files are read by what they contain.** The engine now takes each game file's title and version from inside the file, not from its name, and NX Library does the same when you add games, so both agree on the version to build. A file named with the wrong `[v…]` tag is no longer a problem, and a base game that is already version 1.1.0 builds as such. Games added before this version rebuild without asking you to add their files again.

### Fixed

- **.NET in your home folder is found.** Setup and builds now also look for .NET in `~/.dotnet`, where Microsoft's install script puts it, as the engine does. Before, Setup said .NET was missing.
- **The newest engine can be updated in place.** While you use **Newest engine** (Settings → Engine) and nxnative main moves on, **Check** now offers **Update** beside **Back to tested**, saying how many commits newer main is. With the keyboard or a controller, Left and Right move between the two buttons and Return or A presses the one in focus. In the upstream list, the commit you are on is tagged **In use** in green and those above it **New** in orange. Before, the only way forward was back to the tested engine and then **Use newest** again. A main that needs another Metal compiler is still refused.
- **A mod you installed from GameBanana always shows in the game's Mods band,** with **Update** when its author replaced the file. Before, it vanished while GameBanana left it out of the game's list (as it can while an update waits for approval) or when it was past the band's first twelve mods.
- A library file that can no longer be read is kept beside it as `library.json.damaged-…` instead of being written over by the next save when there was no backup.
- When NX Library cannot save the library (a full disk, for example), it now says so instead of losing recent changes at the next start in silence.
- The private copy of `prod.keys` is created readable only by you from its first byte, not made private just after it is written.
- A game's total play time and last played date no longer shrink after many sessions. NX Library keeps only the latest 200 sessions; older ones now count toward each game's total instead of disappearing from it. Play time already lost before this fix can't be recovered.

## 0.1.4 — 2026-11-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ Before reporting a problem, review copied diagnostics and remove anything person
### Compatibility and limitations

- NX Library uses a manually pinned engine and checks its commit, working tree, CLI contract and Metal compiler before new builds. It does not follow upstream `main` automatically. **Settings → Engine → Newest engine** lets you choose `main` yourself before NX Library has tested it: an orange dot marks it, builds are recorded as untested, **Back to tested** returns to the pinned engine, and it is not offered when `main` needs a different Metal compiler.
- The current engine is `b2d46b86` and certifies Metal `32023.922`. **Build anyway with an untested toolchain** requires explicit confirmation and marks the resulting build as untested. A successful launch does not turn it into a certified combination.
- The current engine is `bd979ced` and certifies Metal `32023.922`. **Build anyway with an untested toolchain** requires explicit confirmation and marks the resulting build as untested. A successful launch does not turn it into a certified combination.
- Game compatibility depends on nxnative, the game version and the toolchain.
- Output follows the window/display; native 1440p/4K internal rendering is not supported. Render scale is 50–100% where the build supports it. MetalFX is spatial upscaling.
- Builds run one at a time. iOS/IPA output, automatic engine updates and notarisation are not currently provided.
Expand Down
74 changes: 71 additions & 3 deletions Sources/NXLibrary/Services/RunningGameMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import NXLibraryCore
/// read from the system's process list (`RunningAppsTracker`) whenever it may have changed: on
/// launch and terminate notifications, on changes to `NSWorkspace.runningApplications`, and when
/// NX Library comes forward. Notifications alone miss a game whose launcher quits from its settings
/// window (it becomes a background process before it exits). A game that stops ends its play session.
/// window (it becomes a background process before it exits). A game that stops ends its play session, and when it
/// quit in front with no other library game open, NX Library comes back to the front (`GameFocus`).
@MainActor
final class RunningGameMonitor: ObservableObject {
/// Time a game gets to quit after Close Game, in nanoseconds (5 s) before it is force-quit.
static let forceQuitDelay: UInt64 = 5_000_000_000

@Published private(set) var runningGameIDs: Set<UUID> = []
/// The latest play session a game's quit ended, for the session card (nil when it was too short for one).
@Published private(set) var endedSession: SessionSummary?

private let center: NotificationCenter
private var tokens: [NSObjectProtocol] = []
Expand All @@ -22,6 +25,8 @@ final class RunningGameMonitor: ObservableObject {
private var activeToken: NSObjectProtocol?
private var games: [PortedGame] = []
private var closeTasks: [String: Task<Void, Never>] = [:]
private var focus = GameFocus()
private var focusToken: NSObjectProtocol?

init(center: NotificationCenter = NSWorkspace.shared.notificationCenter) {
self.center = center
Expand All @@ -40,6 +45,12 @@ final class RunningGameMonitor: ObservableObject {
Task { @MainActor in self?.refresh() }
}
}
focusToken = center.addObserver(forName: NSWorkspace.didActivateApplicationNotification,
object: nil, queue: .main) { [weak self] note in
let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
let bundleID = app?.bundleIdentifier
Task { @MainActor in self?.activated(bundleID) }
}
activeToken = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification,
object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.refresh() }
Expand All @@ -52,6 +63,8 @@ final class RunningGameMonitor: ObservableObject {
func stop() {
for token in tokens { center.removeObserver(token) }
tokens = []
if let focusToken { center.removeObserver(focusToken) }
focusToken = nil
if let activeToken { NotificationCenter.default.removeObserver(activeToken) }
activeToken = nil
processListObservation?.invalidate()
Expand Down Expand Up @@ -125,12 +138,67 @@ final class RunningGameMonitor: ObservableObject {
/// Reads the process list again; a game no longer in it ends its play session and any pending force-quit.
func refresh() {
guard let tracker else { return }
for bundleID in tracker.refresh().ended {
sessions?.end(bundleID: bundleID)
let ended = tracker.refresh().ended
var comesForward = false
for bundleID in ended {
let others = libraryBundleIDs.intersection(tracker.running).subtracting(ended)
if focus.gameEnded(bundleID, othersRunning: !others.isEmpty) { comesForward = true }
for session in sessions?.end(bundleID: bundleID) ?? [] {
guard let game = games.first(where: { $0.id == session.gameID }),
let summary = SessionSummary(session: session, game: game,
total: sessions?.totalDuration(for: game.id) ?? 0) else { continue }
endedSession = summary
}
closeTasks[bundleID]?.cancel()
closeTasks[bundleID] = nil
}
recompute()
if comesForward { comeForward() }
}

private var libraryBundleIDs: Set<String> { Set(games.compactMap(\.effectiveBundleId)) }

/// A library game in front is noted; another app that is still in front a second later, while that game runs,
/// means the player left the game (an app macOS brings forward as a game quits does not count).
private func activated(_ bundleID: String?) {
if let bundleID, libraryBundleIDs.contains(bundleID) {
focus.gameCameForward(bundleID)
return
}
guard let game = focus.gameInFront else { return }
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let self, Self.runningApplication(bundleID: game) != nil,
NSWorkspace.shared.frontmostApplication?.bundleIdentifier != game else { return }
self.focus.otherAppStayedForward(whileRunning: game)
}
}

/// NX Library back in front after the game in front quit. macOS often makes NX Library the active app by itself
/// as the game quits, but leaves the screen on the desktop while NX Library's full-screen window stays in its own
/// Space: activating alone shows nothing. So the main window is also brought forward, which takes macOS to its
/// Space. `ignoringOtherApps: true` covers the case where macOS put another app in front instead. Checked once
/// more after the Space switch; never over a library game.
private func comeForward() {
Self.bringForward(Self.mainWindow)
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 600_000_000)
let window = Self.mainWindow
guard let self, !NSApp.isActive || window?.isOnActiveSpace == false else { return }
if let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier,
self.libraryBundleIDs.contains(front) { return }
Self.bringForward(window)
}
}

/// NX Library's main window: the visible one that can be main (not a panel or the menu bar's).
private static var mainWindow: NSWindow? {
NSApp.mainWindow ?? NSApp.windows.first { $0.isVisible && $0.canBecomeMain }
}

private static func bringForward(_ window: NSWindow?) {
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}

private func recompute() {
Expand Down
3 changes: 2 additions & 1 deletion Sources/NXLibrary/ViewModels/AppViewModel+KeyCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ extension AppViewModel {
keyCheck = nil
Task.detached(priority: .userInitiated) { [weak self] in
let outcome = KeyCheck.run(keysURL: keysURL, files: files)
guard let self else { return }
await MainActor.run {
guard let self, self.keyCheckToken == token else { return }
guard self.keyCheckToken == token else { return }
self.keyCheck = outcome
}
}
Expand Down
15 changes: 12 additions & 3 deletions Sources/NXLibrary/ViewModels/AppViewModel+NewestEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ extension AppViewModel {
}
}

/// The Newest engine row's buttons: Use newest, or Update beside Back to tested once main has moved on.
public var newestEngineActions: [NewestEngineAction] {
NewestEngine.actions(choice: newestEngineChoice, main: newestEngineOffer?.upstreamCommit,
readiness: newestEngineReadiness)
}

public var canSwitchEngine: Bool {
isNxnativeValid && !isSwitchingEngine && !isBuilding && !isUpdatingApp
}

/// Moves the engine to upstream main and turns the untested-toolchain override on.
/// Moves the engine to upstream main and turns the untested-toolchain override on. While the newest engine is
/// already in use this is Update: the checkout moves to the newer main and the override's origin is kept.
public func useNewestEngine() {
guard canSwitchEngine, let status = newestEngineOffer else { return }
if case .needsNewerApp = newestEngineReadiness { return }
Expand All @@ -55,11 +62,13 @@ extension AppViewModel {
self.isSwitchingEngine = false
switch result {
case .success:
let turnedOn = !self.allowUntestedToolchain || self.newestEngineChoice?.turnedOnOverride == true
let previous = self.newestEngineChoice
let turnedOn = !self.allowUntestedToolchain || previous?.turnedOnOverride == true
self.allowUntestedToolchain = true
self.newestEngineChoice = NewestEngineChoice(commit: commit, aheadBy: ahead, turnedOnOverride: turnedOn)
let verb = previous.map { "updated from \($0.shortCommit) to" } ?? "switched to"
self.logs.append(LogEntry(level: .warning,
message: "Engine switched to nxnative main \(commit.prefix(7)) (\(NewestEngineCopy.ahead(ahead))); builds are recorded as untested."))
message: "Engine \(verb) nxnative main \(commit.prefix(7)) (\(NewestEngineCopy.ahead(ahead))); builds are recorded as untested."))
case .failure(let error):
if case NewestEngineError.needsNewerApp(let metal) = error {
self.newestEngineReadiness = .needsNewerApp(metal: metal)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extension AppViewModel {
}
}
guard changed else { return }
try? libraryStore.save(library)
do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) }
loadLibrary()
}

Expand Down
Loading