diff --git a/CHANGELOG.md b/CHANGELOG.md index f8199ae..d7fcab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ee11373..6a02ee5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/NXLibrary/Services/RunningGameMonitor.swift b/Sources/NXLibrary/Services/RunningGameMonitor.swift index a5e03be..6136faf 100644 --- a/Sources/NXLibrary/Services/RunningGameMonitor.swift +++ b/Sources/NXLibrary/Services/RunningGameMonitor.swift @@ -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 = [] + /// 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] = [] @@ -22,6 +25,8 @@ final class RunningGameMonitor: ObservableObject { private var activeToken: NSObjectProtocol? private var games: [PortedGame] = [] private var closeTasks: [String: Task] = [:] + private var focus = GameFocus() + private var focusToken: NSObjectProtocol? init(center: NotificationCenter = NSWorkspace.shared.notificationCenter) { self.center = center @@ -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() } @@ -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() @@ -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 { 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() { diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+KeyCheck.swift b/Sources/NXLibrary/ViewModels/AppViewModel+KeyCheck.swift index b898c28..184468f 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+KeyCheck.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+KeyCheck.swift @@ -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 } } diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+NewestEngine.swift b/Sources/NXLibrary/ViewModels/AppViewModel+NewestEngine.swift index 27f9b09..10c8248 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+NewestEngine.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+NewestEngine.swift @@ -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 } @@ -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) diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift b/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift index 6c06cf4..6d83e80 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift @@ -48,7 +48,7 @@ extension AppViewModel { } } guard changed else { return } - try? libraryStore.save(library) + do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } loadLibrary() } diff --git a/Sources/NXLibrary/ViewModels/AppViewModel.swift b/Sources/NXLibrary/ViewModels/AppViewModel.swift index 263340e..0d67ae6 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel.swift @@ -440,7 +440,7 @@ public final class AppViewModel: ObservableObject { .resolve(runtime: .locate(), repo: URL(fileURLWithPath: nxnativePath)) } - public let pinnedEngineCommit = "b2d46b86d03f4b03afe11ea61c67cdf00d7a5345" + public let pinnedEngineCommit = "bd979ced3ae6bad60bf86429a71d172297d93471" private let core = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/NXLibrary") lazy var libraryStore = LibraryStore(directory: core) @@ -1114,7 +1114,7 @@ public final class AppViewModel: ObservableObject { library.games[index].status = GameResourceState.reconciledStatus( for: library.games[index], appExists: appExists, sourcesAvailable: sourcesExist) } - try? store.save(library) + do { try store.save(library) } catch { self.reportLibrarySaveFailure(error) } self.libraryGames = library.games.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -1345,9 +1345,9 @@ public final class AppViewModel: ObservableObject { } launchProbeTask = Task { [weak self] in let visible = await LaunchReadinessProbe().waitUntilVisible(bundleID: bundleID) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, let self else { return } await MainActor.run { - guard let self, self.launchOverlay?.game.id == game.id else { return } + guard self.launchOverlay?.game.id == game.id else { return } if visible { SoundEffectService.shared.play(.gameReady) } else { @@ -1497,11 +1497,20 @@ public final class AppViewModel: ObservableObject { return PortingProgress.finishingSeconds(inLog: text) } + /// A library that could not be written: what the player sees would be gone at the next start, so say so once + /// (in the log every time) instead of losing it in silence. + func reportLibrarySaveFailure(_ error: Error) { + let message = "NX Library could not save your library, so recent changes may be lost when it quits: " + + error.localizedDescription + logs.append(LogEntry(level: .error, message: message)) + if alertMessage == nil { alertMessage = message } + } + private func updateStoredStatus(_ status: GameStatus, for game: PortedGame) { guard var library = try? libraryStore.load(), let index = library.games.firstIndex(where: { $0.id == game.id }) else { return } library.games[index].status = status - try? libraryStore.save(library) + do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } libraryGames = library.games.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -1866,9 +1875,16 @@ public final class AppViewModel: ObservableObject { var skipped: [String] = [] if capabilities.titleSelection, let keys { let reader = EnginePackageReader(repo: repo, toolchain: toolchain, keys: keys, developerDir: developerDir) + let readings = try reader.contents(of: files.filter { !$0.hasDirectoryPath }.map { + (url: $0, alreadyIdentified: (try? catalog.inspect($0)) != nil) + }) detected = try catalog.scan(files: files, skippingUnreadable: true, onUnreadable: { url, _ in skipped.append(url.lastPathComponent) }) { file in - try reader.contents(of: file, alreadyIdentified: (try? catalog.inspect(file)) != nil) + switch readings[file] { + case .contents(let rows)?: return rows + case .unreadable(let why)?: throw EngineSourceError.unreadablePackage(why) + case nil: return [] + } } } else { detected = try catalog.scan(files: files.filter { (try? catalog.inspect($0)) != nil }) @@ -2430,7 +2446,7 @@ public final class AppViewModel: ObservableObject { let index = library.games.firstIndex(where: { $0.id == target.id }) { library.games[index].workspacePath = target.workspacePath library.games[index].buildCachePath = target.buildCachePath - try? libraryStore.save(library) + do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } libraryGames = library.games.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -2478,7 +2494,8 @@ public final class AppViewModel: ObservableObject { bundleId: target.bundleId ?? "org.nxnative.\(titleId.lowercased())", language: loadRuntimeConfig(for: target).languageForBuild, titleId: engineSourceCapabilities.titleSelection ? titleId : nil, - packageVersion: engineSourceCapabilities.titleSelection ? SourceSelection.versionForBuild(target.sources) : nil, + packageVersion: engineSourceCapabilities.titleSelection + ? SourceSelection.packageVersion(for: target.sources, stagedSelection: !workspace.links.isEmpty) : nil, allowUntestedToolchain: allowUntestedToolchain, extraArguments: extraArguments ) diff --git a/Sources/NXLibrary/Views/Components/SeleneReveal.swift b/Sources/NXLibrary/Views/Components/SeleneReveal.swift index 2e9e736..4e4cf15 100644 --- a/Sources/NXLibrary/Views/Components/SeleneReveal.swift +++ b/Sources/NXLibrary/Views/Components/SeleneReveal.swift @@ -1,4 +1,5 @@ import SwiftUI +import NXLibraryCore /// Arrives a beat after its screen changes: holds `delay`, then fades in while /// rising `rise` Selene units. Give the view an `.id` per item so each change diff --git a/Sources/NXLibrary/Views/Components/SeleneSwitch.swift b/Sources/NXLibrary/Views/Components/SeleneSwitch.swift index aae0108..6535a01 100644 --- a/Sources/NXLibrary/Views/Components/SeleneSwitch.swift +++ b/Sources/NXLibrary/Views/Components/SeleneSwitch.swift @@ -1,4 +1,5 @@ import SwiftUI +import NXLibraryCore struct SeleneSwitch: View { @Environment(\.seleneCanvas) private var canvas diff --git a/Sources/NXLibrary/Views/Components/SeleneValueChip.swift b/Sources/NXLibrary/Views/Components/SeleneValueChip.swift index bd7b7cd..e7a02e5 100644 --- a/Sources/NXLibrary/Views/Components/SeleneValueChip.swift +++ b/Sources/NXLibrary/Views/Components/SeleneValueChip.swift @@ -1,4 +1,5 @@ import SwiftUI +import NXLibraryCore struct SeleneValueChip: View { @Environment(\.seleneCanvas) private var canvas diff --git a/Sources/NXLibrary/Views/Components/SessionCard.swift b/Sources/NXLibrary/Views/Components/SessionCard.swift new file mode 100644 index 0000000..ad6ad6d --- /dev/null +++ b/Sources/NXLibrary/Views/Components/SessionCard.swift @@ -0,0 +1,181 @@ +import AppKit +import SwiftUI +import NXLibraryCore + +/// Holds the session card: the latest ended session waits until NX Library is in front, shows 1.8 s later for 8 s, +/// then leaves. A newer session replaces one still waiting or showing. +@MainActor +final class SessionCardPresenter: ObservableObject { + static let delay: UInt64 = 1_800_000_000 + static let hold: UInt64 = 8_000_000_000 + + @Published private(set) var shown: SessionSummary? + private var pending: SessionSummary? + private var task: Task? + + /// A game closed; `enabled` is the Session card switch. + func ended(_ summary: SessionSummary?, enabled: Bool) { + guard enabled, let summary else { return } + pending = summary + if NSApp.isActive { schedule() } + } + + /// NX Library came forward: a waiting card shows. + func becameActive() { + if pending != nil { schedule() } + } + + /// The switch went off: nothing waiting or showing stays. + func clear() { + task?.cancel() + task = nil + pending = nil + shown = nil + } + + private func schedule() { + task?.cancel() + task = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.delay) + guard let self, !Task.isCancelled, NSApp.isActive, let summary = self.pending else { return } + self.pending = nil + self.shown = summary + SoundEffectService.shared.play(.success) + try? await Task.sleep(nanoseconds: Self.hold) + guard !Task.isCancelled else { return } + self.shown = nil + } + } +} + +/// Selene's session card: "Session ended", the game's name and this session · total on the left, the logo on the +/// right, the game's art faint behind. 640 × 128 units, top right; it takes no input. +struct SessionCardView: View { + @Environment(\.seleneCanvas) private var canvas + let summary: SessionSummary + let game: PortedGame? + let metadata: NintendoStoreMetadata? + let artwork: ArtworkStore + + @State private var hero: NSImage? + @State private var logo: NSImage? + + var body: some View { + ZStack(alignment: .leading) { + if let hero { + Image(nsImage: hero) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fill) + .frame(width: canvas.u(640), height: canvas.u(128)) + .clipped() + .opacity(0.42) + // Faint on the right, gone under the words on the left. + .mask(LinearGradient(stops: [.init(color: .clear, location: 0.15), + .init(color: .black, location: 0.85)], + startPoint: .leading, endPoint: .trailing)) + } + HStack(spacing: canvas.u(16)) { + VStack(alignment: .leading, spacing: 0) { + Text("Session ended") + .seleneFont(15) + .foregroundColor(SeleneTheme.ink.opacity(0.62)) + Text(summary.gameName) + .seleneFont(22, .semibold) + .foregroundColor(SeleneTheme.ink) + .lineLimit(1) + .truncationMode(.tail) + .padding(.top, canvas.u(2)) + .padding(.bottom, canvas.u(8)) + HStack(alignment: .lastTextBaseline, spacing: canvas.u(12)) { + figure(summary.thisSession, "this session") + Text("·") + .seleneFont(18) + .foregroundColor(SeleneTheme.ink.opacity(0.45)) + figure(summary.total, "total") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if let logo { + Image(nsImage: logo) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: canvas.u(170), maxHeight: canvas.u(88)) + .shadow(color: .black.opacity(0.6), radius: canvas.u(5)) + .padding(.trailing, canvas.u(4)) + } + } + .padding(.leading, canvas.u(20)) + .padding(.trailing, canvas.u(18)) + } + .frame(width: canvas.u(640), height: canvas.u(128)) + .background(Color(hex: ThemeStore.shared.palette.glass, opacity: 0.92)) + .clipShape(RoundedRectangle(cornerRadius: canvas.u(14), style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: canvas.u(14), style: .continuous) + .strokeBorder(SeleneTheme.ink.opacity(0.12), lineWidth: max(1, canvas.u(1)))) + .shadow(color: .black.opacity(0.45), radius: canvas.u(18)) + .environment(\.seleneMinimumFont, 12) + .accessibilityElement(children: .combine) + .accessibilityLabel("Session ended, \(summary.gameName): \(summary.thisSession) this session, \(summary.total) total") + .task(id: summary.id) { await loadArt() } + } + + private func figure(_ value: String, _ label: String) -> some View { + HStack(alignment: .lastTextBaseline, spacing: canvas.u(7)) { + Text(value) + .seleneFont(20, .semibold) + .foregroundColor(SeleneTheme.ink) + Text(label) + .seleneFont(15) + .foregroundColor(SeleneTheme.ink.opacity(0.6)) + } + .lineLimit(1) + .fixedSize() + } + + /// The art the game's pages already loaded, else the same lookups they use. + @MainActor + private func loadArt() async { + guard let game else { return } + hero = GameArtLoader.shared.cachedHero(for: game) + logo = GameArtLoader.shared.cachedLogo(for: game) + if hero == nil { + let loaded = await GameArtLoader.shared.hero(for: game, metadata: metadata, artwork: artwork) + withAnimation(.easeOut(duration: 0.3)) { hero = loaded } + } + if logo == nil { + let loaded = await GameArtLoader.shared.logo(for: game, metadata: metadata, artwork: artwork) + withAnimation(.easeOut(duration: 0.3)) { logo = loaded } + } + } +} + +/// The session card's place on the screen: 112 units from the top, 28 from the right, sliding in from the right with a +/// fade as Selene's cards do. It never takes clicks or focus. +struct SessionCardLayer: View { + @Environment(\.seleneCanvas) private var canvas + @Environment(\.seleneReduceMotion) private var reduceMotion + @ObservedObject var presenter: SessionCardPresenter + let games: [PortedGame] + let metadata: [UUID: NintendoStoreMetadata] + let artwork: ArtworkStore + + var body: some View { + ZStack(alignment: .topTrailing) { + if let summary = presenter.shown { + SessionCardView(summary: summary, + game: games.first { $0.id == summary.gameID }, + metadata: metadata[summary.gameID], + artwork: artwork) + .id(summary.id) + .padding(.top, canvas.u(112)) + .padding(.trailing, canvas.u(28)) + .transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(x: canvas.u(40)))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .allowsHitTesting(false) + .animation(.easeOut(duration: 0.25), value: presenter.shown?.id) + } +} diff --git a/Sources/NXLibrary/Views/Detail/ModDetailPanel.swift b/Sources/NXLibrary/Views/Detail/ModDetailPanel.swift index 3103f09..b660fc2 100644 --- a/Sources/NXLibrary/Views/Detail/ModDetailPanel.swift +++ b/Sources/NXLibrary/Views/Detail/ModDetailPanel.swift @@ -123,6 +123,13 @@ struct ModDetailPanel: View { Text(versionLine) .seleneFont(16) .foregroundColor(SeleneTheme.ink75) + if let requirementsLine { + Text(requirementsLine) + .seleneFont(16) + .foregroundColor(SeleneTheme.ink75) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } Text("\(mod.likes) likes · \(mod.downloads) downloads") .seleneFont(15) .foregroundColor(SeleneTheme.ink60) @@ -284,6 +291,13 @@ struct ModDetailPanel: View { return parts.joined(separator: " ") } + /// The author's requirements (game versions, other mods), so a mismatch shows before the download. + private var requirementsLine: String? { + guard !mod.requirements.isEmpty else { return nil } + let names = mod.requirements.map { $0.isOptional ? "\($0.name) (recommended)" : $0.name } + return "Requires: " + names.joined(separator: " · ") + } + // MARK: - Rows private struct RowText { diff --git a/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift b/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift index 2f5506e..4267de1 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift @@ -6,6 +6,7 @@ struct SettingsEngineSection: View { let onActions: ([SettingsRowAction]) -> Void @Environment(\.seleneFocusedRowID) private var focusedRowTitle + @Environment(\.seleneFocusedButton) private var focusedButton private var configurationLocked: Bool { viewModel.isBuilding || viewModel.isUpdatingApp } var body: some View { @@ -278,12 +279,30 @@ struct SettingsEngineSection: View { .seleneFont(14, .semibold) .foregroundColor(SeleneTheme.untested) } - if let title = newestButtonTitle { - Button(title) { newestAction() } - .buttonStyle(.plain) + if viewModel.isSwitchingEngine { + Text("Switching…") .seleneFont(15, .semibold) - .foregroundColor(newestButtonEnabled ? SeleneTheme.glyph : SeleneTheme.ink60) - .disabled(!newestButtonEnabled) + .foregroundColor(SeleneTheme.ink60) + } else { + let actions = viewModel.newestEngineActions + ForEach(Array(actions.enumerated()), id: \.element) { index, action in + // With two buttons, the one Left and Right put the focus on carries the focus ring. + let focused = actions.count > 1 && viewModel.canSwitchEngine + && focusedRowTitle == "Newest engine" && focusedButton == index + Button(newestTitle(action)) { newestAction(action) } + .buttonStyle(.plain) + .seleneFont(15, .semibold) + .foregroundColor(viewModel.canSwitchEngine ? SeleneTheme.glyph : SeleneTheme.ink60) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .overlay { + Capsule() + .strokeBorder(SeleneTheme.focus, lineWidth: 2) + .opacity(focused ? 1 : 0) + } + .animation(SeleneTheme.focusIn, value: focused) + .disabled(!viewModel.canSwitchEngine) + } } } } @@ -297,8 +316,14 @@ struct SettingsEngineSection: View { private var newestSubtitle: String { if viewModel.isSwitchingEngine { return "Switching the engine…" } if let choice = viewModel.newestEngineChoice { - let base = "\(NewestEngineCopy.note) In use: \(choice.shortCommit), " + var base = "\(NewestEngineCopy.note) In use: \(choice.shortCommit), " + "\(NewestEngineCopy.ahead(choice.aheadBy)) of the tested \(seleneShort(viewModel.pinnedEngineCommit))." + if viewModel.newestEngineActions.contains(.update), let offer = viewModel.newestEngineOffer { + base += " " + NewestEngineCopy.update(choice: choice, main: offer.upstreamCommit, + aheadBy: offer.newCommitsCount) + } else if let metal = newestNeedsNewerApp { + base += " " + NewestEngineCopy.needsNewerApp(metal: metal) + } return viewModel.newestEngineProblem.map { "\(base) \($0)" } ?? base } if let metal = newestNeedsNewerApp { return NewestEngineCopy.needsNewerApp(metal: metal) } @@ -307,20 +332,20 @@ struct SettingsEngineSection: View { return NewestEngineCopy.offer(commit: offer.upstreamCommit, aheadBy: offer.newCommitsCount) } - private var newestButtonTitle: String? { - if viewModel.isSwitchingEngine { return "Switching…" } - if viewModel.usesNewestEngine { return "Back to tested" } - return newestNeedsNewerApp == nil ? "Use newest" : nil - } - - private var newestButtonEnabled: Bool { - guard viewModel.canSwitchEngine else { return false } - return viewModel.usesNewestEngine || (viewModel.newestEngineOffer != nil && newestNeedsNewerApp == nil) + private func newestTitle(_ action: NewestEngineAction) -> String { + switch action { + case .useNewest: return "Use newest" + case .update: return "Update" + case .backToTested: return "Back to tested" + } } - private func newestAction() { - guard newestButtonEnabled else { return } - if viewModel.usesNewestEngine { viewModel.useTestedEngine() } else { viewModel.useNewestEngine() } + private func newestAction(_ action: NewestEngineAction) { + guard viewModel.canSwitchEngine else { return } + switch action { + case .useNewest, .update: viewModel.useNewestEngine() + case .backToTested: viewModel.useTestedEngine() + } } private var upstreamButtonTitle: String { @@ -328,12 +353,33 @@ struct SettingsEngineSection: View { return "Check" } + /// Ten commits, or down to the one in use when it is further down, so its tag is always on screen. + private func shownCommitCount(_ status: UpstreamStatus, marks: [String: UpstreamCommitMark]) -> Int { + guard let index = status.commits.firstIndex(where: { marks[$0.sha] == .inUse }) else { return 10 } + return max(10, index + 1) + } + + /// In use (green) on the commit the newest engine is at, New (orange) on the commits above it. + private func commitMark(_ mark: UpstreamCommitMark) -> some View { + let color = mark == .inUse ? SeleneTheme.success : SeleneTheme.untested + return Text(mark == .inUse ? "In use" : "New") + .seleneFont(12, .bold) + .foregroundColor(color) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(color.opacity(0.16)) + .clipShape(Capsule()) + .padding(.bottom, 2) + } + @ViewBuilder private func upstreamCommitsPanel(_ status: UpstreamStatus) -> some View { if !status.commits.isEmpty { + let marks = NewestEngine.marks(status.commits, inUse: viewModel.newestEngineChoice?.commit) + let shown = shownCommitCount(status, marks: marks) SelenePanel("Upstream Commits (\(status.newCommitsCount) newer)") { VStack(alignment: .leading, spacing: 10) { - ForEach(status.commits.prefix(10)) { commit in + ForEach(status.commits.prefix(shown)) { commit in HStack(alignment: .top, spacing: 10) { Text(commit.shortSha) .seleneFont(14, .bold, design: .monospaced) @@ -344,6 +390,9 @@ struct SettingsEngineSection: View { .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) VStack(alignment: .leading, spacing: 2) { + if let mark = marks[commit.sha] { + commitMark(mark) + } Text(commit.subject) .seleneFont(15, .medium) .foregroundColor(SeleneTheme.ink) @@ -366,15 +415,15 @@ struct SettingsEngineSection: View { } Spacer(minLength: 0) } - if commit.id != status.commits.prefix(10).last?.id { + if commit.id != status.commits.prefix(shown).last?.id { Divider() .background(SeleneTheme.ink15) } } - if status.commits.count > 10 { + if status.commits.count > shown { HStack { - Text("+ \(status.commits.count - 10) more commits upstream") + Text("+ \(status.commits.count - shown) more commits upstream") .seleneFont(14, .medium) .foregroundColor(SeleneTheme.ink60) Spacer() @@ -505,9 +554,13 @@ struct SettingsEngineSection: View { viewModel.checkUpstreamChangelog() } ] - if viewModel.usesNewestEngine || viewModel.newestEngineOffer != nil { - actions.append(.init(id: "newest", title: "Newest engine", isEnabled: newestButtonEnabled) { - newestAction() + // Keyboard and controller: the row's first button (Update once main has moved on) is in focus first; Right moves + // to the second (Back to tested), and A or Return presses the one in focus. + if let pad = NewestEngine.controller(viewModel.newestEngineActions) { + let secondary = pad.secondary.map { action in (title: newestTitle(action), activate: { newestAction(action) }) } + actions.append(.init(id: "newest", title: "Newest engine", isEnabled: viewModel.canSwitchEngine, + secondary: secondary) { + newestAction(pad.primary) }) } if case .loaded = viewModel.upstreamState { diff --git a/Sources/NXLibrary/Views/Settings/SettingsGamesSection.swift b/Sources/NXLibrary/Views/Settings/SettingsGamesSection.swift index cc6d051..47fe8e7 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsGamesSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsGamesSection.swift @@ -65,6 +65,11 @@ struct SettingsGamesSection: View { settings.settings.launchShade = settings.settings.launchShade.stepped(step) } + SeleneToggleRow("Session card", + subtitle: "Your playtime when a game closes", + info: "When you close a game, a card shows how long you played this time and in total. Sessions under 10 seconds show none.", + isOn: $settings.settings.showsSessionCard) + SeleneCycleRow("Surprise me pool", subtitle: "Which games Surprise me picks from.", value: surprisePoolTitle) { step in @@ -180,6 +185,9 @@ struct SettingsGamesSection: View { .value("launchShade", "Launch screen shade") { step in settings.settings.launchShade = settings.settings.launchShade.stepped(step) }, + .init(id: "showsSessionCard", title: "Session card") { + settings.settings.showsSessionCard.toggle() + }, .value("surprisePool", "Surprise me pool") { step in settings.settings.surprisePool = settings.settings.surprisePool.stepped(step) } diff --git a/Sources/NXLibrary/Views/Settings/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index 6a661f1..1f50769 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -16,6 +16,8 @@ public struct SettingsView: View { @State private var showingControls = false @State private var focusArea: SettingsFocusArea = .sidebar @State private var rowFocus = FocusIndex() + /// The button in focus inside the focused row: 0 its own, 1 a second one (Back to tested beside Update). + @State private var rowButton = 0 @State private var rowActions: [SettingsRowAction] = [] @State private var keyMonitor: Any? @State private var resetNotice: String? @@ -78,6 +80,7 @@ public struct SettingsView: View { .onChange(of: section) { _ in rowActions = [] rowFocus = FocusIndex() + rowButton = 0 } .onChange(of: initialSection) { value in section = value @@ -128,10 +131,12 @@ public struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .environment(\.seleneFocusedRowID, focusArea == .rows ? focusedRow?.rowID : nil) + .environment(\.seleneFocusedButton, rowButton) .padding(.bottom, canvas.u(60)) } .scrollIndicators(.hidden) .onChange(of: rowFocus.index) { _ in + rowButton = 0 guard focusArea == .rows, rowActions.indices.contains(rowFocus.index) else { return } let id = "selene-row-\(rowActions[rowFocus.index].rowID)" @@ -166,14 +171,14 @@ public struct SettingsView: View { } private func updateActions(_ actions: [SettingsRowAction]) { - // One focus slot per visible row. Secondary buttons in a row stay - // pointer-reachable; the controller uses the row's primary action. + // One focus slot per visible row; Left and Right move between a row's two buttons (rowButton). let focusedID = focusedRow?.id rowActions = SettingsRowAction.primaryActions(actions) if let focusedID, let index = rowActions.firstIndex(where: { $0.id == focusedID }) { rowFocus.index = index } rowFocus.move(by: 0, count: rowActions.count) + if rowButton >= (focusedRow?.buttonCount ?? 1) { rowButton = 0 } if rowActions.isEmpty, focusArea == .rows { focusArea = .sidebar } } @@ -186,6 +191,7 @@ public struct SettingsView: View { private func enterRows() { guard focusArea == .sidebar, !rowActions.isEmpty else { return } focusArea = .rows + rowButton = 0 SoundEffectService.shared.play(.focus) } @@ -247,6 +253,7 @@ public struct SettingsView: View { next.launchStyle = defaults.launchStyle next.launchMotion = defaults.launchMotion next.launchShade = defaults.launchShade + next.showsSessionCard = defaults.showsSessionCard next.surprisePool = defaults.surprisePool let rumble = next.gameDefaults.rumble let gain = next.gameDefaults.rumbleGain @@ -290,8 +297,17 @@ public struct SettingsView: View { if let cycle = focusedRow?.cycle, focusedRow?.isEnabled == true { cycle(delta) SoundEffectService.shared.play(.focus) - } else if delta < 0 { + return + } + let buttons = focusedRow?.isEnabled == true ? (focusedRow?.buttonCount ?? 1) : 1 + switch SettingsRowAction.sideways(from: rowButton, by: delta, buttons: buttons) { + case .button(let index): + rowButton = index + SoundEffectService.shared.play(.focus) + case .leaveRows: leaveRows() + case .stay: + break } } } @@ -374,7 +390,7 @@ public struct SettingsView: View { enterRows() } else if let row = focusedRow, row.isEnabled { SoundEffectService.shared.play(.click) - row.activate() + row.press(button: rowButton) } case .cancel: handleBack() diff --git a/Sources/NXLibrary/Views/Shell/AppShellView.swift b/Sources/NXLibrary/Views/Shell/AppShellView.swift index 311c498..50a8946 100644 --- a/Sources/NXLibrary/Views/Shell/AppShellView.swift +++ b/Sources/NXLibrary/Views/Shell/AppShellView.swift @@ -10,6 +10,7 @@ public struct AppShellView: View { @StateObject private var playSessions = PlaySessionStore() @StateObject private var artwork = ArtworkStore() @StateObject private var runningGames = RunningGameMonitor() + @StateObject private var sessionCard = SessionCardPresenter() @StateObject private var updater = AppUpdater() @StateObject private var nativeMenus = NativeMenuCommands.shared @ObservedObject private var theme = ThemeStore.shared @@ -216,6 +217,10 @@ public struct AppShellView: View { .background(FullscreenCursorView()) .background(StartupWindowView(fullscreen: settings.settings.startInFullscreen)) .focusedSceneValue(\.nxMenuAvailability, nativeMenuAvailability) + .overlay { + SessionCardLayer(presenter: sessionCard, games: viewModel.libraryGames, + metadata: viewModel.storeMetadata, artwork: artwork) + } .overlay { launchLayer } .modifier(SoundCueHooks(router: router, viewModel: viewModel)) // Inside the canvas root so the sheet inherits the window's canvas units. @@ -263,6 +268,15 @@ public struct AppShellView: View { .onChange(of: viewModel.libraryGames) { games in runningGames.setGames(games) } + .onReceive(runningGames.$endedSession) { summary in + sessionCard.ended(summary, enabled: settings.settings.showsSessionCard) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + sessionCard.becameActive() + } + .onChange(of: settings.settings.showsSessionCard) { on in + if !on { sessionCard.clear() } + } .onChange(of: showsUpdateOffer) { viewModel.isUpdateOfferOpen = $0 } .onChange(of: router.route) { route in recordLastHome(route) diff --git a/Sources/NXLibrary/Views/Store/GameBananaBlur.swift b/Sources/NXLibrary/Views/Store/GameBananaBlur.swift index cee3be2..4abb892 100644 --- a/Sources/NXLibrary/Views/Store/GameBananaBlur.swift +++ b/Sources/NXLibrary/Views/Store/GameBananaBlur.swift @@ -14,7 +14,7 @@ enum GameBananaBlur { cache.countLimit = 64 return cache }() - nonisolated(unsafe) private static let context = CIContext(options: [.cacheIntermediates: false]) + private static let context = CIContext(options: [.cacheIntermediates: false]) private static func key(_ url: URL, _ radius: CGFloat) -> NSString { "\(Int(radius))|\(url.absoluteString)" as NSString } diff --git a/Sources/NXLibrary/Views/Theme/SeleneTheme.swift b/Sources/NXLibrary/Views/Theme/SeleneTheme.swift index d93ae19..705b491 100644 --- a/Sources/NXLibrary/Views/Theme/SeleneTheme.swift +++ b/Sources/NXLibrary/Views/Theme/SeleneTheme.swift @@ -143,6 +143,18 @@ public extension EnvironmentValues { } } +private struct SeleneFocusedButtonKey: EnvironmentKey { + static let defaultValue = 0 +} + +public extension EnvironmentValues { + /// Which button of the focused row has the focus, for a row with more than one (0 the first). + var seleneFocusedButton: Int { + get { self[SeleneFocusedButtonKey.self] } + set { self[SeleneFocusedButtonKey.self] = newValue } + } +} + private struct SeleneShowButtonHintsKey: EnvironmentKey { static let defaultValue = true } diff --git a/Sources/NXLibraryCore/Models/AppSettings.swift b/Sources/NXLibraryCore/Models/AppSettings.swift index 519df60..39f533f 100644 --- a/Sources/NXLibraryCore/Models/AppSettings.swift +++ b/Sources/NXLibraryCore/Models/AppSettings.swift @@ -165,6 +165,8 @@ public struct AppSettings: Codable, Equatable { public var settingsWallpaperID: String? = nil /// Spotlight and Collection keep the wallpaper instead of the focused game's art (the game page still shows it). public var keepWallpaperOnGames = false + /// A card with this session's and the total playtime when a game closes. + public var showsSessionCard = true public init(backgroundShade: BackgroundShade = .balanced, readableText: Bool = true, @@ -250,7 +252,7 @@ public struct AppSettings: Codable, Equatable { case introAnimation, viewButtonSwitches, interfaceSounds, soundVolume, quietLaunch case wallpaperID, backgroundSizing, animatedBackground, wallpaperBrightness, launchStyle, launchMotion, launchShade case notifyPortingEnds, checkAppUpdatesAtLaunch - case settingsWallpaperID, keepWallpaperOnGames + case settingsWallpaperID, keepWallpaperOnGames, showsSessionCard } public init(from decoder: Decoder) throws { @@ -295,5 +297,6 @@ public struct AppSettings: Codable, Equatable { self.checkAppUpdatesAtLaunch = try container.decodeIfPresent(Bool.self, forKey: .checkAppUpdatesAtLaunch) ?? true self.settingsWallpaperID = try container.decodeIfPresent(String.self, forKey: .settingsWallpaperID) self.keepWallpaperOnGames = try container.decodeIfPresent(Bool.self, forKey: .keepWallpaperOnGames) ?? false + self.showsSessionCard = try container.decodeIfPresent(Bool.self, forKey: .showsSessionCard) ?? true } } diff --git a/Sources/NXLibraryCore/Models/GameFocus.swift b/Sources/NXLibraryCore/Models/GameFocus.swift new file mode 100644 index 0000000..fde28fa --- /dev/null +++ b/Sources/NXLibraryCore/Models/GameFocus.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Whether NX Library comes back to the front when a game quits: only when the game was the app in front (the player +/// had not moved to another app while it ran) and no other library game is still open. +public struct GameFocus: Equatable { + /// The library game in front, by bundle ID. + public private(set) var gameInFront: String? + + public init() {} + + /// A library game came to the front. + public mutating func gameCameForward(_ bundleID: String) { + gameInFront = bundleID + } + + /// Another app stayed in front while this game kept running: the player left the game. + public mutating func otherAppStayedForward(whileRunning bundleID: String) { + if gameInFront == bundleID { gameInFront = nil } + } + + /// A game quit. True when NX Library should come forward. + public mutating func gameEnded(_ bundleID: String, othersRunning: Bool) -> Bool { + guard gameInFront == bundleID else { return false } + gameInFront = nil + return !othersRunning + } +} diff --git a/Sources/NXLibraryCore/Models/PortingFailureReason.swift b/Sources/NXLibraryCore/Models/PortingFailureReason.swift index f526a38..c61b8d5 100644 --- a/Sources/NXLibraryCore/Models/PortingFailureReason.swift +++ b/Sources/NXLibraryCore/Models/PortingFailureReason.swift @@ -86,7 +86,7 @@ public struct PortingFailureReason: Codable, Equatable, Sendable { case .keysTooOld: return "\(game) needs keys newer than the ones you imported." case .keysWrong: return "header_key does not open \(detail ?? "the game's files")." case .severalGames: return "Each porting needs one game; this folder holds files of several." - case .noTitleID: return "None of the files names a title ID the engine can read." + case .noTitleID: return "None of the files holds a game the engine can read." case .diskFull: if let gb = detail { return "The porting needs at least \(gb) GB free on the work volume." } return "The disk filled up during the porting." @@ -185,7 +185,9 @@ public enum PortingFailureClassifier { "the keys are older than the game"), ("keys.ticket", .keys, "the package has no common ticket for it", "the package has no common ticket for it"), ("games.several", .severalGames, "more than one game in the folder", "more than one game in the folder"), - ("games.noTitleID", .noTitleID, "no NSP with a readable title ID in ", "no NSP with a readable title ID in "), + // nxnative 5c4dc18f reads every package's content meta; before it, "no NSP with a readable title ID in ". + ("games.noTitleID", .noTitleID, "no (NSP with a readable title ID|game package with a readable content meta) in ", + "no game package with a readable content meta in "), ("disk.low", .diskFull, "less than [0-9]+ GB free on the volume of", "GB free on the volume of"), ("disk.full", .diskFull, "No space left on device", nil), ("icloud.detritus", .cloudFolder, "resource fork, Finder information, or similar detritus not allowed", nil), @@ -248,10 +250,10 @@ public enum PortingFailureClassifier { guard let r = log.range(of: "less than [0-9]+ GB free", options: .regularExpression) else { return nil } return log[r].split(separator: " ").dropFirst(2).first.map(String.init) case .noTitleID: - guard let r = log.range(of: #"no NSP with a readable title ID in [^"\n]+"#, options: .regularExpression) - else { return nil } - return String(log[r].dropFirst("no NSP with a readable title ID in ".count)) - .trimmingCharacters(in: .whitespaces) + guard let r = log.range(of: #"no (NSP with a readable title ID|game package with a readable content meta) in [^"\n]+"#, + options: .regularExpression), + let folder = log[r].range(of: " in ") else { return nil } + return String(log[r][folder.upperBound...]).trimmingCharacters(in: .whitespaces) default: return nil } diff --git a/Sources/NXLibraryCore/Models/SessionSummary.swift b/Sources/NXLibraryCore/Models/SessionSummary.swift new file mode 100644 index 0000000..fb0304f --- /dev/null +++ b/Sources/NXLibraryCore/Models/SessionSummary.swift @@ -0,0 +1,28 @@ +import Foundation + +/// What the session card says when a game closes: the game, this session and the game's total, the total written as +/// the game info writes it. Sessions shorter than `minimumDuration` (a launch closed again) show no card. +public struct SessionSummary: Equatable, Identifiable { + public static let minimumDuration: TimeInterval = 10 + + /// The session's own ID, so the same session never shows twice. + public let id: UUID + public let gameID: UUID + public let gameName: String + public let thisSession: String + public let total: String + + public init?(session: PlaySession, game: PortedGame, total: TimeInterval) { + guard session.gameID == game.id, let length = session.duration, + length >= Self.minimumDuration else { return nil } + id = session.id + gameID = game.id + gameName = game.name + thisSession = Self.duration(length) + self.total = Self.duration(max(total, length)) + } + + static func duration(_ interval: TimeInterval) -> String { + interval < 60 ? "Less than a minute" : LibraryPresentation.duration(interval) + } +} diff --git a/Sources/NXLibraryCore/Models/SettingsCatalog.swift b/Sources/NXLibraryCore/Models/SettingsCatalog.swift index 31f6185..1389169 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -117,6 +117,8 @@ public enum SettingsCatalog { "One sync choice for all games, applied at next launch: Off, iCloud Drive or a folder you choose.") { $0.cloudSaves }, row("cloudSavesPickedFolder", .games, "Cloud saves folder", "The chosen folder for save sync; choosing a folder enables it.") { $0.cloudSavesPickedFolder }, + row("showsSessionCard", .games, "Session card", + "When you close a game, a card shows how long you played this time and in total.") { $0.showsSessionCard }, action("hiddenGames", .games, "Hidden games", "Games you hid from Game options; Show puts one back in your lists."), row("gameDefaults.fullscreen", .games, "Fullscreen", diff --git a/Sources/NXLibraryCore/Models/SettingsRowAction.swift b/Sources/NXLibraryCore/Models/SettingsRowAction.swift index fb9c53b..c2008da 100644 --- a/Sources/NXLibraryCore/Models/SettingsRowAction.swift +++ b/Sources/NXLibraryCore/Models/SettingsRowAction.swift @@ -9,15 +9,19 @@ public struct SettingsRowAction: Identifiable { public let isEnabled: Bool public let cycle: ((Int) -> Void)? public let activate: () -> Void + /// A second button on the row, such as Back to tested beside Update: Right moves the focus to it, Left back. + public let secondary: (title: String, activate: () -> Void)? public init(id: String, title: String, rowID: String? = nil, isEnabled: Bool = true, - cycle: ((Int) -> Void)? = nil, activate: @escaping () -> Void) { + cycle: ((Int) -> Void)? = nil, secondary: (title: String, activate: () -> Void)? = nil, + activate: @escaping () -> Void) { self.id = id self.rowID = rowID ?? title self.title = title self.isEnabled = isEnabled self.cycle = cycle self.activate = activate + self.secondary = secondary } public static func value(_ id: String, _ title: String, @@ -25,6 +29,23 @@ public struct SettingsRowAction: Identifiable { SettingsRowAction(id: id, title: title, cycle: cycle) { cycle(1) } } + /// Buttons the focus can move between in this row. + public var buttonCount: Int { secondary == nil ? 1 : 2 } + + /// Presses the row's button at `index` (0 the row's own, 1 the second); any other index presses the first. + public func press(button index: Int) { + if index == 1, let secondary { secondary.activate() } else { activate() } + } + + public enum Sideways: Equatable { case button(Int), leaveRows, stay } + + /// Left and Right inside a row: between its buttons, and Left from the first one leaves the rows for the sidebar. + public static func sideways(from index: Int, by delta: Int, buttons: Int) -> Sideways { + let target = index + delta + if target < 0 { return .leaveRows } + return target < buttons && target != index ? .button(target) : .stay + } + public static func primaryActions(_ actions: [SettingsRowAction]) -> [SettingsRowAction] { var seen = Set() return actions.filter { seen.insert($0.rowID).inserted } diff --git a/Sources/NXLibraryCore/Models/SourceSelection.swift b/Sources/NXLibraryCore/Models/SourceSelection.swift index 15b9bf0..0fe16ef 100644 --- a/Sources/NXLibraryCore/Models/SourceSelection.swift +++ b/Sources/NXLibraryCore/Models/SourceSelection.swift @@ -81,9 +81,23 @@ public enum SourceSelection { files.contains { $0.layout == .package } } + /// The `-p` to pass. A workspace NX Library staged with only the selected packages leaves the engine's default (the + /// newest version there, by content meta) equal to the selection, and no `-p` keeps a version stored by an older scan + /// from being refused. The selection is named when the workspace holds other versions too: a merged package, a NUP + /// (every version in one file), a dump, or a folder used as it is. + public static func packageVersion(for files: [GameFileItem], stagedSelection: Bool) -> String? { + let onlySelected = stagedSelection && files.allSatisfy { $0.layout == .package && $0.requiresTitleSelection != true } + return onlySelected ? nil : versionForBuild(files) + } + public static func versionForBuild(_ files: [GameFileItem]) -> String? { let updates = files.filter { $0.kind == .update } - guard !updates.isEmpty else { return "v0" } + // A base game alone is packed at its own version: v0 for most, v65536 for one the eShop serves at 1.1.0 (nxnative 5c4dc18f). + guard !updates.isEmpty else { + let base = files.first { $0.kind == .base }?.versionString + let digits = base.map { $0.hasPrefix("v") ? String($0.dropFirst()) : $0 } + return "v\(digits.flatMap(Int.init) ?? 0)" + } guard let raw = updates.first?.versionString else { return nil } let digits = raw.hasPrefix("v") ? String(raw.dropFirst()) : raw return Int(digits).map { "v\($0)" } diff --git a/Sources/NXLibraryCore/Services/BuildEventParser.swift b/Sources/NXLibraryCore/Services/BuildEventParser.swift index 0b4bcf0..697700f 100644 --- a/Sources/NXLibraryCore/Services/BuildEventParser.swift +++ b/Sources/NXLibraryCore/Services/BuildEventParser.swift @@ -39,6 +39,9 @@ public enum BuildEventParser { elapsed: wire.elapsed, summary: wire.summary, warn: wire.warn ?? false) case "say": return wire.msg.map { .say($0) } + case "game", "shaders_stale": + // A `say` with the same news follows each (nxnative docs/frontends.md). + return nil default: return .log(trimmed) } diff --git a/Sources/NXLibraryCore/Services/EnginePackageReader.swift b/Sources/NXLibraryCore/Services/EnginePackageReader.swift index 6a4a32f..8e6b4e1 100644 --- a/Sources/NXLibraryCore/Services/EnginePackageReader.swift +++ b/Sources/NXLibraryCore/Services/EnginePackageReader.swift @@ -24,11 +24,16 @@ public struct PackageContent: Equatable { } } -public enum EngineSourceError: Error, LocalizedError { +public enum EngineSourceError: Error, Equatable, LocalizedError { case invalidPackage, invalidDump(String), invalidContainer(String), containerNotAlone, missingProfile(String) + /// The engine's own reason, such as keys that cannot open the packages (it names the missing key, never a value). + case unreadablePackages(String) + case unreadablePackage(String) public var errorDescription: String? { switch self { case .invalidPackage: return "The package contents could not be read. Check the files and imported keys." + case .unreadablePackages(let detail): return "The packages could not be read: \(detail)" + case .unreadablePackage(let detail): return "This package could not be read: \(detail)" case .invalidDump(let detail): return "The extracted folder is incomplete: \(detail)." case .invalidContainer(let detail): return "The NUP file could not be read: \(detail)." case .containerNotAlone: return "Choose one NUP file on its own: it holds the whole game." @@ -76,31 +81,83 @@ public struct EnginePackageReader { self.developerDir = developerDir; self.runner = runner } - public func contents(of package: URL, alreadyIdentified: Bool) throws -> [PackageContent] { + /// Every package's content metas, read the way the engine reads them for the build, so the title IDs and versions NX + /// Library shows and packs (`-p`) are the engine's. nxnative 5c4dc18f reads all packages in one `package_reader` run and + /// never trusts a file name; older engines read one package at a time, and only merged or unidentified ones (an + /// identified one comes back with no rows: NX Library's own reading stands). A package the engine cannot read comes + /// back unreadable with its reason; a run that stops (keys that cannot open the packages) throws the engine's line. + public func contents(of packages: [(url: URL, alreadyIdentified: Bool)]) throws -> [URL: PackageReading] { + guard !packages.isEmpty else { return [:] } let script = #""" -import contextlib, importlib.util, io, os, sys +import contextlib, importlib.util, io, json, os, sys from pathlib import Path -repo, keys, package = map(Path, sys.argv[1:4]) +repo, keys = Path(sys.argv[1]), Path(sys.argv[2]) +packages = [(Path(p), known) for p, known in json.loads(sys.argv[3])] spec = importlib.util.spec_from_file_location('nxlibrary_probe', repo / 'scripts/build_ipa_from_nsp.py') engine = importlib.util.module_from_spec(spec) -spec.loader.exec_module(engine) -if sys.argv[4] == 'known' and not engine.is_merged(package): - sys.exit(0) -environment = dict(os.environ) -engine.configure_dotnet(environment) -with contextlib.redirect_stdout(io.StringIO()): - with engine.package_title_reader(repo, keys, environment) as reader: - rows = reader.contents(package) -if not rows: - sys.exit('Could not read package content metadata') -for kind, title, version in rows: - print(f'content {kind} {title:016X} {version}') +try: + with contextlib.redirect_stdout(io.StringIO()): + spec.loader.exec_module(engine) + environment = dict(os.environ) + engine.configure_dotnet(environment) + if hasattr(engine, 'package_reader'): + with engine.package_reader(repo, keys, environment) as reader: + found = reader.contents([p for p, _ in packages]) + results = [found.get(p, 'not listed by the engine') for p, _ in packages] + else: + results = [[] for _ in packages] + todo = [i for i, (p, known) in enumerate(packages) if not known or engine.is_merged(p)] + if todo: + with engine.package_title_reader(repo, keys, environment) as reader: + for i in todo: results[i] = reader.contents(packages[i][0]) or 'no content meta' +except Exception as e: + sys.exit(' '.join(str(e).split()) or type(e).__name__) +for i, rows in enumerate(results): + print(f'package {i}') + if isinstance(rows, str): + print('unreadable ' + ' '.join(rows.split())) + continue + for kind, title, version in rows: + print(f'content {kind} {title:016X} {version}') """# + let list = packages.map { [$0.url.path, $0.alreadyIdentified] as [Any] } + let json = String(data: try JSONSerialization.data(withJSONObject: list), encoding: .utf8) ?? "[]" let environment = toolchain.environment(base: ProcessInfo.processInfo.environment, developerDir: developerDir) - let result = try runner.run(toolchain.python, - ["-B", "-c", script, repo.path, keys.path, package.path, alreadyIdentified ? "known" : "unknown"], - environment: environment, currentDirectory: repo) - guard result.status == 0 else { throw EngineSourceError.invalidPackage } - return try PackageContent.parse(result.stdout) + let result = try runner.run(toolchain.python, ["-B", "-c", script, repo.path, keys.path, json], + environment: environment, currentDirectory: repo) + guard result.status == 0 else { + let line = result.stderr.split(separator: "\n").last.map { String($0).trimmingCharacters(in: .whitespaces) } + throw line.map { EngineSourceError.unreadablePackages($0) } ?? EngineSourceError.invalidPackage + } + return try PackageReading.parse(result.stdout, packages: packages.map(\.url)) + } +} + +/// What the engine read in one package. +public enum PackageReading: Equatable { + case contents([PackageContent]) + case unreadable(String) + + /// The probe's output: `package `, then that package's `content` lines or one `unreadable ` line. + static func parse(_ output: String, packages: [URL]) throws -> [URL: PackageReading] { + var blocks: [Int: [Substring]] = [:] + var current: Int? + for line in output.split(separator: "\n") { + if line.hasPrefix("package "), let index = Int(line.dropFirst("package ".count)), packages.indices.contains(index) { + current = index + blocks[index] = [] + } else if let current { + blocks[current]?.append(line) + } + } + var readings: [URL: PackageReading] = [:] + for (index, lines) in blocks { + if let why = lines.first(where: { $0.hasPrefix("unreadable ") }) { + readings[packages[index]] = .unreadable(String(why.dropFirst("unreadable ".count))) + } else { + readings[packages[index]] = .contents(try PackageContent.parse(lines.joined(separator: "\n"))) + } + } + return readings } } diff --git a/Sources/NXLibraryCore/Services/EngineToolchain.swift b/Sources/NXLibraryCore/Services/EngineToolchain.swift index 2289630..b5775db 100644 --- a/Sources/NXLibraryCore/Services/EngineToolchain.swift +++ b/Sources/NXLibraryCore/Services/EngineToolchain.swift @@ -51,7 +51,9 @@ public struct EngineToolchain: Equatable { public func environment(base: [String: String], developerDir: URL?) -> [String: String] { var env = base - let path = (extraPath + [Self.standardPath]).joined(separator: ":") + // Microsoft's dotnet-install script puts .NET in ~/.dotnet, which the engine also searches (its TOOL_PATHS). + let home = (base["HOME"] ?? NSHomeDirectory()) + "/.dotnet" + let path = (extraPath + [Self.standardPath, home]).joined(separator: ":") env["PATH"] = path + (base["PATH"].map { ":" + $0 } ?? "") if isBundled { // The app bundle is read-only and must not pick up the player's own packages. diff --git a/Sources/NXLibraryCore/Services/GameBanana/AvailableModsFinder.swift b/Sources/NXLibraryCore/Services/GameBanana/AvailableModsFinder.swift index 7f5dd9a..31ab135 100644 --- a/Sources/NXLibraryCore/Services/GameBanana/AvailableModsFinder.swift +++ b/Sources/NXLibraryCore/Services/GameBanana/AvailableModsFinder.swift @@ -21,10 +21,13 @@ public struct AvailableMod: Equatable, Identifiable, Sendable { /// GameBanana's root category for the mod in its game ("Skins", "Maps"…); empty when unknown. public let category: String public let added: Date? + /// The author's requirements worth showing (game versions, other mods); never a loader, which hides the mod. + public let requirements: [GameBananaRequirement] public init(id: Int, rank: Int, name: String, author: String, avatarURL: URL?, images: [URL], thumbnailURL: URL?, version: String, updated: Date, pageURL: URL, description: String, likes: Int, downloads: Int, - files: [GameBananaFile], category: String = "", added: Date? = nil) { + files: [GameBananaFile], category: String = "", added: Date? = nil, + requirements: [GameBananaRequirement] = []) { self.id = id self.rank = rank self.name = name @@ -41,6 +44,7 @@ public struct AvailableMod: Equatable, Identifiable, Sendable { self.files = files self.category = category self.added = added + self.requirements = requirements } } @@ -54,13 +58,18 @@ public struct AvailableModsFinder: Sendable { public let want: Int public let maxScanned: Int public let perPage: Int + /// GameBanana mods the player installed for this game: each one the list did not give (left out while an update + /// waits for approval, or past `want`) is read from its own page at the end, so it never vanishes from the band. + public let installed: [Int] - public init(client: GameBananaClient, titleID: String, want: Int = 12, maxScanned: Int = 60, perPage: Int = 20) { + public init(client: GameBananaClient, titleID: String, want: Int = 12, maxScanned: Int = 60, perPage: Int = 20, + installed: [Int] = []) { self.client = client self.titleID = titleID self.want = want self.maxScanned = maxScanned self.perPage = perPage + self.installed = installed } public func stream(gameID: Int) -> AsyncThrowingStream { @@ -78,6 +87,21 @@ public struct AvailableModsFinder: Sendable { } private func run(gameID: Int, yield: (AvailableMod) -> Void) async throws { + var shown: Set = [] + let rank = try await scan(gameID: gameID) { mod in + shown.insert(mod.id) + yield(mod) + } + for (offset, id) in installed.enumerated() where !shown.contains(id) && !Task.isCancelled { + if let mod = try await check(modID: id, rank: rank + offset) { + shown.insert(id) + yield(mod) + } + } + } + + /// The list, most downloaded first; returns the rank after the last mod it read. + private func scan(gameID: Int, yield: (AvailableMod) -> Void) async throws -> Int { var found = 0, scanned = 0, page = 1 while found < want, scanned < maxScanned, !Task.isCancelled { let result = try await client.modPage(gameID: gameID, page: page, perPage: perPage) @@ -98,6 +122,13 @@ public struct AvailableModsFinder: Sendable { if result.isComplete || result.records.isEmpty { break } page += 1 } + return scanned + } + + /// An installed mod read from its own page; nil when it is gone or no longer works. + private func check(modID: Int, rank: Int) async throws -> AvailableMod? { + let summary = GameBananaModSummary(id: modID, name: "") + do { return try await check(summary, rank: rank) } catch GameBananaError.unavailable { return nil } } private func check(_ summary: GameBananaModSummary, rank: Int) async throws -> AvailableMod? { @@ -127,7 +158,8 @@ public struct AvailableModsFinder: Sendable { description: GameBananaAPI.plainText(fromHTML: profile.text), likes: max(profile.likes, summary.likes), downloads: profile.downloads, files: files.sorted { ($0.dateAdded ?? .distantPast) > ($1.dateAdded ?? .distantPast) }, - category: summary.rootCategory, added: profile.dateAdded ?? summary.dateAdded) + category: summary.rootCategory, added: profile.dateAdded ?? summary.dateAdded, + requirements: ModRequirements.shown(profile.requirements)) } } diff --git a/Sources/NXLibraryCore/Services/GameBanana/GameBananaAPI.swift b/Sources/NXLibraryCore/Services/GameBanana/GameBananaAPI.swift index 162e369..223885f 100644 --- a/Sources/NXLibraryCore/Services/GameBanana/GameBananaAPI.swift +++ b/Sources/NXLibraryCore/Services/GameBanana/GameBananaAPI.swift @@ -312,6 +312,39 @@ public struct GameBananaFile: Decodable, Equatable, Sendable, Identifiable { } } +/// One line of a mod's Requirements on GameBanana: free text by the author ("Trinity Mod Loader", "SMO v1.3"), +/// optional when the author labels it Recommended or Optional. +public struct GameBananaRequirement: Codable, Hashable, Sendable { + public let name: String + public let isOptional: Bool + + public init(name: String, isOptional: Bool) { + self.name = name + self.isOptional = isOptional + } +} + +/// `_aRequirements` holds `[name, link, label, colour, label, colour…]`; anything else in the list is skipped. +private struct RequirementEntry: Decodable { + let requirement: GameBananaRequirement? + + private struct Part: Decodable { + let text: String? + init(from decoder: Decoder) throws { text = try? decoder.singleValueContainer().decode(String.self) } + } + + init(from decoder: Decoder) throws { + let parts = ((try? decoder.singleValueContainer().decode([Part].self)) ?? []).map(\.text) + guard let name = parts.first??.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { + requirement = nil + return + } + let labels = parts.dropFirst(2).compactMap { $0?.lowercased() } + requirement = GameBananaRequirement(name: name, + isOptional: labels.contains("recommended") || labels.contains("optional")) + } +} + public struct GameBananaModProfile: Decodable, Equatable, Sendable { public let id: Int public let name: String @@ -330,12 +363,14 @@ public struct GameBananaModProfile: Decodable, Equatable, Sendable { public let downloads: Int public let dateAdded: Date? public let dateUpdated: Date? + public let requirements: [GameBananaRequirement] public init(id: Int, name: String, profileURL: URL? = nil, version: String = "", text: String = "", isObsolete: Bool = false, isPrivate: Bool = false, isWithheld: Bool = false, isTrashed: Bool = false, contentRatings: [String: String] = [:], images: [GameBananaImage] = [], submitter: GameBananaSubmitter = GameBananaSubmitter(name: ""), files: [GameBananaFile] = [], - likes: Int = 0, downloads: Int = 0, dateAdded: Date? = nil, dateUpdated: Date? = nil) { + likes: Int = 0, downloads: Int = 0, dateAdded: Date? = nil, dateUpdated: Date? = nil, + requirements: [GameBananaRequirement] = []) { self.id = id self.name = name self.profileURL = profileURL @@ -353,6 +388,7 @@ public struct GameBananaModProfile: Decodable, Equatable, Sendable { self.downloads = downloads self.dateAdded = dateAdded self.dateUpdated = dateUpdated + self.requirements = requirements } public init(from decoder: Decoder) throws { @@ -362,6 +398,7 @@ public struct GameBananaModProfile: Decodable, Equatable, Sendable { profileURL = c.url("_sProfileUrl") version = c.value("_sVersion", "") text = c.value("_sText", "") + requirements = c.value("_aRequirements", [RequirementEntry]()).compactMap(\.requirement) isObsolete = c.value("_bIsObsolete", false) isPrivate = c.value("_bIsPrivate", false) isWithheld = c.value("_bIsWithheld", false) diff --git a/Sources/NXLibraryCore/Services/GameBanana/GameBananaBandModel.swift b/Sources/NXLibraryCore/Services/GameBanana/GameBananaBandModel.swift index caa1ecf..030114d 100644 --- a/Sources/NXLibraryCore/Services/GameBanana/GameBananaBandModel.swift +++ b/Sources/NXLibraryCore/Services/GameBanana/GameBananaBandModel.swift @@ -184,7 +184,8 @@ public final class GameBananaBandModel: ObservableObject { task?.cancel() available = [] phase = .looking - let finder = AvailableModsFinder(client: self.client, titleID: store.titleID) + let finder = AvailableModsFinder(client: self.client, titleID: store.titleID, + installed: store.gameBananaRecords().keys.sorted()) task = Task { [weak self] in // The mods show in two groups at most, each with its art: a row's worth beside Add mods as soon as // there is one, the rest once the search has ended. One at a time, each tile came in on its own and diff --git a/Sources/NXLibraryCore/Services/GameBanana/ModCatalogSnapshots.swift b/Sources/NXLibraryCore/Services/GameBanana/ModCatalogSnapshots.swift index b1657c8..c5d353f 100644 --- a/Sources/NXLibraryCore/Services/GameBanana/ModCatalogSnapshots.swift +++ b/Sources/NXLibraryCore/Services/GameBanana/ModCatalogSnapshots.swift @@ -110,6 +110,8 @@ private struct StoredMod: Codable { let files: [StoredFile] let category: String let added: Date? + /// Missing in snapshots written before 0.1.4.1. + let requirements: [GameBananaRequirement]? init(_ mod: AvailableMod) { id = mod.id @@ -128,6 +130,7 @@ private struct StoredMod: Codable { files = mod.files.map(StoredFile.init) category = mod.category added = mod.added + requirements = mod.requirements } var mod: AvailableMod? { @@ -136,6 +139,7 @@ private struct StoredMod: Codable { return AvailableMod(id: id, rank: rank, name: name, author: author, avatarURL: trusted(avatarURL), images: images.compactMap { trusted($0) }, thumbnailURL: trusted(thumbnailURL), version: version, updated: updated, pageURL: page, description: description, - likes: likes, downloads: downloads, files: files, category: category, added: added) + likes: likes, downloads: downloads, files: files, category: category, added: added, + requirements: requirements ?? []) } } diff --git a/Sources/NXLibraryCore/Services/GameBanana/ModSupport.swift b/Sources/NXLibraryCore/Services/GameBanana/ModSupport.swift index b572ebc..0b324b4 100644 --- a/Sources/NXLibraryCore/Services/GameBanana/ModSupport.swift +++ b/Sources/NXLibraryCore/Services/GameBanana/ModSupport.swift @@ -29,7 +29,7 @@ public enum ModSupport { public static func isVisible(_ profile: GameBananaModProfile) -> Bool { !profile.isObsolete && !profile.isPrivate && !profile.isWithheld && !profile.isTrashed - && profile.contentRatings.isEmpty + && profile.contentRatings.isEmpty && !ModRequirements.needsTool(profile.requirements) } private static func isCode(_ component: String) -> Bool { @@ -39,6 +39,36 @@ public enum ModSupport { } } +/// What a mod's GameBanana Requirements mean here. They are free text, so only clear cases are acted on: a loader or +/// a PC tool the engine cannot run hides the mod (it would install and then not work, or break the game, as exlaunch +/// mods do); notes about a modded Switch or an emulator are left out; everything else (game versions, other mods) is +/// shown in the mod's panel for the player to judge. +public enum ModRequirements { + /// Loaders and tools: exlaunch-based loaders, Smash's Skyline/ARCropolis, mod managers and mergers that must run + /// on the game first, and ReShade, which hooks a PC emulator's renderer. + static let tools = ["exlaunch", "skyline", "arcropolis", "mod loader", "modloader", "mod manager", "tkmm", + "recomp", "reshade"] + static let optionalWords = ["optional", "preferred", "recommended", "not required"] + static let platformWords = ["hacked", "modded", "emulator", "yuzu", "ryujinx", "atmosphere", "sdcard", "sd card", + "cfw", "homebrew"] + static let platformNames: Set = ["computer", "pc"] + + public static func needsTool(_ requirements: [GameBananaRequirement]) -> Bool { + requirements.contains { requirement in + let name = requirement.name.lowercased() + return !requirement.isOptional && !optionalWords.contains(where: name.contains) + && tools.contains(where: name.contains) + } + } + + public static func shown(_ requirements: [GameBananaRequirement]) -> [GameBananaRequirement] { + requirements.filter { requirement in + let name = requirement.name.lowercased() + return !platformNames.contains(name) && !platformWords.contains(where: name.contains) + } + } +} + /// A mod's folder name in the game's mods folder: safe for `DM_ROMFS_OVERLAY` (no `:`), a single path component, /// visible in Finder, and unique (APFS is case-insensitive by default). public enum ModFolderName { diff --git a/Sources/NXLibraryCore/Services/KeyStore.swift b/Sources/NXLibraryCore/Services/KeyStore.swift index ddd1bf9..826a580 100644 --- a/Sources/NXLibraryCore/Services/KeyStore.swift +++ b/Sources/NXLibraryCore/Services/KeyStore.swift @@ -80,7 +80,13 @@ public final class KeyStore { let tmp = keyURL.deletingLastPathComponent() .appendingPathComponent(".prod.keys-\(UUID()).tmp") defer { try? fm.removeItem(at: tmp) } - try data.write(to: tmp, options: .atomic) + // Created private: the copy is never readable by others, not even between the write and a chmod. + let fd = open(tmp.path, O_WRONLY | O_CREAT | O_EXCL, 0o600) + guard fd >= 0 else { throw CocoaError(.fileWriteNoPermission, userInfo: [NSFilePathErrorKey: tmp.path]) } + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp.path) try publish(tmp, keyURL) return summary diff --git a/Sources/NXLibraryCore/Services/LibraryStore.swift b/Sources/NXLibraryCore/Services/LibraryStore.swift index c51dffa..5e3b250 100644 --- a/Sources/NXLibraryCore/Services/LibraryStore.swift +++ b/Sources/NXLibraryCore/Services/LibraryStore.swift @@ -39,6 +39,9 @@ public final class LibraryStore { do { return try Self.decoder().decode(LibraryFile.self, from: Data(contentsOf: fileURL)) } catch { + // Move the unreadable file aside so the next save cannot write over it; the backup (or an empty + // library) carries on, and the games in it can still be recovered by hand. + keepAside() if fm.fileExists(atPath: backupURL.path), let backup = try? Self.decoder().decode(LibraryFile.self, from: Data(contentsOf: backupURL)) { return backup @@ -47,6 +50,14 @@ public final class LibraryStore { } } + /// `library.json.damaged--`, beside the library. + private func keepAside() { + let stamp = ISO8601DateFormatter.string(from: Date(), timeZone: .current, + formatOptions: [.withYear, .withMonth, .withDay, .withTime]) + let id = UUID().uuidString.prefix(4) + try? fm.moveItem(at: fileURL, to: directory.appendingPathComponent("library.json.damaged-\(stamp)-\(id)")) + } + public func save(_ library: LibraryFile) throws { try fm.createDirectory(at: directory, withIntermediateDirectories: true) // Never rotate a corrupt main file over the last known good backup. diff --git a/Sources/NXLibraryCore/Services/NewestEngine.swift b/Sources/NXLibraryCore/Services/NewestEngine.swift index ed0d811..a581941 100644 --- a/Sources/NXLibraryCore/Services/NewestEngine.swift +++ b/Sources/NXLibraryCore/Services/NewestEngine.swift @@ -30,6 +30,16 @@ public enum NewestEngineReadiness: Equatable, Sendable { case unknown } +/// A button on Settings → Engine's Newest engine row. +public enum NewestEngineAction: Equatable, Sendable { + case useNewest, update, backToTested +} + +/// A tag beside an upstream commit while the newest engine is in use. +public enum UpstreamCommitMark: Equatable, Sendable { + case inUse, new +} + /// The words every screen uses for the newest engine, so Settings and Setup say the same thing. public enum NewestEngineCopy { public static let note = "Newest nxnative, not yet tested by NX Library. It usually works, but something may not. " @@ -53,6 +63,14 @@ public enum NewestEngineCopy { "At \(choice.shortCommit), \(ahead(choice.aheadBy)) of the tested \(tested.prefix(7)), chosen in Settings → Engine." } + /// Settings → Engine, Newest engine row, while main is past the commit in use. `aheadBy` counts from the pin, + /// like the choice's own count; a rewritten main (not further ahead) gets no count. + public static func update(choice: NewestEngineChoice, main: String, aheadBy: Int) -> String { + let newer = aheadBy - choice.aheadBy + let count = newer > 0 ? ", \(newer) commit\(newer == 1 ? "" : "s") newer" : "" + return "main has moved on: \(main.prefix(7))\(count)." + } + public static let info = "Builds games with nxnative main instead of the engine this NX Library is tested with. " + "Builds are recorded as untested and the untested-toolchain override stays on. " + "Back to tested returns to the tested engine; games you built keep playing." @@ -113,6 +131,35 @@ public enum NewestEngine { return String(data: data, encoding: .utf8) } + /// The Newest engine row's buttons. `main`: upstream main when it is ahead of the pin (nil when it is the pin or + /// was not checked). Update moves an existing choice to a newer main; a main that needs another Metal compiler + /// offers neither Use newest nor Update. Unknown readiness still offers them: the switch reads the lock first. + public static func actions(choice: NewestEngineChoice?, main: String?, + readiness: NewestEngineReadiness?) -> [NewestEngineAction] { + var blocked = false + if case .needsNewerApp = readiness { blocked = true } + let movable = main != nil && main != choice?.commit && !blocked + guard choice != nil else { return movable ? [.useNewest] : [] } + return movable ? [.update, .backToTested] : [.backToTested] + } + + /// The row's buttons on a controller: A takes the first (Update when main moved on, else Use newest or Back to + /// tested), Y the second (Back to tested beside Update). Nil when the row has no button. + public static func controller(_ actions: [NewestEngineAction]) + -> (primary: NewestEngineAction, secondary: NewestEngineAction?)? { + guard let primary = actions.first else { return nil } + return (primary, actions.dropFirst().first) + } + + /// Tags for Settings → Engine's upstream list (newest first): the commit the newest engine is at is In use, + /// those above it New. Nothing on the tested engine, or when the commit in use is not in the list. + public static func marks(_ commits: [UpstreamCommit], inUse: String?) -> [String: UpstreamCommitMark] { + guard let inUse, let index = commits.firstIndex(where: { $0.sha == inUse }) else { return [:] } + var marks: [String: UpstreamCommitMark] = [inUse: .inUse] + for commit in commits[.. Bool { diff --git a/Sources/NXLibraryCore/Services/PlaySessionStore.swift b/Sources/NXLibraryCore/Services/PlaySessionStore.swift index 2ebbb94..d4ab5d8 100644 --- a/Sources/NXLibraryCore/Services/PlaySessionStore.swift +++ b/Sources/NXLibraryCore/Services/PlaySessionStore.swift @@ -2,10 +2,22 @@ import Foundation import Combine public final class PlaySessionStore: ObservableObject, PlayHistory { + /// Recent sessions, at most `maxSessions` (running sessions are never dropped). @Published public private(set) var sessions: [PlaySession] = [] + /// Play time and last start of sessions trimmed from `sessions`, per game, + /// so totals and last-played survive trimming. + @Published public private(set) var carried: [UUID: CarriedPlaytime] = [:] + public static let maxSessions = 200 public private(set) var loadError: String? + /// Recent sessions, a bare `[PlaySession]` array as in every earlier version, + /// so the released app and a development build can share it. public let fileURL: URL + /// Per-game carried totals, beside `fileURL` (`play-sessions-totals.json`). + public var totalsURL: URL { + fileURL.deletingLastPathComponent() + .appendingPathComponent(fileURL.deletingPathExtension().lastPathComponent + "-totals.json") + } private let fileManager: FileManager private let now: () -> Date @@ -35,8 +47,8 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { lastHeartbeatAt: date ) sessions.append(session) - trim() - save() + let folded = trim() + save(totals: folded) return true } @@ -57,17 +69,20 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { if changed { save() } } - public func end(bundleID: String?) { - guard let bundleID else { return } + /// Ends the game's running sessions and returns them, ended. + @discardableResult + public func end(bundleID: String?) -> [PlaySession] { + guard let bundleID else { return [] } let date = now() - var changed = false + var ended: [PlaySession] = [] for index in sessions.indices where sessions[index].bundleID == bundleID && sessions[index].isActive { sessions[index].endedAt = date sessions[index].lastHeartbeatAt = date - changed = true + ended.append(sessions[index]) } - if changed { save() } + if !ended.isEmpty { save() } + return ended } public func reconcile(runningBundleIDs: Set) { @@ -90,37 +105,53 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { let date = now() return sessions .filter { $0.gameID == gameID } - .reduce(0) { total, session in + .reduce(carried[gameID]?.duration ?? 0) { total, session in total + max(0, (session.endedAt ?? date).timeIntervalSince(session.startedAt)) } } public func lastPlayed(for gameID: UUID) -> Date? { - sessions.filter { $0.gameID == gameID }.map(\.startedAt).max() + let recent = sessions.filter { $0.gameID == gameID }.map(\.startedAt).max() + return [recent, carried[gameID]?.lastPlayed].compactMap { $0 }.max() } public func isRunning(gameID: UUID) -> Bool { sessions.contains { $0.gameID == gameID && $0.isActive } } - private func trim(maxCount: Int = 200) { - guard sessions.count > maxCount else { return } - sessions = Array(sessions.sorted { $0.startedAt > $1.startedAt }.prefix(maxCount)) + /// Drops the oldest ended sessions beyond `maxCount`, folding each into `carried`. + /// Returns whether `carried` changed. + private func trim(maxCount: Int = PlaySessionStore.maxSessions) -> Bool { + let excess = sessions.count - maxCount + guard excess > 0 else { return false } + let dropped = sessions.filter { !$0.isActive } + .sorted { $0.startedAt < $1.startedAt } + .prefix(excess) + guard !dropped.isEmpty else { return false } + for session in dropped { + var entry = carried[session.gameID] ?? CarriedPlaytime(duration: 0, lastPlayed: session.startedAt) + entry.duration += session.duration ?? 0 + entry.lastPlayed = max(entry.lastPlayed, session.startedAt) + carried[session.gameID] = entry + } + let droppedIDs = Set(dropped.map(\.id)) + sessions.removeAll { droppedIDs.contains($0.id) } + return true } - private func save() { + /// Writes the sessions, then (when they changed) the carried totals. A crash in + /// between can lose the just-folded sessions' time, never count it twice. + private func save(totals: Bool = false) { do { try fileManager.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let data = try JSONEncoder().encode(sessions) - let temp = fileURL.appendingPathExtension("tmp") - try data.write(to: temp, options: .atomic) - if fileManager.fileExists(atPath: fileURL.path) { - _ = try fileManager.replaceItemAt(fileURL, withItemAt: temp) - } else { - try fileManager.moveItem(at: temp, to: fileURL) + try write(JSONEncoder().encode(sessions), to: fileURL) + if totals { + let entries = carried.map { CarriedEntry(gameID: $0.key, playtime: $0.value) } + .sorted { $0.gameID.uuidString < $1.gameID.uuidString } + try write(JSONEncoder().encode(entries), to: totalsURL) } loadError = nil } catch { @@ -128,15 +159,51 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { } } + private func write(_ data: Data, to url: URL) throws { + let temp = url.appendingPathExtension("tmp") + try data.write(to: temp, options: .atomic) + if fileManager.fileExists(atPath: url.path) { + _ = try fileManager.replaceItemAt(url, withItemAt: temp) + } else { + try fileManager.moveItem(at: temp, to: url) + } + } + private func load() { - guard fileManager.fileExists(atPath: fileURL.path) else { return } + if fileManager.fileExists(atPath: fileURL.path) { + do { + sessions = try JSONDecoder().decode([PlaySession].self, from: Data(contentsOf: fileURL)) + loadError = nil + } catch { + sessions = [] + loadError = error.localizedDescription + } + } + // No totals file: nothing was trimmed since carried totals began. + guard fileManager.fileExists(atPath: totalsURL.path) else { return } do { - let data = try Data(contentsOf: fileURL) - sessions = try JSONDecoder().decode([PlaySession].self, from: data) - loadError = nil + let entries = try JSONDecoder().decode([CarriedEntry].self, from: Data(contentsOf: totalsURL)) + carried = Dictionary(entries.map { ($0.gameID, $0.playtime) }, + uniquingKeysWith: { first, _ in first }) } catch { - sessions = [] + carried = [:] loadError = error.localizedDescription } } } + +/// Play time folded out of the recent-session list for one game. +public struct CarriedPlaytime: Codable, Equatable { + public var duration: TimeInterval + public var lastPlayed: Date + + public init(duration: TimeInterval, lastPlayed: Date) { + self.duration = duration + self.lastPlayed = lastPlayed + } +} + +private struct CarriedEntry: Codable { + var gameID: UUID + var playtime: CarriedPlaytime +} diff --git a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift index c4162a7..166d311 100644 --- a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift +++ b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift @@ -36,6 +36,19 @@ final class AppSettingsStoreTests: XCTestCase { XCTAssertTrue(AppSettingsStore(fileURL: url).settings.notifyPortingEnds) } + func testSessionCardDefaultsOnForOldFiles() throws { + let url = tempURL() + try Data("{}".utf8).write(to: url) + XCTAssertTrue(AppSettingsStore(fileURL: url).settings.showsSessionCard) + } + + func testSessionCardChoiceSurvivesARoundTrip() throws { + var settings = AppSettings() + settings.showsSessionCard = false + let back = try JSONDecoder().decode(AppSettings.self, from: JSONEncoder().encode(settings)) + XCTAssertFalse(back.showsSessionCard) + } + func testCheckAppUpdatesDefaultsOnForOldFiles() throws { let url = tempURL() try Data("{}".utf8).write(to: url) diff --git a/Tests/NXLibraryCoreTests/AvailableModsFinderTests.swift b/Tests/NXLibraryCoreTests/AvailableModsFinderTests.swift index 0464b63..51dd1b2 100644 --- a/Tests/NXLibraryCoreTests/AvailableModsFinderTests.swift +++ b/Tests/NXLibraryCoreTests/AvailableModsFinderTests.swift @@ -13,9 +13,11 @@ final class AvailableModsFinderTests: XCTestCase { override func tearDownWithError() throws { try? FileManager.default.removeItem(at: dir) } - private func finder(want: Int = 12, maxScanned: Int = 60, perPage: Int = 20) -> AvailableModsFinder { + private func finder(want: Int = 12, maxScanned: Int = 60, perPage: Int = 20, + installed: [Int] = []) -> AvailableModsFinder { let client = GameBananaClient(http: http, cacheFolder: dir.appendingPathComponent("cache"), userAgent: "test") - return AvailableModsFinder(client: client, titleID: tid, want: want, maxScanned: maxScanned, perPage: perPage) + return AvailableModsFinder(client: client, titleID: tid, want: want, maxScanned: maxScanned, perPage: perPage, + installed: installed) } private func summary(_ id: Int, rated: Bool = false) -> String { @@ -28,7 +30,7 @@ final class AvailableModsFinderTests: XCTestCase { } /// A profile whose files are `(fileID, paths)`; each file's listing is served too. - private func profile(_ id: Int, files: [(Int, [String])]) { + private func profile(_ id: Int, files: [(Int, [String])], requirements: String = "[]") { let list = files.map { file in #"{"_idRow": \#(file.0), "_sFile": "f\#(file.0).zip", "_nFilesize": 10, "_tsDateAdded": \#(1_700_000_000 + file.0), "_sDownloadUrl": "https://gamebanana.com/dl/\#(file.0)", "_sMd5Checksum": "x"}"# }.joined(separator: ",") @@ -36,7 +38,7 @@ final class AvailableModsFinderTests: XCTestCase { {"_idRow": \#(id), "_sName": "Mod \#(id)", "_sProfileUrl": "https://gamebanana.com/mods/\#(id)", "_sText": "Hello
world", "_aSubmitter": {"_sName": "Author \#(id)"}, "_aPreviewMedia": {"_aImages": [{"_sBaseUrl": "https://images.gamebanana.com/img/ss/mods", "_sFile": "\#(id).jpg", "_sFile220": "220-\#(id).jpg"}]}, - "_aFiles": [\#(list)]} + "_aRequirements": \#(requirements), "_aFiles": [\#(list)]} """#) for file in files { let paths = file.1.map { "\"\($0)\"" }.joined(separator: ",") @@ -67,6 +69,48 @@ final class AvailableModsFinderTests: XCTestCase { XCTAssertEqual(http.count("Mod/3/ProfilePage"), 0) } + /// A mod that needs a loader is never listed; one with other requirements is, and carries them for its panel. + func testModsNeedingALoaderAreLeftOutAndOthersCarryTheirRequirements() async throws { + page([1, 2], complete: true) + profile(1, files: [(11, ["romfs/a.bin"])], requirements: #"[["Trinity Mod Loader", "https://gamebanana.com/updates/1"]]"#) + profile(2, files: [(21, ["romfs/a.bin"])], requirements: #"[["Hacked switch", ""], ["Super Mario Odyssey v1.3", ""]]"#) + let mods = try await collect(finder()) + XCTAssertEqual(mods.map(\.id), [2]) + XCTAssertEqual(mods[0].requirements, [GameBananaRequirement(name: "Super Mario Odyssey v1.3", isOptional: false)]) + XCTAssertEqual(http.count("itemid=11&"), 0, "a hidden mod's files are not even listed") + } + + /// An installed mod GameBanana's list leaves out for a while (an update waiting for approval) is read from its + /// own page, so the player still sees it, and its Update. It comes after the listed mods. + func testAnInstalledModMissingFromTheListIsReadFromItsPage() async throws { + page([1], complete: true) + profile(1, files: [(11, ["romfs/a.bin"])]) + profile(77, files: [(771, ["Pack/romfs/a.bin"])]) + let mods = try await collect(finder(installed: [77])) + XCTAssertEqual(mods.map(\.id), [1, 77]) + XCTAssertGreaterThan(mods[1].rank, mods[0].rank) + XCTAssertEqual(mods[1].files.map(\.id), [771]) + } + + /// An installed mod past the band's cut (twelve mods) is listed too, once. + func testAnInstalledModPastTheCutIsListedOnce() async throws { + page([1, 2], complete: true) + profile(1, files: [(11, ["romfs/a.bin"])]) + profile(2, files: [(21, ["romfs/a.bin"])]) + let mods = try await collect(finder(want: 1, installed: [1, 2])) + XCTAssertEqual(mods.map(\.id), [1, 2]) + } + + /// An installed mod that no longer works or is gone from GameBanana stays out, and the others still come. + func testAnInstalledModThatNoLongerWorksOrIsGoneStaysOut() async throws { + page([1], complete: true) + profile(1, files: [(11, ["romfs/a.bin"])]) + profile(78, files: [(781, ["exefs/main.npdm"])]) + http.serve("Mod/79/ProfilePage", "gone", status: 404) + let mods = try await collect(finder(installed: [78, 79])) + XCTAssertEqual(mods.map(\.id), [1]) + } + func testStopsAtWant() async throws { page([1, 2, 3, 4, 5], complete: false) for id in 1...5 { profile(id, files: [(id * 10, ["romfs/a"])]) } diff --git a/Tests/NXLibraryCoreTests/BuildEventParserTests.swift b/Tests/NXLibraryCoreTests/BuildEventParserTests.swift index b38957a..ba28e41 100644 --- a/Tests/NXLibraryCoreTests/BuildEventParserTests.swift +++ b/Tests/NXLibraryCoreTests/BuildEventParserTests.swift @@ -16,4 +16,10 @@ final class BuildEventParserTests: XCTestCase { func testNonJSONBecomesLog() { XCTAssertEqual(BuildEventParser.parse("hello"), .log("hello")) } + + /// Both are followed by a `say` with the same news (nxnative docs/frontends.md): the raw JSON stays out of the log. + func testGameAndStaleShaderEventsAreNotLogged() { + XCTAssertNil(BuildEventParser.parse(#"{"ev":"game","title_id":"0100000000010000","name":"Game","version":"1.1.0","bundle_id":"org.nxnative.0100000000010000","out":"/tmp/Game.app","icon":null}"#)) + XCTAssertNil(BuildEventParser.parse(#"{"ev":"shaders_stale","reason":"chain","old":["b-1"],"new":"b-2","count":3}"#)) + } } diff --git a/Tests/NXLibraryCoreTests/EngineToolchainTests.swift b/Tests/NXLibraryCoreTests/EngineToolchainTests.swift index 88e7dec..66b7110 100644 --- a/Tests/NXLibraryCoreTests/EngineToolchainTests.swift +++ b/Tests/NXLibraryCoreTests/EngineToolchainTests.swift @@ -62,7 +62,7 @@ final class EngineToolchainTests: XCTestCase { let env = toolchain.environment(base: ["PATH": "/x", "HOME": "/h"], developerDir: URL(fileURLWithPath: "/Applications/Xcode.app/Contents/Developer")) - XCTAssertEqual(env["PATH"], "/App/Contents/Resources/Runtime/bin:" + EngineToolchain.standardPath + ":/x") + XCTAssertEqual(env["PATH"], "/App/Contents/Resources/Runtime/bin:" + EngineToolchain.standardPath + ":/h/.dotnet:/x") XCTAssertEqual(env["PYTHONNOUSERSITE"], "1") XCTAssertEqual(env["PYTHONDONTWRITEBYTECODE"], "1") XCTAssertEqual(env["DEVELOPER_DIR"], "/Applications/Xcode.app/Contents/Developer") @@ -71,10 +71,23 @@ final class EngineToolchainTests: XCTestCase { func testVenvEnvironmentHasNoPythonFlags() { let toolchain = EngineToolchain.resolve(runtime: nil, repo: URL(fileURLWithPath: "/r")) - let env = toolchain.environment(base: ["PATH": "/x"], developerDir: nil) - XCTAssertEqual(env["PATH"], EngineToolchain.standardPath + ":/x") + let env = toolchain.environment(base: ["PATH": "/x", "HOME": "/h"], developerDir: nil) + XCTAssertEqual(env["PATH"], EngineToolchain.standardPath + ":/h/.dotnet:/x") XCTAssertNil(env["PYTHONNOUSERSITE"]) XCTAssertNil(env["PYTHONDONTWRITEBYTECODE"]) XCTAssertNil(env["DEVELOPER_DIR"]) } + + /// Microsoft's dotnet-install script puts .NET in ~/.dotnet, which the engine also searches (TOOL_PATHS): Setup and + /// the builds find it there, after the system's own .NET. + func testDotnetInTheHomeFolderIsFound() { + let toolchain = EngineToolchain.resolve(runtime: nil, repo: URL(fileURLWithPath: "/r")) + let path = toolchain.environment(base: ["PATH": "/x", "HOME": "/Users/p"], developerDir: nil)["PATH"] ?? "" + let folders = path.split(separator: ":").map(String.init) + let system = folders.firstIndex(of: "/usr/local/share/dotnet") + let home = folders.firstIndex(of: "/Users/p/.dotnet") + XCTAssertNotNil(home) + XCTAssertLessThan(system ?? .max, home ?? -1) + XCTAssertEqual(folders.last, "/x") + } } diff --git a/Tests/NXLibraryCoreTests/EngineUpgradeSourcesTests.swift b/Tests/NXLibraryCoreTests/EngineUpgradeSourcesTests.swift index 337bbec..a7eb247 100644 --- a/Tests/NXLibraryCoreTests/EngineUpgradeSourcesTests.swift +++ b/Tests/NXLibraryCoreTests/EngineUpgradeSourcesTests.swift @@ -279,11 +279,111 @@ final class EngineUpgradeSourcesTests: XCTestCase { XCTAssertTrue(SourceSelection.supported([restored], titleSelection: true, extractedFolders: false)) } - func testMetadataBridgeRunsWithTheUpstreamReaderContract() throws { + /// A base game the eShop serves at 1.1.0 is v65536 by its content meta (nxnative 5c4dc18f): built alone, that is the version to pack. + func testBaseGameAloneIsPackedAtItsOwnVersion() { + let file = URL(fileURLWithPath: "/tmp/base.nsz") + XCTAssertEqual(SourceSelection.versionForBuild([GameFileItem(url: file, kind: .base, versionString: "65536", sizeBytes: 1)]), "v65536") + XCTAssertEqual(SourceSelection.versionForBuild([GameFileItem(url: file, kind: .base, versionString: "v65536", sizeBytes: 1)]), "v65536") + XCTAssertEqual(SourceSelection.versionForBuild([GameFileItem(url: file, kind: .base, sizeBytes: 1)]), "v0") + } + + /// A workspace NX Library staged holds only the selected files, so the engine's own choice (the newest there, by + /// content meta) is the selection: no `-p`, which a version stored from an older scan could get wrong. + func testStagedWorkspaceLeavesTheVersionToTheEngine() { + let file = URL(fileURLWithPath: "/tmp/update [v65536].nsz") + let base = GameFileItem(url: URL(fileURLWithPath: "/tmp/base.nsp"), kind: .base, sizeBytes: 1) + let update = GameFileItem(url: file, kind: .update, versionString: "65536", sizeBytes: 1) + XCTAssertNil(SourceSelection.packageVersion(for: [base, update], stagedSelection: true)) + XCTAssertEqual(SourceSelection.packageVersion(for: [base, update], stagedSelection: false), "v65536") + } + + /// A merged package brings every version it holds into the workspace: the selection must be named. + func testMergedPackageNamesTheVersionEvenWhenStaged() { + let file = URL(fileURLWithPath: "/tmp/merged.nsp") + var base = GameFileItem(url: file, kind: .base, sizeBytes: 1) + base.requiresTitleSelection = true + XCTAssertEqual(SourceSelection.packageVersion(for: [base], stagedSelection: true), "v0") + } + + /// A NUP holds every version in one file, and a dump's folders are named by version: the selection is named. + func testContainersAndDumpsNameTheVersionEvenWhenStaged() { + let nup = URL(fileURLWithPath: "/tmp/game.nup") + let base = GameFileItem(url: nup, kind: .base, versionString: "0", sizeBytes: 1, sourceLayout: .container) + let update = GameFileItem(url: nup, kind: .update, versionString: "131072", sizeBytes: 1, sourceLayout: .container) + XCTAssertEqual(SourceSelection.packageVersion(for: [base], stagedSelection: true), "v0") + XCTAssertEqual(SourceSelection.packageVersion(for: [base, update], stagedSelection: true), "v131072") + let dump = GameFileItem(url: URL(fileURLWithPath: "/tmp/dump"), kind: .base, sizeBytes: 1, sourceLayout: .extracted) + XCTAssertEqual(SourceSelection.packageVersion(for: [dump], stagedSelection: true), "v0") + } + + private func readerFixture(_ script: String) throws -> (EnginePackageReader, URL) { let root = try temporaryFolder() let scripts = root.appendingPathComponent("scripts") try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: true) - let fixture = #""" + try Data(script.utf8).write(to: scripts.appendingPathComponent("build_ipa_from_nsp.py")) + let python = URL(fileURLWithPath: "/usr/bin/python3") + guard FileManager.default.isExecutableFile(atPath: python.path) else { throw XCTSkip("system Python unavailable") } + let toolchain = EngineToolchain(python: python, extraPath: [], isBundled: false) + let reader = EnginePackageReader(repo: root, toolchain: toolchain, + keys: root.appendingPathComponent("private-key-input"), + developerDir: URL(fileURLWithPath: "/Applications/Xcode.app/Contents/Developer")) + return (reader, root) + } + + /// nxnative 5c4dc18f: one `package_reader` run reads every package's content metas; a package it cannot read is named with why. + func testMetadataBridgeReadsEveryPackageInOneRun() throws { + let (reader, root) = try readerFixture(#""" +import contextlib +def configure_dotnet(environment): pass +@contextlib.contextmanager +def package_reader(repo, keys, environment): + assert keys.name == 'private-key-input' + class Reader: + @staticmethod + def contents(paths): + print('probe progress must not reach the parser') + out = {} + for p in paths: + if p.name == 'merged.nsp': out[p] = [('Application', 0x0100000000000000, 0), ('Patch', 0x0100000000000800, 65536)] + elif p.name == 'base.nsz': out[p] = [('Application', 0x0100000000010000, 65536)] + else: out[p] = 'no content meta' + return out + yield Reader +"""#) + let merged = root.appendingPathComponent("merged.nsp") + let base = root.appendingPathComponent("base [v0].nsz") + let renamed = root.appendingPathComponent("base.nsz") + let broken = root.appendingPathComponent("broken.nsp") + let readings = try reader.contents(of: [(merged, true), (renamed, true), (broken, false)]) + XCTAssertEqual(readings[merged], .contents([PackageContent(kind: .base, titleId: 0x0100000000000000, version: 0), + PackageContent(kind: .update, titleId: 0x0100000000000800, version: 65536)])) + XCTAssertEqual(readings[renamed], .contents([PackageContent(kind: .base, titleId: 0x0100000000010000, version: 65536)])) + XCTAssertEqual(readings[broken], .unreadable("no content meta")) + XCTAssertNil(readings[base]) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("scripts/__pycache__").path)) + } + + /// Keys that cannot open the meta NCAs stop the whole run with the engine's line, which names the missing key (never a value). + func testMetadataBridgeReportsTheEnginesKeysMessage() throws { + let (reader, root) = try readerFixture(#""" +import contextlib +class BuildError(Exception): pass +def configure_dotnet(environment): pass +@contextlib.contextmanager +def package_reader(repo, keys, environment): + class Reader: + @staticmethod + def contents(paths): raise BuildError('prod.keys has no key_area_key_application_12 (firmware 19.0.0)') + yield Reader +"""#) + XCTAssertThrowsError(try reader.contents(of: [(root.appendingPathComponent("game.nsp"), false)])) { error in + XCTAssertEqual(error as? EngineSourceError, .unreadablePackages("prod.keys has no key_area_key_application_12 (firmware 19.0.0)")) + } + } + + /// Engines before nxnative 5c4dc18f read one package at a time and only merged or unidentified ones. + func testMetadataBridgeRunsWithTheOlderReaderContract() throws { + let (reader, root) = try readerFixture(#""" import contextlib def is_merged(path): return path.name == 'merged.nsp' def configure_dotnet(environment): pass @@ -295,16 +395,12 @@ def package_title_reader(repo, keys, environment): return [('Application', 0x0100000000000000, 0), ('Patch', 0x0100000000000800, 65536)] print('probe progress must not reach the parser') yield Reader() -"""# - try Data(fixture.utf8).write(to: scripts.appendingPathComponent("build_ipa_from_nsp.py")) - let python = URL(fileURLWithPath: "/usr/bin/python3") - guard FileManager.default.isExecutableFile(atPath: python.path) else { throw XCTSkip("system Python unavailable") } - let toolchain = EngineToolchain(python: python, extraPath: [], isBundled: false) - let reader = EnginePackageReader(repo: root, toolchain: toolchain, - keys: root.appendingPathComponent("private-key-input"), - developerDir: URL(fileURLWithPath: "/Applications/Xcode.app/Contents/Developer")) - XCTAssertEqual(try reader.contents(of: root.appendingPathComponent("merged.nsp"), alreadyIdentified: true).count, 2) - XCTAssertEqual(try reader.contents(of: root.appendingPathComponent("plain.nsp"), alreadyIdentified: true), []) - XCTAssertFalse(FileManager.default.fileExists(atPath: scripts.appendingPathComponent("__pycache__").path)) +"""#) + let merged = root.appendingPathComponent("merged.nsp") + let plain = root.appendingPathComponent("plain.nsp") + let readings = try reader.contents(of: [(merged, true), (plain, true)]) + XCTAssertEqual(readings[merged].map { if case .contents(let rows) = $0 { return rows.count } else { return -1 } }, 2) + XCTAssertEqual(readings[plain], .contents([])) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("scripts/__pycache__").path)) } } diff --git a/Tests/NXLibraryCoreTests/GameBananaAPITests.swift b/Tests/NXLibraryCoreTests/GameBananaAPITests.swift index edbce5d..f7ebd1c 100644 --- a/Tests/NXLibraryCoreTests/GameBananaAPITests.swift +++ b/Tests/NXLibraryCoreTests/GameBananaAPITests.swift @@ -68,6 +68,23 @@ final class GameBananaAPITests: XCTestCase { XCTAssertEqual(clean.text, "") } + /// `_aRequirements` is a list of [name, link, label, colour, …]; the name and an optional label are kept. + func testProfileDecodesRequirements() throws { + let profile = try decode(GameBananaModProfile.self, """ + {"_idRow": 1, "_sName": "Pack", "_aSubmitter": {"_sName": "Maker"}, + "_aRequirements": [["Trinity Mod Loader", "https://gamebanana.com/updates/176846"], + ["Quick Flurry Rush", "https://gamebanana.com/mods/549737", "Installed", "BlueColor", "Recommended", "GreyColor"], + [], [7], [" Game versions 1.1.0-1.4.3 ", ""]]} + """) + XCTAssertEqual(profile.requirements, [ + GameBananaRequirement(name: "Trinity Mod Loader", isOptional: false), + GameBananaRequirement(name: "Quick Flurry Rush", isOptional: true), + GameBananaRequirement(name: "Game versions 1.1.0-1.4.3", isOptional: false), + ]) + let none = try decode(GameBananaModProfile.self, #"{"_idRow": 2, "_aRequirements": {}}"#) + XCTAssertEqual(none.requirements, []) + } + func testImageURLJoinsBaseAndFile() throws { let image = try decode(GameBananaImage.self, """ {"_sBaseUrl": "https://images.gamebanana.com/img/ss/mods", "_sFile": "x.jpg", "_sFile220": "220-90_x.jpg", "_sFile530": "530-90_x.jpg"} diff --git a/Tests/NXLibraryCoreTests/GameBananaBandModelTests.swift b/Tests/NXLibraryCoreTests/GameBananaBandModelTests.swift index 7b0c6fd..de90331 100644 --- a/Tests/NXLibraryCoreTests/GameBananaBandModelTests.swift +++ b/Tests/NXLibraryCoreTests/GameBananaBandModelTests.swift @@ -79,6 +79,27 @@ final class GameBananaBandModelTests: XCTestCase { XCTAssertEqual(model.available.map(\.id), []) } + /// A mod installed from GameBanana shows in the band even when GameBanana's list leaves it out. + func testAnInstalledModShowsEvenWhenTheListLeavesItOut() async throws { + try serveGameA() + http.serve("Mod/55/ProfilePage", #""" + {"_idRow": 55, "_sName": "Renamed UI", "_sProfileUrl": "https://gamebanana.com/mods/55", "_aSubmitter": {"_sName": "B"}, + "_aFiles": [{"_idRow": 551, "_sFile": "ui.zip", "_nFilesize": 10, "_tsDateAdded": 1700000000, + "_sDownloadUrl": "https://gamebanana.com/dl/551", "_sMd5Checksum": "x"}]} + """#) + http.serve("itemid=551&", #"[["UI/romfs/a.bin"]]"#) + let mods = store(gameA) + let staged = dir.appendingPathComponent("staged/romfs") + try FileManager.default.createDirectory(at: staged, withIntermediateDirectories: true) + try mods.adopt(staged: staged, folderName: "UI", + record: ModRecord(folderName: "UI", isOn: false, gameBananaModID: 55, gameBananaFileID: 540), + turnOn: false) + let model = band() + model.start(names: ["A"], store: mods) + await waitUntil { model.phase == .ready } + XCTAssertEqual(model.available.map(\.id), [1, 55]) + } + func testStopForgetsTheGamesMods() async throws { try serveGameA() let model = band() diff --git a/Tests/NXLibraryCoreTests/GameFocusTests.swift b/Tests/NXLibraryCoreTests/GameFocusTests.swift new file mode 100644 index 0000000..fe44c8f --- /dev/null +++ b/Tests/NXLibraryCoreTests/GameFocusTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import NXLibraryCore + +final class GameFocusTests: XCTestCase { + private let zelda = "org.nxnative.zelda" + private let mario = "org.nxnative.mario" + + func testAGameThatQuitsInFrontBringsTheLibraryBack() { + var focus = GameFocus() + focus.gameCameForward(zelda) + XCTAssertTrue(focus.gameEnded(zelda, othersRunning: false)) + } + + func testAGameNeverInFrontDoesNot() { + var focus = GameFocus() + XCTAssertFalse(focus.gameEnded(zelda, othersRunning: false)) + } + + func testAGameLeftForAnotherAppDoesNot() { + var focus = GameFocus() + focus.gameCameForward(zelda) + focus.otherAppStayedForward(whileRunning: zelda) + XCTAssertFalse(focus.gameEnded(zelda, othersRunning: false)) + } + + func testAnotherGameStillOpenKeepsTheLibraryBehind() { + var focus = GameFocus() + focus.gameCameForward(zelda) + XCTAssertFalse(focus.gameEnded(zelda, othersRunning: true)) + } + + func testOnlyTheGameInFrontCounts() { + var focus = GameFocus() + focus.gameCameForward(zelda) + focus.gameCameForward(mario) + XCTAssertFalse(focus.gameEnded(zelda, othersRunning: true)) + XCTAssertTrue(focus.gameEnded(mario, othersRunning: false)) + } + + func testLeavingAnotherGameKeepsTheOneInFront() { + var focus = GameFocus() + focus.gameCameForward(zelda) + focus.otherAppStayedForward(whileRunning: mario) + XCTAssertTrue(focus.gameEnded(zelda, othersRunning: false)) + } + + func testAQuitIsCountedOnce() { + var focus = GameFocus() + focus.gameCameForward(zelda) + XCTAssertTrue(focus.gameEnded(zelda, othersRunning: false)) + XCTAssertFalse(focus.gameEnded(zelda, othersRunning: false)) + } +} diff --git a/Tests/NXLibraryCoreTests/LibraryStoreTests.swift b/Tests/NXLibraryCoreTests/LibraryStoreTests.swift index 3c62643..30d64bc 100644 --- a/Tests/NXLibraryCoreTests/LibraryStoreTests.swift +++ b/Tests/NXLibraryCoreTests/LibraryStoreTests.swift @@ -148,6 +148,20 @@ final class LibraryStoreTests: XCTestCase { XCTAssertEqual(try store.load().games.count, 1) } + /// A library that cannot be read and has no backup is moved aside, never written over by the next save: the + /// games in it can still be recovered by hand (or by a newer NX Library that reads it). + func testUnreadableLibraryWithoutBackupIsKeptAside() throws { + let store = LibraryStore(directory: dir) + let damaged = Data(#"{"schemaVersion": 1, "games": [{"name": "#.utf8) + try damaged.write(to: store.fileURL) + XCTAssertEqual(try store.load().games.count, 0) + try store.save(LibraryFile()) + let kept = try FileManager.default.contentsOfDirectory(atPath: dir.path) + .filter { $0.hasPrefix("library.json.damaged-") } + XCTAssertEqual(kept.count, 1) + XCTAssertEqual(try Data(contentsOf: dir.appendingPathComponent(kept[0])), damaged) + } + func testLoadUsesBackupWhenMainIsMissing() throws { let store = LibraryStore(directory: dir) var game = PortedGame(name: "G", url: URL(fileURLWithPath: "/tmp/g.app"), diff --git a/Tests/NXLibraryCoreTests/ModStoreTests.swift b/Tests/NXLibraryCoreTests/ModStoreTests.swift index 585bda7..199f52e 100644 --- a/Tests/NXLibraryCoreTests/ModStoreTests.swift +++ b/Tests/NXLibraryCoreTests/ModStoreTests.swift @@ -204,7 +204,8 @@ final class ModCatalogSnapshotTests: XCTestCase { thumbnailURL: URL(string: "https://images.gamebanana.com/t.jpg"), version: "1", updated: Date(timeIntervalSince1970: 7), pageURL: URL(string: "https://gamebanana.com/mods/1")!, description: "d", likes: 2, downloads: 3, files: [file], category: "Skins", - added: Date(timeIntervalSince1970: 4)) + added: Date(timeIntervalSince1970: 4), + requirements: [GameBananaRequirement(name: "SMO v1.3", isOptional: true)]) try snapshots.save([good], gameName: "Zelda", titleID: "0100ABC", gameBananaID: 42, at: Date(timeIntervalSince1970: 100)) let loaded = try XCTUnwrap(snapshots.load(titleID: "0100abc")) XCTAssertEqual(loaded.titleID, "0100ABC", "the cover is matched to your games by title ID") @@ -215,6 +216,7 @@ final class ModCatalogSnapshotTests: XCTestCase { XCTAssertEqual(mod.files, [file]) XCTAssertEqual(mod.category, "Skins") XCTAssertEqual(mod.added, Date(timeIntervalSince1970: 4)) + XCTAssertEqual(mod.requirements, [GameBananaRequirement(name: "SMO v1.3", isOptional: true)]) XCTAssertNil(mod.avatarURL, "only GameBanana links survive the disk") XCTAssertEqual(mod.images, [URL(string: "https://images.gamebanana.com/1.jpg")!]) XCTAssertNil(snapshots.load(titleID: "0100FFF")) diff --git a/Tests/NXLibraryCoreTests/ModSupportTests.swift b/Tests/NXLibraryCoreTests/ModSupportTests.swift index b24f100..cd312ab 100644 --- a/Tests/NXLibraryCoreTests/ModSupportTests.swift +++ b/Tests/NXLibraryCoreTests/ModSupportTests.swift @@ -70,4 +70,41 @@ final class ModSupportTests: XCTestCase { XCTAssertEqual(ModFolderName.make(from: String(repeating: "a", count: 200), modID: 1, existing: []).count, 80) XCTAssertEqual(ModFolderName.make(from: "Line\nbreak\u{2028}x", modID: 1, existing: []), "Line break x") } + + // MARK: - Requirements + + private func needs(_ name: String, optional: Bool = false) -> GameBananaRequirement { + GameBananaRequirement(name: name, isOptional: optional) + } + + /// A loader or a PC tool the engine cannot run hides the mod: it would install and then not work, or break the game. + func testAModNeedingALoaderOrToolIsHidden() { + for name in ["Trinity Mod Loader", "exlaunch", "Skyline", "ARCropolis 3.0", "TKMM", "TotK Mod Manager", + "TotK Recomp", "ReShade 5.8.0"] { + XCTAssertTrue(ModRequirements.needsTool([needs("Super Mario Odyssey v1.3"), needs(name)]), name) + } + } + + /// A tool the author only recommends, a game version, another mod or the game itself never hides a mod. + func testOptionalToolsVersionsAndOtherModsDoNotHide() { + XCTAssertFalse(ModRequirements.needsTool([ + needs("TotK Recomp (Preferred, since it creates the sizetable automaticcally)"), + needs("Trinity Mod Loader", optional: true), + needs("ReShade (optional)"), + needs("Game versions 1.1.0-1.4.3"), + needs("Quick Flurry Rush"), + needs("TOTK"), + ])) + XCTAssertFalse(ModRequirements.needsTool([])) + } + + /// Notes about a modded Switch or an emulator say nothing to a Mac player and are left out of the panel. + func testPlatformNotesAreLeftOut() { + let shown = ModRequirements.shown([ + needs("Hacked switch"), needs("Or emulator"), needs("Computer"), needs("Have yuzu downloaded"), + needs("Atmosphere or Ryujinx"), needs("Switch must be modded"), needs("Updated sdcard files for Nintendo Switch"), + needs("Super Mario Odyssey v1.3"), needs("Quick Flurry Rush", optional: true), + ]) + XCTAssertEqual(shown, [needs("Super Mario Odyssey v1.3"), needs("Quick Flurry Rush", optional: true)]) + } } diff --git a/Tests/NXLibraryCoreTests/NewestEngineTests.swift b/Tests/NXLibraryCoreTests/NewestEngineTests.swift index 1fa8f86..a22d334 100644 --- a/Tests/NXLibraryCoreTests/NewestEngineTests.swift +++ b/Tests/NXLibraryCoreTests/NewestEngineTests.swift @@ -91,6 +91,71 @@ final class NewestEngineTests: XCTestCase { XCTAssertEqual(choice.shortCommit, "9c1d2e7") } + // MARK: - Row actions + + private let newer = "b7e3f0a100000000000000000000000000000000" + + func testWithoutAChoiceANewerMainOffersUseNewest() { + XCTAssertEqual(NewestEngine.actions(choice: nil, main: main, readiness: .usable), [.useNewest]) + XCTAssertEqual(NewestEngine.actions(choice: nil, main: main, readiness: nil), [.useNewest], "lock still loading") + XCTAssertEqual(NewestEngine.actions(choice: nil, main: nil, readiness: nil), [], "main is the pin") + XCTAssertEqual(NewestEngine.actions(choice: nil, main: main, readiness: .needsNewerApp(metal: "32100.1")), []) + } + + func testAChoiceBehindMainOffersUpdateBesideBackToTested() { + let choice = NewestEngineChoice(commit: main, aheadBy: 12, turnedOnOverride: true) + XCTAssertEqual(NewestEngine.actions(choice: choice, main: newer, readiness: .usable), [.update, .backToTested]) + XCTAssertEqual(NewestEngine.actions(choice: choice, main: newer, readiness: nil), [.update, .backToTested]) + } + + func testAChoiceAtMainOrBeforeAnotherMetalOffersOnlyBackToTested() { + let choice = NewestEngineChoice(commit: main, aheadBy: 12, turnedOnOverride: true) + XCTAssertEqual(NewestEngine.actions(choice: choice, main: main, readiness: .usable), [.backToTested]) + XCTAssertEqual(NewestEngine.actions(choice: choice, main: nil, readiness: nil), [.backToTested], "not checked") + XCTAssertEqual(NewestEngine.actions(choice: choice, main: newer, readiness: .needsNewerApp(metal: "32100.1")), + [.backToTested]) + } + + func testTheUpdateSentenceCountsCommitsPastTheOneInUse() { + let choice = NewestEngineChoice(commit: main, aheadBy: 12, turnedOnOverride: true) + XCTAssertEqual(NewestEngineCopy.update(choice: choice, main: newer, aheadBy: 23), + "main has moved on: b7e3f0a, 11 commits newer.") + XCTAssertEqual(NewestEngineCopy.update(choice: choice, main: newer, aheadBy: 13), + "main has moved on: b7e3f0a, 1 commit newer.") + XCTAssertEqual(NewestEngineCopy.update(choice: choice, main: newer, aheadBy: 12), + "main has moved on: b7e3f0a.", "history rewritten: no count") + } + + func testTheControllerUpdatesWithAAndGoesBackToTestedWithY() { + let both = NewestEngine.controller([.update, .backToTested]) + XCTAssertEqual(both?.primary, .update) + XCTAssertEqual(both?.secondary, .backToTested) + XCTAssertEqual(NewestEngine.controller([.backToTested])?.primary, .backToTested) + XCTAssertNil(NewestEngine.controller([.backToTested])?.secondary) + XCTAssertEqual(NewestEngine.controller([.useNewest])?.primary, .useNewest) + XCTAssertNil(NewestEngine.controller([])) + } + + // MARK: - Commit marks + + private func commits(_ shas: [String]) -> [UpstreamCommit] { + shas.map { UpstreamCommit(sha: $0, author: "seedds", date: "", subject: $0) } + } + + func testCommitsAboveTheOneInUseAreNewAndItIsInUse() { + let list = commits(["c3", "c2", "c1", "c0"]) + XCTAssertEqual(NewestEngine.marks(list, inUse: "c1"), ["c3": .new, "c2": .new, "c1": .inUse]) + } + + func testTheNewestCommitInUseHasNothingNewAbove() { + XCTAssertEqual(NewestEngine.marks(commits(["c3", "c2"]), inUse: "c3"), ["c3": .inUse]) + } + + func testNoMarksOnTheTestedEngineOrForACommitNotListed() { + XCTAssertEqual(NewestEngine.marks(commits(["c3", "c2"]), inUse: nil), [:], "tested engine") + XCTAssertEqual(NewestEngine.marks(commits(["c3", "c2"]), inUse: "zz"), [:], "history rewritten") + } + // MARK: - Switching func testUseNewestFetchesReadsTheLockAndSwitches() throws { diff --git a/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift b/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift index d656e9d..e09a6f8 100644 --- a/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift +++ b/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift @@ -101,4 +101,78 @@ final class PlaySessionStoreTests: XCTestCase { XCTAssertTrue(store.sessions.isEmpty) XCTAssertNotNil(store.loadError) } + + func testEndByBundleIDReturnsTheSessionsItEnded() { + let store = store() + let game = game() + XCTAssertTrue(store.start(for: game)) + now = now.addingTimeInterval(95) + let ended = store.end(bundleID: "org.nxnative.0100000000010000") + XCTAssertEqual(ended.map(\.gameID), [game.id]) + XCTAssertEqual(ended.first?.duration ?? 0, 95, accuracy: 0.001) + XCTAssertTrue(store.end(bundleID: "org.nxnative.0100000000010000").isEmpty) + } + + private func play(_ game: PortedGame, for seconds: TimeInterval, in store: PlaySessionStore) { + XCTAssertTrue(store.start(for: game)) + now = now.addingTimeInterval(seconds) + store.end(gameID: game.id) + now = now.addingTimeInterval(10) + } + + func testTotalAndLastPlayedSurviveTrimming() { + let store = store() + let old = game("Old", titleId: "0100000000020000", bundleId: "org.nxnative.0100000000020000") + let busy = game("Busy") + let oldStart = now! + play(old, for: 3600, in: store) + for _ in 0..<250 { play(busy, for: 60, in: store) } + + XCTAssertLessThanOrEqual(store.sessions.count, 200) + XCTAssertFalse(store.sessions.contains { $0.gameID == old.id }) + XCTAssertEqual(store.totalDuration(for: old.id), 3600, accuracy: 0.001) + XCTAssertEqual(store.lastPlayed(for: old.id), oldStart) + XCTAssertEqual(store.totalDuration(for: busy.id), 250 * 60, accuracy: 0.001) + + let reloaded = self.store() + XCTAssertNil(reloaded.loadError) + XCTAssertEqual(reloaded.totalDuration(for: old.id), 3600, accuracy: 0.001) + XCTAssertEqual(reloaded.lastPlayed(for: old.id), oldStart) + XCTAssertEqual(reloaded.totalDuration(for: busy.id), 250 * 60, accuracy: 0.001) + } + + func testTrimmingKeepsRunningSession() { + let store = store() + let running = game("Running", titleId: "0100000000020000", bundleId: "org.nxnative.0100000000020000") + let busy = game("Busy") + XCTAssertTrue(store.start(for: running)) + for _ in 0..<250 { play(busy, for: 60, in: store) } + XCTAssertTrue(store.isRunning(gameID: running.id)) + store.end(gameID: running.id) + XCTAssertEqual(store.totalDuration(for: running.id), 250 * 70, accuracy: 0.001) + } + + func testLoadsLegacyArrayFile() throws { + let game = game() + let session = PlaySession(gameID: game.id, titleID: "0100000000010000", + bundleID: game.bundleId, startedAt: now, + endedAt: now.addingTimeInterval(300), + lastHeartbeatAt: now.addingTimeInterval(300)) + try JSONEncoder().encode([session]).write(to: url) + let store = store() + XCTAssertNil(store.loadError) + XCTAssertEqual(store.sessions, [session]) + XCTAssertEqual(store.totalDuration(for: game.id), 300, accuracy: 0.001) + XCTAssertEqual(store.lastPlayed(for: game.id), now) + } + /// NX Library 0.1.4 and earlier read this file as a bare array; it must stay one + /// so switching between app versions keeps the history. + func testSessionsFileStaysReadableByOlderVersions() throws { + let store = store() + let busy = game("Busy") + for _ in 0..<205 { play(busy, for: 60, in: store) } + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode([PlaySession].self, from: data) + XCTAssertEqual(decoded, store.sessions) + } } diff --git a/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift b/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift index c90bee8..c6f7d85 100644 --- a/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift +++ b/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift @@ -21,7 +21,7 @@ final class PortingFailureReasonTests: XCTestCase { ("keys.tooOld", .keysTooOld, "[RED] Example.nsp: prod.keys has no key_area_key_application_14 (master key 14, which came with firmware 21.0.0): the keys are older than the game; dump prod.keys again from a console on firmware 21.0.0 or later"), ("keys.ticket", .keys, "[RED] Example.nsp: no title key for rights ID 00000000000000000000000000000000: the package has no common ticket for it"), ("games.several", .severalGames, "[RED] more than one game in the folder, keep one game per folder: 0100000000010000, 0100000000020000"), - ("games.noTitleID", .noTitleID, "[RED] no NSP with a readable title ID in /Users/x/Games/Foo"), + ("games.noTitleID", .noTitleID, "[RED] no game package with a readable content meta in /Users/x/Games/Foo"), ("disk.low", .diskFull, "[RED] less than 40 GB free on the volume of /Users/x/work"), ("disk.full", .diskFull, "OSError: [Errno 28] No space left on device"), ("icloud.detritus", .cloudFolder, "x.app: resource fork, Finder information, or similar detritus not allowed"), @@ -104,6 +104,8 @@ final class PortingFailureReasonTests: XCTestCase { } func testNoTitleIDDetailIsFolder() { + XCTAssertEqual(classify("[RED] no game package with a readable content meta in /Users/x/Foo").detail, "/Users/x/Foo") + // Engines before nxnative 5c4dc18f said it this way. XCTAssertEqual(classify("[RED] no NSP with a readable title ID in /Users/x/Foo").detail, "/Users/x/Foo") } diff --git a/Tests/NXLibraryCoreTests/SessionSummaryTests.swift b/Tests/NXLibraryCoreTests/SessionSummaryTests.swift new file mode 100644 index 0000000..25cfc65 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SessionSummaryTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import NXLibraryCore + +final class SessionSummaryTests: XCTestCase { + private let game = PortedGame(name: "Zelda", + url: URL(fileURLWithPath: "/tmp/Zelda.app"), + platform: .macApp, + sizeBytes: 1, + modificationDate: Date(timeIntervalSince1970: 0), + bundleId: "org.nxnative.0100000000010000", + titleId: "0100000000010000") + + private func session(lasting seconds: TimeInterval?) -> PlaySession { + let start = Date(timeIntervalSince1970: 1_000) + return PlaySession(gameID: game.id, titleID: "0100000000010000", bundleID: game.bundleId, + startedAt: start, endedAt: seconds.map { start.addingTimeInterval($0) }, + lastHeartbeatAt: start) + } + + func testAnEndedSessionGivesTheGameThisSessionAndTotal() throws { + let summary = try XCTUnwrap(SessionSummary(session: session(lasting: 4_320), game: game, total: 122_700)) + XCTAssertEqual(summary.gameID, game.id) + XCTAssertEqual(summary.gameName, "Zelda") + XCTAssertEqual(summary.thisSession, "1h 12m") + XCTAssertEqual(summary.total, "34h 5m") + } + + func testTheTotalReadsLikeTheGameInfo() throws { + let summary = try XCTUnwrap(SessionSummary(session: session(lasting: 600), game: game, total: 122_700)) + XCTAssertEqual(summary.total, LibraryPresentation.duration(122_700)) + XCTAssertEqual(summary.thisSession, "10m") + } + + func testUnderAMinuteSaysSo() throws { + let summary = try XCTUnwrap(SessionSummary(session: session(lasting: 42), game: game, total: 42)) + XCTAssertEqual(summary.thisSession, "Less than a minute") + XCTAssertEqual(summary.total, "Less than a minute") + } + + func testSessionsShorterThanTenSecondsShowNoCard() { + XCTAssertNil(SessionSummary(session: session(lasting: 9), game: game, total: 9)) + XCTAssertNotNil(SessionSummary(session: session(lasting: 10), game: game, total: 10)) + } + + func testARunningSessionShowsNoCard() { + XCTAssertNil(SessionSummary(session: session(lasting: nil), game: game, total: 3_600)) + } + + func testAnotherGamesSessionShowsNoCard() { + let other = PlaySession(gameID: UUID(), titleID: "x", bundleID: nil, + startedAt: Date(timeIntervalSince1970: 0), + endedAt: Date(timeIntervalSince1970: 600), + lastHeartbeatAt: Date(timeIntervalSince1970: 0)) + XCTAssertNil(SessionSummary(session: other, game: game, total: 600)) + } +} diff --git a/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift b/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift index 350d989..ee212c1 100644 --- a/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift +++ b/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift @@ -33,6 +33,13 @@ final class SettingsCatalogTests: XCTestCase { XCTAssertEqual(SettingsCatalog.changed(in: settings).map(\.id), ["wallpaperBrightness"]) } + func testSessionCardIsAGamesRowThatCountsAsChanged() { + XCTAssertEqual(SettingsCatalog.search("Session card").first { $0.id == "showsSessionCard" }?.group, .games) + var settings = AppSettings() + settings.showsSessionCard = false + XCTAssertEqual(SettingsCatalog.changed(in: settings).map(\.id), ["showsSessionCard"]) + } + func testSearchFindsHiddenGames() { let hits = SettingsCatalog.search("hidden") XCTAssertEqual(hits.first { $0.id == "hiddenGames" }?.group, .games) diff --git a/Tests/NXLibraryCoreTests/SettingsRowButtonsTests.swift b/Tests/NXLibraryCoreTests/SettingsRowButtonsTests.swift new file mode 100644 index 0000000..bf65b72 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SettingsRowButtonsTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import NXLibraryCore + +/// A row with two buttons (Update and Back to tested): Left and Right move between them, A presses the one in focus. +final class SettingsRowButtonsTests: XCTestCase { + func testRightMovesToTheSecondButtonAndStopsThere() { + XCTAssertEqual(SettingsRowAction.sideways(from: 0, by: 1, buttons: 2), .button(1)) + XCTAssertEqual(SettingsRowAction.sideways(from: 1, by: 1, buttons: 2), .stay) + } + + func testLeftReturnsToTheFirstButtonThenLeavesTheRows() { + XCTAssertEqual(SettingsRowAction.sideways(from: 1, by: -1, buttons: 2), .button(0)) + XCTAssertEqual(SettingsRowAction.sideways(from: 0, by: -1, buttons: 2), .leaveRows) + } + + func testOneButtonRowKeepsLeftToLeave() { + XCTAssertEqual(SettingsRowAction.sideways(from: 0, by: -1, buttons: 1), .leaveRows) + XCTAssertEqual(SettingsRowAction.sideways(from: 0, by: 1, buttons: 1), .stay) + } + + func testConfirmPressesTheButtonInFocus() { + var pressed: [String] = [] + let row = SettingsRowAction(id: "newest", title: "Newest engine", + secondary: (title: "Back to tested", activate: { pressed.append("back") })) { + pressed.append("update") + } + XCTAssertEqual(row.buttonCount, 2) + row.press(button: 1) + row.press(button: 0) + row.press(button: 5) + XCTAssertEqual(pressed, ["back", "update", "update"]) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 69e3c94..51184ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # NX Library — guida tecnica -Aggiornata il 5 novembre 2026 sul codice del repository. La mappa comprende 253 file Swift di produzione e 104 file di test e supporto ai test. Il ramo porta nxnative a b2d46b86 (6 novembre 2026, 1076 test Swift superati; build e avvio di Ocarina of Time confermati dal maintainer). Le funzioni della 0.1.3 e della 0.1.4 aggiunte dopo l'ultima conferma restano in attesa di validazione manuale. +Aggiornata il 5 novembre 2026 sul codice del repository. La mappa comprende 253 file Swift di produzione e 104 file di test e supporto ai test. Il ramo porta nxnative a bd979ced (8 novembre 2026, 0.1.4.1; candidato in attesa di validazione manuale: rebuild di Ocarina of Time, che ritraduce gli shader). Il pin precedente, e5f03403, è stato confermato dal maintainer con un rebuild di Ocarina of Time; l'aggiunta di un gioco NSP con le chiavi resta da provare a mano. Le funzioni della 0.1.3 e della 0.1.4 aggiunte dopo l'ultima conferma restano in attesa di validazione manuale. Questa guida descrive il funzionamento attuale. Il [catalogo dei file](file-map.md) assegna una responsabilità a ogni file versionato, suddiviso per area. @@ -117,7 +117,7 @@ Get ready mostra soltanto i sei requisiti tecnici, in una griglia 3×2; i proble `CompatibilityChecker` verifica commit, modifiche del checkout, contratto CLI e compilatore Metal richiesto dal lock del motore. `ToolchainRequirements.current` contiene Xcode minimo, major .NET e asset Metal. `ToolchainGateCopy` produce la spiegazione del risultato per la UI. -Il lock del pin attuale certifica Metal `32023.922`, asset Apple `27B47`, con Xcode minimo `27.2`. La [nota dell’upgrade del 6 novembre](upstream-upgrades/2026-11-06-4a0551e4-04df08d4-review.md) registra 04df08d4, confermato dal maintainer con un build di Ocarina of Time; quella [04df08d4 → b2d46b86](upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md) registra il pin attuale, che aggiunge il controllo dei file `.zs` danneggiati in NUP e cartelle (riga `files.format` del classificatore); quella [del 5 novembre](upstream-upgrades/2026-11-05-b64a5ff0-4a0551e4-review.md) registra gate certificato senza override, build reale di Wonder con archivio pipeline e avvio confermato dall’utente per 4a0551e4. Un build con override riceve `toolchainVerified=false` e `--allow-other-metal`. Play valuta la disponibilità dell’app già installata separatamente dal gate per un nuovo porting. +Il lock del pin attuale certifica Metal `32023.922`, asset Apple `27B47`, con Xcode minimo `27.2`. La [nota dell’upgrade del 6 novembre](upstream-upgrades/2026-11-06-4a0551e4-04df08d4-review.md) registra 04df08d4, confermato dal maintainer con un build di Ocarina of Time; quella [e5f03403 → bd979ced](upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md) registra il pin attuale, candidato in attesa di validazione manuale; quella [7fe9fc4d → e5f03403](upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [b2d46b86 → 7fe9fc4d](upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [04df08d4 → b2d46b86](upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md) registra b2d46b86, che aggiunge il controllo dei file `.zs` danneggiati in NUP e cartelle (riga `files.format` del classificatore); quella [del 5 novembre](upstream-upgrades/2026-11-05-b64a5ff0-4a0551e4-review.md) registra gate certificato senza override, build reale di Wonder con archivio pipeline e avvio confermato dall’utente per 4a0551e4. Un build con override riceve `toolchainVerified=false` e `--allow-other-metal`. Play valuta la disponibilità dell’app già installata separatamente dal gate per un nuovo porting. L'engine più nuovo è una scelta esplicita del giocatore (Settings → Engine → **Newest engine**). Dopo il controllo Upstream, che misura sempre i commit rispetto al pin, `NewestEngine.fetchLock` legge il lock di main da GitHub: se certifica un Metal diverso da quello del lock del pin, la riga dice che serve un NX Library più nuovo e non offre il cambio. `NewestEngineSwitcher` rifiuta un checkout con file modificati, scarica, rilegge il lock con git e solo allora fa `checkout --detach`. La scelta (`NewestEngineChoice`) è salvata, diventa il commit atteso dal gate e quello registrato nelle build, accende l'override (che resta bloccato finché la scelta è attiva) e marca le build come non testate; il gate mostra **Newest engine** in arancione, mai Certified. Il Setup continua a installare il pin, ma per il commit scelto mostra la scheda pronta in arancione con **Back to tested** e non la riporta indietro da solo. La scelta decade quando il checkout non è più su quel commit o quando un aggiornamento di NX Library porta un pin che lo contiene; se l'override l'aveva acceso la scelta, torna spento. Settings e Setup usano la stessa frase (`NewestEngineCopy.note`). @@ -238,7 +238,8 @@ I percorsi sono relativi alla home dell’utente. `Application Support/NXLibrary |---|---| | `Application Support/NXLibrary/library.json` e `.bak` | Catalogo e ultima copia valida, gestiti da `LibraryStore`. | | `Application Support/NXLibrary/settings.json` | Impostazioni globali, gestite da `AppSettingsStore`. | -| `Application Support/NXLibrary/play-sessions.json` | Sessioni e tempi di gioco, gestiti da `PlaySessionStore`. | +| `Application Support/NXLibrary/play-sessions.json` | Sessioni e tempi di gioco, gestiti da `PlaySessionStore`. Array semplice delle ultime 200 sessioni, leggibile anche dalle versioni precedenti. | +| `Application Support/NXLibrary/play-sessions-totals.json` | Tempo e ultima partita delle sessioni oltre le 200, per gioco: `totalDuration(for:)` e `lastPlayed(for:)` li sommano alle sessioni recenti. | | `Application Support/NXLibrary/keys/prod.keys` | Chiavi importate da `KeyStore`, con permessi `0600`. | | `Application Support/NXLibrary/app-sizes.json` | Cache dimensioni dei bundle. | | `Application Support/NXLibrary/workspaces/` e `Temp/` | Collegamenti alle sorgenti, workspace e app temporanee dei porting, quando l'app va sul volume del Mac (o su un volume che non accetta link simbolici). | diff --git a/docs/file-map.md b/docs/file-map.md index 7878a8f..41e7602 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -152,7 +152,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT | [Sources/NXLibraryCore/Services/NXNativeEnvironment.swift](../Sources/NXLibraryCore/Services/NXNativeEnvironment.swift) | Ispeziona strumenti, Python, Xcode, Metal e .NET e restituisce uno stato dell’ambiente. | | [Sources/NXLibraryCore/Services/EngineLocation.swift](../Sources/NXLibraryCore/Services/EngineLocation.swift) | Risolve checkout predefinito o scelto e produce i candidati per prod.keys. | | [Sources/NXLibraryCore/Services/EngineToolchain.swift](../Sources/NXLibraryCore/Services/EngineToolchain.swift) | Individua runtime incluso o venv del checkout e prepara ambiente e PATH dei processi. | -| [Sources/NXLibraryCore/Services/EnginePackageReader.swift](../Sources/NXLibraryCore/Services/EnginePackageReader.swift) | Legge capacità delle sorgenti e content meta tramite il lettore upstream, validando identità e tipi senza esporre chiavi. | +| [Sources/NXLibraryCore/Services/EnginePackageReader.swift](../Sources/NXLibraryCore/Services/EnginePackageReader.swift) | Legge capacità delle sorgenti e content meta di tutti i pacchetti in un solo passaggio del lettore upstream (`package_reader`, o `package_title_reader` sugli engine precedenti a 5c4dc18f), validando identità e tipi senza esporre chiavi. | | [Sources/NXLibraryCore/Models/BuildWarningCopy.swift](../Sources/NXLibraryCore/Models/BuildWarningCopy.swift) | Testo mostrato a fine build per una fase terminata con `warn` dell’engine (archivio delle pipeline mancante). | | [Sources/NXLibraryCore/Models/ReleaseNotesLayout.swift](../Sources/NXLibraryCore/Models/ReleaseNotesLayout.swift) | Divide le note di release in titoli, voci e testo, una riga per volta, per il dialogo degli aggiornamenti. | | [Sources/NXLibraryCore/Services/NewestEngine.swift](../Sources/NXLibraryCore/Services/NewestEngine.swift) | Scelta dell’engine più nuovo: testi condivisi, lettura del Metal dal lock di main, cambio di commit con git senza toccare file modificati. | diff --git a/docs/upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md b/docs/upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md index e6bbd06..e594709 100644 --- a/docs/upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md +++ b/docs/upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md @@ -4,7 +4,7 @@ - Candidate: `b2d46b86d03f4b03afe11ea61c67cdf00d7a5345` (origin/main on 2026-11-06) - Delta: 8 commits. `dmsh_chain.lock` and `.github/requirements/tools.txt` unchanged: certified Metal `32023.922`, Xcode 27.2 minimum. No `Front-ends:` lines in the range. -**Status: adopted.** Every commit was reviewed, the frontend suite passes against it, and on 2026-11-06 the maintainer confirmed in NX Library Dev a rebuild of Ocarina of Time with this engine and a working launch. +**Status: adopted, then superseded by [`7fe9fc4d`](2026-11-07-b2d46b86-7fe9fc4d-review.md) for 0.1.4.1.** Every commit was reviewed, the frontend suite passes against it, and on 2026-11-06 the maintainer confirmed in NX Library Dev a rebuild of Ocarina of Time with this engine and a working launch. ## Product decision diff --git a/docs/upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md b/docs/upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md new file mode 100644 index 0000000..ccf98ab --- /dev/null +++ b/docs/upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md @@ -0,0 +1,47 @@ +# 2026-11-07 — nxnative 7fe9fc4d → e5f03403 + +- Previous pin: `7fe9fc4df952577c13110f4e5cc7412b74e5c446` +- Candidate: `e5f03403bfd2ca9c07b890f1cf33bd65230b5103` (origin/main on 2026-11-07) +- Delta: 26 commits, 122 files. `dmsh_chain.lock` is unchanged: certified Metal stays `32023.922`, Xcode 27.2 minimum. The README's `pip install` line is unchanged. `build_ipa_from_nsp.py` keeps the same flags. Two `Front-ends:` notes: the `game` progress event (d70e6a3a) and `DM_CTRL_PROMPT_TEXT` (24fe1e5a). + +**Status: adopted.** Every commit was reviewed, the frontend suite passes against it, and on 2026-11-07 the maintainer confirmed in NX Library Dev a rebuild and launch of Ocarina of Time with this engine. Adding an NSP, NSZ or XCI game with keys is still to be checked by hand: no such file was at hand. + +## Product decision + +Adopted for hotfix 0.1.4.1 because it brings what Ocarina of Time players asked about (a first start that prepares its pipelines before the first frame, less memory, foliage, steadier 30 fps), more games, and because the engine's change to how it reads packages (5c4dc18f) needs a matching change in NX Library: without it, adding NSP, NSZ or XCI files with keys fails on this engine, which the Newest engine choice already reaches. + +| Area | Commits | Integration decision | +|---|---|---| +| Shader / pipeline | 540f7977 (Ocarina's prewarm seed list), 5e344a87, d777f0a3 (mesh outputs: Ocarina's foliage), c8bfa161 (faster cold Mac shader builds, Mario Kart 8 Deluxe), ff33639c (short NDSR packs), 050580df (encrypted Unreal paks: Little Nightmares II), 32335650 (image slot views: Mario Kart World), f25fa956 (shader keys without making pools resident) | Backend. | +| Host / runtime | 5b84d37e, 863bc6b7 (Ocarina's command allocator memory, dynamic-resolution depth), d4c11e2b (The Great Ace Attorney Chronicles), 4861fb03 (NVN pool storage), fa9cfb84 (texture residency: GPU page faults), a631295b (Monster Jam Steel Titans 2), db206a46 (WaitSynchronization), f5f6d4b5 (DRAGON BALL: Sparking! ZERO), e5f03403 (program keys by serial: Ori and the Will of the Wisps) | Backend. | +| Input / display / audio | 1ed28ed0 (identical pads, two Pro Controllers over USB), 490859ea (30 fps fullscreen pacing on a fixed-rate screen), 62be7fac (**Press a button to join**, `DM_PAD_PRESS_TO_JOIN`), 24fe1e5a (the controller applet's prompt when a game wants more players) | Press a button to join: **UI**, with no NX Library change. It is a `settings.tsv` row in Controls, so it reaches the game's `launcher.json` and Game options draws it after the game is rebuilt. Prompt texts (`DM_CTRL_PROMPT_TEXT`): **not adopted** now. NX Library shows English only, which is the prompt's own language; it belongs with the languages feature (0.1.5), with `DM_HOME_QUIT_TEXT`. | +| Game profiles | Ocarina of Time, The Great Ace Attorney Chronicles, Monster Jam Steel Titans 2, Little Nightmares II, DRAGON BALL: Sparking! ZERO (new profile), Castlevania: Belmont's Curse note | Backend. | +| Build / extraction | 5c4dc18f (every package's title ID and version from its content metas, in one `nx_extract --contents` run; `is_merged`, `title_of` and `--title` removed), 2bd920a9 (the script's fallback tool folders after the caller's PATH), d70e6a3a (`game` event) | **Adopted in NX Library.** See below. PATH order: no change; NX Library already puts its bundled tools first in `PATH`, which the engine now keeps first. | +| CLI / environment | New `DM_CTRL_PROMPT_TEXT`, `DM_PAD_IDENTICAL`, `DM_PAD_PRESS_TO_JOIN`; diagnostic `DM_NVN_END_ALIAS`, `DM_NVN_ALLOCPROF=2` | `DM_PAD_PRESS_TO_JOIN` through Game options, as above. `DM_PAD_IDENTICAL` is on by default and only a way out: not adopted. Diagnostic switches: not adopted. | +| Python | Nothing new to install. | Nothing to bundle. | +| Docs / tests | 0caa6f93, 12ddb1fb (tests), reference docs | Adopted unchanged. | + +## NX Library changes + +- **Reading packages (5c4dc18f).** NX Library's metadata bridge called the engine's `is_merged` and `package_title_reader`, which are gone. It now reads every package in one `package_reader` run, as the engine does for the build, and takes each package's title ID and version from its content metas, never from its file name. A package the engine cannot read is skipped and named, as before. Keys that cannot open the packages stop the scan with the engine's own line, which names the missing key (never a value). Engines before 5c4dc18f still go through the older reader. +- **Version to pack (`-p`).** NX Library passes the selected version to the engine, which now refuses one its content metas do not show. Versions come from the content metas (above), and a base game built alone is packed at its own version: `v0` for most, `v65536` for a base game the eShop serves at 1.1.0 (Castlevania: Belmont's Curse). +- **Failure table.** "no NSP with a readable title ID in" became "no game package with a readable content meta in": **No game found in the files** reads both. +- **`game` event (d70e6a3a).** Read and kept out of the build log, like `shaders_stale`: a `say` with the same news follows each. Its name, version and icon are not used: NX Library already has the game's name, its version from the content metas, and its artwork. + +## Toolchain and frontend contract + +- Capability markers (`--title-id`, `import pack_dump`, `import pack_nup`, `survey_nup`, `survey_dump`), the other progress events, the `Mac shaders` stage and its steps are unchanged. +- Every engine message the failure table reads is still printed (`PortingFailureEngineTests` against `e5f03403`). +- No change to source linking, key storage, library schema or launch settings. No engine patch; main is not followed automatically. +- A library entry scanned before this change keeps the version it was given then, which may come from a file name. So NX Library passes `-p` only when the workspace holds versions besides the selected ones (a merged package, a NUP, a dump, or a folder used as it is): a workspace it staged with the selected packages alone leaves the choice to the engine, which reads it from the content metas. An older entry with a wrong `[v]` tag in a merged package or a folder used as it is could still be refused ("the version to pack (-p / PACKAGE) is not in the folder"); adding its files again reads the right version. + +## Verification record + +- Frontend suite with the pin: **1119 tests passed**, including `PortingFailureEngineTests` and `EngineRuntimeRequirementsTests`. +- The metadata bridge was run against the real engine at `e5f03403` with NX Library Dev's bundled runtime: `nx_extract` built (1.7 s incremental), two files that are not packages came back unreadable with the engine's reason, and the checkout stayed clean. No real NSP was at hand: reading a real package is a manual check. +- Maintainer-confirmed in NX Library Dev (2026-11-07): Ocarina of Time rebuilt with `e5f03403`, launched and played. +- Still open: adding an NSP, NSZ or XCI game with keys, which reads packages through the new bridge. + +## Adoption and rollback + +Installed games keep their engine until rebuilt. Rollback is the previous pin with the older reader, which NX Library still supports. diff --git a/docs/upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md b/docs/upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md new file mode 100644 index 0000000..c4169eb --- /dev/null +++ b/docs/upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md @@ -0,0 +1,37 @@ +# 2026-11-07 — nxnative b2d46b86 → 7fe9fc4d + +- Previous pin: `b2d46b86d03f4b03afe11ea61c67cdf00d7a5345` +- Candidate: `7fe9fc4df952577c13110f4e5cc7412b74e5c446` (origin/main on 2026-11-07) +- Delta: 39 commits, 94 files. `dmsh_chain.lock` changes only the translation tools' fingerprints: certified Metal stays `32023.922`, Xcode 27.2 minimum. `.github/requirements/tools.txt` and the README's `pip install` line are unchanged. No `Front-ends:` lines in the range. `build_ipa_from_nsp.py` keeps the same flags. + +**Status: adopted.** Every commit was reviewed, the frontend suite passes against it, and on 2026-11-07 the maintainer confirmed in NX Library Dev a rebuild of Ocarina of Time with this engine, a working launch and no dotted rows. + +## Product decision + +Adopted for hotfix 0.1.4.1 because it fixes what Ocarina of Time players see today: the dotted rows across fog and light (13b432b4, upstream issue #1537; PR #1544 was closed for it), holes in the ground (e2b76d4e, b547f1e2), Kokiri Forest's white blotches and whiteout through the game's own upscaler (d365496d, 16776913) and the Poe Sisters' and Phantom Ganon's painting colours (3ccb7da0). The shader chain moves, so every game translates its shaders again at its next build; installed apps keep their engine until rebuilt. + +| Area | Commits | Integration decision | +|---|---|---| +| Shader / pipeline | 13b432b4 (HMMA outside a chain), 16776913 (LDSM fenced), 473c9026 (pixel depth after the colour targets), ffebfd59 (byte/half shared stores), d365496d (mesh stage memory), 3ccb7da0 (mesh attributes as the guest lays them out), 645d3029, 9dc81953, b97f0dc6, 1a410e6e (Ampere ALU, half pairs, rounding, shared atomics), b5948342 (Donkey Kong Bananza's SSAO), 822f724c (Mario Kart World's shader tables pass the table check again, issue #1592), c8aff387 (Super Smash Bros. Ultimate's pipeline archive for its depth mode), 30fb2136 (compute pipelines in the pipeline archive), f09f721c (deferred uniform lookups) | Backend. | +| Host / runtime | e2b76d4e, b547f1e2 (texture first-use rule: Ocarina's terrain), 0e4480d2 (released texture views), 2d544e0d (pool buffers over writable pages), 6d5ed718 (Splatoon Raiders' first boss), 922679a1 (Hyrule Warriors: Age of Calamity cutscenes), e9af16d6 (Tetris 99), c127df82, c0401e92 (Pokémon Friends), 10fd9705 (Trails in the Sky), e49afb1e, 9671a581 (jump tables, Cuphead 1.3.7), 5636e7a7 (a GPU whose access macOS revoked stops the game, exit code 70 after its own alert) | Backend. Exit code 70: **not adopted** — NX Library opens games through macOS and never reads their exit code; the game's own alert explains the stop. | +| Input / display / audio | 463d21f7 (a HID pad that GameController says it will list gets two seconds first); 08c27ed7 (**Picture shape** `auto`, Match screen) | Backend. `auto` is iOS-only: `dm/games/settings.py` drops `?platform:ios` choices from a Mac app's `launcher.json`, which is what Game options reads, so the Mac row is unchanged. | +| Game profiles | 822f724c, c8aff387, 922679a1 and the fixes above | Backend. | +| Build / extraction | 253edb28 (an intact NUP image is kept on a stamp miss instead of unpacked again), 581f9c1d (a NUP or dump build shows its version again), bada9f21 (each program of a multi-program game gets its own folder under `--work-dir`, which NX Library passes for builds on the game apps' drive), e73258ca (heap check after a heap resize) | Backend. No new engine message; the failure table is unchanged. | +| CLI / environment | New `DM_NVN_SKIP_MESH` and `DM_TEST_GPU_LOST` (diagnostic); `DM_SCREEN_ASPECT` gains `auto` (iOS). | Not adopted: diagnostic or iOS-only. | +| Python | Nothing new to install. | Nothing to bundle. | +| Docs / tests | 7fe9fc4d, befb490e (lint), c9fc4492, 060aef96, 362f7725 (tests), reference docs. | Adopted unchanged. | + +## Toolchain and frontend contract + +- Capability markers, progress events, the `Mac shaders` stage and its steps are unchanged. +- Every engine message the failure table reads is still printed (`PortingFailureEngineTests` against `7fe9fc4d`). +- No change to source linking, key storage, library schema or launch settings. No engine patch; main is not followed automatically. + +## Verification record + +- Frontend suite with the pin: **1082 tests passed** at the re-pin (1110 with the rest of 0.1.4.1), including `PortingFailureEngineTests` and `EngineRuntimeRequirementsTests`. +- Maintainer-confirmed in NX Library Dev (2026-11-07): Ocarina of Time rebuilt with `7fe9fc4d` (the app records `DMCommit 7fe9fc4df952`, shader chain `57da6a2d2750`), launched and played; the dotted rows across fog and light are gone. + +## Adoption and rollback + +Installed games keep their engine until rebuilt. Rollback is the previous pin; a rollback translates shaders again, since the chain differs. diff --git a/docs/upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md b/docs/upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md new file mode 100644 index 0000000..ded963a --- /dev/null +++ b/docs/upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md @@ -0,0 +1,36 @@ +# 2026-11-08 — nxnative e5f03403 → bd979ced + +- Previous pin: `e5f03403bfd2ca9c07b890f1cf33bd65230b5103` +- Candidate: `bd979ced3ae6bad60bf86429a71d172297d93471` (origin/main on 2026-11-08) +- Delta: 4 commits, 26 files. `dmsh_chain.lock` is unchanged: certified Metal stays `32023.922`, Xcode 27.2 minimum. The shader translation chain moves (`DMSH_CHAIN` 1d619d0f520b → 3ee52af3ca18), so every game translates its shaders again at its next build. The README's `pip install` line, `docs/env_switches.tsv`, `dm/games/settings.tsv` and `docs/frontends.md` are unchanged; no `Front-ends:` notes. `build_ipa_from_nsp.py` keeps the same flags and messages (its only change: the code rewrite is redone when `fps_target.py` or `ue_fixed_rate.py` changes). + +**Status: candidate, awaiting human validation.** Every commit was reviewed and the frontend suite passes against it. Adoption needs the maintainer's rebuild and launch in NX Library Dev. + +## Product decision + +Adopted for hotfix 0.1.4.1 because it fixes what Ocarina of Time players see today, its foliage flashing white for single frames, and brings Pikmin 4 to 60 fps (up to 120 on the Mac). The cost is one shader translation per game at its next build. + +| Area | Commits | Integration decision | +|---|---|---| +| Shader / pipeline | e5f1adee (mesh shaders' position is invariant, as a vertex shader's: Ocarina's foliage no longer flashes white; chain 3ee52af3ca18) | Backend. | +| Host / runtime | 267ef2d3 (a GPU counter report's time from Metal's paired CPU sample, projected onto the guest clock) | Backend. | +| Game profiles / frame rate | 01abf279, bd979ced (Unreal's fixed frame rate found from where the engine is made: Pikmin 4 at 60 fps, up to 120 on the Mac; DRAGON BALL: Sparking! ZERO found the same way; the row is written only while the engine steps at a fixed rate) | Backend. Pikmin 4's 120 fps choice reaches Game options through its `launcher.json` (`DM_FPS_TARGET`'s 120 option shows for `DM_GAME_FPS_MAX==120` on the Mac) after the game is rebuilt: no NX Library change. | +| Build / extraction | bd979ced, 01abf279 (the rewrite's fingerprint includes `fps_target.py` and `ue_fixed_rate.py`) | Backend. A rebuild redoes the code rewrite once. | +| CLI / environment | None. | Nothing to wire. | +| Python | Nothing new to install. | Nothing to bundle. | +| Docs / tests | `t_counter_clock` in CI, frame-rate and shader references, gallery caption | Adopted unchanged. | + +## Toolchain and frontend contract + +- Capability markers, progress events, the `Mac shaders` stage and its steps are unchanged. +- Every engine message the failure table reads is still printed (`PortingFailureEngineTests` against `bd979ced`). +- No change to source linking, key storage, library schema or launch settings. No engine patch; main is not followed automatically. + +## Verification record + +- Frontend suite with the pin: **1127 tests passed**, including `PortingFailureEngineTests` and `EngineRuntimeRequirementsTests`. +- Awaiting the maintainer: a rebuild and launch of Ocarina of Time in NX Library Dev with `bd979ced` (its shaders translate again), with no white flashes in the foliage. + +## Adoption and rollback + +Installed games keep their engine until rebuilt. Rollback is the previous pin; a rollback translates shaders again, since the chain differs. diff --git a/docs/upstream-upgrades/README.md b/docs/upstream-upgrades/README.md index 6550557..0945ee2 100644 --- a/docs/upstream-upgrades/README.md +++ b/docs/upstream-upgrades/README.md @@ -73,3 +73,6 @@ This directory records the review that must happen before the pin changes, plus - [2026-11-05 — b64a5ff0 → 4a0551e4](2026-11-05-b64a5ff0-4a0551e4-review.md) (certified Wonder build, maintainer-confirmed visible launch; provisional profiles, build warnings, Home quit) - [2026-11-06 — 4a0551e4 → 04df08d4](2026-11-06-4a0551e4-04df08d4-review.md) (Ocarina of Time build and play with mods confirmed by the maintainer) - [2026-11-06 — 04df08d4 → b2d46b86](2026-11-06-04df08d4-b2d46b86-review.md) (Ocarina of Time rebuild and launch confirmed by the maintainer; damaged NUP/dump files mapped to **Some game files are damaged**) +- [2026-11-07 — b2d46b86 → 7fe9fc4d](2026-11-07-b2d46b86-7fe9fc4d-review.md) (0.1.4.1: Ocarina of Time rebuild confirmed by the maintainer without the dotted rows; terrain holes and white blotches fixed upstream) +- [2026-11-07 — 7fe9fc4d → e5f03403](2026-11-07-7fe9fc4d-e5f03403-review.md) (0.1.4.1: Ocarina of Time rebuild confirmed by the maintainer; packages read by their content metas, NSP with keys still to check by hand) +- [2026-11-08 — e5f03403 → bd979ced](2026-11-08-e5f03403-bd979ced-review.md) (0.1.4.1 candidate: Ocarina's foliage no longer flashes white, Pikmin 4 at 60 fps; shaders translate again; awaiting the maintainer's rebuild) diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh index ce3845a..70b15c8 100755 --- a/scripts/build_dmg.sh +++ b/scripts/build_dmg.sh @@ -16,5 +16,11 @@ cp -R "$APP" "$STAGE/NX Library.app" ln -s /Applications "$STAGE/Applications" mkdir -p "$ROOT/dist" -hdiutil create -volname "NX Library" -srcfolder "$STAGE" -ov -format UDZO "$OUT" >/dev/null +rm -f "$OUT" +# macOS 26 deprecates `hdiutil create -volname`; `diskutil image` makes the same compressed image (APFS inside). +if diskutil image create from --help >/dev/null 2>&1; then + diskutil image create from --format UDZO --volumeName "NX Library" "$STAGE" "$OUT" >/dev/null +else + hdiutil create -volname "NX Library" -srcfolder "$STAGE" -ov -format UDZO "$OUT" >/dev/null +fi echo "Built $OUT ($(du -h "$OUT" | awk '{print $1}'))"