diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a324f5..3cf876d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: run: python3 -m unittest discover -s Tests/PublicationChecks - name: Swift tests run: swift test + - name: Catalog notice regressions without UI + run: python3 Tests/CatalogAppChecks/run_notices.py + - name: Shared game header regression without UI + run: python3 Tests/CatalogAppChecks/run_game_header.py - name: Release compilation run: swift build -c release --product NXLibrary - name: Package and verify development app diff --git a/README.md b/README.md index 3b9dcf9..f37c1ad 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Discovery includes subfolders of Game apps. It stays inside the chosen folders, Import your `prod.keys` when prompted. NX Library keeps a private copy with mode `0600` and leaves the original file unchanged. Key values are not displayed or logged. -Reopen both setup steps from **Settings → Start-up → Open Setup** whenever needed; this keeps your library and completion state. NX Library never runs `sudo`, accepts the Xcode licence for you or changes `xcode-select`. +Reopen both setup steps from **Settings → Switch · Engine → Open Setup** whenever needed; this keeps your library and completion state. NX Library never runs `sudo`, accepts the Xcode licence for you or changes `xcode-select`. Already have a built nxnative game app? Use **Settings → Library → Scan game apps → Scan**. The same action finds existing builds and refreshes the library. Play remains available without the original sources; rebuilding requires attaching them again. diff --git a/Sources/NXLibrary/NXLibraryApp.swift b/Sources/NXLibrary/NXLibraryApp.swift index 7cc8371..9f9f8e3 100644 --- a/Sources/NXLibrary/NXLibraryApp.swift +++ b/Sources/NXLibrary/NXLibraryApp.swift @@ -16,6 +16,7 @@ extension Notification.Name { static let nxSetupCheckGamepadAction = Notification.Name("NXSetupCheckGamepadAction") static let nxThemePickerGamepadAction = Notification.Name("NXThemePickerGamepadAction") static let nxLibraryOpenSettings = Notification.Name("NXLibraryOpenSettings") + static let nxLibraryRetryCatalogSave = Notification.Name("NXLibraryRetryCatalogSave") static let nxLibraryOpenConsole = Notification.Name("NXLibraryOpenConsole") static let nxLibraryOpenLibrary = Notification.Name("NXLibraryOpenLibrary") static let nxLibraryOpenAddGame = Notification.Name("NXLibraryOpenAddGame") @@ -28,18 +29,9 @@ struct NXLibraryApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @FocusedValue(\.nxMenuAvailability) private var availability @ObservedObject private var localization = AppLocalization.shared - private let settingsStore: AppSettingsStore - - init() { - let store = AppSettingsStore() - settingsStore = store - // Publish the initial locale before SwiftUI starts constructing the window graph. - AppLocalization.shared.configure(store.settings) - } - var body: some Scene { Window("NX Library", id: "main") { - MainView(settingsStore: settingsStore) + DevelopmentStartupView() } .windowStyle(.hiddenTitleBar) .defaultSize(width: 1440, height: 900) @@ -50,6 +42,12 @@ struct NXLibraryApp: App { .keyboardShortcut("n", modifiers: [.command]) .disabled(availability?.general != true) } + CommandGroup(after: .newItem) { + if availability?.catalogRecovery == true { + Button(L10n.text("Retry saving library")) { NativeMenuCommands.shared.post(.nxLibraryRetryCatalogSave) } + .keyboardShortcut("s", modifiers: [.command]) + } + } CommandGroup(after: .appInfo) { Button(L10n.text("Check for Updates…")) { NativeMenuCommands.shared.post(.nxLibraryCheckAppUpdates) } .disabled(availability?.general != true) diff --git a/Sources/NXLibrary/Services/AppUpdater.swift b/Sources/NXLibrary/Services/AppUpdater.swift index cd29c44..438efb5 100644 --- a/Sources/NXLibrary/Services/AppUpdater.swift +++ b/Sources/NXLibrary/Services/AppUpdater.swift @@ -22,7 +22,7 @@ final class AppUpdater: ObservableObject { private let repository: String? private let runningVersion: String - private let defaults = UserDefaults.standard + private let defaults = AppData.preferences private var downloadSession: URLSession? private var lastRelease: AppUpdate.Release? private var shouldOfferResult = false @@ -276,8 +276,7 @@ final class AppUpdater: ObservableObject { } private static func workFolder() -> URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("NXLibrary/Temp/Update") + AppData.supportRoot.appendingPathComponent("Temp/Update") } nonisolated private static func run(_ tool: String, _ arguments: [String]) -> Bool { diff --git a/Sources/NXLibrary/Services/GameControllerService.swift b/Sources/NXLibrary/Services/GameControllerService.swift index 70c8373..9f6186b 100644 --- a/Sources/NXLibrary/Services/GameControllerService.swift +++ b/Sources/NXLibrary/Services/GameControllerService.swift @@ -19,7 +19,7 @@ public final class GameControllerService: ObservableObject { @Published public var deadzone: Float = 0.35 { didSet { let clamped = min(max(deadzone, 0.10), 0.80) - UserDefaults.standard.set(Double(clamped), forKey: deadzoneKey) + AppData.preferences.set(Double(clamped), forKey: deadzoneKey) } } @@ -28,8 +28,8 @@ public final class GameControllerService: ObservableObject { private var lastNavTime: TimeInterval = 0 private init() { - if UserDefaults.standard.object(forKey: deadzoneKey) != nil { - deadzone = min(max(Float(UserDefaults.standard.double(forKey: deadzoneKey)), 0.10), 0.80) + if AppData.preferences.object(forKey: deadzoneKey) != nil { + deadzone = min(max(Float(AppData.preferences.double(forKey: deadzoneKey)), 0.10), 0.80) } setupObservers() refreshControllers() diff --git a/Sources/NXLibrary/Services/NativeMenuCommands.swift b/Sources/NXLibrary/Services/NativeMenuCommands.swift index 0a0f185..228cc79 100644 --- a/Sources/NXLibrary/Services/NativeMenuCommands.swift +++ b/Sources/NXLibrary/Services/NativeMenuCommands.swift @@ -6,6 +6,7 @@ struct NativeMenuAvailability { var play = false var details = false var options = false + var catalogRecovery = false } private struct NativeMenuAvailabilityKey: FocusedValueKey { diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift new file mode 100644 index 0000000..6f20b3a --- /dev/null +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -0,0 +1,245 @@ +import AppKit +import Combine +import NXLibraryCore + +/// Play for Steam games and their real sessions (`SteamPlayState`). Play sends `steam://run/` +/// to the registered Steam client only; it never opens the game's app itself. The process list is +/// read on launches, quits and the app list changing, and every 30 s for waits and heartbeats: the +/// installation's own app starts a session, its exit ends it, Steam staying open does not count. +/// The catalog is not written here; only `PlaySessionStore` is. +/// +/// With "Keep Steam in the background" (`SteamBackgroundLaunch`), a Steam that Play starts opens +/// with `-silent -nofriendsui`, and an open Steam is hidden while the game is on its way. Past +/// `attentionDelay` Steam is shown again and the head says to check it; once Play is over, +/// Steam comes back only if it was in view before. +@MainActor +final class SteamPlayCoordinator: ObservableObject { + static let shared = SteamPlayCoordinator() + nonisolated static let steamBundleID = "com.valvesoftware.steam" + private static let tick: TimeInterval = 30 + + @Published private(set) var states: [UUID: SteamPlayState] = [:] + /// Waits past `attentionDelay` of a background launch: Steam is back in view and may need the player. + @Published private(set) var late: Set = [] + + private var games: [CatalogGame] = [] + private var steamEnabled = false + private var sessions: PlaySessionStore? + private var settings: AppSettingsStore? + /// Background launches in progress, with whether Steam was in view before Play. + private var backgrounds: [UUID: Bool] = [:] + private var tokens: [NSObjectProtocol] = [] + /// Each running app's paths as the system gives them and resolved, by pid: resolved once while it lives. + private var canonicalProcesses: [Int32: (observed: NativeProcessObservation, canonical: NativeProcessObservation)] = [:] + private var listObservation: NSKeyValueObservation? + private var timer: Timer? + + func phase(of game: CatalogGame) -> SteamPlayState.Phase { states[game.id]?.phase ?? .idle } + func isRunning(_ game: CatalogGame) -> Bool { if case .running = phase(of: game) { return true }; return false } + /// One available, recognized installation in an enabled source: what Play needs. + func canPlay(_ game: CatalogGame) -> Bool { + CatalogGamePresentation(game: game, sourceEnabled: steamEnabled).launchTarget != nil + } + + /// The A hint for a Steam game, matching the Play pill; none while Play cannot do anything. + func hint(for game: CatalogGame) -> [HintItem] { + switch phase(of: game) { + case .running: return [HintItem(.a, L10n.text("Resume"))] + case .starting: return [HintItem(.a, L10n.text("Cancel"))] + case .failed: return [HintItem(.a, L10n.text("Try again"))] + case .idle: return canPlay(game) ? [HintItem(.a, L10n.text("Play"))] : [] + } + } + + /// The pill and A press: waiting cancels, anything else plays (or brings a running game forward). + func press(_ game: CatalogGame) { + if case .starting = phase(of: game) { cancel(game) } else { play(game) } + } + + /// Steam is coming back for the player: the game is late and Steam may be asking for something. + func mayNeedSteam(_ game: CatalogGame) -> Bool { late.contains(game.id) } + + func start(sessions: PlaySessionStore, settings: AppSettingsStore) { + guard tokens.isEmpty else { return } + self.sessions = sessions + self.settings = settings + let center = NSWorkspace.shared.notificationCenter + // Launches and quits arrive through the `runningApplications` observation below, once each. + // Steam brings itself forward when it gets steam://run; during a background launch it goes away again. + tokens = [NSWorkspace.didActivateApplicationNotification, NSWorkspace.didUnhideApplicationNotification].map { + center.addObserver(forName: $0, object: nil, queue: .main) { [weak self] note in + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + guard app?.bundleIdentifier == Self.steamBundleID else { return } + Task { @MainActor in self?.followBackground() } + } + } + listObservation = NSWorkspace.shared.observe(\.runningApplications) { [weak self] _, _ in + Task { @MainActor in self?.refresh() } + } + timer = Timer.scheduledTimer(withTimeInterval: Self.tick, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refresh(heartbeat: true) } + } + refresh() + } + + func stop() { + let center = NSWorkspace.shared.notificationCenter + for token in tokens { center.removeObserver(token) } + tokens = [] + listObservation?.invalidate(); listObservation = nil + timer?.invalidate(); timer = nil + // A later start re-reads the processes: `RunningGameMonitor.start` closes open Steam sessions + // at their last heartbeat, and a game still running must start a new one, not stay "running". + states = [:] + canonicalProcesses = [:] + sessions = nil + settings = nil + if backgrounds.values.contains(true) { Self.showSteam() } + backgrounds = [:] + late = [] + } + + /// The catalog changed. A game no longer in it keeps its session until its process ends. + func setGames(_ all: [CatalogGame], sources: [CatalogSourceConfiguration]) { + games = all.filter { $0.integration == .steam } + steamEnabled = sources.contains { $0.integration == .steam && $0.isEnabled } + refresh() + } + + /// The registered handler of steam:// URLs, when it is Valve's Steam client. + static var steamClientURL: URL? { + guard let probe = URL(string: "steam://"), + let handler = NSWorkspace.shared.urlForApplication(toOpen: probe), + Bundle(url: handler)?.bundleIdentifier == steamBundleID else { return nil } + return handler + } + + func play(_ game: CatalogGame) { + // A game that quit since the last notification is not resumed: read the processes first. + refresh() + let presentation = CatalogGamePresentation(game: game, sourceEnabled: steamEnabled) + let client = Self.steamClientURL + var state = states[game.id] ?? SteamPlayState() + let request = state.requestPlay(target: presentation.launchTarget, clientAvailable: client != nil, now: Date()) + states[game.id] = state + switch request { + case .resume(let pid): + if let app = NSRunningApplication(processIdentifier: pid) { RunningGameMonitor.activate(app) } + case .handOff(let url, let generation): + guard let client else { return } + // Steam gets the request in the background: NX Library stays in front until the game's + // own window appears, instead of Steam's library flashing up in between. + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + let id = game.id + let background = settings?.settings.steamInBackground ?? false + let steam = Self.steamApplication + // Launch arguments reach only a Steam that this request starts. + configuration.arguments = SteamBackgroundLaunch.arguments(enabled: background, steamRunning: steam != nil) + late.remove(id) + if background { + backgrounds[id] = backgrounds[id] ?? (steam.map { !$0.isHidden } ?? false) + Timer.scheduledTimer(withTimeInterval: SteamBackgroundLaunch.attentionDelay + 0.5, repeats: false) { [weak self] _ in + Task { @MainActor in self?.followBackground() } + } + followBackground() + } + NSWorkspace.shared.open([url], withApplicationAt: client, configuration: configuration) { [weak self] _, error in + let accepted = error == nil + Task { @MainActor in self?.finishHandOff(id, accepted: accepted, generation: generation) } + } + case .wait, .refuse: + break + } + } + + /// Stop waiting, or dismiss a failure. A game already running is not touched. + func cancel(_ game: CatalogGame) { + guard var state = states[game.id] else { return } + state.cancel(); states[game.id] = state + followBackground() + } + + private func finishHandOff(_ id: UUID, accepted: Bool, generation: Int) { + guard var state = states[id] else { return } + state.handoffFinished(accepted: accepted, generation: generation) + states[id] = state + refresh() + } + + private func refresh(heartbeat: Bool = false) { + // Before `start` there is no session store: a game seen now would turn "running" with no session. + guard sessions != nil else { return } + // The list drops an app when it quits (no `isTerminated` round trip to LaunchServices per app), and a pid's + // paths are resolved once while it lives. + var resolved: [Int32: NativeProcessObservation] = [:] + let processes = NSWorkspace.shared.runningApplications.map { app -> NativeProcessObservation in + let pid = app.processIdentifier + let observed = NativeProcessObservation(pid: pid, bundleURL: app.bundleURL, executableURL: app.executableURL) + let process = canonicalProcesses[pid].flatMap { $0.observed == observed ? $0.canonical : nil } ?? observed.canonical() + canonicalProcesses[pid] = (observed, process) + resolved[pid] = process + return process + } + canonicalProcesses = canonicalProcesses.filter { resolved[$0.key] != nil } + let now = Date() + var next = states + let followed = Set(games.map(\.id)).union(states.keys) + for id in followed { + let game = games.first { $0.id == id } + let app = game?.steamGame?.installations.first { $0.availability == .available }?.primaryApplication + var state = next[id] ?? SteamPlayState() + switch state.observe(processes, application: app, now: now) { + case .started: + if let appID = game?.steamGame?.appID { sessions?.start(gameID: id, sourceID: "steam-\(appID)") } + case .ended: + sessions?.end(gameID: id) + case nil: + if heartbeat, case .running = state.phase { sessions?.heartbeat(for: id) } + } + if state.phase == .idle, game == nil { next[id] = nil } else { next[id] = state } + } + if next != states { states = next } + followBackground() + } + + // MARK: - Keep Steam in the background + + private static var steamApplication: NSRunningApplication? { + NSRunningApplication.runningApplications(withBundleIdentifier: steamBundleID).first { !$0.isTerminated } + } + + private static func showSteam() { + // Shown, not brought forward: NX Library or the game keeps the focus. + if let steam = steamApplication, steam.isHidden { steam.unhide() } + } + + /// Hides Steam while a background launch is on its way; shows it again when the game is late, + /// and once Play is over if it was in view before. + private func followBackground(now: Date = Date()) { + var keepAway = false + for (id, wasVisible) in backgrounds { + let phase = states[id]?.phase ?? .idle + switch phase { + case .starting: + if SteamBackgroundLaunch.mayNeedPlayer(enabled: true, phase: phase, now: now) { + backgrounds[id] = nil + late.insert(id) + Self.showSteam() + } else if SteamBackgroundLaunch.keepsSteamAway(enabled: true, phase: phase, now: now) { + keepAway = true + } + case .running: + break + case .idle, .failed: + backgrounds[id] = nil + if wasVisible { Self.showSteam() } + } + } + let waiting = Set(states.compactMap { id, state -> UUID? in + if case .starting = state.phase { return id } else { return nil } + }) + if !late.isSubset(of: waiting) { late.formIntersection(waiting) } + if keepAway, let steam = Self.steamApplication, !steam.isHidden { steam.hide() } + } +} diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+AppIcon.swift b/Sources/NXLibrary/ViewModels/AppViewModel+AppIcon.swift index eab4b3e..788e394 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+AppIcon.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+AppIcon.swift @@ -11,7 +11,7 @@ extension AppViewModel { static let appIconWriter = AppIconWriter() static let automaticIconArtwork = ArtworkStore() - private func iconKey(_ game: PortedGame) -> String { game.titleId ?? game.uniqueKey } + private func iconKey(_ game: PortedGame) -> String { personalizationKey(for: game) } /// Resolve the image independently of whether the player wants it applied to the Mac app. func automaticIconURL(for game: PortedGame) async -> URL? { @@ -69,6 +69,7 @@ extension AppViewModel { /// The pop-up's answer: show the player's Icon on the app too, or keep the game's own. public func setUsesPickedAppIcon(_ use: Bool, for game: PortedGame) { + guard authorizeDevelopmentOperation(.icon, game: game) else { return } updateAppIconState(game) { $0.usesPickedAppIcon = use $0.appIconOnMac = use @@ -79,6 +80,7 @@ extension AppViewModel { /// Automatic removes the personal image without changing the player's destination choice. public func appIconPickChanged(for game: PortedGame, removed: Bool) { + guard authorizeDevelopmentOperation(.icon, game: game) else { return } updateAppIconState(game) { if removed { $0.useAutomaticAppIcon() } $0.appIconPending = true @@ -89,6 +91,7 @@ extension AppViewModel { /// After a library scan: an app whose icon differs from what the rule wants (a pick chosen while the app was /// elsewhere or missing, an app moved or built again, a change lost to a crash) is put right. public func reconcileAppIcons() { + guard !AppData.profile.isDevelopment else { return } for game in libraryGames where !game.appIconPending && !appIconsInFlight.contains(game.id) { guard let app = installedApp(game) else { continue } let writer = Self.appIconWriter @@ -107,6 +110,7 @@ extension AppViewModel { /// the same app is being signed runs again once that finishes, so the last choice wins. public func syncPendingAppIcons() { for game in libraryGames where game.appIconPending { + guard developmentOperations.permits(.icon, gameID: game.id) else { continue } guard !appIconsInFlight.contains(game.id) else { appIconRerun.insert(game.id) continue @@ -178,15 +182,8 @@ extension AppViewModel { } private func updateAppIconState(_ game: PortedGame, _ change: (inout PortedGame) -> Void) { - guard var library = try? libraryStore.load(), - let index = library.games.firstIndex(where: { $0.id == game.id }) else { return } - change(&library.games[index]) - guard (try? libraryStore.save(library)) != nil else { return } - if let shown = libraryGames.firstIndex(where: { $0.id == game.id }) { - libraryGames[shown].usesPickedAppIcon = library.games[index].usesPickedAppIcon - libraryGames[shown].appIconOnMac = library.games[index].appIconOnMac - libraryGames[shown].appIconPending = library.games[index].appIconPending - } + do { applyCatalog(try catalogRepository.updateNXNative(id: game.id, change)) } + catch { reportCatalogFailure(error) } } /// Finder and the Dock read icons through Launch Services, which caches them by the bundle's date. diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift b/Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift new file mode 100644 index 0000000..7f79700 --- /dev/null +++ b/Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift @@ -0,0 +1,51 @@ +import NXLibraryCore + +extension AppViewModel { + func personalizationKey(for game: PortedGame) -> String { personalizations.nxnativeKey(for: game) } + + /// One reported failure, with a reload action instead of saving displayed + /// rows over an unreadable or newer catalog. Build retries keep their result. + func reportCatalogFailure(_ error: Error) { + logs.append(LogEntry(level: .error, message: error.localizedDescription)) + showSettingsNotice(title: L10n.text("Library not saved"), message: error.localizedDescription, + retry: { [weak self] in self?.reloadCatalog() }) + } + + func reloadCatalog() { + do { applyCatalog(try catalogRepository.load()) } + catch { reportCatalogFailure(error) } + } + + @discardableResult + func persistBuildOutcome(_ game: PortedGame, retrying: Bool = false) -> Bool { + if retrying { + guard !isBuilding, catalogBuildRecoveries[game.id] == game else { + reportCatalogFailure(CatalogStoreError.staleRevision) + return false + } + } else { catalogBuildRecoveries.retain(game) } + do { + applyCatalog(try catalogRepository.recordNXNativeBuild(game)) + catalogBuildRecoveries.acknowledge(id: game.id, matching: game) + return true + } catch { + logs.append(LogEntry(level: .error, message: error.localizedDescription)) + if game.pendingInstall != nil { + presentInstallFailure(for: game, error: error.localizedDescription) + return false + } + showSettingsNotice(title: L10n.text("Library not saved"), + message: L10n.text("The build result is kept for this session. Free space if needed, then retry saving before closing NX Library."), + retry: { [weak self] in _ = self?.persistBuildOutcome(game, retrying: true) }) + return false + } + } + + func retrySavingBuildResults() { + guard !isBuilding else { return } + for result in catalogBuildRecoveries.values { + if !persistBuildOutcome(result, retrying: true) { break } + } + } + +} diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+Controller.swift b/Sources/NXLibrary/ViewModels/AppViewModel+Controller.swift index 1633b82..f79d066 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+Controller.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+Controller.swift @@ -13,9 +13,10 @@ extension AppViewModel { return libraryGames.first { $0.id == id && !$0.isHidden } } guard router?.route == .collection else { return nil } - let games = librarySelection.query.apply(to: libraryGames, history: playSessionStore ?? NoPlayHistory()) + let games = librarySelection.query.applyCatalog(to: visibleCatalogGames, history: playSessionStore ?? NoPlayHistory()) guard !games.isEmpty else { return nil } - return games.first { $0.id == librarySelection.selectedGameID } ?? games.first + let selected = games.first { $0.id == librarySelection.selectedGameID } ?? games.first + return selected?.nxnativeGame } public func revealSaveFolder(_ url: URL) { diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+Mods.swift b/Sources/NXLibrary/ViewModels/AppViewModel+Mods.swift index 2c3417f..ec799c6 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+Mods.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+Mods.swift @@ -10,10 +10,11 @@ extension AppViewModel { guard let titleID = CheatLocator.titleID(appURL: game.url, fallback: game.titleId) else { return nil } let support = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") return ModStore(supportFolder: support.appendingPathComponent("nxnative/\(titleID)"), - recordsFile: support.appendingPathComponent("NXLibrary/mods/\(titleID).json"), + recordsFile: AppData.supportRoot.appendingPathComponent("mods/\(titleID).json"), titleID: titleID, canWrite: { RunningGameMonitor.runningApplication(for: game) == nil }, - trash: { try FileManager.default.trashItem(at: $0, resultingItemURL: nil) }) + trash: { try FileManager.default.trashItem(at: $0, resultingItemURL: nil) }, + allowsAutomaticMaintenance: !AppData.profile.isDevelopment) } func modsRowValue(for game: PortedGame) -> String { diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift b/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift index dea4956..6964b6e 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+PendingInstall.swift @@ -4,55 +4,71 @@ import NXLibraryCore /// Builds that finished while their game was running (`PendingInstall`): installed once the game is no longer /// running, when a game quits, at launch of NX Library and before the game is played again. extension AppViewModel { - func installPendingBuilds(only gameID: UUID? = nil) { + @discardableResult + func authorizeDevelopmentOperation(_ kind: DevelopmentOperationStore.Kind, game: PortedGame) -> Bool { + do { try developmentOperations.authorize(kind, gameID: game.id); return true } + catch { + logs.append(LogEntry(level: .error, message: error.localizedDescription)) + showSettingsNotice(title: L10n.text("Options not saved"), message: L10n.text("Your changes could not be saved. Try again.")) + return false + } + } + + func installPendingBuilds(only gameID: UUID? = nil, automatic: Bool = true) { recoverSavesAtStartup() - guard libraryGames.contains(where: { $0.pendingInstall != nil }) else { return } - var library = (try? libraryStore.load()) ?? LibraryFile(games: libraryGames) - var changed = false - for index in library.games.indices { - guard gameID == nil || library.games[index].id == gameID else { continue } - // An explicit retry can still use the in-memory ready build if the - // previous catalog write failed (for example, a full disk). - if gameID != nil, library.games[index].pendingInstall == nil { - library.games[index].pendingInstall = libraryGames.first { $0.id == gameID }?.pendingInstall - } - guard library.games[index].pendingInstall != nil else { continue } - let name = library.games[index].name - guard RunningGameMonitor.runningApplication(for: library.games[index]) == nil else { continue } - guard let release = acquireSaveInstallLease(for: library.games[index]) else { continue } - defer { release() } + let candidates = Set(libraryGames.filter { $0.pendingInstall != nil }.map(\.id)) + .union(catalogBuildRecoveries.values.filter { $0.pendingInstall != nil }.map(\.id)) + for id in candidates { + guard gameID == nil || id == gameID else { continue } + if automatic && (!developmentOperations.permits(.build, gameID: id) || catalogBuildRecoveries[id] != nil) { continue } + let recoveryGeneration = catalogBuildRecoveries[id] + var outcome: PortedGame? + var release: (() -> Void)? + defer { release?() } do { - switch try PendingInstaller.install(&library.games[index]) { - case .installed: - logs.append(LogEntry(level: .success, message: L10n.format("Installed the new build of %@.", [String(describing: (name))]))) - library.games[index].appIconPending = true - if let path = library.games[index].lastBuild?.logPath, !path.isEmpty { - appIconBuildLogs[library.games[index].id] = URL(fileURLWithPath: path) + let catalog = try catalogRepository.updateNXNative(id: id) { game in + guard let candidate = try catalogBuildRecoveries.pendingCandidate(persisted: game, explicitRetry: !automatic) else { return } + game = candidate + guard game.pendingInstall != nil else { + if recoveryGeneration != nil { outcome = game } + return + } + if !automatic { + guard authorizeDevelopmentOperation(.build, game: game), + authorizeDevelopmentOperation(.icon, game: game) else { return } } - if gameWithInstallFailure?.id == library.games[index].id { gameWithInstallFailure = nil } - if canRetryReadyInstall, lastBuildGameID == library.games[index].id { - buildState = .succeeded - buildSuccessPath = library.games[index].url.path - buildError = nil - lastError = nil - currentStageName = "Build completed" - portingProgress?.finish(success: true) + guard RunningGameMonitor.runningApplication(for: game) == nil else { return } + guard let lease = acquireSaveInstallLease(for: game) else { return } + release = lease + switch try PendingInstaller.install(&game) { + case .installed: + _ = authorizeDevelopmentOperation(.icon, game: game) + game.appIconPending = true + logs.append(LogEntry(level: .success, message: L10n.format("Installed the new build of %@.", [game.name]))) + if let path = game.lastBuild?.logPath, !path.isEmpty { appIconBuildLogs[id] = URL(fileURLWithPath: path) } + case .gone: + logs.append(LogEntry(level: .warning, message: L10n.format("The new build of %@ that waited for the game to quit is gone. Port it again.", [game.name]))) + case nil: return } - case .gone: - logs.append(LogEntry(level: .warning, - message: L10n.format("The new build of %@ that waited for the game to quit is gone. Port it again.", [String(describing: (name))]))) - case nil: - continue + outcome = game + } + applyCatalog(catalog) + guard let outcome else { continue } + catalogBuildRecoveries.acknowledge(id: id, matching: recoveryGeneration) + if gameWithInstallFailure?.id == id { gameWithInstallFailure = nil } + if buildHeldGameID == id { buildHeldGameID = nil } + if lastBuildGameID == id, outcome.pendingInstall == nil, outcome.status == .built { + buildState = .succeeded; buildSuccessPath = outcome.url.path + buildError = nil; lastError = nil; currentStageName = "Build completed" + portingProgress?.finish(success: true) } - changed = true - if buildHeldGameID == library.games[index].id { buildHeldGameID = nil } } catch { - presentInstallFailure(for: library.games[index], error: error.localizedDescription) + if let outcome { _ = persistBuildOutcome(outcome) } + else if let game = libraryGames.first(where: { $0.id == id }) { + presentInstallFailure(for: game, error: error.localizedDescription) + } else { reportCatalogFailure(error) } } } - guard changed else { return } - do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } - loadLibrary() } func presentInstallFailure(for game: PortedGame, error: String) { @@ -64,7 +80,7 @@ extension AppViewModel { func retryFailedInstallation() { guard let game = gameWithInstallFailure else { return } gameWithInstallFailure = nil - installPendingBuilds(only: game.id) + installPendingBuilds(only: game.id, automatic: false) } /// The game as it is after its waiting build went in; the caller has checked it is not running. diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+Saves.swift b/Sources/NXLibrary/ViewModels/AppViewModel+Saves.swift index 4839cff..abea016 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel+Saves.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel+Saves.swift @@ -12,7 +12,7 @@ extension AppViewModel { } var saveBackupRoot: URL { - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/NXLibrary/SaveBackups") + AppData.supportRoot.appendingPathComponent("SaveBackups") } func saveContext(for game: PortedGame) throws -> SaveContext { guard let id = game.titleId else { throw SaveFailure.invalidTitleID } @@ -71,6 +71,8 @@ extension AppViewModel { } /// Only app-owned journals for known games; no startup walk through save contents. func recoverSavesAtStartup() { + // Imported launcher records do not authorize repair of a real game save. + guard !AppData.profile.isDevelopment else { return } for game in libraryGames { guard let location = try? defaultSaveLocation(for: game), SaveTransaction.needsRecovery(location: location), RunningGameMonitor.runningApplication(for: game) == nil, diff --git a/Sources/NXLibrary/ViewModels/AppViewModel+Sources.swift b/Sources/NXLibrary/ViewModels/AppViewModel+Sources.swift new file mode 100644 index 0000000..2f88aa7 --- /dev/null +++ b/Sources/NXLibrary/ViewModels/AppViewModel+Sources.swift @@ -0,0 +1,28 @@ +import AppKit +import NXLibraryCore + +extension AppViewModel { + var visibleCatalogGames: [CatalogGame] { + CatalogLibraryProjection.visible(games: catalogGames, sources: catalogSourceConfigurations) + } + func setCatalogFavorite(_ game: CatalogGame) { + do { applyCatalog(try catalogRepository.toggleFavorite(for: game.id)) } + catch { reportCatalogFailure(error) } + } + @discardableResult + func setCatalogHidden(_ hidden: Bool, game: CatalogGame) -> Bool { + do { applyCatalog(try catalogRepository.setHidden(hidden, for: game.id)); return true } + catch { reportCatalogFailure(error); return false } + } + var currentFocusedCatalogGame: CatalogGame? { + let id: UUID? + switch router?.route { + case .detail(let value): id = value + case .library: id = spotlight.selectedGameID + case .collection: id = librarySelection.selectedGameID + default: id = nil + } + guard let id else { return nil } + return visibleCatalogGames.first { $0.id == id } + } +} diff --git a/Sources/NXLibrary/ViewModels/AppViewModel.swift b/Sources/NXLibrary/ViewModels/AppViewModel.swift index b1d4250..7596288 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel.swift @@ -74,7 +74,7 @@ public final class AppViewModel: ObservableObject { @Published public var prodKeysPath: String = "" { didSet { if !prodKeysPath.isEmpty { - UserDefaults.standard.set(prodKeysPath, forKey: StorageKeys.savedProdKeysPath) + AppData.preferences.set(prodKeysPath, forKey: StorageKeys.savedProdKeysPath) } } } @@ -86,7 +86,7 @@ public final class AppViewModel: ObservableObject { didSet { if let folder = selectedGameFolder { if !keepsRememberedGameFolder { - UserDefaults.standard.set(folder.path, forKey: StorageKeys.savedGameFolder) + AppData.preferences.set(folder.path, forKey: StorageKeys.savedGameFolder) } if !restoringGameFolder { selectedPackageURLs = nil @@ -94,7 +94,7 @@ public final class AppViewModel: ObservableObject { } } else { if !keepsRememberedGameFolder { - UserDefaults.standard.removeObject(forKey: StorageKeys.savedGameFolder) + AppData.preferences.removeObject(forKey: StorageKeys.savedGameFolder) } scanGeneration = UUID() selectedPackageURLs = nil @@ -112,7 +112,7 @@ public final class AppViewModel: ObservableObject { private var selectedPackageURLs: [URL]? /// The global source-folder preference is independent of the current Add Game draft. public var gameFilesFolder: URL? { - guard let path = UserDefaults.standard.string(forKey: StorageKeys.savedGameFolder), + guard let path = AppData.preferences.string(forKey: StorageKeys.savedGameFolder), !path.isEmpty, path != "/" else { return nil } return URL(fileURLWithPath: path) } @@ -284,22 +284,33 @@ public final class AppViewModel: ObservableObject { @Published public var dropErrorMessage: String? = nil // MARK: - Library - @Published public var libraryGames: [PortedGame] = [] + @Published public private(set) var libraryGames: [PortedGame] = [] + @Published public private(set) var catalogGames: [CatalogGame] = [] + @Published public private(set) var catalogSourceConfigurations: [CatalogSourceConfiguration] = [] + @Published private(set) var personalizations = CatalogPersonalizations.empty + /// Finished file operations whose catalog save must still be retried. + @Published var catalogBuildRecoveries = CatalogBuildRecoveries() @Published public var libraryFolder: URL? { didSet { if let folder = libraryFolder { - UserDefaults.standard.set(folder.path, forKey: StorageKeys.savedLibraryFolder) + AppData.preferences.set(folder.path, forKey: StorageKeys.savedLibraryFolder) } } } /// First use asks where to find existing games and install new ones; later visits keep that choice. public var requiresGameAppsFolderChoice: Bool { - !setupCompleted && UserDefaults.standard.string(forKey: StorageKeys.savedLibraryFolder) == nil + !setupCompleted && AppData.preferences.string(forKey: StorageKeys.savedLibraryFolder) == nil } @Published public var isScanningLibrary: Bool = false /// Non-blocking scan details shown beside Library refresh, never an automatic launch dialog. @Published public var libraryScanWarnings: [String] = [] @Published public var gameForSettingsSheet: PortedGame? = nil + /// The Steam game whose Options are open (`SteamOptionsView`); never open with `gameForSettingsSheet`. + @Published var steamOptionsGame: CatalogGame? = nil + /// Any game's Options are open: the screens underneath stop reading input. + var isGameOptionsOpen: Bool { gameForSettingsSheet != nil || steamOptionsGame != nil } + /// The game whose Options are open, for the panels' own key monitors. + var optionsGameID: UUID? { gameForSettingsSheet?.id ?? steamOptionsGame?.id } /// Where the open Game options came from; decides its rows (Move is Spotlight-only). @Published public var gameOptionsOrigin: GameOptionsOrigin = .collection /// Game options opens straight on its Mods panel (the Details page's Mods band). @@ -333,11 +344,11 @@ public final class AppViewModel: ObservableObject { lazy var setupModel = SetupCheckModel(viewModel: self) @Published public private(set) var firstRunSetup = FirstRunSetup() { didSet { - UserDefaults.standard.set(firstRunSetup.isCompleted, forKey: StorageKeys.setupCompleted) + AppData.preferences.set(firstRunSetup.isCompleted, forKey: StorageKeys.setupCompleted) if let page = firstRunSetup.pageToRemember { - UserDefaults.standard.set(page, forKey: StorageKeys.setupPage) + AppData.preferences.set(page, forKey: StorageKeys.setupPage) } else { - UserDefaults.standard.removeObject(forKey: StorageKeys.setupPage) + AppData.preferences.removeObject(forKey: StorageKeys.setupPage) } } } @@ -351,16 +362,16 @@ public final class AppViewModel: ObservableObject { @Published public var lastBuildGameID: UUID? = nil @Published public var allowUntestedToolchain: Bool = false { didSet { - UserDefaults.standard.set(allowUntestedToolchain, forKey: StorageKeys.allowUntestedToolchain) + AppData.preferences.set(allowUntestedToolchain, forKey: StorageKeys.allowUntestedToolchain) } } /// The newest nxnative the player chose in Settings → Engine; nil while the tested engine is in use. @Published public internal(set) var newestEngineChoice: NewestEngineChoice? = nil { didSet { if let choice = newestEngineChoice, let data = try? JSONEncoder().encode(choice) { - UserDefaults.standard.set(data, forKey: StorageKeys.newestEngineChoice) + AppData.preferences.set(data, forKey: StorageKeys.newestEngineChoice) } else { - UserDefaults.standard.removeObject(forKey: StorageKeys.newestEngineChoice) + AppData.preferences.removeObject(forKey: StorageKeys.newestEngineChoice) } } } @@ -404,12 +415,13 @@ public final class AppViewModel: ObservableObject { var canRetryReadyInstall: Bool { buildState == .failed && portingFailureReason == nil - && libraryGames.contains { $0.id == lastBuildGameID && $0.pendingInstall != nil } + && (libraryGames.contains { $0.id == lastBuildGameID && $0.pendingInstall != nil } + || catalogBuildRecoveries.values.contains { $0.id == lastBuildGameID && $0.pendingInstall != nil }) } @Published public var buildWizardStep: Int = 1 // 1: Select Game, 2: Configure & Output, 3: Porting @Published public var iconAppearanceMode: AppIconAppearanceMode = .auto { didSet { - UserDefaults.standard.set(iconAppearanceMode.rawValue, forKey: StorageKeys.savedIconAppearanceMode) + AppData.preferences.set(iconAppearanceMode.rawValue, forKey: StorageKeys.savedIconAppearanceMode) updateDockIcon() } } @@ -457,15 +469,15 @@ public final class AppViewModel: ObservableObject { } public let pinnedEngineCommit = "8a5c5e7e1ee606c650fe1aef6959a83009b6a2fc" - private let core = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary") - lazy var libraryStore = LibraryStore(directory: core) + private let core = AppData.supportRoot + let catalogRepository: CatalogRepository lazy var keyStore = KeyStore(directory: core.appendingPathComponent("keys")) /// Sizes of built apps are remembered between launches, so a scan walks only new or rebuilt apps. private lazy var importService = ImportService(sizeCache: AppSizeCache(file: core.appendingPathComponent("app-sizes.json"))) private let launchService = LaunchService() private lazy var upstreamChecker = UpstreamChecker() private lazy var storeMetadataService = NintendoStoreMetadataService() + let developmentOperations = DevelopmentOperationStore() private var metadataLocaleGeneration = UUID() private var localizedMetadataRefresh: Task? private let upstreamCacheKey = "NXLibrary_UpstreamStatus" @@ -473,6 +485,7 @@ public final class AppViewModel: ObservableObject { private let sourceCatalog = SourceCatalog() private lazy var workspaceManager = WorkspaceManager(root: core.appendingPathComponent("workspaces")) private var scanGeneration = UUID() + private var libraryRescanRequested = false private var buildService: BuildService? private var buildPreviousStatus: GameStatus? private var activeBuildToolchain: ToolchainInfo? @@ -485,8 +498,10 @@ public final class AppViewModel: ObservableObject { private var pipelinePollGeneration = UUID() private var isPollingPipelineArchive = false - public init(settingsStore: AppSettingsStore = AppSettingsStore()) { + public init(settingsStore: AppSettingsStore, catalog: CatalogDocument, repository: CatalogRepository) { + self.catalogRepository = repository self.settingsStore = settingsStore + applyCatalog(catalog) saveGate.onActivityChange = { [weak self] in DispatchQueue.main.async { guard let self else { return } @@ -494,9 +509,9 @@ public final class AppViewModel: ObservableObject { } } let startup = settingsStore.settings - let didCompleteSetup = UserDefaults.standard.bool(forKey: StorageKeys.setupCompleted) + let didCompleteSetup = AppData.preferences.bool(forKey: StorageKeys.setupCompleted) self.firstRunSetup = FirstRunSetup(completed: didCompleteSetup, - savedPage: UserDefaults.standard.string(forKey: StorageKeys.setupPage)) + savedPage: AppData.preferences.string(forKey: StorageKeys.setupPage)) self.showSetupCheck = !didCompleteSetup // 1. Resolve safe root directory (anchored to SSD / Bundle, NEVER "/") @@ -505,31 +520,31 @@ public final class AppViewModel: ObservableObject { // The launcher's old ~/Games default is not a Mac-app library. let oldGamesDefault = root.appendingPathComponent("Games").standardized.path - if let savedLib = UserDefaults.standard.string(forKey: StorageKeys.savedLibraryFolder), + if let savedLib = AppData.preferences.string(forKey: StorageKeys.savedLibraryFolder), savedLib != oldGamesDefault, !savedLib.isEmpty, savedLib != "/" { self.libraryFolder = URL(fileURLWithPath: savedLib) } else { self.libraryFolder = root.appendingPathComponent("Applications") - UserDefaults.standard.removeObject(forKey: StorageKeys.savedLibraryFolder) + AppData.preferences.removeObject(forKey: StorageKeys.savedLibraryFolder) } // Restore user preferences - if let savedMode = UserDefaults.standard.string(forKey: StorageKeys.savedIconAppearanceMode), + if let savedMode = AppData.preferences.string(forKey: StorageKeys.savedIconAppearanceMode), let mode = AppIconAppearanceMode(rawValue: savedMode) { self.iconAppearanceMode = mode == .mono ? .mono : .auto } - if UserDefaults.standard.object(forKey: StorageKeys.allowUntestedToolchain) != nil { - self.allowUntestedToolchain = UserDefaults.standard.bool(forKey: StorageKeys.allowUntestedToolchain) + if AppData.preferences.object(forKey: StorageKeys.allowUntestedToolchain) != nil { + self.allowUntestedToolchain = AppData.preferences.bool(forKey: StorageKeys.allowUntestedToolchain) } - if let data = UserDefaults.standard.data(forKey: StorageKeys.newestEngineChoice) { + if let data = AppData.preferences.data(forKey: StorageKeys.newestEngineChoice) { self.newestEngineChoice = try? JSONDecoder().decode(NewestEngineChoice.self, from: data) } - restoreBuildInputs() - if startup.checkEngineAtLaunch || !didCompleteSetup { refreshCompatibility() } + restoreBuildInputs(automatic: true) + if !AppData.profile.isDevelopment && (startup.checkEngineAtLaunch || !didCompleteSetup) { refreshCompatibility() } // Restore saved game folder if it exists - if let savedGamePath = UserDefaults.standard.string(forKey: StorageKeys.savedGameFolder) { + if let savedGamePath = AppData.preferences.string(forKey: StorageKeys.savedGameFolder) { if !savedGamePath.isEmpty, savedGamePath != "/" { restoringGameFolder = !startup.refreshLibraryAtLaunch self.selectedGameFolder = URL(fileURLWithPath: savedGamePath) @@ -537,10 +552,10 @@ public final class AppViewModel: ObservableObject { } } - restoreLibraryFromDisk() + recoverSavesAtStartup() if !setupCompleted && firstRunSetup.page == .games { prepareInitialGameAppsFolder() } - if startup.refreshLibraryAtLaunch || (!setupCompleted && firstRunSetup.page == .games) { loadLibrary() } - if let data = UserDefaults.standard.data(forKey: upstreamCacheKey), + if startup.refreshLibraryAtLaunch || (!setupCompleted && firstRunSetup.page == .games) { scanLibrary(using: catalog) } + if let data = AppData.preferences.data(forKey: upstreamCacheKey), let cached = try? JSONDecoder().decode(UpstreamStatus.self, from: data), cached.baseCommit == pinnedEngineCommit { self.upstreamState = .loaded(cached) @@ -549,8 +564,8 @@ public final class AppViewModel: ObservableObject { NotificationService.shared.requestAuthorization() let held = PendingInstaller.keptFolders(libraryGames) - let staging = BuildScratch.tempFolders( - core: core, appFolders: [gameAppsFolder] + libraryGames.map { $0.url.deletingLastPathComponent() }) + let staging = AppData.profile.automaticStagingFolders( + appFolders: [gameAppsFolder] + libraryGames.map { $0.url.deletingLastPathComponent() }) DispatchQueue.global(qos: .utility).async { [weak self] in self?.sweepStaleBuildTemp(in: staging, keeping: held) } @@ -601,12 +616,12 @@ public final class AppViewModel: ObservableObject { refreshCompatibility() } - private func restoreBuildInputs(customRyujinxDir: String? = nil) { + private func restoreBuildInputs(customRyujinxDir: String? = nil, automatic: Bool = false) { let nxResult = SystemDetector.shared.detectNxnativeDirectory( - saved: UserDefaults.standard.string(forKey: StorageKeys.savedEngineFolder)) + saved: AppData.preferences.string(forKey: StorageKeys.savedEngineFolder)) self.nxnativePath = nxResult.path self.isNxnativeValid = nxResult.isValid - ensureKeysImported(customRyujinxDir: customRyujinxDir) + if !automatic || !AppData.profile.isDevelopment { ensureKeysImported(customRyujinxDir: customRyujinxDir) } } /// With launch checks off, inspecting a build screen performs the check on demand. @@ -634,7 +649,7 @@ public final class AppViewModel: ObservableObject { } return } - let saved = UserDefaults.standard.string(forKey: StorageKeys.savedProdKeysPath) + let saved = AppData.preferences.string(forKey: StorageKeys.savedProdKeysPath) let detected = SystemDetector.shared.detectProdKeys(additionalSearchDir: customRyujinxDir) let candidate = [saved, detected?.path].compactMap { $0 }.first { !$0.isEmpty && fm.fileExists(atPath: $0) @@ -747,7 +762,7 @@ public final class AppViewModel: ObservableObject { let status = try await checker.check(from: base) self.upstreamState = .loaded(status) if let data = try? JSONEncoder().encode(status) { - UserDefaults.standard.set(data, forKey: self.upstreamCacheKey) + AppData.preferences.set(data, forKey: self.upstreamCacheKey) } self.checkNewestEngineReadiness(status) } catch { @@ -833,7 +848,7 @@ public final class AppViewModel: ObservableObject { /// Keeps the fetched metadata and shows it with the player's choices (`MetadataPicks`) over it. private func setStoreMetadata(_ metadata: NintendoStoreMetadata, for game: PortedGame) { storeMetadataOriginals[game.id] = metadata - let shown = MetadataPicks.shared.applied(to: metadata, titleID: game.titleId ?? game.uniqueKey) + let shown = MetadataPicks.shared.applied(to: metadata, titleID: personalizationKey(for: game)) storeMetadata[game.id] = screenshotFallback.filled(shown) } @@ -867,7 +882,7 @@ public final class AppViewModel: ObservableObject { /// Keeps the player's value for a field, or (nil) goes back to the store's. public func pickMetadata(_ value: String?, field: MetadataField, for game: PortedGame) { - let key = game.titleId ?? game.uniqueKey + let key = personalizationKey(for: game) if let value { MetadataPicks.shared.set(value, titleID: key, field: field) } else { MetadataPicks.shared.remove(titleID: key, field: field) } if let original = storeMetadataOriginals[game.id] { setStoreMetadata(original, for: game) } @@ -968,7 +983,7 @@ public final class AppViewModel: ObservableObject { public func advanceSetupToGames() { guard showSetupCheck else { return } if !setupCompleted { prepareInitialGameAppsFolder() } - loadLibrary() + importExistingBuilds() firstRunSetup.advance() } @@ -977,14 +992,14 @@ public final class AppViewModel: ObservableObject { private func prepareInitialGameAppsFolder() { do { try GameAppDestination.prepareInitialFolder(gameAppsFolder, - hasExplicitChoice: UserDefaults.standard.string(forKey: StorageKeys.savedLibraryFolder) != nil) + hasExplicitChoice: AppData.preferences.string(forKey: StorageKeys.savedLibraryFolder) != nil) } catch { logs.append(LogEntry(level: .warning, message: L10n.format("Could not prepare the default Game apps folder: %@", [String(describing: (error.localizedDescription))]))) } } public func completeSetup() { - UserDefaults.standard.set(gameAppsFolder.path, forKey: StorageKeys.savedLibraryFolder) + AppData.preferences.set(gameAppsFolder.path, forKey: StorageKeys.savedLibraryFolder) firstRunSetup.finish() showSetupCheck = false logs.append(LogEntry(level: .success, message: L10n.text("Setup completed."))) @@ -1124,46 +1139,56 @@ public final class AppViewModel: ObservableObject { loadLibrary() } - private func restoreLibraryFromDisk() { - do { - libraryGames = try libraryStore.load().games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - recoverSavesAtStartup() - } catch { - lastError = error.localizedDescription - logs.append(LogEntry(level: .error, message: L10n.format("Could not read the library: %@", [String(describing: (error.localizedDescription))]))) + func applyCatalog(_ document: CatalogDocument) { + catalogGames = document.games + catalogSourceConfigurations = document.sources + personalizations = CatalogPersonalizations(document: document) + libraryGames = document.nxnativeGames.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } public func loadLibrary() { + guard !isScanningLibrary else { libraryRescanRequested = true; return } + libraryRescanRequested = false + do { + let current = try catalogRepository.load() + applyCatalog(current) + scanLibrary(using: current) + } catch { reportCatalogFailure(error) } + } + + /// Startup consumes its already-validated document, without a second + /// fallible read before publishing the shell or starting runtime hooks. + private func scanLibrary(using current: CatalogDocument) { guard !isScanningLibrary else { return } + let choosingSwitch = !setupCompleted && firstRunSetup.page == .games + guard current.source(.nxnative)?.isEnabled == true || choosingSwitch else { return } + let existing = current.nxnativeGames isScanningLibrary = true - // At launch the saved library shows at once; the folder scan below then brings in new and changed apps. - if libraryGames.isEmpty, let stored = try? libraryStore.load(), !stored.games.isEmpty { - libraryGames = stored.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - } let fm = FileManager.default let directories = [gameAppsFolder] - let store = libraryStore let importer = importService let folderSnapshot = libraryFolder?.standardizedFileURL DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let loaded = (try? store.load()) ?? LibraryFile() let scanned = importer.scan(directories: directories) let scanWarnings = importer.scanWarnings - let selected = importer.appsForLibrary(scanned: scanned, existing: loaded.games) + let selected = importer.appsForLibrary(scanned: scanned, existing: existing) let importedGames = selected.map { importer.makeGame(from: $0) } DispatchQueue.main.async { guard let self else { return } - // A folder chosen during the startup scan gets its own scan before results appear. guard self.libraryFolder?.standardizedFileURL == folderSnapshot else { self.isScanningLibrary = false self.loadLibrary() return } + defer { + self.isScanningLibrary = false + if self.libraryRescanRequested { + self.libraryRescanRequested = false + self.loadLibrary() + } + } self.importedApps = selected var scannedByTitle: [String: ImportedApp] = [:] for app in selected { @@ -1171,52 +1196,40 @@ public final class AppViewModel: ObservableObject { scannedByTitle[titleId.uppercased()] = app } } - var library = loaded do { - library = try store.merge(scanned: importedGames) - } catch { - self.lastError = error.localizedDescription - self.logs.append(LogEntry(level: .error, - message: L10n.format("Could not update the library: %@", [String(describing: (error.localizedDescription))]))) - } - for index in library.games.indices { - let scannedApp = library.games[index].titleId - .flatMap { scannedByTitle[$0.uppercased()] } - if let scannedApp, !fm.fileExists(atPath: library.games[index].url.path), - fm.fileExists(atPath: scannedApp.appURL.path) { - library.games[index].url = scannedApp.appURL + let catalog = try self.catalogRepository.mergeNXNative(scanned: importedGames, enableSource: choosingSwitch, observedFrom: existing) { games, acceptedIDs in + for index in games.indices where acceptedIDs.contains(games[index].id) { + let scannedApp = games[index].titleId + .flatMap { scannedByTitle[$0.uppercased()] } + if let scannedApp, !fm.fileExists(atPath: games[index].url.path), + fm.fileExists(atPath: scannedApp.appURL.path) { + games[index].url = scannedApp.appURL + } + if let scannedApp, fm.fileExists(atPath: scannedApp.appURL.path) { + games[index].sizeBytes = scannedApp.sizeBytes + games[index].versionString = scannedApp.version + ?? games[index].versionString + games[index].bundleId = scannedApp.bundleId + ?? games[index].bundleId + } + let appExists = fm.fileExists(atPath: games[index].url.path) + let sourcesExist = GameResourceState.sourcesAvailable(for: games[index], fileManager: fm) + games[index].status = GameResourceState.reconciledStatus( + for: games[index], appExists: appExists, sourcesAvailable: sourcesExist) + } } - if let scannedApp, fm.fileExists(atPath: scannedApp.appURL.path) { - library.games[index].sizeBytes = scannedApp.sizeBytes - library.games[index].versionString = scannedApp.version - ?? library.games[index].versionString - library.games[index].bundleId = scannedApp.bundleId - ?? library.games[index].bundleId - } - let appExists = fm.fileExists(atPath: library.games[index].url.path) - let sourcesExist = GameResourceState.sourcesAvailable(for: library.games[index], fileManager: fm) - library.games[index].status = GameResourceState.reconciledStatus( - for: library.games[index], appExists: appExists, sourcesAvailable: sourcesExist) - } - do { try store.save(library) } catch { self.reportLibrarySaveFailure(error) } - self.libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + self.applyCatalog(catalog) + self.reconcileAppIcons() + } catch { self.reportCatalogFailure(error) } self.libraryScanWarnings = scanWarnings - self.isScanningLibrary = false - // Paths are known again: icons chosen while an app was elsewhere, or left half done, go on now. - self.reconcileAppIcons() - if !scanWarnings.isEmpty { - for warning in scanWarnings { - self.logs.append(LogEntry(level: .warning, message: warning)) - } - } + for warning in scanWarnings { self.logs.append(LogEntry(level: .warning, message: warning)) } } } } public func importExistingBuilds() { - loadLibrary() + do { applyCatalog(try catalogRepository.configureSource(.nxnative, enabled: true, root: nil)); loadLibrary() } + catch { reportCatalogFailure(error) } } /// Attach source files to an already-imported app. The app URL, version and @@ -1453,9 +1466,18 @@ public final class AppViewModel: ObservableObject { public func openGameOptions(_ game: PortedGame, from origin: GameOptionsOrigin, opensMods: Bool = false) { gameOptionsOrigin = origin gameOptionsOpensMods = opensMods + steamOptionsGame = nil gameForSettingsSheet = game } + /// Opens a Steam game's Options, remembering the screen it was opened from. + func openSteamOptions(_ game: CatalogGame, from origin: GameOptionsOrigin) { + guard game.integration == .steam else { return } + gameOptionsOrigin = origin + gameForSettingsSheet = nil + steamOptionsGame = game + } + /// Flips the favorite heart and saves the library. public func toggleFavorite(_ game: PortedGame) { updateStoredGame(game) { $0.isFavorite.toggle() } @@ -1506,18 +1528,8 @@ public final class AppViewModel: ObservableObject { /// Applies one change to the stored game and saves the library, the same way for every flag. private func updateStoredGame(_ game: PortedGame, _ change: (inout PortedGame) -> Void) { - guard var library = try? libraryStore.load(), - let index = library.games.firstIndex(where: { $0.id == game.id }) else { return } - change(&library.games[index]) - do { - try libraryStore.save(library) - } catch { - alertMessage = error.localizedDescription - return - } - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + do { applyCatalog(try catalogRepository.updateNXNative(id: game.id, change)) } + catch { reportCatalogFailure(error) } } /// Opens the Close Game confirmation for a running game (Spotlight and Details). @@ -1554,17 +1566,17 @@ public final class AppViewModel: ObservableObject { /// Seeded once from the newest porting log when nothing is stored yet. public var lastPortingFinishSeconds: Double? { get { - let stored = UserDefaults.standard.double(forKey: StorageKeys.lastPortingFinishSeconds) + let stored = AppData.preferences.double(forKey: StorageKeys.lastPortingFinishSeconds) if stored > 0 { return stored } guard !didSeedFinishSeconds else { return nil } didSeedFinishSeconds = true guard let seeded = Self.finishSecondsFromNewestLog(in: core.appendingPathComponent("logs")) else { return nil } - UserDefaults.standard.set(seeded, forKey: StorageKeys.lastPortingFinishSeconds) + AppData.preferences.set(seeded, forKey: StorageKeys.lastPortingFinishSeconds) return seeded } - set { UserDefaults.standard.set(newValue ?? 0, forKey: StorageKeys.lastPortingFinishSeconds) } + set { AppData.preferences.set(newValue ?? 0, forKey: StorageKeys.lastPortingFinishSeconds) } } private static func finishSecondsFromNewestLog(in directory: URL) -> Double? { @@ -1590,13 +1602,7 @@ public final class AppViewModel: ObservableObject { } 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 - do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + updateStoredGame(game) { $0.status = status } } public func loadRuntimeConfig(for game: PortedGame) -> GameRuntimeConfig { @@ -1701,14 +1707,16 @@ public final class AppViewModel: ObservableObject { return } let fm = FileManager.default - guard var library = try? libraryStore.load(), - let index = library.games.firstIndex(where: { $0.id == game.id }) else { + let current: CatalogDocument + do { current = try catalogRepository.load() } + catch { reportCatalogFailure(error); return } + guard current.nxnativeGames.contains(where: { $0.id == game.id }) else { alertMessage = L10n.format("Could not load the library entry for %@.", [game.name]) return } let appPath = game.url.standardizedFileURL.path - let otherGames = library.games.filter { $0.id != game.id } + let otherGames = current.nxnativeGames.filter { $0.id != game.id } var removedCount = 0 var removedBytes: Int64 = 0 var failures: [String] = [] @@ -1757,7 +1765,7 @@ public final class AppViewModel: ObservableObject { } } - var updated = library.games[index] + var updated = current.nxnativeGames.first { $0.id == game.id }! updated.sources = updated.sources.filter { source in var isDir: ObjCBool = false return fm.fileExists(atPath: source.url.path, isDirectory: &isDir) @@ -1772,19 +1780,18 @@ public final class AppViewModel: ObservableObject { if updated.sources.isEmpty { updated.status = .builtSourcesMissing } - library.games[index] = updated - do { - try libraryStore.save(library) + applyCatalog(try catalogRepository.updateNXNative(id: game.id) { stored in + stored.sources = updated.sources + stored.workspacePath = updated.workspacePath + stored.buildCachePath = updated.buildCachePath + stored.status = updated.status + }) } catch { alertMessage = L10n.format("The sources were removed, but the library could not be saved: %@", [error.localizedDescription]) logs.append(LogEntry(level: .error, message: alertMessage!)) return } - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - let removed = L10n.count("Removed %lld source files (%@).", removedCount, arguments: [Self.formatBytes(removedBytes)]) + (removedWorkspace ? " " + L10n.text("The build cache was also removed.") : "") if failures.isEmpty { @@ -1844,12 +1851,9 @@ public final class AppViewModel: ObservableObject { } } - var library = try libraryStore.load() - library.games.removeAll { $0.id == game.id } - try libraryStore.save(library) - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + applyCatalog(try catalogRepository.updateNXNativeGames { games in + games.removeAll { $0.id == game.id } + }) if deleteBundle { logs.append(LogEntry(level: .warning, message: L10n.format("Game app deleted: %@%@", [String(describing: (game.name)), String(describing: (removedWorkspace ? "; staged workspace removed" : ""))]))) @@ -2056,9 +2060,9 @@ public final class AppViewModel: ObservableObject { } // Remembered, so launch and every check use it; ~/nxnative itself needs nothing stored. if let saved = EngineLocation.savedValue(for: url, home: SystemDetector.shared.resolveRootDirectory()) { - UserDefaults.standard.set(saved, forKey: StorageKeys.savedEngineFolder) + AppData.preferences.set(saved, forKey: StorageKeys.savedEngineFolder) } else { - UserDefaults.standard.removeObject(forKey: StorageKeys.savedEngineFolder) + AppData.preferences.removeObject(forKey: StorageKeys.savedEngineFolder) } self.nxnativePath = url.path self.isNxnativeValid = true @@ -2395,11 +2399,9 @@ public final class AppViewModel: ObservableObject { game.status = .notBuilt game.settings = pendingRuntimeConfig - let library = try libraryStore.merge(scanned: [game], replacingSources: true) - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - return library.games.first(where: { $0.titleId == titleId }) ?? game + let catalog = try catalogRepository.mergeNXNative(scanned: [game], replacingSources: true, enableSource: true) + applyCatalog(catalog) + return catalog.nxnativeGames.first(where: { $0.titleId == titleId }) ?? game } public func startBuild(for game: PortedGame, extraArguments: [String] = []) { @@ -2482,11 +2484,9 @@ public final class AppViewModel: ObservableObject { : usesNewestEngine ? "Newest engine, not yet tested by NX Library" : "Untested toolchain (explicit override)") - let merged = try libraryStore.merge(scanned: [target]) - libraryGames = merged.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - guard let stored = merged.games.first(where: { $0.titleId == titleId }) else { + let merged = try catalogRepository.mergeNXNative(scanned: [target], enableSource: true) + applyCatalog(merged) + guard let stored = merged.nxnativeGames.first(where: { $0.titleId == titleId }) else { throw NXLibraryError.missingSources } target = stored @@ -2529,15 +2529,12 @@ public final class AppViewModel: ObservableObject { target.buildCachePath = workspace.buildCache.path } - if var library = try? libraryStore.load(), - let index = library.games.firstIndex(where: { $0.id == target.id }) { - library.games[index].workspacePath = target.workspacePath - library.games[index].buildCachePath = target.buildCachePath - do { try libraryStore.save(library) } catch { reportLibrarySaveFailure(error) } - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - } + do { + applyCatalog(try catalogRepository.updateNXNative(id: target.id) { + $0.workspacePath = target.workspacePath + $0.buildCachePath = target.buildCachePath + }) + } catch { reportCatalogFailure(error); throw error } let requiredDisk = SourceSelection.uniqueSize(target.sources) * 2 + 10_000_000_000 var measured = Set() @@ -2681,7 +2678,7 @@ public final class AppViewModel: ObservableObject { return } if canRetryReadyInstall { - installPendingBuilds(only: id) + installPendingBuilds(only: id, automatic: false) return } startBuild(for: game) @@ -2829,9 +2826,9 @@ public final class AppViewModel: ObservableObject { if result.heldApp == nil { try? FileManager.default.removeItem(at: tempDir) } buildTempDirectory = nil - var library = (try? libraryStore.load()) ?? LibraryFile(games: libraryGames) - if let index = library.games.firstIndex(where: { $0.id == gameID }) { - var game = library.games[index] + // Displayed state can retain a finished result for an explicit retry, + // but is never serialized as a replacement catalog. + if var game = libraryGames.first(where: { $0.id == gameID }) { let toolchain = activeBuildToolchain ?? ToolchainInfo(engineCommit: activeEngineCommit, verified: false) let verified = toolchain.verified let record = BuildRecord( @@ -2848,6 +2845,8 @@ public final class AppViewModel: ObservableObject { ) if result.success || result.installationFailed { + _ = authorizeDevelopmentOperation(.build, game: game) + _ = authorizeDevelopmentOperation(.icon, game: game) if let held = result.heldApp { PendingInstaller.hold(PendingInstall(tempApp: held, finalApp: finalApp, record: record, toolchain: toolchain), for: &game) @@ -2914,21 +2913,7 @@ public final class AppViewModel: ObservableObject { notifyPortingEnded(game, success: false) } - library.games[index] = game - var saved = true - do { - try libraryStore.save(library) - } catch { - saved = false - if game.pendingInstall != nil { - presentInstallFailure(for: game, error: error.localizedDescription) - } else { - logs.append(LogEntry(level: .error, message: L10n.format("Could not save the library: %@", [String(describing: (error.localizedDescription))]))) - } - } - libraryGames = library.games.sorted { - $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + let saved = persistBuildOutcome(game) if result.success && saved { if result.heldApp == nil { appIconBuildLogs[gameID] = result.logURL } loadLibrary() diff --git a/Sources/NXLibrary/ViewModels/GameBananaServices.swift b/Sources/NXLibrary/ViewModels/GameBananaServices.swift index 14e3ec8..d53a991 100644 --- a/Sources/NXLibrary/ViewModels/GameBananaServices.swift +++ b/Sources/NXLibrary/ViewModels/GameBananaServices.swift @@ -16,8 +16,8 @@ final class GameBananaServices: @unchecked Sendable { let fm = FileManager.default let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" userAgent = GameBananaClient.userAgent(version: version) - let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("NXLibrary/gamebanana") - let support = fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/NXLibrary/gamebanana") + let caches = AppData.cacheRoot.appendingPathComponent("gamebanana") + let support = AppData.supportRoot.appendingPathComponent("gamebanana") client = GameBananaClient(cacheFolder: caches.appendingPathComponent("api"), userAgent: userAgent) images = GameBananaImageCache(folder: caches.appendingPathComponent("images"), userAgent: userAgent) matches = GameBananaMatchStore(file: support.appendingPathComponent("matches.json")) diff --git a/Sources/NXLibrary/ViewModels/ModStoreHost.swift b/Sources/NXLibrary/ViewModels/ModStoreHost.swift index b866e34..bf50067 100644 --- a/Sources/NXLibrary/ViewModels/ModStoreHost.swift +++ b/Sources/NXLibrary/ViewModels/ModStoreHost.swift @@ -38,8 +38,7 @@ final class ModStoreHost: ObservableObject { init() { let services = GameBananaServices.shared - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("NXLibrary/gamebanana/store") + let caches = AppData.cacheRoot.appendingPathComponent("gamebanana/store") snapshots = ModCatalogSnapshots(folder: caches) catalog = ModStoreModel(client: services.client, matches: services.matches, snapshots: snapshots) // After the catalog has published its new value; held while the page moves. diff --git a/Sources/NXLibrary/ViewModels/SetupCheckModel.swift b/Sources/NXLibrary/ViewModels/SetupCheckModel.swift index 7426cfa..9e1b9fd 100644 --- a/Sources/NXLibrary/ViewModels/SetupCheckModel.swift +++ b/Sources/NXLibrary/ViewModels/SetupCheckModel.swift @@ -86,11 +86,11 @@ final class SetupCheckModel: ObservableObject { } func rememberStates() { - var states = UserDefaults.standard.dictionary(forKey: Self.lastStatesKey) as? [String: String] ?? [:] + var states = AppData.preferences.dictionary(forKey: Self.lastStatesKey) as? [String: String] ?? [:] for item in items where item.state != .working { states[item.id.rawValue] = item.state.rawValue } - UserDefaults.standard.set(states, forKey: Self.lastStatesKey) + AppData.preferences.set(states, forKey: Self.lastStatesKey) } /// At launch, after setup: opens the Setup check when something that was ready @@ -104,7 +104,7 @@ final class SetupCheckModel: ObservableObject { DispatchQueue.main.async { guard !viewModel.isBuilding, !viewModel.isUpdatingApp, viewModel.nxnativePath == enginePath else { return } - let stored = UserDefaults.standard.dictionary(forKey: lastStatesKey) as? [String: String] ?? [:] + let stored = AppData.preferences.dictionary(forKey: lastStatesKey) as? [String: String] ?? [:] var previous: [SetupItemID: SetupState] = [:] for (key, value) in stored { if let id = SetupItemID(rawValue: key), let state = SetupState(rawValue: value) { previous[id] = state } @@ -112,7 +112,7 @@ final class SetupCheckModel: ObservableObject { if SetupCheck.shouldReopen(previous: previous, current: current) { viewModel.showSetupCheck = true } else { - UserDefaults.standard.set( + AppData.preferences.set( Dictionary(uniqueKeysWithValues: current.map { ($0.id.rawValue, $0.state.rawValue) }), forKey: lastStatesKey) } diff --git a/Sources/NXLibrary/Views/AddGame/AddGameStepThreeView.swift b/Sources/NXLibrary/Views/AddGame/AddGameStepThreeView.swift index e9ade79..86a5ad1 100644 --- a/Sources/NXLibrary/Views/AddGame/AddGameStepThreeView.swift +++ b/Sources/NXLibrary/Views/AddGame/AddGameStepThreeView.swift @@ -322,9 +322,10 @@ struct AddGameStepThreeView: View { } if let path = viewModel.buildSuccessPath { - return [ + let needsSave = viewModel.lastBuildGameID.map { viewModel.catalogBuildRecoveries[$0] != nil } ?? false + var actions = [ StepAction(id: "play", title: L10n.text("Play"), - systemImage: "play.fill", kind: .primary, + systemImage: "play.fill", kind: needsSave ? .secondary : .primary, isEnabled: FileManager.default.fileExists(atPath: path)) { play(path: path) }, @@ -349,6 +350,13 @@ struct AddGameStepThreeView: View { viewModel.openOutputInFinder() } ] + if needsSave { + actions.insert(StepAction(id: "saveLibrary", title: L10n.text("Retry saving library"), + systemImage: "arrow.clockwise", kind: .primary, isEnabled: !viewModel.isBuilding) { + viewModel.retrySavingBuildResults() + }, at: 0) + } + return actions } if viewModel.buildError != nil || viewModel.buildState == .cancelled { diff --git a/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift b/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift new file mode 100644 index 0000000..b0c2038 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift @@ -0,0 +1,22 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// Native-app artwork for non-nxnative entries, in the tile's layout role: a square +/// shows the app's own icon (or the player's pick), a portrait the cover. It never +/// asks Nintendo artwork services or writes the installed app. +struct CatalogNativeArtwork: View { + let game: CatalogGame + var role: LibraryArtworkRole = .cover + @Environment(\.seleneCanvas) private var canvas + var body: some View { + ZStack(alignment: .bottomLeading) { + SeleneTheme.glassSolid + SteamArtwork(game: game, slot: role.slot) + Text("Steam").seleneFont(12, .semibold).foregroundColor(SeleneTheme.ink) + .padding(.horizontal, canvas.u(8)).padding(.vertical, canvas.u(4)) + .background(Capsule().fill(SeleneTheme.glassSolid)).padding(canvas.u(9)) + } + .accessibilityLabel(game.name) + } +} diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift new file mode 100644 index 0000000..ef46dbd --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -0,0 +1,168 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// A Steam game's head in the same Selene layout as an nxnative one: the shared +/// logo area, the Play/Options/favorite positions, the stats line and (in Collection) the genre +/// chips. Play follows `SteamPlayCoordinator`: Resume while the game runs, Cancel while +/// waiting for Steam, Try again after a failure, with a one-line note above the pills. +/// Options opens `SteamOptionsView`. +struct CatalogSteamInfo: View { + let game: CatalogGame + let totalPlaytime: TimeInterval + let lastPlayed: Date? + /// Collection shows no pills, like its nxnative column. + var collection = false + var focused: Int? = nil + var onAction: (Int) -> Void = { _ in } + var onHover: (Int) -> Void = { _ in } + @Environment(\.seleneCanvas) private var canvas + @Environment(\.seleneReduceMotion) private var reduceMotion + @Environment(\.nxAppLocale) private var localization + @ObservedObject private var fields = SteamPersonalizations.shared.metadata + @ObservedObject private var picks = SteamPersonalizations.shared.artwork + @ObservedObject private var facts = SteamGameFacts.shared + @ObservedObject private var player = SteamPlayCoordinator.shared + @ObservedObject private var logos = SteamLogoCache.shared + private var metadata: SteamMetadata? { facts.metadata(for: game) } + @State private var shown = true + @State private var restShown = true + @State private var slid = true + + private var presentation: LibraryPresentation { + let key = game.personalizationReference.key + var overrides: [MetadataField: String] = [:] + for field in MetadataField.allCases { overrides[field] = fields.value(titleID: key, field: field) } + return LibraryPresentation(game: game, steamMetadata: metadata, overrides: overrides, + totalPlaytime: totalPlaytime, lastPlayed: lastPlayed) + } + private var currentLogoKey: String { "\(game.id)|\(picks.revision)" } + + var body: some View { + let presentation = presentation + VStack(alignment: .leading, spacing: 0) { + // Spotlight moves only the logo and fades the rest in after it; Collection moves the whole column + // (`SpotlightGameInfoView`, `CollectionInfoView`). + GameLogoArea(title: game.name, logo: logos.logo(for: currentLogoKey), collection: collection) + .offset(x: collection || slid ? 0 : canvas.u(SpotlightGameInfoView.logoSlideDistance)) + .opacity(collection || shown ? 1 : 0) + rest(presentation) + .opacity(collection || restShown ? 1 : 0) + } + .frame(width: collection ? nil : canvas.u(900), alignment: .leading) + .frame(maxWidth: collection ? .infinity : nil, alignment: .topLeading) + .opacity(!collection || shown ? 1 : 0) + .offset(x: !collection || slid ? 0 : canvas.u(CollectionInfoView.slideDistance)) + .onAppear { reveal() } + .onChange(of: game.id) { _ in reveal() } + .task(id: "\(game.id)|\(localization.language.rawValue)") { + await facts.load(game, language: localization.language) + } + .task(id: currentLogoKey) { + await logos.load(game, key: currentLogoKey, picks: picks) + } + } + + @ViewBuilder + private func rest(_ presentation: LibraryPresentation) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let note = playNote { + Text(note) + .seleneFont(19, .semibold) + .foregroundColor(SeleneTheme.ink75) + .padding(.leading, canvas.u(collection ? 0 : 4)) + .padding(.top, canvas.u(20)) + } + if !collection { + GamePrimaryActions(primaryTitle: primaryTitle, + isFavorite: game.isFavorite, focused: focused, + primaryEnabled: primaryEnabled, + onAction: onAction, onHover: onHover) + .padding(.leading, canvas.u(2)).padding(.top, canvas.u(18)) + } + SeleneStatsRow(presentation: presentation) + .padding(.leading, canvas.u(collection ? 0 : 4)).padding(.top, canvas.u(collection ? 28 : 26)) + // Spotlight places the genres in their own block under the head, as for nxnative games. + if collection, !presentation.genres.isEmpty { + SpotlightGenreChips(genres: presentation.genres).padding(.top, canvas.u(20)) + } + } + } + + private var phase: SteamPlayState.Phase { player.phase(of: game) } + private var primaryEnabled: Bool { + if case .idle = phase { return player.canPlay(game) } + return true + } + + private var primaryTitle: String { + switch phase { + case .running: return L10n.text("Resume") + case .starting: return L10n.text("Cancel") + case .failed: return L10n.text("Try again") + case .idle: return player.canPlay(game) ? L10n.text("Play") : L10n.text("Unavailable") + } + } + + private var playNote: String? { + switch phase { + case .starting: + return player.mayNeedSteam(game) ? L10n.text("Steam may need you. Check Steam, the game starts after.") + : L10n.text("Waiting for Steam to start the game…") + case .failed(.clientMissing): return L10n.text("Steam is not installed on this Mac.") + case .failed(.handoffRejected): return L10n.text("Steam did not receive the request. Try again.") + case .failed(.notStarted): return L10n.text("The game did not start. Check Steam, then try again.") + case .failed(.unavailable): return L10n.text("Refresh the Steam source or check the installation in Steam.") + case .idle: return player.canPlay(game) ? nil : L10n.text("Refresh the Steam source or check the installation in Steam.") + case .running: return nil + } + } + + /// The nxnative head's arrival, motion for motion: in Spotlight the logo holds, then fades and slides while + /// the pills and stats fade in after it; in Collection the whole column fades and slides. Reduce motion + /// keeps the fades and drops the slides. + private func reveal() { + var reset = Transaction() + reset.disablesAnimations = true + withTransaction(reset) { shown = false; restShown = false; slid = reduceMotion } + DispatchQueue.main.async { + if collection { + withAnimation(CollectionInfoView.fade) { shown = true; restShown = true } + if !reduceMotion { withAnimation(CollectionInfoView.slide) { slid = true } } + } else { + withAnimation(SpotlightGameInfoView.logoFade) { shown = true } + withAnimation(SpotlightGameInfoView.restMotion) { restShown = true } + if !reduceMotion { withAnimation(SpotlightGameInfoView.logoSlide) { slid = true } } + } + } + } +} + +/// Decoded Steam logos, kept like nxnative ones (`GameArtLoader`): a game seen before shows its logo from the +/// first frame of its arrival, and a logo that lands later fades in over its title. +@MainActor +final class SteamLogoCache: ObservableObject { + static let shared = SteamLogoCache() + private let logos = NSCache() + /// Bumped (animated) when a logo lands, so heads reading it redraw. + @Published private var tick = 0 + + func logo(for key: String) -> NSImage? { + _ = tick + return logos.object(forKey: key as NSString) + } + + /// `key` names the game and the player's picks revision, so a new pick loads again. + func load(_ game: CatalogGame, key: String, picks: SteamPersonalizationStore) async { + guard logos.object(forKey: key as NSString) == nil else { return } + let request = SteamArtworkRequest(game: game, role: .logo) + var url = request.localURL(picks: picks) + if url == nil, let id = request.appID { url = await SteamMetadataService.shared.image(appID: id, slot: .logo) } + guard !Task.isCancelled, let url else { return } + // Decoded off the main thread like an nxnative logo: a lazily decoded file cost the frame that drew it. + let image = await GameArtLoader.decodeLogo(at: url) + guard !Task.isCancelled, let image else { return } + logos.setObject(image, forKey: key as NSString) + withAnimation(.easeOut(duration: 0.3)) { tick += 1 } + } +} diff --git a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift new file mode 100644 index 0000000..a17e642 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -0,0 +1,83 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// Same source-qualified personal picks as metadata; downloaded art never writes +/// into Steam or its app bundles. Task identity includes locale-independent URL. +struct SteamArtwork: View { + let game: CatalogGame + let slot: ArtworkSlot + var remote: URL? = nil + @State private var image: NSImage? + @State private var loadedKey = "" + /// The installed app's own icon, the cover/icon fallback: read once per bundle, not on every redraw. + @State private var appIcon: NSImage? + @Environment(\.seleneCanvas) private var canvas + @ObservedObject private var picks = SteamPersonalizations.shared.artwork + private var installedApp: CatalogNativeApplication? { + game.steamGame?.installations.first(where: { $0.availability == .available })?.primaryApplication + } + /// The installed app's path is part of it: an installation that moves brings its icon along. + private var key: String { + "\(game.id)|\(slot.rawValue)|\(remote?.absoluteString ?? "")|\(picks.revision)|\(installedApp?.url.path ?? "")" + } + private var role: LibraryArtworkRole { LibraryArtworkRole.allCases.first { $0.slot == slot } ?? .hero } + var body: some View { + ZStack { + if let image, loadedKey == key { + Image(nsImage: image).resizable().aspectRatio(contentMode: slot == .logo ? .fit : .fill) + } else if slot == .cover || slot == .icon { + if let appIcon { + Image(nsImage: appIcon).resizable().scaledToFit().padding(canvas.u(16)) + } else { Image(systemName: "gamecontroller.fill").resizable().scaledToFit().padding(canvas.u(30)) } + } else { Color.clear } + } + .clipped() + .task(id: key) { + let expected = key + if slot == .cover || slot == .icon { + appIcon = installedApp.map { SteamAppIconCache.icon(at: $0.url) } + } + guard let id = game.steamGame?.appID else { return } + let request = SteamArtworkRequest(game: game, role: role) + let url: URL? + if remote == nil, let local = request.localURL(picks: picks) { url = local } + // A square never falls back to store art: that would be a cropped portrait cover. + else if role == .square { url = nil } + else { url = await SteamMetadataService.shared.image(appID: id, slot: slot, remote: remote) } + guard !Task.isCancelled else { return } + // Decoded off the main thread at the size its nxnative twin uses: a lazily decoded file cost the + // first frame that drew it 40–60 ms. + let decoded: NSImage? = if let url { await GameArtLoader.decode(at: url, maxPixelSize: slot.decodedSize) } else { nil } + guard !Task.isCancelled else { return } + image = decoded; loadedKey = expected + } + + .accessibilityLabel(game.name) + } +} + +/// Installed apps' own icons by bundle path, shared by every tile that shows one. +@MainActor +private enum SteamAppIconCache { + private static let icons = NSCache() + static func icon(at url: URL) -> NSImage { + if let icon = icons.object(forKey: url.path as NSString) { return icon } + let icon = NSWorkspace.shared.icon(forFile: url.path) + icons.setObject(icon, forKey: url.path as NSString) + return icon + } +} + +private extension ArtworkSlot { + /// The nxnative sizes: tiles' icons 512 px (`SpotlightTileArtwork`), box covers 900 (`SwitchBoxArtCover`), + /// logos and heroes as `GameArtLoader`. + var decodedSize: Int { + switch self { + case .icon: return 512 + case .cover: return 900 + case .logo: return 1600 + case .background: return 2560 + } + } +} diff --git a/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift new file mode 100644 index 0000000..b3ced31 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift @@ -0,0 +1,99 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// Steam store facts for the games on screen, one copy shared by Spotlight and Collection, +/// so the head, the page and the input counts read the same answer. Facts are per AppID +/// and language; a reply for a game or language no longer asked for is dropped. +@MainActor +final class SteamGameFacts: ObservableObject { + static let shared = SteamGameFacts() + @Published private(set) var facts: [UInt32: SteamMetadata] = [:] + /// AppIDs whose answer (facts or none) is in for the current language. + @Published private(set) var known: Set = [] + private var language = "" + /// When an AppID last came back without facts: asked again after a minute, not on every draw. + private var missedAt: [UInt32: Date] = [:] + private static let retryAfter: TimeInterval = 60 + + func metadata(for game: CatalogGame) -> SteamMetadata? { + game.steamGame.flatMap { facts[$0.appID] } + } + + func isKnown(_ game: CatalogGame) -> Bool { + game.steamGame.map { known.contains($0.appID) } ?? true + } + + func load(_ game: CatalogGame, language app: AppLanguage) async { + guard let appID = game.steamGame?.appID else { return } + let language = SteamMetadata.language(app) + if language != self.language { self.language = language; facts = [:]; known = []; missedAt = [:] } + // An answer without facts (offline, store busy) is retried later; facts once found are kept. + let retry = facts[appID] == nil && missedAt[appID].map { Date().timeIntervalSince($0) > Self.retryAfter } == true + guard !known.contains(appID) || retry else { return } + let result = await SteamMetadataService.shared.metadata(appID: appID, language: language) + guard !Task.isCancelled, language == self.language else { return } + if let result { facts[appID] = result; missedAt[appID] = nil } else { missedAt[appID] = Date() } + known.insert(appID) + } +} + +extension BuildSourcesRow { + /// A Steam game's page actions, in grid order: only what NX Library can do for it today. + @MainActor + static func steamRows(for game: CatalogGame, presentation p: GameDetailPresentation, + viewModel: AppViewModel) -> [BuildSourcesRow] { + var rows: [BuildSourcesRow] = [] + if !p.appPath.isEmpty { + rows.append(BuildSourcesRow(id: "app", title: L10n.text("Reveal app in Finder")) { + viewModel.revealInFinder(url: URL(fileURLWithPath: p.appPath)) + }) + } + if let store = p.storeURL { + rows.append(BuildSourcesRow(id: "store", title: L10n.text("Store page")) { viewModel.openURL(store) }) + } + if let hltb = p.hltbURL { + rows.append(BuildSourcesRow(id: "hltb", title: L10n.text("HowLongToBeat")) { viewModel.openURL(hltb) }) + } + return rows + } +} + +/// The Steam counterpart of Build & Sources in the page's right column: where the game +/// is installed and the same action grid. No engine, build or sources lines. +struct SteamInstallationPanel: View { + @Environment(\.seleneCanvas) private var canvas + let presentation: GameDetailPresentation + let rows: [BuildSourcesRow] + let focusedRow: Int? + let onHoverRow: (Int) -> Void + let onPressRow: (Int) -> Void + var width: CGFloat = BuildSourcesPanel.narrowWidth + + private var installed: Bool { !presentation.appPath.isEmpty } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(L10n.text("Installation")) + .seleneFont(21, .semibold).foregroundColor(SeleneTheme.ink) + .padding(.top, canvas.u(2)).padding(.bottom, canvas.u(14)).padding(.leading, canvas.u(2)) + HStack(spacing: canvas.u(7)) { + Circle().fill(installed ? SeleneTheme.success : SeleneTheme.untested) + .frame(width: canvas.u(9), height: canvas.u(9)) + Text(installed ? "Steam · " + L10n.text("Mac game") : L10n.text("Installation not detected")) + .seleneFont(15, .medium) + .foregroundColor(installed ? SeleneTheme.success.opacity(0.9) : SeleneTheme.untested.opacity(0.9)) + } + .padding(.leading, canvas.u(2)) + if installed { + Text(URL(fileURLWithPath: presentation.appPath).lastPathComponent) + .seleneFont(15).foregroundColor(SeleneTheme.ink60) + .lineLimit(1).truncationMode(.middle) + .padding(.top, canvas.u(4)).padding(.leading, canvas.u(2)) + } + SeleneActionGrid(rows: rows, focusedRow: focusedRow, onHoverRow: onHoverRow, onPressRow: onPressRow) + .padding(.top, canvas.u(14)) + } + .frame(width: canvas.u(width), alignment: .topLeading) + } +} diff --git a/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift new file mode 100644 index 0000000..543a06e --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift @@ -0,0 +1,11 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers +import NXLibraryCore + +@MainActor +final class SteamPersonalizations { + static let shared = SteamPersonalizations() + let metadata = SteamPersonalizationStore(directory: AppData.supportRoot.appendingPathComponent("integrations/steam/personal")) + var artwork: SteamPersonalizationStore { metadata } +} diff --git a/Sources/NXLibrary/Views/Collection/CollectionCoverRow.swift b/Sources/NXLibrary/Views/Collection/CollectionCoverRow.swift index 6e593d0..115d5f2 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionCoverRow.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionCoverRow.swift @@ -10,7 +10,7 @@ struct CollectionCoverRow: View { @Environment(\.seleneCanvas) private var canvas @Environment(\.seleneReduceMotion) private var reduceMotion - let games: [PortedGame] + let games: [CatalogGame] let selectedID: UUID? let artwork: ArtworkStore let boxArt: BoxArtService @@ -21,8 +21,8 @@ struct CollectionCoverRow: View { let needsMetadata: (PortedGame) -> Void /// The porting look of a game's cover (`PortingTileLook`). var tileLook: (PortedGame) -> PortingTileLook = { _ in .normal } - let onSelect: (PortedGame) -> Void - let onActivate: (PortedGame) -> Void + let onSelect: (CatalogGame) -> Void + let onActivate: (CatalogGame) -> Void /// The first cover lines up with Spotlight's 80 margin (Selene's list had 14). private static let startInset: CGFloat = 80 @@ -59,19 +59,25 @@ struct CollectionCoverRow: View { tilesAcross: Int((canvas.size.width / pitch).rounded(.up))) } - private func cover(_ game: PortedGame, selected: Bool) -> some View { + private func cover(_ game: CatalogGame, selected: Bool) -> some View { let w = canvas.u(CollectionRow.coverWidth), h = canvas.u(CollectionRow.coverHeight) let face = RoundedRectangle(cornerRadius: canvas.u(12), style: .continuous) return ZStack { - SwitchBoxArtCover(game: game, width: w, height: h, officialName: officialName(game), + Group { + if let native = game.nxnativeGame { + SwitchBoxArtCover(game: native, width: w, height: h, officialName: officialName(native), boxArt: boxArt, artwork: artwork, indexVersion: boxArtIndexVersion) - .portingLook(tileLook(game), cornerRadius: canvas.u(12)) + .portingLook(tileLook(native), cornerRadius: canvas.u(12)) .frame(width: w, height: h) .clipShape(face) // One picture that grows and slides whole: a drawn cover's title, a lock or build progress would // otherwise snap to the pixel grid on their own at every step and shake against the cover. .drawingGroup() .shadow(color: .black.opacity(0.55), radius: canvas.u(9)) + } else { + CatalogNativeArtwork(game: game).frame(width: w, height: h).clipShape(face) + } + } SeleneShine(cornerRadius: canvas.u(12), active: selected) .frame(width: w, height: h) SeleneTileRing(cornerRadius: canvas.u(18), active: selected) @@ -86,6 +92,6 @@ struct CollectionCoverRow: View { .accessibilityElement(children: .ignore) .accessibilityLabel(game.name) .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) - .task(id: game.id) { needsMetadata(game) } + .task(id: game.id) { if let native = game.nxnativeGame { needsMetadata(native) } } } } diff --git a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift index cf14e99..011b785 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift @@ -6,18 +6,18 @@ import NXLibraryCore extension CollectionView { func handle(_ action: GamepadAction) { var moved = false - let all = viewModel.libraryGames + let all = viewModel.visibleCatalogGames switch action { case .navigateLeft, .navigateRight: let before = viewModel.librarySelection.selectedGameID - viewModel.librarySelection.move(by: action == .navigateLeft ? -1 : 1, + viewModel.librarySelection.moveCatalog(by: action == .navigateLeft ? -1 : 1, in: all, history: playSessions) moved = viewModel.librarySelection.selectedGameID != before case .navigateUp, .navigateDown: break case .jumpToStart, .jumpToEnd: let before = viewModel.librarySelection.selectedGameID - viewModel.librarySelection.jump(toEnd: action == .jumpToEnd, in: all, history: playSessions) + viewModel.librarySelection.jumpCatalog(toEnd: action == .jumpToEnd, in: all, history: playSessions) moved = viewModel.librarySelection.selectedGameID != before case .tabPrevious: setFilter(filter.shifted(by: -1)) @@ -26,15 +26,21 @@ extension CollectionView { case .rightStickPress: setFilter(filter.cycled()) case .confirm: - if let game = selectedGame { - play(game) + if let record = selectedCatalogGame { + activateCatalog(record) } else if !hasAnyGame, !viewModel.isScanningLibrary { SoundEffectService.shared.play(.click) router.openAddGame() } case .secondaryAction: - if let game = selectedGame { router.showDetail(game.id) } + // A Steam game's details are its Spotlight page; Collection has no Details screen for it. + if let game = selectedCatalogGame, game.integration != .steam { router.showDetail(game.id) } case .contextAction: + if let steam = selectedCatalogGame, steam.integration == .steam { + SoundEffectService.shared.play(.click) + viewModel.openSteamOptions(steam, from: .collection) + return + } if let game = selectedGame { viewModel.openGameOptions(game, from: .collection) } case .cancel: router.showLibrary() // B goes home, like Selene's library. @@ -50,6 +56,12 @@ extension CollectionView { if moved { SoundEffectService.shared.play(.focus) } } + func activateCatalog(_ record: CatalogGame) { + if let native = record.nxnativeGame { play(native) } + else if record.integration == .steam { SoundEffectService.shared.play(.click); SteamPlayCoordinator.shared.press(record) } + else { SoundEffectService.shared.play(.click); router.showDetail(record.id) } + } + /// A on a cover: plays, or opens Details when the game is not built (Spotlight's rule). func play(_ game: PortedGame) { SoundEffectService.shared.play(.click) @@ -73,7 +85,7 @@ extension CollectionView { !router.isIntroShowing, router.route == .collection, viewModel.alertMessage == nil, viewModel.settingsNotice == nil, - viewModel.gameForSettingsSheet == nil, + !viewModel.isGameOptionsOpen, viewModel.gameToClose == nil, viewModel.gameToRemove == nil, !viewModel.isUpdateOfferOpen, diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 303ab99..9aa4296 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -20,6 +20,8 @@ public struct CollectionView: View { @StateObject var art = SeleneHeroArt() /// Art the player picks redraws the hero and logo (their task keys carry its revision). @ObservedObject var picks = ArtworkPicks.shared + /// Steam Play phases drive the A hint. + @ObservedObject var steamPlayer = SteamPlayCoordinator.shared @State var keyMonitor: Any? public init(viewModel: AppViewModel, router: AppRouter, settings: AppSettingsStore, @@ -36,18 +38,19 @@ public struct CollectionView: View { /// The filter is shared with Spotlight. var filter: LibraryFilter { viewModel.librarySelection.query.filter } - var games: [PortedGame] { - viewModel.librarySelection.query.apply(to: viewModel.libraryGames, history: playSessions) + var games: [CatalogGame] { + viewModel.librarySelection.query.applyCatalog(to: viewModel.visibleCatalogGames, history: playSessions) } - var hasAnyGame: Bool { viewModel.libraryGames.contains { !$0.isHidden } } + var hasAnyGame: Bool { !viewModel.visibleCatalogGames.isEmpty } - func selectedGame(in games: [PortedGame]) -> PortedGame? { + func selectedGame(in games: [CatalogGame]) -> PortedGame? { guard let id = viewModel.librarySelection.selectedGameID else { return nil } - return games.first { $0.id == id } + return games.first { $0.id == id }?.nxnativeGame } var selectedGame: PortedGame? { selectedGame(in: games) } + var selectedCatalogGame: CatalogGame? { games.first { $0.id == viewModel.librarySelection.selectedGameID } } // MARK: - Body @@ -68,10 +71,11 @@ public struct CollectionView: View { stage: { stage(games: games, game: game) }) .onAppear(perform: appear) .onDisappear(perform: removeKeyMonitor) - .onChange(of: viewModel.libraryGames) { _ in + .onChange(of: viewModel.catalogGames) { _ in viewModel.preloadCachedStoreMetadata() reconcile() } + .onChange(of: viewModel.catalogSourceConfigurations) { _ in reconcile() } .onChange(of: filter) { _ in reconcile() } .onChange(of: viewModel.librarySelection.selectedGameID) { _ in selectionChanged() } .onReceive(NotificationCenter.default.publisher(for: .nxCollectionGamepadAction)) { note in @@ -87,10 +91,11 @@ public struct CollectionView: View { .onChange(of: playSessions.sessions.count) { _ in reconcile() } .onChange(of: viewModel.isScanningLibrary) { _ in reconcile() } .task(id: art.heroTaskKey(game, metadata: game.flatMap { viewModel.storeMetadata[$0.id] })) { await loadHero(game) } + .task(id: art.steamHeroTaskKey(selectedCatalogGame)) { await art.loadSteamHero(game: selectedCatalogGame, current: { selectedCatalogGame?.id }, artwork: artwork) } .task(id: art.logoTaskKey(game, metadata: game.flatMap { viewModel.storeMetadata[$0.id] })) { await loadLogo(game) } } - private func stage(games: [PortedGame], game: PortedGame?) -> some View { + private func stage(games: [CatalogGame], game: PortedGame?) -> some View { ZStack(alignment: .topLeading) { SpotlightTopBar(filter: filter, showClock: settings.settings.showClock, onSelect: { setFilter($0) }) @@ -105,6 +110,12 @@ public struct CollectionView: View { // Spotlight's left margin; the logo starts just under the top bar. .frame(width: canvas.u(1100), alignment: .topLeading) .offset(x: canvas.u(80), y: canvas.u(110)) + } else if let record = selectedCatalogGame, record.integration == .steam { + CatalogSteamInfo(game: record, totalPlaytime: playSessions.totalDuration(for: record.id), + lastPlayed: playSessions.lastPlayed(for: record.id), collection: true) + .id(record.id) + .frame(width: canvas.u(1100), alignment: .topLeading) + .offset(x: canvas.u(80), y: canvas.u(110)) } if games.isEmpty { @@ -121,7 +132,7 @@ public struct CollectionView: View { needsMetadata: { viewModel.loadStoreMetadata(for: $0) }, tileLook: viewModel.portingTileLook, onSelect: { select($0.id) }, - onActivate: { select($0.id); play($0) }) + onActivate: { record in select(record.id); activateCatalog(record) }) .offset(y: canvas.u(576)) } } @@ -160,6 +171,10 @@ public struct CollectionView: View { // MARK: - Hints private func contextHints(_ game: PortedGame?) -> [HintItem] { + if let steam = selectedCatalogGame, steam.integration == .steam { + return SteamPlayCoordinator.shared.hint(for: steam) + + [HintItem(.y, L10n.text("Game options")), HintItem(.rs, L10n.text("Filter"))] + } guard let game else { if viewModel.isScanningLibrary { return [] } return hasAnyGame ? [HintItem(.rs, L10n.text("Filter"))] : [HintItem(.a, L10n.text("Add game"))] @@ -189,7 +204,7 @@ public struct CollectionView: View { /// Keeps the selection inside the filtered list, in the same frame as any change to it. func reconcile() { var state = viewModel.librarySelection - state.reconcile(with: viewModel.libraryGames, history: playSessions, + state.reconcileCatalog(with: viewModel.visibleCatalogGames, history: playSessions, preferredGameID: settings.settings.restoreLastGame ? settings.settings.lastSelectedGameID : nil) if state != viewModel.librarySelection { viewModel.librarySelection = state } } @@ -206,6 +221,7 @@ public struct CollectionView: View { } private func selectionChanged() { + if let id = selectedCatalogGame?.id, settings.settings.lastSelectedGameID != id { settings.settings.lastSelectedGameID = id } guard let game = selectedGame else { return } if settings.settings.lastSelectedGameID != game.id { settings.settings.lastSelectedGameID = game.id } viewModel.loadStoreMetadata(for: game) @@ -214,13 +230,15 @@ public struct CollectionView: View { // MARK: - Art private func loadHero(_ game: PortedGame?) async { + // On a Steam game the nxnative art stays until the Steam hero replaces it, so the change is one crossfade. await art.loadHero(game: game, current: { selectedGame?.id }, - metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, artwork: artwork) + metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, artwork: artwork, personalizations: viewModel.personalizations, + keepsWithoutGame: selectedCatalogGame?.integration == .steam) } private func loadLogo(_ game: PortedGame?) async { await art.loadLogo(game: game, metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, - artwork: artwork) + artwork: artwork, personalizations: viewModel.personalizations) } } @@ -267,36 +285,20 @@ struct CollectionInfoView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .opacity(shown ? 1 : 0) - .offset(x: slid ? 0 : canvas.u(-24)) + .offset(x: slid ? 0 : canvas.u(Self.slideDistance)) .onAppear { reveal() } } private var logoArea: some View { - ZStack(alignment: .bottomLeading) { - if let logo { - Image(nsImage: logo) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .frame(maxWidth: canvas.u(700), maxHeight: canvas.u(240), alignment: .bottomLeading) - .shadow(color: .black.opacity(0.35), radius: canvas.u(8)) - } else { - Text(game.name) - .seleneFont(56) - .foregroundColor(SeleneTheme.ink) - .multilineTextAlignment(.leading) - .lineLimit(3) - .frame(maxHeight: canvas.u(220)) - .shadow(color: .black.opacity(0.45), radius: canvas.u(6)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: canvas.u(250), alignment: .bottomLeading) - .accessibilityElement(children: .ignore) - .accessibilityLabel(game.name) - .accessibilityAddTraits(.isHeader) + GameLogoArea(title: game.name, logo: logo, collection: true) } + /// The whole column's fade and slide after a change. A Steam game's column uses the same. + /// Quicker than Spotlight's head (a grid is browsed fast) but with its curve, easing out like `logoFade`. + static let fade = Animation.easeOut(duration: 0.3).delay(0.12) + static let slide = SeleneTheme.cubicOut(0.3).delay(0.12) + static let slideDistance: CGFloat = -24 + /// The fade always runs; the slide is movement and drops under reduce motion. private func reveal() { var reset = Transaction() @@ -306,9 +308,9 @@ struct CollectionInfoView: View { slid = reduceMotion } DispatchQueue.main.async { - withAnimation(.linear(duration: 0.3).delay(0.12)) { shown = true } + withAnimation(Self.fade) { shown = true } if !reduceMotion { - withAnimation(SeleneTheme.cubicOut(0.3).delay(0.12)) { slid = true } + withAnimation(Self.slide) { slid = true } } } } diff --git a/Sources/NXLibrary/Views/Components/GameLogoArea.swift b/Sources/NXLibrary/Views/Components/GameLogoArea.swift new file mode 100644 index 0000000..c8ec646 --- /dev/null +++ b/Sources/NXLibrary/Views/Components/GameLogoArea.swift @@ -0,0 +1,47 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// Shared geometry preserves the established Spotlight and Collection layouts. +/// Provider loading and settings stay outside this purely visual component. +struct GameLogoArea: View { + let title: String + let logo: NSImage? + var collection = false + @Environment(\.seleneCanvas) private var canvas + var body: some View { + Group { + if collection { collectionArea } + else { spotlightArea } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title).accessibilityAddTraits(.isHeader) + } + private var spotlightArea: some View { + ZStack(alignment: .bottomLeading) { + if let logo { + Image(nsImage: logo).resizable().interpolation(.high).aspectRatio(contentMode: .fit) + .frame(width: canvas.u(700), height: canvas.u(240), alignment: .bottomLeading) + .shadow(color: .black.opacity(0.35), radius: canvas.u(8)) + .transition(.opacity) + } else { + Text(title).seleneFont(56).foregroundColor(SeleneTheme.ink).lineLimit(1).truncationMode(.tail) + .shadow(color: .black.opacity(0.45), radius: canvas.u(6)) + } + }.frame(width: canvas.u(900), height: canvas.u(195), alignment: .bottomLeading) + } + private var collectionArea: some View { + ZStack(alignment: .bottomLeading) { + if let logo { + Image(nsImage: logo).resizable().interpolation(.high).aspectRatio(contentMode: .fit) + .frame(maxWidth: canvas.u(700), maxHeight: canvas.u(240), alignment: .bottomLeading) + .shadow(color: .black.opacity(0.35), radius: canvas.u(8)) + } else { + Text(title).seleneFont(56).foregroundColor(SeleneTheme.ink).multilineTextAlignment(.leading) + .lineLimit(3).frame(maxHeight: canvas.u(220)) + .shadow(color: .black.opacity(0.45), radius: canvas.u(6)) + } + }.frame(maxWidth: .infinity, alignment: .leading) + .frame(height: canvas.u(250), alignment: .bottomLeading) + } +} diff --git a/Sources/NXLibrary/Views/Components/GamePrimaryActions.swift b/Sources/NXLibrary/Views/Components/GamePrimaryActions.swift new file mode 100644 index 0000000..26bb4c2 --- /dev/null +++ b/Sources/NXLibrary/Views/Components/GamePrimaryActions.swift @@ -0,0 +1,28 @@ +import SwiftUI +import NXLibraryCore + +/// Play/Options/favorite visual and input targets are identical across sources. +struct GamePrimaryActions: View { + let primaryTitle: String + var secondaryTitle = "Options" + let isFavorite: Bool + let focused: Int? + var primaryEnabled = true + let onAction: (Int) -> Void + var onHover: (Int) -> Void = { _ in } + @Environment(\.seleneCanvas) private var canvas + var body: some View { + HStack(spacing: canvas.u(14)) { + pill(0, SelenePillButton(title: primaryTitle, height: 52, focused: focused == 0, + enabled: primaryEnabled) { onAction(0) }) + pill(1, SelenePillButton(title: secondaryTitle, height: 52, focused: focused == 1) { onAction(1) }) + pill(2, SelenePillButton(title: nil, icon: isFavorite ? .favorite : .favoriteBorder, + iconSize: 20, minWidth: 52, height: 52, horizontalPadding: 0, + focused: focused == 2) { onAction(2) }) + .accessibilityLabel(isFavorite ? L10n.text("Remove from favorites") : L10n.text("Add to favorites")) + } + } + private func pill(_ index: Int, _ view: V) -> some View { + view.frame(height: canvas.u(60)).onHover { if $0 { onHover(index) } } + } +} diff --git a/Sources/NXLibrary/Views/Components/SeleneHomeChrome.swift b/Sources/NXLibrary/Views/Components/SeleneHomeChrome.swift index ab6d872..835d82a 100644 --- a/Sources/NXLibrary/Views/Components/SeleneHomeChrome.swift +++ b/Sources/NXLibrary/Views/Components/SeleneHomeChrome.swift @@ -51,7 +51,7 @@ extension AppRouter { /// so the screen's step aside instead of showing through. @MainActor func overlayHasHints(_ viewModel: AppViewModel) -> Bool { - isMenuPresented || isSearchPresented || viewModel.gameForSettingsSheet != nil || route.isOverlay + isMenuPresented || isSearchPresented || viewModel.isGameOptionsOpen || route.isOverlay } } diff --git a/Sources/NXLibrary/Views/Components/SelenePillButton.swift b/Sources/NXLibrary/Views/Components/SelenePillButton.swift index bcde129..a812db9 100644 --- a/Sources/NXLibrary/Views/Components/SelenePillButton.swift +++ b/Sources/NXLibrary/Views/Components/SelenePillButton.swift @@ -5,6 +5,7 @@ import NXLibraryCore struct SelenePillButton: View { @Environment(\.seleneCanvas) private var canvas @Environment(\.seleneReduceMotion) private var reduceMotion + @Environment(\.nxAppLocale) private var localization let title: String? var icon: MaterialIcon? = nil @@ -28,7 +29,7 @@ struct SelenePillButton: View { MaterialIconView(icon, size: iconSize) } if let title { - Text(L10n.text(title)).seleneFont(21) + Text(L10n.text(title, context: localization)).seleneFont(21) } if waveDots { WaveDots() diff --git a/Sources/NXLibrary/Views/Components/SeleneStatsRow.swift b/Sources/NXLibrary/Views/Components/SeleneStatsRow.swift index 94a94d2..fa020df 100644 --- a/Sources/NXLibrary/Views/Components/SeleneStatsRow.swift +++ b/Sources/NXLibrary/Views/Components/SeleneStatsRow.swift @@ -73,7 +73,10 @@ struct SeleneStatsRow: View { value(text) case .platform(let console): HStack(spacing: canvas.u(9)) { - SwitchPlatformIcon() + Group { + if console == "Mac" { Image(systemName: "laptopcomputer").resizable().scaledToFit() } + else { SwitchPlatformIcon() } + } .foregroundColor(SeleneTheme.ink) .frame(width: canvas.u(27), height: canvas.u(27)) value(console) diff --git a/Sources/NXLibrary/Views/Components/SessionCard.swift b/Sources/NXLibrary/Views/Components/SessionCard.swift index 6e2a1ce..869c9cf 100644 --- a/Sources/NXLibrary/Views/Components/SessionCard.swift +++ b/Sources/NXLibrary/Views/Components/SessionCard.swift @@ -51,6 +51,7 @@ final class SessionCardPresenter: ObservableObject { /// 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(\.catalogPersonalizations) private var personalizations @Environment(\.seleneCanvas) private var canvas let summary: SessionSummary let game: PortedGame? @@ -143,11 +144,11 @@ struct SessionCardView: View { 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) + let loaded = await GameArtLoader.shared.hero(for: game, metadata: metadata, artwork: artwork, personalizations: personalizations) withAnimation(.easeOut(duration: 0.3)) { hero = loaded } } if logo == nil { - let loaded = await GameArtLoader.shared.logo(for: game, metadata: metadata, artwork: artwork) + let loaded = await GameArtLoader.shared.logo(for: game, metadata: metadata, artwork: artwork, personalizations: personalizations) withAnimation(.easeOut(duration: 0.3)) { logo = loaded } } } diff --git a/Sources/NXLibrary/Views/Components/SpotlightTileArtwork.swift b/Sources/NXLibrary/Views/Components/SpotlightTileArtwork.swift index 4697cb4..7d8093e 100644 --- a/Sources/NXLibrary/Views/Components/SpotlightTileArtwork.swift +++ b/Sources/NXLibrary/Views/Components/SpotlightTileArtwork.swift @@ -9,6 +9,7 @@ import NXLibraryCore /// generic icon. Crossfades when fetched art arrives. /// The caller applies the corner radius. struct SpotlightTileArtwork: View { + @Environment(\.catalogPersonalizations) private var personalizations let game: PortedGame /// Side length in points, already scaled by the caller. let side: CGFloat @@ -48,7 +49,7 @@ struct SpotlightTileArtwork: View { .frame(width: side, height: side) .clipped() .task(id: "\(game.id)|\(picks.revision)") { - if let url = picks.url(titleID: game.titleId ?? game.uniqueKey, slot: .icon) { + if let url = picks.url(titleID: personalizations.nxnativeKey(for: game), slot: .icon) { picked = await GameArtLoader.decode(at: url, maxPixelSize: 512) return } diff --git a/Sources/NXLibrary/Views/Components/SwitchBoxArtCover.swift b/Sources/NXLibrary/Views/Components/SwitchBoxArtCover.swift index 3040f9e..d5833f0 100644 --- a/Sources/NXLibrary/Views/Components/SwitchBoxArtCover.swift +++ b/Sources/NXLibrary/Views/Components/SwitchBoxArtCover.swift @@ -8,6 +8,7 @@ import NXLibraryCore /// is found, or when GameTDB has none, a composed cover: the blurred Title DB /// banner, the game's icon and its name. The caller applies the corner radius. struct SwitchBoxArtCover: View { + @Environment(\.catalogPersonalizations) private var personalizations let game: PortedGame let width: CGFloat let height: CGFloat @@ -81,7 +82,7 @@ struct SwitchBoxArtCover: View { [game.name] + (officialName.map { [$0] } ?? []) } - private var titleID: String { game.titleId ?? game.uniqueKey } + private var titleID: String { personalizations.nxnativeKey(for: game) } private var coverKey: String { "\(game.id)|\(officialName ?? "")|\(indexVersion)|\(picks.revision)" } diff --git a/Sources/NXLibrary/Views/Detail/BuildSourcesPanel.swift b/Sources/NXLibrary/Views/Detail/BuildSourcesPanel.swift index 7f0d9ac..786a871 100644 --- a/Sources/NXLibrary/Views/Detail/BuildSourcesPanel.swift +++ b/Sources/NXLibrary/Views/Detail/BuildSourcesPanel.swift @@ -144,9 +144,24 @@ struct BuildSourcesPanel: View { } } - /// Small tool buttons, three to a row (`SpotlightState.actionColumns`): squarer - /// and quieter than Play and Options above, so they read as secondary. private var grid: some View { + SeleneActionGrid(rows: rows, focusedRow: focusedRow, onHoverRow: onHoverRow, onPressRow: onPressRow) + } +} + +/// Small tool buttons, three to a row (`SpotlightState.actionColumns`): squarer +/// and quieter than Play and Options above, so they read as secondary. Shared by +/// every source's page, so Left/Right and Up/Down move the same way (`DetailsFocus`). +struct SeleneActionGrid: View { + @Environment(\.seleneCanvas) private var canvas + let rows: [BuildSourcesRow] + let focusedRow: Int? + let onHoverRow: (Int) -> Void + let onPressRow: (Int) -> Void + static let columns = SpotlightState.actionColumns + static let cellWidth: CGFloat = 168 + + var body: some View { VStack(alignment: .leading, spacing: canvas.u(8)) { ForEach(Array(stride(from: 0, to: rows.count, by: Self.columns)), id: \.self) { first in HStack(spacing: canvas.u(8)) { diff --git a/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift b/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift index 042b5d9..4c56812 100644 --- a/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift +++ b/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift @@ -209,7 +209,7 @@ extension GameDetailView { func openViewer(at index: Int) { let urls = currentModel.screenshots guard !urls.isEmpty else { return } - viewer = ViewerContext(titleID: game.titleId ?? game.uniqueKey, urls: urls, + viewer = ViewerContext(titleID: viewModel.personalizationKey(for: game), urls: urls, state: ScreenshotViewerState(count: urls.count, start: index)) SoundEffectService.shared.play(.click) } @@ -246,7 +246,7 @@ extension GameDetailView { !NativeMenuCommands.shared.isTracking, case .detail(let id) = router.route, id == game.id, viewModel.alertMessage == nil, viewModel.settingsNotice == nil, - viewModel.gameForSettingsSheet == nil, + !viewModel.isGameOptionsOpen, viewModel.gameToClose == nil, viewModel.gameToRemove == nil, !viewModel.isUpdateOfferOpen, diff --git a/Sources/NXLibrary/Views/Detail/GameDetailView.swift b/Sources/NXLibrary/Views/Detail/GameDetailView.swift index d5d1a2b..e0afe15 100644 --- a/Sources/NXLibrary/Views/Detail/GameDetailView.swift +++ b/Sources/NXLibrary/Views/Detail/GameDetailView.swift @@ -158,7 +158,7 @@ struct GameDetailView: View { .onChange(of: viewer != nil) { router.isScreenshotViewerOpen = $0 } .onChange(of: model.bands) { _ in settleFocus(model.bands) } // Game options (its Mods panel) closing, or NX Library coming back from Finder, re-reads the mods. - .onChange(of: viewModel.gameForSettingsSheet == nil) { closed in if closed { reloadMods() } } + .onChange(of: !viewModel.isGameOptionsOpen) { closed in if closed { reloadMods() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in reloadMods() } // The key monitor captures this view's value: reinstall it when the game changes. .onChange(of: game) { _ in @@ -172,10 +172,10 @@ struct GameDetailView: View { if let action = note.object as? GamepadAction { handle(action) } } .task(id: art.heroTaskKey(game, metadata: metadata)) { - await art.loadHero(game: game, current: { game.id }, metadata: metadata, artwork: artwork) + await art.loadHero(game: game, current: { game.id }, metadata: metadata, artwork: artwork, personalizations: viewModel.personalizations) } .task(id: art.logoTaskKey(game, metadata: metadata)) { - await art.loadLogo(game: game, metadata: metadata, artwork: artwork) + await art.loadLogo(game: game, metadata: metadata, artwork: artwork, personalizations: viewModel.personalizations) } } @@ -239,7 +239,7 @@ struct GameDetailView: View { band(zone: DetailsFocus.screenshotsZone) { DetailsScreenshotsBand( - titleID: game.titleId ?? game.uniqueKey, + titleID: viewModel.personalizationKey(for: game), urls: model.screenshots, source: metadata?.screenshotSource, known: model.screenshotsKnown, diff --git a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift index d37e4af..2e066a2 100644 --- a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift +++ b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift @@ -10,6 +10,8 @@ import NXLibraryCore /// Title DB, the Nintendo store and the online sources (`OnlineArtwork`: stores, databases, /// wikis, Wallhaven) offer, with its size in pixels, and From file…. /// A choice is kept in `ArtworkPicks` and never overwritten by automatic art; Automatic takes it back. +/// A Steam game (`PersonalizationSubject`) gets Steam's own art for its AppID instead of the Switch +/// sources, keeps its choices in the Steam personal store and has no Mac app icon step. /// /// Browsing a source replaces the grid with that source's images for the open tab, as the Theme screen browses /// wallpaper sites: a search prefilled with the game's name, each image credited with its kind or author and its @@ -20,10 +22,11 @@ import NXLibraryCore /// A click chooses. struct ChangeArtworkView: View { @ObservedObject var viewModel: AppViewModel - let game: PortedGame + let subject: PersonalizationSubject let onClose: () -> Void @ObservedObject private var picks = ArtworkPicks.shared + @ObservedObject private var steamPicks = SteamPersonalizations.shared.artwork @State private var slot: ArtworkSlot = .cover /// The focused tile; while browsing, -1 is the search field. @State private var focus = 0 @@ -74,12 +77,14 @@ struct ChangeArtworkView: View { case automatic, yourPick, iconLocation, art(Found), browse(ArtworkBrowseSource), file } - private var titleKey: String { game.titleId ?? game.uniqueKey } - private var hasPick: Bool { picks.url(titleID: titleKey, slot: slot) != nil } - private var switch2: Bool { game.titleId?.hasPrefix("04") == true } + private var hasPick: Bool { subject.artworkURL(slot) != nil } + private var pickRevision: Int { picks.revision &+ steamPicks.revision } + private var switch2: Bool { subject.titleId?.hasPrefix("04") == true } + /// The installed nxnative game: only it has a Mac app icon to change. + private var macApp: PortedGame? { subject.ported } private var gameNames: [String] { - let metadata = viewModel.storeMetadata[game.id] - return [game.name, metadata?.name, titleDB?.name].compactMap { $0 } + let metadata = viewModel.storeMetadata[subject.id] + return [subject.name, metadata?.name, titleDB?.name].compactMap { $0 } .reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } } } @@ -87,7 +92,7 @@ struct ChangeArtworkView: View { if browsing != nil { return (browseItems[slot] ?? []).map(Tile.art) } // The sources to browse come right after Automatic (and Your pick), so a search is one move away and never // waits for the found images to load; From file… ends the grid. - return [.automatic] + (hasPick ? [.yourPick] : []) + (slot == .icon ? [.iconLocation] : []) + ArtworkBrowseSource.allCases.map(Tile.browse) + return [.automatic] + (hasPick ? [.yourPick] : []) + (slot == .icon && macApp != nil ? [.iconLocation] : []) + ArtworkBrowseSource.allCases.map(Tile.browse) + (found[slot] ?? []).map(Tile.art) + [.file] } @@ -123,7 +128,7 @@ struct ChangeArtworkView: View { .seleneFont(15, .semibold) .foregroundColor(SeleneTheme.ink60) .padding(.leading, canvas.u(8)) - Text(game.name) + Text(subject.name) .seleneFont(40) .foregroundColor(SeleneTheme.ink) .lineLimit(1) @@ -167,8 +172,8 @@ struct ChangeArtworkView: View { } } } - .task(id: "\(slot.rawValue)|\(picks.revision)") { await loadPickPreview() } - .task(id: "icon|\(slot.rawValue)|\(picks.revision)") { await loadAutomaticIcon() } + .task(id: "\(slot.rawValue)|\(pickRevision)") { await loadPickPreview() } + .task(id: "icon|\(slot.rawValue)|\(pickRevision)") { await loadAutomaticIcon() } .overlay { if askAppIcon { ZStack { @@ -188,7 +193,7 @@ struct ChangeArtworkView: View { } private var appIconMessage: String { - let running = RunningGameMonitor.runningApplication(for: game) != nil + let running = macApp.map { RunningGameMonitor.runningApplication(for: $0) != nil } ?? false return L10n.text("Only here changes the image inside NX Library. Use on the Mac also shows it in Finder and the Dock. Automatic uses the game's own icon, then an online image if available. Changing the image keeps this location choice.") + (running ? L10n.text(" It changes when the game quits.") : L10n.text(" A large game takes about a minute.")) } @@ -196,14 +201,14 @@ struct ChangeArtworkView: View { private func answerAppIcon(_ use: Bool) { SoundEffectService.shared.play(.click) askAppIcon = false - viewModel.setUsesPickedAppIcon(use, for: game) + if let macApp { viewModel.setUsesPickedAppIcon(use, for: macApp) } } /// Ask for every game, including NUPs, dumps and games not built yet. private func iconPicked() { - guard slot == .icon else { return } + guard slot == .icon, let macApp else { return } editingIconLocation = false - appIconFocus = viewModel.usesIconOnMac(for: game) ? .confirm : .cancel + appIconFocus = viewModel.usesIconOnMac(for: macApp) ? .confirm : .cancel askAppIcon = true } @@ -275,8 +280,8 @@ struct ChangeArtworkView: View { /// On the Icon tab, how the game app's own icon change stands: signing a large app takes about a minute. private var appIconStatus: String? { - guard slot == .icon else { return nil } - let progress = viewModel.appIconProgress[game.id] + guard slot == .icon, macApp != nil else { return nil } + let progress = viewModel.appIconProgress[subject.id] if let progress { switch progress { case .searching, .updating, .failed: return progress.message @@ -294,7 +299,7 @@ struct ChangeArtworkView: View { if downloading { return L10n.format("Downloading the full image from %@…", [String(describing: (source.title))]) } guard let result = browseResult else { return L10n.format("Searching %@ for “%@”…", [String(describing: (source.title)), String(describing: (browseQuery))]) } guard result.page != nil else { - return L10n.format("%@ has no %@ called “%@”", [String(describing: (source.title)), String(describing: (source == .launchBox ? L10n.text("Switch game") : "game")), String(describing: (browseQuery))]) + return L10n.format("%@ has no %@ called “%@”", [String(describing: (source.title)), String(describing: (source == .launchBox && subject.steam == nil ? L10n.text("Switch game") : "game")), String(describing: (browseQuery))]) } if browseLoading.contains(slot) && (browseItems[slot] ?? []).isEmpty { return L10n.text("Loading…") } if (browseItems[slot] ?? []).isEmpty { @@ -418,11 +423,16 @@ struct ChangeArtworkView: View { Image(nsImage: image) .resizable() .interpolation(.high) - .aspectRatio(contentMode: slot == .logo ? .fit : .fill) + .aspectRatio(contentMode: fits(image) ? .fit : .fill) .padding(slot == .logo ? canvas.u(18) : 0) } - private func glyphFace(_ icon: MaterialIcon) -> some View { + /// Logos, and banners much wider than the tile (Steam's library hero), show whole instead of cropped. + private func fits(_ image: NSImage) -> Bool { + slot == .logo || (slot == .background && image.size.width > image.size.height * 2.2) + } + + private func glyphFace(_ icon: MaterialIcon) -> some View { MaterialIconView(icon, size: 44) .foregroundColor(SeleneTheme.ink60) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -432,6 +442,7 @@ struct ChangeArtworkView: View { switch tile { case .automatic: switch slot { + case _ where subject.steam != nil: return (L10n.text("Automatic"), steamAutomatic) case .cover: return (L10n.text("Automatic"), L10n.text("GameTDB, or a composed cover")) case .background: return (L10n.text("Automatic"), L10n.text("The game's own art, then Title DB")) case .icon: return (L10n.text("Automatic"), L10n.text("The game's own icon, then Title DB")) @@ -440,7 +451,7 @@ struct ChangeArtworkView: View { case .yourPick: return (L10n.text("Your pick"), L10n.text("Saved on this Mac")) case .iconLocation: - return (L10n.text("Icon location"), viewModel.usesIconOnMac(for: game) ? L10n.text("Use on the Mac") : "Only here") + return (L10n.text("Icon location"), macApp.map(viewModel.usesIconOnMac) == true ? L10n.text("Use on the Mac") : "Only here") case .art(let item): return (item.art.candidate.source, item.art.resolution) case .browse(let source): @@ -451,6 +462,16 @@ struct ChangeArtworkView: View { } } + /// What Automatic shows for a Steam game in this tab. + private var steamAutomatic: String { + switch slot { + case .cover: return L10n.text("Steam’s library cover") + case .background: return L10n.text("Screen-shaped art, then Steam") + case .icon: return L10n.text("The Mac app’s own icon") + case .logo: return L10n.text("Steam’s logo") + } + } + private func isCurrent(_ tile: Tile) -> Bool { switch tile { case .automatic: return !hasPick @@ -466,27 +487,34 @@ struct ChangeArtworkView: View { guard found[slot] == nil, !searching.contains(slot) else { return } searching.insert(slot) defer { searching.remove(slot) } - if !titleDBLoaded, let titleId = game.titleId { + if !titleDBLoaded, let titleId = subject.titleId { titleDB = await TitleDBInfo.fetch(titleID: titleId) titleDBLoaded = true } - let metadata = viewModel.storeMetadata[game.id] + let metadata = viewModel.storeMetadata[subject.id] let names = gameNames - let entries = slot == .cover ? await viewModel.boxArt.pickerEntries(names: names) : [] - if online == nil { online = await OnlineArtwork.find(names: names) } - let candidates = ArtworkCandidates.list(for: slot, entries: entries, titleDB: titleDB, + var candidates: [ArtworkCandidate] + found[slot] = [] + if let steam = subject.steam, let appID = subject.steamAppID { + // Steam's own art for this AppID shows at once; the online sources take seconds and follow it. + let screenshots = SteamGameFacts.shared.metadata(for: steam)?.screenshots ?? [] + let own = ArtworkCandidates.steamList(for: slot, appID: appID, screenshots: screenshots) + await collect(own, slot: slot, from: 0) + guard !Task.isCancelled else { found[slot] = nil; return } + if online == nil { online = await OnlineArtwork.find(names: names) } + candidates = ArtworkCandidates.steamList(for: slot, appID: appID, screenshots: screenshots, online: online ?? []) + await collect(Array(candidates.dropFirst(own.count)), slot: slot, from: own.count) + } else { + let entries = slot == .cover ? await viewModel.boxArt.pickerEntries(names: names) : [] + if online == nil { online = await OnlineArtwork.find(names: names) } + candidates = ArtworkCandidates.list(for: slot, entries: entries, titleDB: titleDB, storeHeader: metadata?.headerImageURL, storeImage: metadata?.imageURL, storeLogo: metadata?.logoURL, online: online ?? []) - found[slot] = [] - for await item in Self.previews(of: candidates) { - var list = found[slot] ?? [] - list.append(item) - list.sort { $0.order < $1.order } - found[slot] = list + await collect(candidates, slot: slot, from: 0) } if slot == .icon, !Task.isCancelled, - let titleID = game.titleId, - let url = await AppViewModel.automaticIconArtwork.automaticIcon(titleID: titleID, name: game.name), + let titleID = subject.titleId, + let url = await AppViewModel.automaticIconArtwork.automaticIcon(titleID: titleID, name: subject.name), AppViewModel.automaticIconArtwork.automaticIconSource(titleID: titleID) == "Switch Icon Showdown", let data = try? Data(contentsOf: url), let size = ArtworkCandidates.pixelSize(of: data), let preview = await GameArtLoader.decode(at: url, maxPixelSize: 600), !Task.isCancelled { @@ -498,6 +526,16 @@ struct ChangeArtworkView: View { if Task.isCancelled { found[slot] = nil } } + /// Adds the candidates' previews to the slot as they arrive, in list order after `offset`. + private func collect(_ candidates: [ArtworkCandidate], slot: ArtworkSlot, from offset: Int) async { + for await item in Self.previews(of: candidates) { + var list = found[slot] ?? [] + list.append(Found(order: item.order + offset, art: item.art, preview: item.preview)) + list.sort { $0.order < $1.order } + found[slot] = list + } + } + /// Downloads each candidate's preview at once and yields those that are images, as they arrive. private static func previews(of candidates: [ArtworkCandidate]) -> AsyncStream { AsyncStream { continuation in @@ -531,7 +569,7 @@ struct ChangeArtworkView: View { let round = browseRound if browseResult == nil { let key = source.needsKey ? SteamGridDBKey().load() : nil - let result = await source.search(browseQuery, gameNames: gameNames, switch2: switch2, key: key) + let result = await source.search(browseQuery, gameNames: gameNames, switch2: switch2, computer: subject.steam != nil, key: key) guard round == browseRound, browsing == source, !Task.isCancelled else { return } browseResult = result } @@ -551,7 +589,7 @@ struct ChangeArtworkView: View { } private func loadPickPreview() async { - guard let url = picks.url(titleID: titleKey, slot: slot) else { + guard let url = subject.artworkURL(slot) else { pickPreview = nil return } @@ -561,7 +599,10 @@ struct ChangeArtworkView: View { private func loadAutomaticIcon() async { guard slot == .icon else { return } automaticIconChecked = false - let url = await viewModel.automaticIconURL(for: game) + let url: URL? + if let macApp { url = await viewModel.automaticIconURL(for: macApp) } + else if let steam = subject.steam { url = SteamArtworkRequest(game: steam, role: .square).automaticLocalURL } + else { url = nil } let preview: NSImage? if let url { preview = await GameArtLoader.decode(at: url, maxPixelSize: 600) } else { preview = nil } @@ -588,12 +629,19 @@ struct ChangeArtworkView: View { switch tile { case .automatic: guard hasPick || slot == .icon else { return } - if hasPick { picks.remove(titleID: titleKey, slot: slot) } + if hasPick { + do { try subject.removeArtwork(slot) } catch { + message = error.localizedDescription + SoundEffectService.shared.play(.error) + return + } + } applied() - if slot == .icon { viewModel.appIconPickChanged(for: game, removed: true) } + if slot == .icon, let macApp { viewModel.appIconPickChanged(for: macApp, removed: true) } case .iconLocation: + guard let macApp else { return } editingIconLocation = true - appIconFocus = viewModel.usesIconOnMac(for: game) ? .confirm : .cancel + appIconFocus = viewModel.usesIconOnMac(for: macApp) ? .confirm : .cancel askAppIcon = true SoundEffectService.shared.play(.click) case .yourPick: @@ -636,7 +684,7 @@ struct ChangeArtworkView: View { private func save(_ data: Data) { do { - try picks.save(data, titleID: titleKey, slot: slot) + try subject.saveArtwork(data, slot: slot) applied() // The check moves to Your pick, second in the grid. focus = 1 @@ -649,7 +697,7 @@ struct ChangeArtworkView: View { /// Screens reload their art from the new revision; decoded copies of the old art go. private func applied() { - GameArtLoader.shared.forget(game) + if let macApp { GameArtLoader.shared.forget(macApp) } SoundEffectService.shared.play(.click) } @@ -658,7 +706,7 @@ struct ChangeArtworkView: View { panel.allowedContentTypes = [.png, .jpeg, .heic, .webP, .image] panel.allowsMultipleSelection = false panel.canChooseDirectories = false - panel.message = L10n.format("Choose a %@ for %@.", [String(describing: (slot.title.lowercased())), String(describing: (game.name))]) + panel.message = L10n.format("Choose a %@ for %@.", [String(describing: (slot.title.lowercased())), String(describing: (subject.name))]) guard panel.runModal() == .OK, let url = panel.url else { return } guard let data = try? Data(contentsOf: url) else { message = L10n.text("That file could not be read.") @@ -672,17 +720,17 @@ struct ChangeArtworkView: View { private func openBrowse(_ source: ArtworkBrowseSource) { browsing = source - browseDraft = game.name - browseQuery = game.name + browseDraft = subject.name + browseQuery = subject.name startBrowseSearch() } private func submitSearch() { let text = browseDraft.trimmingCharacters(in: .whitespacesAndNewlines) - if text.isEmpty { browseDraft = game.name } + if text.isEmpty { browseDraft = subject.name } fieldFocused = false SoundEffectService.shared.play(.click) - let query = text.isEmpty ? game.name : text + let query = text.isEmpty ? subject.name : text guard query != browseQuery || browseResult?.page == nil else { focus = tiles.isEmpty ? -1 : 0 return @@ -790,7 +838,7 @@ struct ChangeArtworkView: View { case .cancel: askAppIcon = false // Back dismisses the location question. A newly chosen image still follows the saved scope. - if !editingIconLocation { viewModel.appIconPickChanged(for: game, removed: false) } + if !editingIconLocation, let macApp { viewModel.appIconPickChanged(for: macApp, removed: false) } SoundEffectService.shared.play(.focus) default: break } @@ -815,7 +863,7 @@ struct ChangeArtworkView: View { private func installKeyMonitor() { guard keyMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard viewModel.gameForSettingsSheet?.id == game.id, !viewModel.isEditingSteamGridDBKey, + guard viewModel.optionsGameID == subject.id, !viewModel.isEditingSteamGridDBKey, let window = event.window, !window.isSheet, window.attachedSheet == nil, NSApp.modalWindow == nil, !(window.firstResponder is NSText) else { return event } guard event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return event } diff --git a/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift b/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift index 1f2f5fb..871965f 100644 --- a/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift +++ b/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift @@ -6,20 +6,25 @@ import NXLibraryCore /// genres, developer, publisher, release date), Automatic (the store's value, as /// NX Library reads it), what Title DB and GameTDB offer instead, or the player's /// own text. A choice is kept in `MetadataPicks` and laid over the store's data. +/// A Steam game's Automatic is the Steam store's text; it has no Switch databases to offer, +/// and its choices go to the Steam personal store. /// /// Input: Up/Down move, L/R (Q/E, or Left/Right) change the field, A chooses, /// B (or Esc) returns to Game options. A click chooses. struct ChangeMetadataView: View { @ObservedObject var viewModel: AppViewModel - let game: PortedGame + let subject: PersonalizationSubject let onClose: () -> Void @ObservedObject private var picks = MetadataPicks.shared + @ObservedObject private var steamPicks = SteamPersonalizations.shared.metadata + @ObservedObject private var steamFacts = SteamGameFacts.shared + @State private var failure: String? @State private var field: MetadataField = .description @State private var focus = 0 @State private var titleDB: TitleDBInfo? @State private var gameTDB: [GameTDBEntry] = [] - @State private var searching = true + @State private var searching: Bool @State private var keyMonitor: Any? @State private var editing = false @State private var draft = "" @@ -36,13 +41,30 @@ struct ChangeMetadataView: View { case write } - private var titleKey: String { game.titleId ?? game.uniqueKey } - private var pick: String? { picks.value(titleID: titleKey, field: field) } - private var store: NintendoStoreMetadata? { viewModel.storeMetadataAsFetched(for: game) } + init(viewModel: AppViewModel, subject: PersonalizationSubject, onClose: @escaping () -> Void) { + self.viewModel = viewModel + self.subject = subject + self.onClose = onClose + // Steam games have no Switch databases to search. + _searching = State(initialValue: subject.ported != nil) + } + + private var pick: String? { + _ = picks.revision; _ = steamPicks.revision + return subject.metadataPick(field) + } + private var store: NintendoStoreMetadata? { subject.ported.flatMap(viewModel.storeMetadataAsFetched) } + + /// What Automatic shows: the Nintendo or Steam store's value. + private var automaticValue: String? { + if let steam = subject.steam { return steamFacts.metadata(for: steam)?.value(field) } + return MetadataOptions.list(for: field, store: store, titleDB: nil, gameTDB: []).first?.value + } /// Automatic is the store's value, so the store's option is not listed again. private var options: [MetadataOption] { - MetadataOptions.list(for: field, store: store, titleDB: titleDB, gameTDB: gameTDB) + guard subject.ported != nil else { return [] } + return MetadataOptions.list(for: field, store: store, titleDB: titleDB, gameTDB: gameTDB) .filter { $0.source != "Nintendo store" } } @@ -69,14 +91,14 @@ struct ChangeMetadataView: View { .seleneFont(15, .semibold) .foregroundColor(SeleneTheme.ink60) .padding(.leading, canvas.u(8)) - Text(game.name) + Text(subject.name) .seleneFont(40) .foregroundColor(SeleneTheme.ink) .lineLimit(1) .padding(.leading, canvas.u(6)) .padding(.bottom, canvas.u(14)) tabs - Text(searching ? L10n.text("Looking on Title DB and GameTDB…") : " ") + Text(failure ?? (searching ? L10n.text("Looking on Title DB and GameTDB…") : " ")) .seleneFont(14) .foregroundColor(SeleneTheme.ink60) .padding(.leading, canvas.u(10)) @@ -171,8 +193,8 @@ struct ChangeMetadataView: View { private func describe(_ row: Row) -> (title: String, value: String?) { switch row { case .automatic: - let value = MetadataOptions.list(for: field, store: store, titleDB: nil, gameTDB: []).first?.value - return (L10n.text("Automatic"), value.map { L10n.format("Nintendo store: %@", [displayValue($0)]) } ?? L10n.text("Nothing from the store yet.")) + let format = subject.steam != nil ? "Steam: %@" : "Nintendo store: %@" + return (L10n.text("Automatic"), automaticValue.map { L10n.format(format, [displayValue($0)]) } ?? L10n.text("Nothing from the store yet.")) case .yourPick(let value): return (L10n.text("Your own"), displayValue(value)) case .option(let option): @@ -204,9 +226,10 @@ struct ChangeMetadataView: View { // MARK: - Loading private func search() async { + guard subject.ported != nil else { return } defer { searching = false } - if let titleId = game.titleId { titleDB = await TitleDBInfo.fetch(titleID: titleId) } - let names = [game.name, store?.name, titleDB?.name].compactMap { $0 } + if let titleId = subject.titleId { titleDB = await TitleDBInfo.fetch(titleID: titleId) } + let names = [subject.name, store?.name, titleDB?.name].compactMap { $0 } // Only exact titles: a look-alike game's synopsis would be the wrong game's. let all = await viewModel.boxArt.pickerEntries(names: names) gameTDB = all.filter { entry in names.contains { GameTDB.normalize($0) == GameTDB.normalize(entry.title) } } @@ -218,24 +241,33 @@ struct ChangeMetadataView: View { switch row { case .automatic: guard pick != nil else { return } - viewModel.pickMetadata(nil, field: field, for: game) + save(nil) case .yourPick: break case .option(let option): - viewModel.pickMetadata(option.value, field: field, for: game) + save(option.value) case .write: - draft = pick ?? MetadataOptions.list(for: field, store: store, titleDB: nil, gameTDB: []).first?.value ?? "" + draft = pick ?? automaticValue ?? "" editorFocus = .cancel editing = true } SoundEffectService.shared.play(.click) } + /// Keeps the value (nil: the store's), reporting a save that failed instead of showing it as chosen. + private func save(_ value: String?) { + do { try subject.pickMetadata(value, field: field, viewModel: viewModel); failure = nil } + catch { + failure = error.localizedDescription + SoundEffectService.shared.play(.error) + } + } + private var editor: some View { ZStack { SeleneTheme.scrim45.ignoresSafeArea().contentShape(Rectangle()) .onTapGesture { editing = false } - SeleneConfirmDialog(title: field.title, message: game.name, confirmTitle: L10n.text("Save"), + SeleneConfirmDialog(title: field.title, message: subject.name, confirmTitle: L10n.text("Save"), focus: $editorFocus, onCancel: { editing = false }, onConfirm: saveDraft) { VStack(alignment: .leading, spacing: canvas.u(12)) { if field == .description { @@ -267,7 +299,7 @@ struct ChangeMetadataView: View { private func saveDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - viewModel.pickMetadata(text.isEmpty ? nil : text, field: field, for: game) + save(text.isEmpty ? nil : text) editing = false SoundEffectService.shared.play(.click) } @@ -331,7 +363,7 @@ struct ChangeMetadataView: View { private func installKeyMonitor() { guard keyMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard viewModel.gameForSettingsSheet?.id == game.id, viewModel.settingsNotice == nil, + guard viewModel.optionsGameID == subject.id, viewModel.settingsNotice == nil, let window = event.window, !window.isSheet, window.attachedSheet == nil, NSApp.modalWindow == nil else { return event } if editing { diff --git a/Sources/NXLibrary/Views/GameOptions/GameOptionsView.swift b/Sources/NXLibrary/Views/GameOptions/GameOptionsView.swift index 1f1e0bf..38099ba 100644 --- a/Sources/NXLibrary/Views/GameOptions/GameOptionsView.swift +++ b/Sources/NXLibrary/Views/GameOptions/GameOptionsView.swift @@ -136,13 +136,13 @@ struct GameOptionsView: View { .allowsHitTesting(!panelOpen) if artworkOpen { - ChangeArtworkView(viewModel: viewModel, game: current) { artworkOpen = false } + ChangeArtworkView(viewModel: viewModel, subject: PersonalizationSubject(current, viewModel: viewModel)) { artworkOpen = false } .transition(.opacity) .zIndex(2) } if metadataOpen { - ChangeMetadataView(viewModel: viewModel, game: current) { metadataOpen = false } + ChangeMetadataView(viewModel: viewModel, subject: PersonalizationSubject(current, viewModel: viewModel)) { metadataOpen = false } .transition(.opacity) .zIndex(2) } diff --git a/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift b/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift new file mode 100644 index 0000000..fd7355c --- /dev/null +++ b/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift @@ -0,0 +1,77 @@ +import Foundation +import NXLibraryCore + +/// The game Change artwork and Change metadata personalize, whatever its source. nxnative +/// games keep their picks in `ArtworkPicks`/`MetadataPicks` and their Mac app icon flow; +/// Steam games keep theirs in `SteamPersonalizationStore` and never touch the installed app. +@MainActor +struct PersonalizationSubject { + let id: UUID + let name: String + /// The picks' key: the nxnative personalization key, or "steam-". + let key: String + /// The nxnative game, for Switch sources (Title DB, GameTDB, Nintendo store) and the app icon. + let ported: PortedGame? + /// The Steam game, for Steam's own art and store facts. + let steam: CatalogGame? + + init(_ game: PortedGame, viewModel: AppViewModel) { + id = game.id; name = game.name; key = viewModel.personalizationKey(for: game) + ported = game; steam = nil + } + + init(steam game: CatalogGame) { + id = game.id; name = game.name; key = game.personalizationReference.key + ported = nil; steam = game + } + + var titleId: String? { ported?.titleId } + var steamAppID: UInt32? { steam?.steamGame?.appID } + + private var steamArt: SteamPersonalizationStore { SteamPersonalizations.shared.artwork } + private var steamFields: SteamPersonalizationStore { SteamPersonalizations.shared.metadata } + + // MARK: Artwork picks + + func artworkURL(_ slot: ArtworkSlot) -> URL? { + steam != nil ? steamArt.url(titleID: key, slot: slot) : ArtworkPicks.shared.url(titleID: key, slot: slot) + } + + func saveArtwork(_ data: Data, slot: ArtworkSlot) throws { + if steam != nil { try steamArt.save(fields: [:], images: [slot: data], automatic: [], titleID: key) } + else { _ = try ArtworkPicks.shared.save(data, titleID: key, slot: slot) } + } + + func removeArtwork(_ slot: ArtworkSlot) throws { + if steam != nil { try steamArt.save(fields: [:], images: [:], automatic: [slot], titleID: key) } + else { ArtworkPicks.shared.remove(titleID: key, slot: slot) } + } + + // MARK: Metadata picks + + func metadataPick(_ field: MetadataField) -> String? { + steam != nil ? steamFields.value(titleID: key, field: field) : MetadataPicks.shared.value(titleID: key, field: field) + } + + /// Keeps the player's value, or (nil) goes back to the store's. + func pickMetadata(_ value: String?, field: MetadataField, viewModel: AppViewModel) throws { + if steam != nil { try steamFields.save(fields: [field: value ?? ""], images: [:], automatic: [], titleID: key) } + else if let ported { viewModel.pickMetadata(value, field: field, for: ported) } + } +} + +extension SteamMetadata { + /// The store's value for a Change metadata field, as one line (genres comma-separated). + func value(_ field: MetadataField) -> String? { + let text: String? + switch field { + case .description: text = description + case .genres: text = genres.joined(separator: ", ") + case .developer: text = developers.joined(separator: ", ") + case .publisher: text = publishers.joined(separator: ", ") + case .releaseDate: text = releaseDate + } + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/NXLibrary/Views/GameOptions/SteamOptionsView.swift b/Sources/NXLibrary/Views/GameOptions/SteamOptionsView.swift new file mode 100644 index 0000000..4f3cba6 --- /dev/null +++ b/Sources/NXLibrary/Views/GameOptions/SteamOptionsView.swift @@ -0,0 +1,277 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// A Steam game's Options, in Game options' look and input: the same dimmed screen, +/// centred column, glass rows and hints. Rows and copy come from `SteamOptionsPresentation`; +/// Edit details opens the shared Change artwork and Change metadata panels on the Steam +/// game's own picks. Every row acts on the record it was opened for, re-read from the catalog. +/// +/// Input: Up/Down move (clamped), A activates, B (or Esc) leaves Edit details, then closes; +/// X and Menu close. A click activates; a click on the dimmed area steps back. +struct SteamOptionsView: View { + @ObservedObject var viewModel: AppViewModel + let game: CatalogGame + let origin: GameOptionsOrigin + let onClose: () -> Void + let onMove: () -> Void + + @ObservedObject private var player = SteamPlayCoordinator.shared + @State private var level: GameOptionsLevel = .main + @State private var pickedRow: GameOptionsRow? + @State private var artworkOpen = false + @State private var metadataOpen = false + @State private var keyMonitor: Any? + + @Environment(\.seleneCanvas) private var canvas + @Environment(\.seleneReduceMotion) private var reduceMotion + + /// The record as the catalog holds it now: Favorite and Hidden change it while open. + private var current: CatalogGame { + viewModel.catalogGames.first { $0.id == game.id } ?? game + } + + private var application: CatalogNativeApplication? { + current.steamGame?.installations.first { $0.availability == .available }?.primaryApplication + } + + private var presentation: SteamOptionsPresentation { + SteamOptionsPresentation(canPlay: player.canPlay(current), hasApp: application != nil) + } + + private var rows: [GameOptionsRow] { presentation.focusableRows(level: level, origin: origin) } + + private var focusedRow: GameOptionsRow? { + let rows = self.rows + return GameOptionsFocus(row: pickedRow, in: rows).row(in: rows) + } + + private var panelOpen: Bool { artworkOpen || metadataOpen } + + var body: some View { + let p = presentation + return ZStack { + SeleneTheme.scrim45 + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { back() } + + VStack(spacing: 0) { + column(p) + SeleneHintsBar(left: [HintItem(.b, L10n.text("Back"))], right: [HintItem(.a, L10n.text("Select"))], + bottom: SeleneBottomScrim.hintsBottom, leftInset: 18) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(panelOpen ? 0 : 1) + .allowsHitTesting(!panelOpen) + + if artworkOpen { + ChangeArtworkView(viewModel: viewModel, subject: PersonalizationSubject(steam: current)) { artworkOpen = false } + .transition(.opacity) + .zIndex(2) + } + if metadataOpen { + ChangeMetadataView(viewModel: viewModel, subject: PersonalizationSubject(steam: current)) { metadataOpen = false } + .transition(.opacity) + .zIndex(2) + } + } + .animation(reduceMotion ? nil : SeleneTheme.quick, value: panelOpen) + .onAppear(perform: installKeyMonitor) + .onDisappear(perform: removeKeyMonitor) + .onReceive(NotificationCenter.default.publisher(for: .nxGameOptionsGamepadAction)) { note in + guard let action = note.object as? GamepadAction else { return } + handleGamepad(action) + } + .task(id: game.id) { + // Change metadata's Automatic is the store's text; ask for it before the panel opens. + await SteamGameFacts.shared.load(current, language: L10n.context.language) + } + } + + private func column(_ p: SteamOptionsPresentation) -> some View { + let focused = focusedRow + return VStack(alignment: .leading, spacing: 0) { + Text(L10n.text("GAME OPTIONS")) + .seleneFont(15, .semibold) + .foregroundColor(SeleneTheme.ink60) + .padding(.leading, canvas.u(8)) + Text(current.name) + .seleneFont(40) + .foregroundColor(SeleneTheme.ink) + .lineLimit(1) + .padding(.leading, canvas.u(6)) + .padding(.bottom, canvas.u(14)) + VStack(spacing: canvas.u(8)) { + ForEach(Array(p.entries(level: level, origin: origin).enumerated()), id: \.offset) { _, entry in + entryView(entry, p, focused: focused) + } + } + .padding(.top, canvas.u(6)) + .id(level) + } + .frame(width: canvas.u(1040), alignment: .leading) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, canvas.u(90)) + .padding(.bottom, canvas.u(110)) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func entryView(_ entry: GameOptionsEntry, _ p: SteamOptionsPresentation, focused: GameOptionsRow?) -> some View { + switch entry { + case .row(let row): + SeleneOptionRow(icon: row.icon, title: row.title, about: p.about(row), focused: focused == row, + destructive: false, enabled: !p.isDimmed(row)) { + trailing(row) + } + .contentShape(Rectangle()) + .onTapGesture { activate(row) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(row.title) + .accessibilityHint(p.about(row) ?? "") + .accessibilityValue(row.isToggle ? (toggleValue(row) ? "On" : "Off") : "") + .accessibilityAddTraits(.isButton) + .accessibilityAction { activate(row) } + case .heading(let text): + Text(L10n.text(text)) + .seleneFont(17, .semibold) + .foregroundColor(SeleneTheme.ink75) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, canvas.u(26)) + .padding(.top, canvas.u(16)) + case .note, .gap: + EmptyView() + } + } + + @ViewBuilder + private func trailing(_ row: GameOptionsRow) -> some View { + if row.isToggle { + SeleneSwitch(isOn: .constant(toggleValue(row))).allowsHitTesting(false) + } else if row.opensSubmenu { + MaterialIconView(.chevron, size: 24).foregroundColor(SeleneTheme.ink60) + } else { + EmptyView() + } + } + + private func toggleValue(_ row: GameOptionsRow) -> Bool { + switch row { + case .favorite: return current.isFavorite + case .hidden: return current.isHidden + default: return false + } + } + + // MARK: - Actions + + private func activate(_ row: GameOptionsRow) { + pickedRow = row + perform(row) + } + + private func perform(_ row: GameOptionsRow) { + let game = current + switch row { + case .play: + guard presentation.canPlay else { SoundEffectService.shared.play(.error); return } + SoundEffectService.shared.play(.click) + onClose() + SteamPlayCoordinator.shared.press(game) + case .editDetails: + SoundEffectService.shared.play(.click) + pickedRow = presentation.focusableRows(level: .editDetails, origin: origin).first + level = .editDetails + case .move: + SoundEffectService.shared.play(.click) + onMove() + case .favorite: + SoundEffectService.shared.play(.click) + viewModel.setCatalogFavorite(game) + case .hidden: + SoundEffectService.shared.play(.click) + let hiding = !game.isHidden + // Close only once the hidden flag is saved: a failed save leaves the game listed. + if viewModel.setCatalogHidden(hiding, game: game), hiding { onClose() } + case .showInFinder: + guard let application else { SoundEffectService.shared.play(.error); return } + SoundEffectService.shared.play(.click) + viewModel.revealInFinder(url: application.url) + case .changeArtwork: + SoundEffectService.shared.play(.click) + artworkOpen = true + case .changeMetadata: + SoundEffectService.shared.play(.click) + metadataOpen = true + case .storePage: + guard let appID = game.steamGame?.appID, let url = URL(string: "https://store.steampowered.com/app/\(appID)/") else { return } + SoundEffectService.shared.play(.click) + viewModel.openURL(url) + default: + break + } + } + + // MARK: - Input + + private func moveFocus(by delta: Int) { + let rows = self.rows + let previous = focusedRow + var focus = GameOptionsFocus(row: pickedRow, in: rows) + focus.move(by: delta, count: rows.count) + pickedRow = focus.row(in: rows) + if focusedRow != previous { SoundEffectService.shared.play(.focus) } + } + + private func back() { + guard !panelOpen else { return } + if level == .editDetails { + level = .main + pickedRow = .editDetails + SoundEffectService.shared.play(.focus) + } else { + onClose() + } + } + + private func handleGamepad(_ action: GamepadAction) { + // Change artwork and Change metadata read the same notifications while open. + guard !panelOpen else { return } + switch action { + case .navigateUp: moveFocus(by: -1) + case .navigateDown: moveFocus(by: 1) + case .confirm: if let row = focusedRow { perform(row) } + case .cancel: back() + case .secondaryAction, .menu: onClose() + default: break + } + } + + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard viewModel.optionsGameID == game.id, + !viewModel.isUpdateOfferOpen, + !NativeMenuCommands.shared.isTracking, + !panelOpen, + viewModel.alertMessage == nil, viewModel.settingsNotice == nil, + let window = event.window, !window.isSheet, window.attachedSheet == nil, + NSApp.modalWindow == nil, !(window.firstResponder is NSText) else { return event } + guard event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return event } + switch event.keyCode { + case 126: moveFocus(by: -1) + case 125: moveFocus(by: 1) + case 36, 76, 49: if let row = focusedRow { perform(row) } + case 53: back() + default: return event + } + return nil + } + } + + private func removeKeyMonitor() { + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + keyMonitor = nil + } +} diff --git a/Sources/NXLibrary/Views/Launch/DevelopmentStartupView.swift b/Sources/NXLibrary/Views/Launch/DevelopmentStartupView.swift new file mode 100644 index 0000000..bb23b44 --- /dev/null +++ b/Sources/NXLibrary/Views/Launch/DevelopmentStartupView.swift @@ -0,0 +1,116 @@ +import AppKit +import SwiftUI +import NXLibraryCore +import os + +@MainActor +final class DevelopmentStartup: ObservableObject { + @Published private(set) var shell: AppShellState? + @Published private(set) var failure: String? + @Published private(set) var working = false + @Published private(set) var recovery: CatalogRecoveryCandidate? + private let logger = Logger(subsystem: "local.nxlibrary", category: "Startup") + + func prepare() async { + guard shell == nil, !working else { return } + working = true + failure = nil + recovery = nil + var profileReady = false + do { + // Services and SwiftUI StateObjects are created only after the profile transaction finishes. + if AppData.profile.isDevelopment, + NSRunningApplication.runningApplications(withBundleIdentifier: "local.nxlibrary") + .contains(where: { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) { + throw DevelopmentStartupFailure.otherCopyRunning + } + try await Task.detached(priority: .userInitiated) { try AppData.prepare() }.value + profileReady = true + let catalog = try await Task.detached(priority: .userInitiated) { + try CatalogStore(directory: AppData.supportRoot).loadOrMigrate() + }.value + logger.notice("Launcher data ready; preparing shell models") + let store = AppSettingsStore() + // Complete model/AppKit startup outside the render that inserts MainView. + shell = AppShellState(settings: store, catalog: catalog, repository: CatalogRepository(store: CatalogStore(directory: AppData.supportRoot))) + logger.notice("Shell models ready") + } catch { + logger.error("Launcher preparation failed") + failure = error.localizedDescription + if profileReady { + recovery = try? await Task.detached(priority: .userInitiated) { + try CatalogStore(directory: AppData.supportRoot).recoveryCandidate() + }.value + } + } + working = false + } + + func restoreBackup() async { + guard !working, let candidate = recovery else { return } + working = true + do { + _ = try await Task.detached(priority: .userInitiated) { + try CatalogStore(directory: AppData.supportRoot).restoreBackup(candidate) + }.value + working = false + await prepare() + } catch { + failure = error.localizedDescription + recovery = nil + working = false + } + } + +} + +private enum DevelopmentStartupFailure: LocalizedError { + case otherCopyRunning + var errorDescription: String? { + L10n.text("Close the other NX Library copy, then try again. Your original library has not been changed.", context: AppLocale(language: .automatic, region: nil)) + } +} + +/// Bootstrap has no app stores, themes or game-operation hooks until its profile is ready. +struct DevelopmentStartupView: View { + @StateObject private var startup = DevelopmentStartup() + private let context = AppLocale(language: .automatic, region: nil) + var body: some View { + Group { + if let shell = startup.shell { + MainView(state: shell) + .onAppear { + Logger(subsystem: "local.nxlibrary", category: "Startup").notice("Library view appeared") + } + } else { + VStack(spacing: 20) { + if startup.working { ProgressView().controlSize(.large) } + Text(L10n.text(startup.failure == nil ? "Preparing your library" : "Library unavailable", context: context)) + .font(.system(size: 28, weight: .semibold)) + Text(startup.failure ?? L10n.text("Preparing a separate copy of your launcher data. Games stay in their current locations.", context: context)) + .font(.system(size: 17)).multilineTextAlignment(.center) + if startup.failure != nil { + if let candidate = startup.recovery { + Button(L10n.count("Restore backup (%lld games)", candidate.gameCount, context: context)) { + Task { await startup.restoreBackup() } + }.keyboardShortcut("r", modifiers: [.command, .shift]) + .disabled(startup.working) + } + HStack(spacing: 24) { + Button(L10n.text("Close", context: context)) { NSApplication.shared.terminate(nil) } + .keyboardShortcut(.cancelAction) + Button(L10n.text("Open library folder", context: context)) { NSWorkspace.shared.open(AppData.supportRoot) } + .keyboardShortcut("o", modifiers: [.command]) + Button(L10n.text("Retry", context: context)) { Task { await startup.prepare() } } + .keyboardShortcut(.defaultAction) + }.disabled(startup.working) + } + } + .foregroundColor(.white) + .padding(48).frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0.08, green: 0.09, blue: 0.12)) + } + } + .task { await startup.prepare() } + } +} diff --git a/Sources/NXLibrary/Views/Launch/LaunchScreenView.swift b/Sources/NXLibrary/Views/Launch/LaunchScreenView.swift index 4de871f..6151e38 100644 --- a/Sources/NXLibrary/Views/Launch/LaunchScreenView.swift +++ b/Sources/NXLibrary/Views/Launch/LaunchScreenView.swift @@ -152,12 +152,12 @@ struct LaunchScreenView: View { let metadata = viewModel.storeMetadata[game.id] if hero == nil, showsArt { let loaded = await GameArtLoader.shared.hero( - for: game, metadata: metadata, artwork: artwork) + for: game, metadata: metadata, artwork: artwork, personalizations: viewModel.personalizations) withAnimation(.easeOut(duration: 0.3)) { hero = loaded } } if logo == nil, settings.launchStyle != .minimal, settings.launchStyle != .artOnly { let loaded = await GameArtLoader.shared.logo( - for: game, metadata: metadata, artwork: artwork) + for: game, metadata: metadata, artwork: artwork, personalizations: viewModel.personalizations) withAnimation(.easeOut(duration: 0.3)) { logo = loaded } } } diff --git a/Sources/NXLibrary/Views/MainView.swift b/Sources/NXLibrary/Views/MainView.swift index 4b285b9..0a4fe7d 100644 --- a/Sources/NXLibrary/Views/MainView.swift +++ b/Sources/NXLibrary/Views/MainView.swift @@ -2,11 +2,11 @@ import SwiftUI import NXLibraryCore public struct MainView: View { - private let settingsStore: AppSettingsStore + private let state: AppShellState - public init(settingsStore: AppSettingsStore) { - self.settingsStore = settingsStore + @MainActor init(state: AppShellState) { + self.state = state } - public var body: some View { AppShellView(settingsStore: settingsStore) } + public var body: some View { AppShellView(state: state) } } diff --git a/Sources/NXLibrary/Views/Search/FindGameView.swift b/Sources/NXLibrary/Views/Search/FindGameView.swift index f1723b3..7d7d23c 100644 --- a/Sources/NXLibrary/Views/Search/FindGameView.swift +++ b/Sources/NXLibrary/Views/Search/FindGameView.swift @@ -26,10 +26,10 @@ struct FindGameView: View { private static let columns = 6 private static let limit = 12 - private var results: [PortedGame] { + private var results: [CatalogGame] { guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return [] } let all = LibraryQuery(filter: .all, searchText: query) - .apply(to: viewModel.libraryGames, history: playSessions) + .applyCatalog(to: viewModel.visibleCatalogGames, history: playSessions) return Array(all.prefix(Self.limit)) } @@ -60,6 +60,7 @@ struct FindGameView: View { } .onDisappear(perform: removeKeyMonitor) .onChange(of: query) { _ in focused = nil } + .onChange(of: results.map(\.id)) { _ in focused = nil } .onReceive(NotificationCenter.default.publisher(for: .nxFindGameGamepadAction)) { note in guard let action = note.object as? GamepadAction else { return } handle(action) @@ -105,9 +106,12 @@ struct FindGameView: View { } } - private func tile(_ game: PortedGame, selected: Bool) -> some View { + private func tile(_ game: CatalogGame, selected: Bool) -> some View { VStack(spacing: canvas.u(10)) { - SpotlightTileArtwork(game: game, side: canvas.u(160), artwork: artwork) + Group { + if let native = game.nxnativeGame { SpotlightTileArtwork(game: native, side: canvas.u(160), artwork: artwork) } + else { CatalogNativeArtwork(game: game, role: .square) } + } .frame(width: canvas.u(160), height: canvas.u(160)) .clipShape(RoundedRectangle(cornerRadius: canvas.u(12), style: .continuous)) .overlay(SeleneTileRing(cornerRadius: canvas.u(12), active: selected)) @@ -166,7 +170,7 @@ struct FindGameView: View { SoundEffectService.shared.play(.focus) } - private func pick(_ game: PortedGame?) { + private func pick(_ game: CatalogGame?) { guard let game else { return } SoundEffectService.shared.play(.click) router.isSearchPresented = false diff --git a/Sources/NXLibrary/Views/Settings/SettingsControllersSection.swift b/Sources/NXLibrary/Views/Settings/SettingsControllersSection.swift index 6df7612..d27fe99 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsControllersSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsControllersSection.swift @@ -51,7 +51,7 @@ struct SettingsControllersSection: View { SettingsSubheading(L10n.text("New games")) SeleneToggleRow("Rumble", - subtitle: "Controller vibration", + subtitle: "Vibration in new Switch games", isOn: $settings.settings.gameDefaults.rumble) SeleneRow("Rumble strength", diff --git a/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift b/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift index 9aa5762..074f544 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsEngineSection.swift @@ -3,6 +3,7 @@ import NXLibraryCore struct SettingsEngineSection: View { @ObservedObject var viewModel: AppViewModel + @ObservedObject var settings: AppSettingsStore let onActions: ([SettingsRowAction]) -> Void @Environment(\.seleneFocusedRowID) private var focusedRowTitle @@ -98,6 +99,11 @@ struct SettingsEngineSection: View { } .seleneRowFocus(focusedRowTitle == "Compatibility", radius: SeleneTheme.cardRadius) + SeleneToggleRow("Check engine at launch", + subtitle: "Verify the pinned engine commit and toolchain when the app starts.", + info: "Checks the nxnative checkout against the certified engine commit and confirms your installed Metal compiler matches the lock file before builds are attempted.", + isOn: $settings.settings.checkEngineAtLaunch) + SeleneRow("Untested toolchain override", subtitle: overrideSubtitle, info: "Permits building games when the toolchain differs from the certified engine and Metal combination shown above. Passes --allow-other-metal to nxnative. Builds are recorded as unverified.") { @@ -176,6 +182,13 @@ struct SettingsEngineSection: View { .fixedSize(horizontal: false, vertical: true) } .seleneRowFocus(focusedRowTitle == "prod.keys", radius: SeleneTheme.cardRadius) + + SeleneButtonRow("Setup", + subtitle: "Open the Setup check: what NX Library needs and what is ready.", + buttonTitle: "Open Setup") { + viewModel.reopenSetup() + } + .disabled(configurationLocked) } .onAppear { viewModel.prepareBuildEnvironmentIfNeeded() @@ -539,6 +552,9 @@ struct SettingsEngineSection: View { .init(id: "recheck", title: "Compatibility", isEnabled: !configurationLocked) { viewModel.detectEnvironment() }, + .init(id: "engineAtLaunch", title: "Check engine at launch") { + settings.settings.checkEngineAtLaunch.toggle() + }, .init(id: "override", title: "Untested toolchain override", isEnabled: canChangeOverride) { if viewModel.allowUntestedToolchain { @@ -568,6 +584,9 @@ struct SettingsEngineSection: View { actions.append(.init(id: "keys", title: "prod.keys", isEnabled: !configurationLocked) { viewModel.selectProdKeysFile() }) + actions.append(.init(id: "setup", title: "Setup", isEnabled: !configurationLocked) { + viewModel.reopenSetup() + }) onActions(actions) } } diff --git a/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift index c2a239e..2bb941e 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift @@ -1,15 +1,12 @@ import SwiftUI +import AppKit import NXLibraryCore struct SettingsLibrarySection: View { @ObservedObject var viewModel: AppViewModel @ObservedObject var settings: AppSettingsStore - @ObservedObject var artwork: ArtworkStore let onActions: ([SettingsRowAction]) -> Void - @State private var metadataBytes: Int64 = 0 - @State private var artworkBytes: Int64 = 0 - var body: some View { VStack(spacing: 9) { SeleneRow(GameFoldersCopy.appsTitle, @@ -65,7 +62,7 @@ struct SettingsLibrarySection: View { ? "Scanning game apps…" : "Find existing nxnative builds and refresh the library.", info: "Checks Game apps and its subfolders. Existing library entries are kept; no apps are moved. Source files are optional for Play.", buttonTitle: "Scan") { - viewModel.refreshLibrary() + viewModel.importExistingBuilds() } .disabled(viewModel.isScanningLibrary) @@ -83,32 +80,20 @@ struct SettingsLibrarySection: View { .padding(.vertical, 8) } + SeleneToggleRow("Refresh library at launch", + subtitle: "Rescan the app library for new or removed builds.", + isOn: $settings.settings.refreshLibraryAtLaunch) + SeleneToggleRow("Notify when a porting ends", subtitle: "A notification when a porting finishes while NX Library is in the background.", isOn: $settings.settings.notifyPortingEnds) - SeleneValueRow("Metadata cache", - subtitle: "Nintendo Store descriptions, genres and links.", - info: "Locally cached game metadata such as title IDs, publisher names, release dates, and category tags fetched during library scans.", - value: seleneBytes(metadataBytes), - actionTitle: "Clear") { - viewModel.clearMetadataCache() - metadataBytes = viewModel.metadataCacheSize() - } - - SeleneValueRow("Artwork cache", - subtitle: "Hero and cover art downloaded for this library.", - info: "High-resolution game box art and hero wallpapers cached locally from the Nintendo Store catalog.", - value: seleneBytes(artworkBytes), - actionTitle: "Clear") { - artwork.clear() - artworkBytes = artwork.cacheSize() + SeleneButtonRow("Box covers", + subtitle: "GameTDB covers", + buttonTitle: "Open GameTDB") { + NSWorkspace.shared.open(GameTDB.siteURL) } } - .task { - metadataBytes = viewModel.metadataCacheSize() - artworkBytes = artwork.cacheSize() - } .onAppear(perform: publishActions) .onChange(of: viewModel.isScanningLibrary) { _ in publishActions() } } @@ -122,18 +107,16 @@ struct SettingsLibrarySection: View { viewModel.selectGameFolder() }, .init(id: "refresh", title: "Scan game apps", isEnabled: !viewModel.isScanningLibrary) { - viewModel.refreshLibrary() + viewModel.importExistingBuilds() + }, + .init(id: "refreshAtLaunch", title: "Refresh library at launch") { + settings.settings.refreshLibraryAtLaunch.toggle() }, .init(id: "notify", title: "Notify when a porting ends") { settings.settings.notifyPortingEnds.toggle() }, - .init(id: "metadataClear", title: "Metadata cache") { - viewModel.clearMetadataCache() - metadataBytes = viewModel.metadataCacheSize() - }, - .init(id: "artworkClear", title: "Artwork cache") { - artwork.clear() - artworkBytes = artwork.cacheSize() + .init(id: "covers", title: "Box covers") { + NSWorkspace.shared.open(GameTDB.siteURL) } ]) } diff --git a/Sources/NXLibrary/Views/Settings/SettingsLookSection.swift b/Sources/NXLibrary/Views/Settings/SettingsLookSection.swift index da99dd0..500da7b 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsLookSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsLookSection.swift @@ -116,12 +116,6 @@ struct SettingsLookSection: View { SettingsSubheading(L10n.text("Artwork")) - SeleneButtonRow("Box covers", - subtitle: "GameTDB covers", - buttonTitle: "Open GameTDB") { - NSWorkspace.shared.open(GameTDB.siteURL) - } - SeleneButtonRow("SteamGridDB", subtitle: viewModel.hasSteamGridDBKey ? "Key saved · covers, backgrounds, logos and icons in Change artwork" @@ -161,9 +155,6 @@ struct SettingsLookSection: View { settings.settings.reduceMotion.toggle() }, .value("icon", "App icon") { stepIcon($0) }, - .init(id: "covers", title: "Box covers") { - NSWorkspace.shared.open(GameTDB.siteURL) - }, .init(id: "SteamGridDB", title: "SteamGridDB") { steamGridDBAction() } ]) } diff --git a/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift index c65d73f..af88c57 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift @@ -6,6 +6,9 @@ struct SettingsSearchSection: View { @ObservedObject var settings: AppSettingsStore let onOpenGroup: (SettingsSection) -> Void let onSubmit: () -> Void + /// Bumped when the player opens the field (A or Right from the sidebar). Selecting Search never + /// takes the keys by itself: Up/Down keep moving through the sidebar. + let fieldRequest: Int let onActions: ([SettingsRowAction]) -> Void @Environment(\.seleneCanvas) private var canvas @@ -43,10 +46,8 @@ struct SettingsSearchSection: View { } SettingsDescriptorList(rows: results, onOpenGroup: onOpenGroup) } - .onAppear { - fieldFocused = true - publishActions() - } + .onAppear(perform: publishActions) + .onChange(of: fieldRequest) { _ in fieldFocused = true } .onChange(of: query) { _ in publishActions() } } @@ -103,7 +104,7 @@ struct SettingsDescriptorList: View { let onOpenGroup: (SettingsSection) -> Void @Environment(\.seleneCanvas) private var canvas - static func title(_ row: SettingDescriptor) -> String { "\(row.group.title) · \(L10n.text(row.title))" } + static func title(_ row: SettingDescriptor) -> String { "\(row.group.place) · \(L10n.text(row.title))" } static func actions(_ rows: [SettingDescriptor], onOpenGroup: @escaping (SettingsSection) -> Void) -> [SettingsRowAction] { diff --git a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift index 84ac9ed..856501d 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -12,18 +12,19 @@ struct SettingsSectionContent: View { let onOpenConsole: () -> Void let onOpenGroup: (SettingsSection) -> Void let onSearchSubmit: () -> Void + let searchFieldRequest: Int let onActions: ([SettingsRowAction]) -> Void var body: some View { switch section { case .search: SettingsSearchSection(settings: settings, onOpenGroup: onOpenGroup, - onSubmit: onSearchSubmit, onActions: onActions) + onSubmit: onSearchSubmit, fieldRequest: searchFieldRequest, onActions: onActions) case .changed: SettingsChangedSection(settings: settings, viewModel: viewModel, onOpenGroup: onOpenGroup, onActions: onActions) case .startUp: - SettingsStartUpSection(viewModel: viewModel, settings: settings, onActions: onActions, onChooseLanguage: onChooseLanguage, onChooseRegion: onChooseRegion) + SettingsStartUpSection(settings: settings, onActions: onActions, onChooseLanguage: onChooseLanguage, onChooseRegion: onChooseRegion) case .navigation: SettingsNavigationSection(settings: settings, onActions: onActions) case .spotlight: @@ -38,13 +39,14 @@ struct SettingsSectionContent: View { SettingsControllersSection(settings: settings, onActions: onActions, onViewControls: onViewControls) case .library: - SettingsLibrarySection(viewModel: viewModel, settings: settings, - artwork: artwork, onActions: onActions) + SettingsLibrarySection(viewModel: viewModel, settings: settings, onActions: onActions) + case .sources: + SettingsSourcesSection(viewModel: viewModel, settings: settings, onActions: onActions) case .storage: SettingsStorageSection(viewModel: viewModel, artwork: artwork, onActions: onActions, onOpenConsole: onOpenConsole) case .engine: - SettingsEngineSection(viewModel: viewModel, onActions: onActions) + SettingsEngineSection(viewModel: viewModel, settings: settings, onActions: onActions) case .about: SettingsAboutSection(viewModel: viewModel, onActions: onActions) } @@ -59,7 +61,7 @@ struct SettingsSubheading: View { init(_ title: String) { self.title = title } var body: some View { - Text(title) + Text(L10n.text(title)) .seleneFont(17, .semibold) .foregroundColor(SeleneTheme.glyph) .padding(.top, canvas.u(18)) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift index dafa733..301d643 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift @@ -2,7 +2,7 @@ import SwiftUI import AppKit import NXLibraryCore -/// Left column of Settings: app mark, name and version, then the groups (Selene `SettingsScreen.cs`). +/// Left column of Settings: app mark, name and version, then the sections under their group headings. struct SettingsSidebar: View { @Binding var selection: SettingsSection var isFocused: Bool = false @@ -17,26 +17,34 @@ struct SettingsSidebar: View { VStack(alignment: .leading, spacing: 0) { header .padding(.bottom, canvas.u(26)) - ScrollView { - groups + // The groups can be taller than the window: the selected section is always scrolled into view, + // at once like a macOS sidebar. An animated scroll started in the same frame as the new section's + // content and stuttered. + ScrollViewReader { proxy in + ScrollView { + groups + } + .scrollIndicators(.hidden) + .onAppear { proxy.scrollTo(selection) } + .onChange(of: selection) { section in proxy.scrollTo(section) } } - .scrollIndicators(.hidden) } } + /// App-wide groups, then one per platform (`SettingsSidebarGroup`); a heading is a label, not a stop. private var groups: some View { VStack(alignment: .leading, spacing: canvas.u(6)) { - ForEach(SettingsSection.allCases) { section in - groupRow(section) - if section == .changed { - Rectangle() - .fill(SeleneTheme.ink.opacity(0.16)) - .frame(height: max(1, canvas.u(1))) + ForEach(SettingsSidebarGroup.all) { group in + if let heading = group.title(in: context) { + Text(heading) + .seleneFont(15, .semibold) + .foregroundColor(SeleneTheme.ink60) .padding(.leading, canvas.u(20)) - .padding(.top, canvas.u(8)) - .padding(.trailing, canvas.u(44)) - .padding(.bottom, canvas.u(14)) + .padding(.top, canvas.u(18)) + .padding(.bottom, canvas.u(2)) + .accessibilityAddTraits(.isHeader) } + ForEach(group.sections) { section in groupRow(section).id(section) } } } } diff --git a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift new file mode 100644 index 0000000..1675e4a --- /dev/null +++ b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift @@ -0,0 +1,112 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +@MainActor +struct SettingsSourcesSection: View { + @ObservedObject var viewModel: AppViewModel + @ObservedObject var settings: AppSettingsStore + @EnvironmentObject private var sources: CatalogSourcesModel + @Environment(\.nxAppLocale) private var localization + let onActions: ([SettingsRowAction]) -> Void + + private var defaultRoot: URL { AppData.profile.home.appendingPathComponent("Library/Application Support/Steam") } + private var hidden: [CatalogGame] { viewModel.catalogGames.filter { $0.integration == .steam && $0.isHidden } } + private var review: [CatalogGame] { + viewModel.catalogGames.filter { !$0.isHidden && $0.steamGame.map { !$0.hasBeenRecognized || $0.needsInstallationReview } == true } + } + private var statusText: String { + switch sources.status { + case .disabled: return L10n.text("Enable Steam to find its installed Mac games.", context: localization) + case .idle: return L10n.text("Ready to check Steam.", context: localization) + case .scanning: return L10n.text("Checking Steam libraries…", context: localization) + case .ready: return L10n.count("%lld Steam installations found", sources.observedGameCount, context: localization) + case .partial: return L10n.text("Some installations could not be checked. Your library has been kept.", context: localization) + case .cancelled: return L10n.text("Scan stopped. Refresh when you are ready.", context: localization) + case .failed: return L10n.text("Steam could not be refreshed. Your library has been kept.", context: localization) + } + } + var body: some View { + VStack(spacing: 9) { + SeleneToggleRow("Steam", subtitle: "Find installed native Mac games in your Steam libraries.", + info: "Your Steam files stay where they are. Disabling this source keeps its games and personal choices.", + isOn: Binding(get: { sources.configuration.isEnabled }, set: { enabled in + sources.configure(enabled: enabled, root: sources.configuration.rootURL ?? defaultRoot) + })) + SeleneButtonRow("Refresh Steam", subtitle: statusText, + buttonTitle: sources.status == .scanning ? "Cancel" : "Refresh") { + if sources.status == .scanning { sources.cancel() } else { sources.refresh() } + }.disabled(!sources.configuration.isEnabled) + SeleneValueRow("Steam folder", subtitle: "Choose the Steam folder containing steamapps.", + value: (sources.configuration.rootURL ?? defaultRoot).path, actionTitle: "Change", action: chooseFolder) + SettingsSubheading("Play") + SeleneToggleRow("Keep Steam in the background", + subtitle: "Steam stays out of sight while a game starts, and comes back if it needs you.", + info: "When Play opens Steam, it starts without its window. An open Steam is hidden until the game appears. If the game takes longer than 15 seconds, Steam comes back so you can answer it.", + isOn: $settings.settings.steamInBackground) + if let error = sources.errorMessage { + Text(error).seleneFont(14).foregroundColor(SeleneTheme.danger) + .fixedSize(horizontal: false, vertical: true).frame(maxWidth: .infinity, alignment: .leading).padding(24) + } + if sources.reviewCount > 0 { + Text(L10n.count("%lld Steam games need review", sources.reviewCount, context: localization)) + .seleneFont(15).foregroundColor(SeleneTheme.ink60).padding(.vertical, 10) + } + if !review.isEmpty { + SettingsSubheading("Review installations") + Text(L10n.text("Check these installations in Steam, then refresh this source. Only recognized native Mac games appear in the library.", context: localization)) + .seleneFont(14).foregroundColor(SeleneTheme.ink60).fixedSize(horizontal: false, vertical: true) + ForEach(review) { game in + SeleneRow(game.name, id: "review-\(game.id)", subtitle: "Needs review", verbatimTitle: true) { + Button(L10n.text("Reveal", context: localization)) { reveal(game) }.buttonStyle(.plain).foregroundColor(SeleneTheme.glyph) + } + } + } + if !hidden.isEmpty { + SettingsSubheading("Hidden Steam games") + ForEach(hidden) { game in + SeleneRow(game.name, id: "hidden-\(game.id)", subtitle: "Hidden from the library", verbatimTitle: true) { + Button(L10n.text("Show", context: localization)) { viewModel.setCatalogHidden(false, game: game) } + .buttonStyle(.plain).foregroundColor(SeleneTheme.glyph) + } + } + } + } + .onAppear(perform: publishActions) + .onChange(of: sources.status) { _ in publishActions() } + .onChange(of: sources.configuration) { _ in publishActions() } + .onChange(of: viewModel.catalogGames) { _ in publishActions() } + } + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false + panel.directoryURL = sources.configuration.rootURL ?? defaultRoot + panel.message = L10n.text("Choose the Steam folder containing steamapps.", context: localization) + guard panel.runModal() == .OK, let chosen = panel.url else { return } + let root = chosen.resolvingSymlinksInPath().standardizedFileURL + let apps = root.appendingPathComponent("steamapps").resolvingSymlinksInPath().standardizedFileURL + var directory: ObjCBool = false + guard apps.path.hasPrefix(root.path + "/"), FileManager.default.fileExists(atPath: apps.path, isDirectory: &directory), directory.boolValue else { + viewModel.showSettingsNotice(title: L10n.text("Steam folder", context: localization), message: L10n.text("Choose the Steam folder containing steamapps.", context: localization)) + return + } + sources.configure(enabled: sources.configuration.isEnabled, root: root) + } + private func reveal(_ game: CatalogGame) { + guard let location = game.steamGame?.installations.first?.contentURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([location]) + } + private func publishActions() { + var rows: [SettingsRowAction] = [ + .init(id: "steamSource", title: "Steam") { sources.configure(enabled: !sources.configuration.isEnabled, root: sources.configuration.rootURL ?? defaultRoot) }, + .init(id: "steamRefresh", title: "Refresh Steam", isEnabled: sources.configuration.isEnabled) { + if sources.status == .scanning { sources.cancel() } else { sources.refresh() } + }, + .init(id: "steamFolder", title: "Steam folder", activate: chooseFolder), + .init(id: "steamInBackground", title: "Keep Steam in the background") { settings.settings.steamInBackground.toggle() } + ] + rows += review.map { game in .init(id: "review-\(game.id)", title: game.name, rowID: "review-\(game.id)") { reveal(game) } } + rows += hidden.map { game in .init(id: "hidden-\(game.id)", title: game.name, rowID: "hidden-\(game.id)") { viewModel.setCatalogHidden(false, game: game) } } + onActions(rows) + } +} diff --git a/Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift b/Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift index 31a5758..0bcaea2 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift @@ -2,7 +2,6 @@ import SwiftUI import NXLibraryCore struct SettingsStartUpSection: View { - @ObservedObject var viewModel: AppViewModel @ObservedObject var settings: AppSettingsStore let onActions: ([SettingsRowAction]) -> Void let onChooseLanguage: () -> Void @@ -17,13 +16,6 @@ struct SettingsStartUpSection: View { } } - private var readiness: Bool { - viewModel.isProdKeysValid - && viewModel.isNxnativeValid - && viewModel.environmentStatus?.canBuild == true - && viewModel.canPassToolchainGate - } - var body: some View { VStack(spacing: canvas.u(8)) { SettingsSubheading(L10n.text("Language & region")) @@ -50,15 +42,6 @@ struct SettingsStartUpSection: View { subtitle: "Applies at the next launch.", isOn: $settings.settings.startInFullscreen) - SeleneToggleRow("Check engine at launch", - subtitle: "Verify the pinned engine commit and toolchain when the app starts.", - info: "Checks the nxnative checkout against the certified engine commit and confirms your installed Metal compiler matches the lock file before builds are attempted.", - isOn: $settings.settings.checkEngineAtLaunch) - - SeleneToggleRow("Refresh library at launch", - subtitle: "Rescan the app library for new or removed builds.", - isOn: $settings.settings.refreshLibraryAtLaunch) - SeleneToggleRow("Check for app updates", subtitle: "Look for a newer NX Library release at launch, at most once a day.", isOn: $settings.settings.checkAppUpdatesAtLaunch) @@ -66,59 +49,12 @@ struct SettingsStartUpSection: View { SeleneToggleRow("Intro animation", subtitle: "Plays a short opening with the boot sound when the app opens.", isOn: $settings.settings.introAnimation) - - SettingsSubheading(L10n.text("Setup")) - - SeleneButtonRow("Setup", - subtitle: "Open the Setup check: what NX Library needs and what is ready.", - buttonTitle: "Open Setup") { - viewModel.reopenSetup() - } - .disabled(viewModel.isBuilding || viewModel.isUpdatingApp) - - SeleneButtonRow("Environment", - subtitle: "Re-run the environment and compatibility checks.", - buttonTitle: "Re-check") { - viewModel.detectEnvironment() - } - .disabled(viewModel.isBuilding || viewModel.isUpdatingApp) - - SelenePanel(L10n.text("Readiness"), - info: L10n.text("Summarizes the 4 essential gates: valid prod.keys, cloned nxnative repository, Python build environment, and certified Metal compiler version.")) { - HStack(spacing: 9) { - Circle() - .fill(readiness ? SeleneTheme.success : SeleneTheme.untested) - .frame(width: 8, height: 8) - Text(viewModel.isCheckingEnvironment ? L10n.text("Checking…") - : viewModel.environmentStatus == nil && viewModel.setupCompleted ? L10n.text("Not checked") - : readiness ? L10n.text("Ready to build and play") : L10n.text("Setup needed or builds blocked")) - .seleneFont(16, .semibold) - .foregroundColor(SeleneTheme.ink) - Spacer() - } - - Text(readinessLine) - .seleneFont(13, .regular, design: .monospaced) - .foregroundColor(SeleneTheme.ink60) - .fixedSize(horizontal: false, vertical: true) - } } .onAppear(perform: publishActions) - .onChange(of: viewModel.isBuilding) { _ in publishActions() } - .onChange(of: viewModel.isUpdatingApp) { _ in publishActions() } .onChange(of: settings.settings.appLanguage) { _ in publishActions() } .onChange(of: settings.settings.appRegion) { _ in publishActions() } } - private var readinessLine: String { - let engine = seleneShort(viewModel.pinnedEngineCommit) - let metal = viewModel.installedMetalVersion ?? L10n.text("Metal unknown") - let xcode = viewModel.environmentStatus?.xcodeVersion ?? L10n.text("Xcode unknown") - let python = viewModel.environmentStatus?.pythonURL == nil ? L10n.text("Python missing") - : viewModel.engineToolchain.isBundled ? L10n.text("Python included") : L10n.text("Python venv ready") - return L10n.format("Engine %@ · %@ · %@ · %@", [String(describing: (engine)), String(describing: (metal)), String(describing: (xcode)), String(describing: (python))]) - } - private func publishActions() { onActions([ .init(id: "appLanguage", title: "App language", activate: onChooseLanguage), @@ -132,23 +68,11 @@ struct SettingsStartUpSection: View { .init(id: "startupFullscreen", title: "Start in fullscreen") { settings.settings.startInFullscreen.toggle() }, - .init(id: "engine", title: "Check engine at launch") { - settings.settings.checkEngineAtLaunch.toggle() - }, - .init(id: "refresh", title: "Refresh library at launch") { - settings.settings.refreshLibraryAtLaunch.toggle() - }, .init(id: "appUpdates", title: "Check for app updates") { settings.settings.checkAppUpdatesAtLaunch.toggle() }, .init(id: "intro", title: "Intro animation") { settings.settings.introAnimation.toggle() - }, - .init(id: "setup", title: "Setup", isEnabled: !viewModel.isBuilding && !viewModel.isUpdatingApp) { - viewModel.reopenSetup() - }, - .init(id: "recheck", title: "Environment", isEnabled: !viewModel.isBuilding && !viewModel.isUpdatingApp) { - viewModel.detectEnvironment() } ]) } diff --git a/Sources/NXLibrary/Views/Settings/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index 02b481d..f93114f 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -12,6 +12,8 @@ public struct SettingsView: View { @ObservedObject var router: AppRouter @ObservedObject var artwork: ArtworkStore + /// Opens the Search field when the player asks for it (`enterSection`). + @State private var searchFieldRequest = 0 @State private var section: SettingsSection @State private var showingControls = false @State private var localePicker: LocalePickerMode? @@ -124,7 +126,7 @@ public struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: canvas.u(8)) { - Text(section.title) + Text(section.place) .seleneFont(15, .semibold) .foregroundColor(SeleneTheme.ink.opacity(0.55)) .padding(.bottom, canvas.u(6)) @@ -139,6 +141,7 @@ public struct SettingsView: View { onOpenConsole: { router.openConsole() }, onOpenGroup: openGroup, onSearchSubmit: enterRows, + searchFieldRequest: searchFieldRequest, onActions: updateActions ) } @@ -170,7 +173,7 @@ public struct SettingsView: View { hints.append(HintItem(.x, L10n.text("Reset group")) { resetSection() }) } if focusArea == .sidebar { - hints.append(HintItem(.a, L10n.text("Open")) { enterRows() }) + hints.append(HintItem(.a, L10n.text("Open")) { enterSection() }) } else if focusedRow?.cycle != nil { hints.append(HintItem(.a, L10n.text("Change"))) } else { @@ -201,6 +204,17 @@ public struct SettingsView: View { SoundEffectService.shared.play(.click) } + /// A or Right on the sidebar. Search opens its field; every other section enters its rows. + private func enterSection() { + guard focusArea == .sidebar else { return } + if section == .search { + searchFieldRequest += 1 + SoundEffectService.shared.play(.focus) + } else { + enterRows() + } + } + private func enterRows() { guard focusArea == .sidebar, !rowActions.isEmpty else { return } focusArea = .rows @@ -217,7 +231,7 @@ public struct SettingsView: View { switch section { case .startUp, .navigation, .spotlight, .soundMusic, .look, .games, .controllers, .library: return true - case .search, .changed, .engine, .storage, .about: + case .search, .changed, .sources, .engine, .storage, .about: return false } } @@ -233,8 +247,6 @@ public struct SettingsView: View { next.startOn = defaults.startOn next.restoreLastGame = defaults.restoreLastGame next.startInFullscreen = defaults.startInFullscreen - next.checkEngineAtLaunch = defaults.checkEngineAtLaunch - next.refreshLibraryAtLaunch = defaults.refreshLibraryAtLaunch next.checkAppUpdatesAtLaunch = defaults.checkAppUpdatesAtLaunch next.introAnimation = defaults.introAnimation case .navigation: @@ -282,11 +294,12 @@ public struct SettingsView: View { next.gameDefaults.rumbleGain = defaults.gameDefaults.rumbleGain case .library: resetNotice = L10n.text("Sources folder kept") + next.refreshLibraryAtLaunch = defaults.refreshLibraryAtLaunch next.notifyPortingEnds = defaults.notifyPortingEnds viewModel.libraryFolder = URL(fileURLWithPath: viewModel.defaultInstallPath) .appendingPathComponent("Applications") viewModel.loadLibrary() - case .search, .changed, .engine, .storage, .about: + case .search, .changed, .sources, .engine, .storage, .about: break } settings.settings = next @@ -307,7 +320,7 @@ public struct SettingsView: View { private func moveSideways(_ delta: Int) { switch focusArea { case .sidebar: - if delta > 0 { enterRows() } + if delta > 0 { enterSection() } case .rows: if let cycle = focusedRow?.cycle, focusedRow?.isEnabled == true { cycle(delta) @@ -370,7 +383,6 @@ public struct SettingsView: View { if typing { return event } action = .confirm case 53: - if typing { window.makeFirstResponder(nil) } action = .cancel case 7: // X if typing { return event } @@ -406,7 +418,7 @@ public struct SettingsView: View { case .navigateRight: moveSideways(1) case .confirm: if focusArea == .sidebar { - enterRows() + enterSection() } else if let row = focusedRow, row.isEnabled { SoundEffectService.shared.play(.click) row.press(button: rowButton) @@ -422,8 +434,12 @@ public struct SettingsView: View { } } + /// B or Esc: steps back one level. Typing in Search, it closes the field and stays on Search. private func handleBack() { - if focusArea == .rows { + if let window = NSApp.keyWindow, window.firstResponder is NSText { + window.makeFirstResponder(nil) + SoundEffectService.shared.play(.focus) + } else if focusArea == .rows { leaveRows() } else { router.back() diff --git a/Sources/NXLibrary/Views/Shell/AppShellState.swift b/Sources/NXLibrary/Views/Shell/AppShellState.swift new file mode 100644 index 0000000..6fa8e02 --- /dev/null +++ b/Sources/NXLibrary/Views/Shell/AppShellState.swift @@ -0,0 +1,27 @@ +import NXLibraryCore + +/// Prepared before the shell enters SwiftUI's view graph. Model startup can +/// update AppKit and shared observable services; it must not run in a view's +/// StateObject creation while replacing the already-visible preparation screen. +@MainActor +final class AppShellState { + let settings: AppSettingsStore + let viewModel: AppViewModel + let router: AppRouter + let sources: CatalogSourcesModel + + init(settings: AppSettingsStore, catalog: CatalogDocument, repository: CatalogRepository) { + self.settings = settings + AppLocalization.shared.configure(settings.settings) + ThemeStore.shared.apply(id: settings.settings.colorThemeID) + let model = AppViewModel(settingsStore: settings, catalog: catalog, repository: repository) + viewModel = model + sources = CatalogSourcesModel(repository: repository, initialConfiguration: catalog.source(.steam) ?? .init(integration: .steam)) { [weak model] document in + model?.applyCatalog(document) + } + let router = AppRouter() + router.setInitialRoute(AppRouter.initialRoute(for: settings.settings.startOn, last: settings.settings.lastHome)) + router.isIntroShowing = settings.settings.introAnimation + self.router = router + } +} diff --git a/Sources/NXLibrary/Views/Shell/AppShellView.swift b/Sources/NXLibrary/Views/Shell/AppShellView.swift index 7cf701b..95b5f6d 100644 --- a/Sources/NXLibrary/Views/Shell/AppShellView.swift +++ b/Sources/NXLibrary/Views/Shell/AppShellView.swift @@ -12,6 +12,7 @@ public struct AppShellView: View { @StateObject private var runningGames = RunningGameMonitor() @StateObject private var sessionCard = SessionCardPresenter() @StateObject private var updater = AppUpdater() + @StateObject private var sources: CatalogSourcesModel @StateObject private var nativeMenus = NativeMenuCommands.shared @ObservedObject private var theme = ThemeStore.shared @ObservedObject private var localization = AppLocalization.shared @@ -22,10 +23,11 @@ public struct AppShellView: View { @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion - public init(settingsStore store: AppSettingsStore) { - _settings = StateObject(wrappedValue: store) - _viewModel = StateObject(wrappedValue: AppViewModel(settingsStore: store)) - _router = StateObject(wrappedValue: Self.startingRouter(saved: store.settings)) + @MainActor init(state: AppShellState) { + _sources = StateObject(wrappedValue: state.sources) + _settings = StateObject(wrappedValue: state.settings) + _viewModel = StateObject(wrappedValue: state.viewModel) + _router = StateObject(wrappedValue: state.router) } private func updateLocale() { @@ -36,16 +38,6 @@ public struct AppShellView: View { } } - /// The router, already on the screen the Start on setting names. - private static func startingRouter(saved: AppSettings) -> AppRouter { - // The palette is in place before the first frame. - ThemeStore.shared.apply(id: saved.colorThemeID) - let router = AppRouter() - router.setInitialRoute(AppRouter.initialRoute(for: saved.startOn, last: saved.lastHome)) - router.isIntroShowing = saved.introAnimation - return router - } - private var backdropStyle: SeleneBackdropStyle { SeleneBackdropStyle(wallpaperURL: theme.wallpaper(for: settings.settings.wallpaperID)?.url, sizing: settings.settings.backgroundSizing, @@ -86,11 +78,18 @@ public struct AppShellView: View { private var nativeMenuAvailability: NativeMenuAvailability { let general = !router.isIntroShowing && !viewModel.showSetupCheck && !isCloseGameOpen && !router.isScreenshotViewerOpen && !router.isSearchPresented - && viewModel.gameForSettingsSheet == nil && viewModel.launchOverlay == nil && NSApp.modalWindow == nil + && !viewModel.isGameOptionsOpen && viewModel.launchOverlay == nil && NSApp.modalWindow == nil && NSApp.keyWindow?.attachedSheet == nil && NSApp.keyWindow?.isSheet != true let game = general && !router.isMenuPresented ? viewModel.currentFocusedGame : nil - return NativeMenuAvailability(general: general, play: game?.isPlayableLocally == true, - details: isHomeRoute && game != nil, options: game != nil) + let focused = general && !router.isMenuPresented ? viewModel.currentFocusedCatalogGame : nil + let steam = focused?.integration == .steam ? focused : nil + // A Steam game's details are its Spotlight page; it has no separate Details screen. + let catalog = viewModel.currentFocusedCatalogGame + return NativeMenuAvailability(general: general, + play: game?.isPlayableLocally == true || steam.map(SteamPlayCoordinator.shared.canPlay) == true, + details: isHomeRoute && catalog != nil && (catalog?.integration != .steam || router.route == .library), + options: game != nil || steam != nil, + catalogRecovery: general && !viewModel.isBuilding && !viewModel.catalogBuildRecoveries.isEmpty) } /// The launch screen sits over everything, menu included, until the game's window shows. @@ -115,8 +114,8 @@ public struct AppShellView: View { SeleneTheme.scrim.ignoresSafeArea() content // Game options, Add game and the console blur the screen behind instead of darkening it. - .blur(radius: viewModel.gameForSettingsSheet == nil && !router.route.isOverlay ? 0 : 10) - .allowsHitTesting(!router.route.isOverlay && viewModel.gameForSettingsSheet == nil) + .blur(radius: !viewModel.isGameOptionsOpen && !router.route.isOverlay ? 0 : 10) + .allowsHitTesting(!router.route.isOverlay && !viewModel.isGameOptionsOpen) if router.route == .addGame { AddGameView(viewModel: viewModel, router: router, @@ -215,6 +214,21 @@ public struct AppShellView: View { .transition(overlayTransition) .zIndex(2) } + if let game = viewModel.steamOptionsGame { + SteamOptionsView( + viewModel: viewModel, + game: game, + origin: viewModel.gameOptionsOrigin, + onClose: { viewModel.steamOptionsGame = nil }, + onMove: { + viewModel.steamOptionsGame = nil + router.requestSpotlightMove(SpotlightItem.game(game.id).key) + } + ) + .id(game.id) + .transition(overlayTransition) + .zIndex(2) + } } } @@ -234,7 +248,7 @@ public struct AppShellView: View { // Inside the canvas root so the sheet inherits the window's canvas units. .seleneCanvasRoot() .animation(reduceMotion ? nil : SeleneTheme.screen, - value: viewModel.gameForSettingsSheet?.id) + value: viewModel.optionsGameID) .animation(reduceMotion ? nil : SeleneTheme.quick, value: router.isMenuPresented) .animation(reduceMotion ? nil : SeleneTheme.screen, value: router.isThemePickerOpen) @@ -256,6 +270,8 @@ public struct AppShellView: View { .environment(\.seleneShowButtonHints, settings.settings.showButtonHints) .environment(\.seleneButtonNames, settings.settings.buttonNames) .environmentObject(updater) + .environmentObject(sources) + .onAppear { sources.startIfEnabled() } .preferredColorScheme(.dark) .frame(minWidth: 1100, minHeight: 700) .onAppear(perform: startRuntime) @@ -271,11 +287,18 @@ public struct AppShellView: View { } .onDisappear { runningGames.stop() + SteamPlayCoordinator.shared.stop() removeGestureKeyMonitor() } .onChange(of: viewModel.libraryGames) { games in runningGames.setGames(games) } + .onChange(of: viewModel.catalogGames) { games in + SteamPlayCoordinator.shared.setGames(games, sources: viewModel.catalogSourceConfigurations) + } + .onChange(of: viewModel.catalogSourceConfigurations) { sources in + SteamPlayCoordinator.shared.setGames(viewModel.catalogGames, sources: sources) + } .onReceive(runningGames.$endedSession) { summary in sessionCard.ended(summary, enabled: settings.settings.showsSessionCard) } @@ -308,35 +331,38 @@ public struct AppShellView: View { styled .environment(\.locale, localization.context.formatLocale) .environment(\.nxAppLocale, localization.context) + .environment(\.catalogPersonalizations, viewModel.personalizations) .onChange(of: settings.settings.appLanguage) { _ in updateLocale() } .onChange(of: settings.settings.appRegion) { _ in updateLocale() } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryPlayFocused)) { _ in guard !router.isMenuPresented, nativeMenuAvailability.play, - viewModel.gameForSettingsSheet == nil, - !isCloseGameOpen, - let game = viewModel.currentFocusedGame else { return } - viewModel.launchGame(game) + !viewModel.isGameOptionsOpen, + !isCloseGameOpen else { return } + if let steam = viewModel.currentFocusedCatalogGame, steam.integration == .steam { + SteamPlayCoordinator.shared.press(steam) + } else if let game = viewModel.currentFocusedGame { + viewModel.launchGame(game) + } } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenDetails)) { _ in guard !router.isMenuPresented, - viewModel.gameForSettingsSheet == nil, + !viewModel.isGameOptionsOpen, !isCloseGameOpen, isHomeRoute, - let game = viewModel.currentFocusedGame else { return } + let game = viewModel.currentFocusedCatalogGame else { return } if router.route == .library { viewModel.spotlight.showGameDetails() } - else { router.showDetail(game.id) } + else if game.integration != .steam { router.showDetail(game.id) } } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenGameOptions)) { _ in guard nativeMenuAvailability.options else { return } switch router.route { - case .library: - if let game = viewModel.currentFocusedGame { - viewModel.openGameOptions(game, from: .spotlight) - } - case .collection: - if let game = viewModel.currentFocusedGame { - viewModel.openGameOptions(game, from: .collection) + case .library, .collection: + let origin: GameOptionsOrigin = router.route == .library ? .spotlight : .collection + if let steam = viewModel.currentFocusedCatalogGame, steam.integration == .steam { + viewModel.openSteamOptions(steam, from: origin) + } else if let game = viewModel.currentFocusedGame { + viewModel.openGameOptions(game, from: origin) } case .detail(let id): if let game = viewModel.libraryGames.first(where: { $0.id == id }) { @@ -356,21 +382,25 @@ public struct AppShellView: View { updater.checkNow() } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenMenu)) { _ in - guard !isCloseGameOpen, viewModel.gameForSettingsSheet == nil, !router.isScreenshotViewerOpen else { return } + guard !isCloseGameOpen, !viewModel.isGameOptionsOpen, !router.isScreenshotViewerOpen else { return } router.isMenuPresented = true } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenSettings)) { _ in - guard !isCloseGameOpen, !router.isScreenshotViewerOpen, viewModel.gameForSettingsSheet == nil else { return } + guard !isCloseGameOpen, !router.isScreenshotViewerOpen, !viewModel.isGameOptionsOpen else { return } router.closeMenu() router.openSettings(.startUp) } + .onReceive(NotificationCenter.default.publisher(for: .nxLibraryRetryCatalogSave)) { _ in + guard nativeMenuAvailability.catalogRecovery else { return } + viewModel.retrySavingBuildResults() + } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenConsole)) { _ in - guard !isCloseGameOpen, !router.isScreenshotViewerOpen, viewModel.gameForSettingsSheet == nil else { return } + guard !isCloseGameOpen, !router.isScreenshotViewerOpen, !viewModel.isGameOptionsOpen else { return } router.closeMenu() router.openConsole() } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryPortingNoticeOpened)) { note in - guard !isCloseGameOpen, !router.isScreenshotViewerOpen, viewModel.gameForSettingsSheet == nil, + guard !isCloseGameOpen, !router.isScreenshotViewerOpen, !viewModel.isGameOptionsOpen, let id = (note.userInfo?["gameID"] as? String).flatMap(UUID.init(uuidString:)) else { return } router.closeMenu() if note.userInfo?["outcome"] as? String == "failed" { @@ -387,13 +417,13 @@ public struct AppShellView: View { } } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenLibrary)) { _ in - guard !isCloseGameOpen, !router.isScreenshotViewerOpen, viewModel.gameForSettingsSheet == nil else { return } + guard !isCloseGameOpen, !router.isScreenshotViewerOpen, !viewModel.isGameOptionsOpen else { return } router.closeMenu() router.showLibrary() } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenAddGame)) { _ in guard !isCloseGameOpen, !router.isMenuPresented, !router.isScreenshotViewerOpen, - viewModel.gameForSettingsSheet == nil else { return } + !viewModel.isGameOptionsOpen else { return } router.closeMenu() openFreshAddGame() } @@ -410,7 +440,7 @@ public struct AppShellView: View { viewModel.dismissRemoveGame() } else if showsUpdateOffer { updater.later() - } else if viewModel.gameForSettingsSheet != nil { + } else if viewModel.isGameOptionsOpen { // Game options steps back (dialog, then Edit details) or closes. NotificationCenter.default.post(name: .nxGameOptionsGamepadAction, object: GamepadAction.cancel) } else if router.isMenuPresented { @@ -432,6 +462,9 @@ public struct AppShellView: View { .onChange(of: viewModel.alertMessage) { _ in presentLegacyNotice() } + .onChange(of: viewModel.settingsNotice?.id) { id in + if id == nil { presentLegacyNotice() } + } } /// The current screen; under Add game or the console, the screen they were opened from. @@ -492,9 +525,12 @@ public struct AppShellView: View { SoundEffectService.shared.bind(to: settings) artwork.trimCache() viewModel.refreshBoxArtIndex() - if settings.settings.checkEngineAtLaunch { SetupCheckModel.checkAtLaunch(viewModel: viewModel) } + if settings.settings.checkEngineAtLaunch && !AppData.profile.isDevelopment { SetupCheckModel.checkAtLaunch(viewModel: viewModel) } runningGames.start(sessions: playSessions) runningGames.setGames(viewModel.libraryGames) + // After the nxnative reconcile, which closes a Steam session left by a restart at its last heartbeat. + SteamPlayCoordinator.shared.setGames(viewModel.catalogGames, sources: viewModel.catalogSourceConfigurations) + SteamPlayCoordinator.shared.start(sessions: playSessions, settings: settings) viewModel.installPendingBuilds() viewModel.syncPendingAppIcons() closeGameWatch = runningGames.$runningGameIDs.sink { [weak viewModel] ids in @@ -573,7 +609,7 @@ public struct AppShellView: View { menuInput(action) return } - if viewModel.gameForSettingsSheet != nil { + if viewModel.isGameOptionsOpen { NotificationCenter.default.post(name: .nxGameOptionsGamepadAction, object: action) return } @@ -640,10 +676,11 @@ public struct AppShellView: View { viewModel.settingsNotice = nil notice?.onDismiss?() if choice == 1 { notice?.retry?() } + presentLegacyNotice() } private func presentLegacyNotice() { - guard let message = viewModel.alertMessage else { return } + guard viewModel.settingsNotice == nil, let message = viewModel.alertMessage else { return } viewModel.alertMessage = nil viewModel.showSettingsNotice(title: L10n.text("NX Library"), message: message) } @@ -983,6 +1020,10 @@ public struct AppShellView: View { case .refreshLibrary: router.closeMenu() viewModel.refreshLibrary() + if sources.configuration.isEnabled { sources.refresh() } + case .manageSources: + router.closeMenu() + router.openSettings(.sources) case .settings: router.closeMenu() router.openSettings(.startUp) @@ -1079,7 +1120,7 @@ private struct SoundCueHooks: ViewModifier { .onChange(of: router.isMenuPresented) { open in SoundEffectService.shared.play(open ? .openPanel : .back) } - .onChange(of: viewModel.gameForSettingsSheet?.id) { id in + .onChange(of: viewModel.optionsGameID) { id in SoundEffectService.shared.play(id == nil ? .back : .openPanel) } .onChange(of: viewModel.gameToClose?.id) { id in diff --git a/Sources/NXLibrary/Views/Spotlight/GameArtLoader.swift b/Sources/NXLibrary/Views/Spotlight/GameArtLoader.swift index 95fea85..46a1a64 100644 --- a/Sources/NXLibrary/Views/Spotlight/GameArtLoader.swift +++ b/Sources/NXLibrary/Views/Spotlight/GameArtLoader.swift @@ -30,9 +30,9 @@ final class GameArtLoader { /// The player's pick, local hero files, the disk cache, then the store / /// titledb banner. - func hero(for game: PortedGame, metadata: NintendoStoreMetadata?, artwork: ArtworkStore) async -> NSImage? { + func hero(for game: PortedGame, metadata: NintendoStoreMetadata?, artwork: ArtworkStore, personalizations: CatalogPersonalizations) async -> NSImage? { if let cached = cachedHero(for: game) { return cached } - let titleKey = game.titleId ?? game.uniqueKey + let titleKey = personalizations.nxnativeKey(for: game) var image: NSImage? if let picked = ArtworkPicks.shared.url(titleID: titleKey, slot: .background) { @@ -71,9 +71,9 @@ final class GameArtLoader { /// The player's pick, the disk cache, a local logo.png, the store logo then /// the online sources' logos in their order (Steam, GOG, TheGamesDB, /// Wikidata, Commons, the fan wikis). - func logo(for game: PortedGame, metadata: NintendoStoreMetadata?, artwork: ArtworkStore) async -> NSImage? { + func logo(for game: PortedGame, metadata: NintendoStoreMetadata?, artwork: ArtworkStore, personalizations: CatalogPersonalizations) async -> NSImage? { if let cached = cachedLogo(for: game) { return cached } - let titleKey = game.titleId ?? game.uniqueKey + let titleKey = personalizations.nxnativeKey(for: game) var image: NSImage? // Every read and decode runs off the main thread (`decodeLogo`); a diff --git a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift index e7db59b..df5238c 100644 --- a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift +++ b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift @@ -34,7 +34,7 @@ final class SeleneHeroArt: ObservableObject { /// `current` re-reads the screen's selected game after each await. /// `settle` is the pause before loading; Collection waits longer so its logo leads. func loadHero(game: PortedGame?, current: () -> UUID?, metadata: NintendoStoreMetadata?, - artwork: ArtworkStore, + artwork: ArtworkStore, personalizations: CatalogPersonalizations, settle: UInt64 = 150_000_000, keepsWithoutGame: Bool = false) async { guard let game else { // Spotlight keeps the last game's art over a card, so coming back to that game needs no reload. @@ -50,19 +50,57 @@ final class SeleneHeroArt: ObservableObject { try? await Task.sleep(nanoseconds: settle) guard !Task.isCancelled else { return } let loaded = await GameArtLoader.shared.hero( - for: game, metadata: metadata, artwork: artwork) + for: game, metadata: metadata, artwork: artwork, personalizations: personalizations) + guard !Task.isCancelled, current() == game.id else { return } + image = loaded + key = loaded.map { loadedFor + "\(ObjectIdentifier($0).hashValue)" } ?? "none" + } + + func steamHeroTaskKey(_ game: CatalogGame?) -> String { + guard let game, game.integration == .steam else { return "none" } + return "steam-hero|\(game.id)|\(SteamPersonalizations.shared.artwork.revision)" + } + + /// A Steam game's hero through the same image, key and crossfade as an nxnative one: the + /// player's pick, else the automatic choice kept from last time, else a new one (`SteamBackdropChoice`: + /// screen-shaped store art, LaunchBox's PC fan art, then the first screenshot, the library banner last). No game (or an + /// nxnative one) leaves the image to `loadHero`. + func loadSteamHero(game: CatalogGame?, current: () -> UUID?, artwork: ArtworkStore, + settle: UInt64 = 150_000_000) async { + guard let game, game.integration == .steam else { return } + let picks = SteamPersonalizations.shared.artwork + let loadedFor = "\(game.id.uuidString)|s\(picks.revision)|" + if key.hasPrefix(loadedFor) { return } + try? await Task.sleep(nanoseconds: settle) + guard !Task.isCancelled else { return } + let request = SteamArtworkRequest(game: game, role: .hero) + let titleKey = game.personalizationReference.key + var url = request.localURL(picks: picks) ?? artwork.cachedURL(titleID: titleKey, kind: .hero) + if url == nil, let id = request.appID { + await SteamGameFacts.shared.load(game, language: L10n.context.language) + let shots = SteamGameFacts.shared.metadata(for: game)?.screenshots ?? [] + let banner = SteamStoreArt.candidates(appID: Int(id), slot: .background).first?.url + let store = await SteamBackdropChoice.storeArt(names: [game.name]) + let order = SteamBackdropChoice.ordered(storeArt: store, screenshots: shots, libraryHero: banner) + if let chosen = await SteamBackdropChoice.choose(order, fallback: banner, data: { await SteamBackdropChoice.download($0) }), !Task.isCancelled { + url = await artwork.fetch(titleID: titleKey, remoteURL: chosen, kind: .hero) + } + } + guard !Task.isCancelled else { return } + // Decoded off the main thread like an nxnative hero; a lazily decoded file cost its first frame 40–60 ms. + let loaded: NSImage? = if let url { await GameArtLoader.decodeHero(at: url) } else { nil } guard !Task.isCancelled, current() == game.id else { return } image = loaded key = loaded.map { loadedFor + "\(ObjectIdentifier($0).hashValue)" } ?? "none" } func loadLogo(game: PortedGame?, metadata: NintendoStoreMetadata?, - artwork: ArtworkStore) async { + artwork: ArtworkStore, personalizations: CatalogPersonalizations) async { guard let game, GameArtLoader.shared.cachedLogo(for: game) == nil else { return } try? await Task.sleep(nanoseconds: 150_000_000) guard !Task.isCancelled else { return } let loaded = await GameArtLoader.shared.logo( - for: game, metadata: metadata, artwork: artwork) + for: game, metadata: metadata, artwork: artwork, personalizations: personalizations) guard loaded != nil, !Task.isCancelled else { return } withAnimation(.easeOut(duration: 0.3)) { logoTick += 1 } } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift index f6aeef3..de0f1d7 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift @@ -36,7 +36,7 @@ struct SpotlightGameInfoView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { logoArea - .offset(x: slid ? 0 : canvas.u(-30)) + .offset(x: slid ? 0 : canvas.u(Self.logoSlideDistance)) .opacity(shown ? 1 : 0) Group { if isRunning { @@ -76,29 +76,7 @@ struct SpotlightGameInfoView: View { } private var logoArea: some View { - ZStack(alignment: .bottomLeading) { - if let logo { - Image(nsImage: logo) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - // The logo box: 700 × 240 (195 plus a −45 top margin), bottom-left. - .frame(width: canvas.u(700), height: canvas.u(240), alignment: .bottomLeading) - .shadow(color: .black.opacity(0.35), radius: canvas.u(8)) - .transition(.opacity) - } else { - Text(game.name) - .seleneFont(56) - .foregroundColor(SeleneTheme.ink) - .lineLimit(1) - .truncationMode(.tail) - .shadow(color: .black.opacity(0.45), radius: canvas.u(6)) - } - } - .frame(width: canvas.u(900), height: canvas.u(195), alignment: .bottomLeading) - .accessibilityElement(children: .ignore) - .accessibilityLabel(game.name) - .accessibilityAddTraits(.isHeader) + GameLogoArea(title: game.name, logo: logo) } /// The time left while porting, or the last failure over a working app. @@ -155,16 +133,11 @@ struct SpotlightGameInfoView: View { } private var normalPills: some View { - HStack(spacing: canvas.u(14)) { - pill(0, SelenePillButton(title: primaryTitle, height: 52, focused: focusedPill == 0, - enabled: isRunning || pillSet.unavailableReason == nil) { onPill(0) }) - pill(1, SelenePillButton(title: isRunning ? L10n.text("Close Game") : L10n.text("Options"), height: 52, - focused: focusedPill == 1) { onPill(1) }) - pill(2, SelenePillButton(title: nil, icon: game.isFavorite ? .favorite : .favoriteBorder, - iconSize: 20, minWidth: 52, height: 52, horizontalPadding: 0, - focused: focusedPill == 2) { onPill(2) }) - .accessibilityLabel(game.isFavorite ? L10n.text("Remove from favorites") : L10n.text("Add to favorites")) - } + GamePrimaryActions(primaryTitle: primaryTitle, + secondaryTitle: isRunning ? "Close Game" : "Options", + isFavorite: game.isFavorite, focused: focusedPill, + primaryEnabled: isRunning || pillSet.unavailableReason == nil, + onAction: onPill, onHover: onHoverPill) } private var primaryTitle: String { @@ -199,6 +172,10 @@ struct SpotlightGameInfoView: View { /// The pills and stats' fade after a change (the genres below come in with them, `SpotlightInfoFollow`). static let restMotion = Animation.easeOut(duration: 0.4).delay(0.42) + /// The logo's fade and slide after a change. A Steam game's head uses the same three motions. + static let logoFade = Animation.easeOut(duration: 0.45).delay(0.3) + static let logoSlide = SeleneTheme.cubicOut(0.55).delay(0.3) + static let logoSlideDistance: CGFloat = -30 /// Reduce motion keeps the fades and drops the slide. private func reveal() { @@ -210,10 +187,10 @@ struct SpotlightGameInfoView: View { slid = reduceMotion } DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.45).delay(0.3)) { shown = true } + withAnimation(Self.logoFade) { shown = true } withAnimation(Self.restMotion) { restShown = true } if !reduceMotion { - withAnimation(SeleneTheme.cubicOut(0.55).delay(0.3)) { slid = true } + withAnimation(Self.logoSlide) { slid = true } } } } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift index 78a1f23..2c20eb5 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift @@ -12,6 +12,7 @@ struct SpotlightRowView: View { let items: [SpotlightItem] let gamesByID: [UUID: PortedGame] + var catalogGamesByID: [UUID: CatalogGame] = [:] let selectedKey: String? /// The tile lifted for moving: raised 12u, its ring stays. var liftedKey: String? = nil @@ -143,6 +144,8 @@ struct SpotlightRowView: View { .portingLook(tileLook(game), cornerRadius: canvas.u(12)) .frame(width: side, height: side) .clipShape(face) + } else if let game = catalogGamesByID[id] { + CatalogNativeArtwork(game: game, role: .square).frame(width: side, height: side).clipShape(face) } case .card(.mods): ModsCardCover(urls: modsCover, side: side) @@ -169,7 +172,7 @@ struct SpotlightRowView: View { private func title(of item: SpotlightItem) -> String { switch item { case .card(let card): return card.title - case .game(let id): return gamesByID[id]?.name ?? "" + case .game(let id): return gamesByID[id]?.name ?? catalogGamesByID[id]?.name ?? "" } } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift index 57452d5..91169b0 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift @@ -36,7 +36,7 @@ extension SpotlightView { case .navigateDown where selectedItem == .card(.mods) && viewModel.spotlight.focus == .row: enterStore() case .navigateDown: - let isGame = selectedGame != nil + let isGame = selectedCatalogGame != nil let isActivity = selectedItem == .card(.activity) mutate { moved = $0.moveDown(selectedIsGame: isGame, screenshotCount: shots, @@ -58,10 +58,15 @@ extension SpotlightView { mutate { if $0.focus != .row { $0.focus = .row; moved = true } } case .secondaryAction: // X scrolls to the game's page (the pills), like Down from the row. - if selectedGame != nil { + if selectedGame != nil || selectedCatalogGame?.integration == .steam { mutate { if $0.focus == .row { moved = $0.showGameDetails() } } } case .contextAction: + if let steam = selectedCatalogGame, steam.integration == .steam { + SoundEffectService.shared.play(.click) + viewModel.openSteamOptions(steam, from: .spotlight) + return + } if let game = selectedGame { viewModel.openGameOptions(game, from: .spotlight) } else if case .card(let card) = selectedItem { @@ -82,6 +87,16 @@ extension SpotlightView { if moved { SoundEffectService.shared.play(.focus) } } + func pressCatalogPill(_ index: Int, game: CatalogGame) { + switch index { + // The positions of Play, Options and favorite, as for an nxnative game. + case 0: SoundEffectService.shared.play(.click); SteamPlayCoordinator.shared.press(game) + case 1: SoundEffectService.shared.play(.click); viewModel.openSteamOptions(game, from: .spotlight) + case 2: viewModel.setCatalogFavorite(game) + default: break + } + } + private func confirm() { let state = viewModel.spotlight switch state.focus { @@ -89,6 +104,7 @@ extension SpotlightView { if let item = selectedItem { activate(item) } case .pills: if let game = selectedGame { pressPill(state.pillIndex, game: game) } + else if let game = selectedCatalogGame { pressCatalogPill(state.pillIndex, game: game) } case .strip: openViewer(at: state.stripIndex) case .about: @@ -116,11 +132,15 @@ extension SpotlightView { // MARK: - Screenshot viewer func openViewer(at index: Int) { - guard let game = selectedGame else { return } - let urls = Array((viewModel.storeMetadata[game.id]?.screenshotURLs ?? []) - .prefix(SpotlightScreenshotStrip.maxCount)) + let titleID: String, all: [URL] + if let game = selectedGame { + titleID = viewModel.personalizationKey(for: game); all = viewModel.storeMetadata[game.id]?.screenshotURLs ?? [] + } else if let game = selectedCatalogGame, game.integration == .steam { + titleID = game.personalizationReference.key; all = steamFacts.metadata(for: game)?.screenshots ?? [] + } else { return } + let urls = Array(all.prefix(SpotlightScreenshotStrip.maxCount)) guard !urls.isEmpty else { return } - viewer = ViewerContext(titleID: game.titleId ?? game.uniqueKey, urls: urls, + viewer = ViewerContext(titleID: titleID, urls: urls, state: ScreenshotViewerState(count: urls.count, start: index)) SoundEffectService.shared.play(.click) } @@ -162,12 +182,9 @@ extension SpotlightView { var order: [String]? mutate { order = $0.drop() } if let order { - // Under a filter, fold the visible order into the full one. - let full = SpotlightRow.build( - games: LibraryQuery(filter: .all).apply(to: viewModel.libraryGames, history: playSessions), - enabledCards: settings.settings.visibleSpotlightCards, - order: settings.settings.spotlightOrder).map(\.key) - settings.settings.spotlightOrder = SpotlightRow.merging(visibleOrder: order, into: full) + settings.settings.spotlightOrder = SpotlightRow.saving( + visibleOrder: order, gameIDs: viewModel.catalogGames.map(\.id), + previousOrder: settings.settings.spotlightOrder) } SoundEffectService.shared.play(.click) case .cancel: @@ -223,6 +240,9 @@ extension SpotlightView { func activate(_ item: SpotlightItem) { switch item { case .game(let id): + if let steam = viewModel.visibleCatalogGames.first(where: { $0.id == id }), steam.integration == .steam { + SoundEffectService.shared.play(.click); SteamPlayCoordinator.shared.press(steam); return + } guard let game = viewModel.libraryGames.first(where: { $0.id == id }) else { return } if viewModel.portingPills(game) == .porting { SoundEffectService.shared.play(.click) @@ -346,7 +366,7 @@ extension SpotlightView { !router.isIntroShowing, router.route == .library, viewModel.alertMessage == nil, viewModel.settingsNotice == nil, - viewModel.gameForSettingsSheet == nil, + !viewModel.isGameOptionsOpen, viewModel.gameToClose == nil, viewModel.gameToRemove == nil, !viewModel.isUpdateOfferOpen, diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index 819ae18..ef82cfa 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -18,6 +18,8 @@ public struct SpotlightView: View { @ObservedObject var router: AppRouter @ObservedObject var settings: AppSettingsStore @ObservedObject var playSessions: PlaySessionStore + /// Steam Play phases drive the A hint. + @ObservedObject var steamPlayer = SteamPlayCoordinator.shared @ObservedObject var runningGames: RunningGameMonitor @ObservedObject var artwork: ArtworkStore @@ -27,6 +29,9 @@ public struct SpotlightView: View { @StateObject var art = SeleneHeroArt() /// Art the player picks redraws the hero and logo (their task keys carry its revision). @ObservedObject var picks = ArtworkPicks.shared + /// Steam store facts and personal fields, shared with the Steam head. + @ObservedObject var steamFacts = SteamGameFacts.shared + @ObservedObject var steamFields = SteamPersonalizations.shared.metadata @State var keyMonitor: Any? @State var hadGames = false /// The card Y menu: which card and the focused row. @@ -100,8 +105,15 @@ public struct SpotlightView: View { LibraryQuery(filter: filter).apply(to: viewModel.libraryGames, history: playSessions) } + var rowGames: [CatalogGame] { + LibraryQuery(filter: filter).applyCatalog(to: viewModel.visibleCatalogGames, history: playSessions) + } + var selectedCatalogGame: CatalogGame? { + guard case .game(let id) = selectedItem else { return nil } + return viewModel.visibleCatalogGames.first { $0.id == id } + } var items: [SpotlightItem] { - SpotlightRow.build(games: games, + SpotlightRow.build(gameIDs: rowGames.map(\.id), enabledCards: settings.settings.visibleSpotlightCards, order: viewModel.spotlight.lift?.order ?? settings.settings.spotlightOrder, front: viewModel.frontGameID) @@ -120,6 +132,8 @@ public struct SpotlightView: View { let gamesByID: [UUID: PortedGame] let selectedItem: SpotlightItem? let selectedGame: PortedGame? + let catalogGamesByID: [UUID: CatalogGame] + let selectedCatalogGame: CatalogGame? } private func snapshot() -> Snapshot { @@ -128,7 +142,9 @@ public struct SpotlightView: View { let item = viewModel.spotlight.selectedItem(in: items) var game: PortedGame? if case .game(let id) = item { game = gamesByID[id] } - return Snapshot(items: items, gamesByID: gamesByID, selectedItem: item, selectedGame: game) + let catalog = Dictionary(viewModel.visibleCatalogGames.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + return Snapshot(items: items, gamesByID: gamesByID, selectedItem: item, selectedGame: game, + catalogGamesByID: catalog, selectedCatalogGame: selectedCatalogGame) } /// One pill while the selected game ports, three otherwise. @@ -137,6 +153,9 @@ public struct SpotlightView: View { } var screenshotCount: Int { + if let game = selectedCatalogGame, game.integration == .steam { + return min(steamFacts.metadata(for: game)?.screenshots.count ?? 0, SpotlightScreenshotStrip.maxCount) + } guard let game = selectedGame else { return 0 } let urls = viewModel.storeMetadata[game.id]?.screenshotURLs ?? [] return min(urls.count, SpotlightScreenshotStrip.maxCount) @@ -183,7 +202,8 @@ public struct SpotlightView: View { pillTask?.cancel() mutate { $0.cancelLift() } } - .onChange(of: viewModel.libraryGames) { _ in gamesChanged() } + .onChange(of: viewModel.catalogGames) { _ in gamesChanged() } + .onChange(of: viewModel.catalogSourceConfigurations) { _ in gamesChanged() } .onChange(of: filter) { newFilter in reconcile() filterChanged(to: newFilter) @@ -199,7 +219,7 @@ public struct SpotlightView: View { startModsOnline(focus) } // Game options (its Mods panel) closing, or NX Library coming back from Finder, re-reads the mods. - .onChange(of: viewModel.gameForSettingsSheet == nil) { closed in if closed { reloadMods() } } + .onChange(of: !viewModel.isGameOptionsOpen) { closed in if closed { reloadMods() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in reloadMods() } .onChange(of: blocks) { _ in syncPage() } // Inside the store the page glides section to section on a shorter curve. @@ -223,6 +243,10 @@ public struct SpotlightView: View { } .onChange(of: playSessions.sessions.count) { _ in reconcile() } .task(id: art.heroTaskKey(model.selectedGame, metadata: model.selectedGame.flatMap { viewModel.storeMetadata[$0.id] })) { await loadHero(model.selectedGame) } + .task(id: art.steamHeroTaskKey(model.selectedCatalogGame)) { + await art.loadSteamHero(game: model.selectedCatalogGame, current: { selectedCatalogGame?.id }, artwork: artwork, settle: 420_000_000) + if model.selectedCatalogGame?.integration == .steam, !Task.isCancelled { modsBackdrop = false } + } .task(id: art.logoTaskKey(model.selectedGame, metadata: model.selectedGame.flatMap { viewModel.storeMetadata[$0.id] })) { await loadLogo(model.selectedGame) } } @@ -254,6 +278,7 @@ public struct SpotlightView: View { SpotlightRowView( items: items, gamesByID: model.gamesByID, + catalogGamesByID: model.catalogGamesByID, selectedKey: state.selectedKey, liftedKey: state.lift?.key, isEmptyFilter: LibraryEmptyState.showsMessage( @@ -277,7 +302,7 @@ public struct SpotlightView: View { .opacity(rowShown ? 1 : 0) .allowsHitTesting(rowShown) - SpotlightFilterPill(filter: pillFilter, count: games.count) + SpotlightFilterPill(filter: pillFilter, count: rowGames.count) .offset(x: canvas.u(80), y: canvas.u(88)) // Opacity fades always run, reduce motion or not. .opacity(pillVisible ? 1 : 0) @@ -361,44 +386,47 @@ public struct SpotlightView: View { .spotlightBlock(.genres) } - SpotlightScreenshotStrip( - titleID: game.titleId ?? game.uniqueKey, - urls: metadata?.screenshotURLs ?? [], - known: screenshotsKnown(game, metadata: metadata), - focusedIndex: state.focus == .strip ? state.stripIndex : nil, - scrollIndex: state.stripIndex, - artwork: artwork, - onSelect: { index in - guard !state.isLifted else { return } - mutate { $0.focus = .strip; $0.stripIndex = index } - openViewer(at: index) - } - ) - // On the row the screenshots dissolve downwards (Selene's home): the - // hints sit on the dark below them, and the fade says there is more below. - // Gradient stops do not animate, so the fade lifts by a solid layer - // fading in over it, along with the glide. The mask reaches past the - // strip's edges so the focus ring outside a tile shows. - .mask(ZStack { - LinearGradient(stops: [ - .init(color: .black, location: 0), - .init(color: .black, location: 0.3), - .init(color: .black.opacity(0.35), location: 0.62), - .init(color: .black.opacity(0), location: 0.86) - ], startPoint: .top, endPoint: .bottom) - Color.black.opacity(stripFull ? 1 : 0) - } - .padding(-canvas.u(16))) - // They arrive after the logo, not before it. - .seleneReveal(delay: 0.6) - .id(game.id) - .padding(.top, canvas.u(30)) - .spotlightBlock(.strip) + screenshotStrip(id: game.id, titleID: viewModel.personalizationKey(for: game), + urls: metadata?.screenshotURLs ?? [], + known: screenshotsKnown(game, metadata: metadata), state: state) pageBelow(game, page: page, state: state) // Hidden while the row has focus: it rises in with the page. .opacity(belowShown ? 1 : 0) } + } else if let game = model.selectedCatalogGame, game.integration == .steam { + let page = steamPage(for: game) + VStack(alignment: .leading, spacing: 0) { + CatalogSteamInfo(game: game, totalPlaytime: playSessions.totalDuration(for: game.id), + lastPlayed: playSessions.lastPlayed(for: game.id), + focused: state.focus == .pills ? state.pillIndex : nil, + onAction: { index in + guard !state.isLifted else { return } + mutate { $0.focus = .pills; $0.pillIndex = index } + pressCatalogPill(index, game: game) + }, + onHover: { index in + guard !state.isLifted, state.focus == .pills, !isGliding else { return } + mutate { $0.pillIndex = index } + }) + .id(game.id) + .spotlightBlock(.head) + + if !page.presentation.genres.isEmpty { + SpotlightGenreChips(genres: page.presentation.genres) + .modifier(SpotlightInfoFollow()) + .id(game.id) + .padding(.top, canvas.u(20)) + .spotlightBlock(.genres) + } + + screenshotStrip(id: game.id, titleID: game.personalizationReference.key, + urls: steamFacts.metadata(for: game)?.screenshots ?? [], + known: steamFacts.isKnown(game), state: state) + + steamPageBelow(page, state: state) + .opacity(belowShown ? 1 : 0) + } } else if model.selectedItem == .card(.mods) { VStack(alignment: .leading, spacing: 0) { SpotlightCardInfoView( @@ -415,12 +443,12 @@ public struct SpotlightView: View { } else if case .card(let card) = model.selectedItem { let summary = card == .activity ? activitySummary : ActivitySummary() let top = summary.topGames.compactMap { model.gamesByID[$0] }.filter { !$0.isHidden } - let visible = viewModel.libraryGames.filter { !$0.isHidden } + let visible = viewModel.visibleCatalogGames // Selene's card panels sit 40 under the row, 10 lower than a game's. SpotlightCardInfoView( card: card, gameCount: visible.count, - installedCount: visible.filter(\.isPlayableLocally).count, + installedCount: LibraryQuery(filter: .installed).applyCatalog(to: visible, history: playSessions).count, poolLabel: Self.poolLabel(settings.settings.surprisePool), activity: summary, topGames: top, @@ -515,6 +543,9 @@ public struct SpotlightView: View { let more = [HintItem(.y, L10n.text("Card options"))] return card == .activity ? more : more + [HintItem(.a, card == .surprise ? L10n.text("Pick") : card == .mods ? L10n.text("Browse") : L10n.text("Open"))] case .game: + if let steam = model.selectedCatalogGame, steam.integration == .steam { + return [HintItem(.y, L10n.text("Options"))] + SteamPlayCoordinator.shared.hint(for: steam) + } guard let game = model.selectedGame else { return [] } if viewModel.portingPills(game) == .porting { return [HintItem(.y, L10n.text("Options")), HintItem(.a, L10n.text("Porting"))] @@ -548,7 +579,7 @@ public struct SpotlightView: View { mutate { $0.startingTabApplied = true } viewModel.librarySelection.query.filter = LibraryFilter(startingTab: settings.settings.startingTab) } - hadGames = !viewModel.libraryGames.isEmpty + hadGames = !viewModel.visibleCatalogGames.isEmpty reconcile() selectionChanged() pillFilter = filter @@ -592,7 +623,7 @@ public struct SpotlightView: View { viewModel.preloadCachedStoreMetadata() // The whole library, not the filtered row: a filter change never // counts as "the library arrived". - let hasGames = !viewModel.libraryGames.isEmpty + let hasGames = !viewModel.visibleCatalogGames.isEmpty // The library loads after the first frame: a card picked while it was // empty gives way to the restored (or first) game. if !hadGames, hasGames, case .card = selectedItem { @@ -624,9 +655,11 @@ public struct SpotlightView: View { private func selectionChanged() { if selectedItem == .card(.mods) { openStore() } else { storeHost.close() } updateModsBackdrop() - artRule.select(viewModel.spotlight.selectedKey, isGame: selectedGame != nil) + // A Steam game is a game too: moving to or from it keeps the art until the next arrives, never the wallpaper. + artRule.select(viewModel.spotlight.selectedKey, isGame: selectedGame != nil || selectedCatalogGame?.integration == .steam) reloadMods() startModsOnline(viewModel.spotlight.focus) + if let id = selectedCatalogGame?.id, settings.settings.lastSelectedGameID != id { settings.settings.lastSelectedGameID = id } guard let game = selectedGame else { return } if settings.settings.lastSelectedGameID != game.id { settings.settings.lastSelectedGameID = game.id @@ -690,7 +723,7 @@ public struct SpotlightView: View { private func loadHero(_ game: PortedGame?) async { await art.loadHero(game: game, current: { selectedGame?.id }, - metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, artwork: artwork, + metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, artwork: artwork, personalizations: viewModel.personalizations, // The logo leads (0.3 s); the art turns after it. settle: 420_000_000, keepsWithoutGame: true) // The game's art is in (or it has none): the gold gives way under it. @@ -701,8 +734,9 @@ public struct SpotlightView: View { /// loads when browsing from game to game (never another game's art right after a card). The rule answers for /// the selection this render sees, so the art never drops out for the render before `onChange` runs. private func shownArt(_ model: Snapshot) -> NSImage? { - guard let game = model.selectedGame, let image = art.image else { return nil } - return art.key.hasPrefix(game.id.uuidString) + let id = model.selectedGame?.id ?? model.selectedCatalogGame.flatMap { $0.integration == .steam ? $0.id : nil } + guard let id, let image = art.image else { return nil } + return art.key.hasPrefix(id.uuidString) || artRule.keepsOtherArt(selection: viewModel.spotlight.selectedKey, isGame: true) ? image : nil } @@ -861,7 +895,27 @@ public struct SpotlightView: View { || state.focus == .store } - var currentPage: SpotlightPageModel? { selectedGame.map(page(for:)) } + var currentPage: SpotlightPageModel? { + if let game = selectedGame { return page(for: game) } + return selectedCatalogGame.flatMap { $0.integration == .steam ? steamPage(for: $0) : nil } + } + + func steamPage(for game: CatalogGame) -> SpotlightPageModel { + let visible = viewModel.visibleCatalogGames + let playtimes = Dictionary(visible.map { ($0.id, playSessions.totalDuration(for: $0.id)) }, + uniquingKeysWith: { first, _ in first }) + let key = game.personalizationReference.key + var overrides: [MetadataField: String] = [:] + for field in MetadataField.allCases { overrides[field] = steamFields.value(titleID: key, field: field) } + let presentation = GameDetailPresentation( + steamRecord: game, metadata: steamFacts.metadata(for: game), overrides: overrides, + totalPlaytime: playSessions.totalDuration(for: game.id), + lastPlayed: playSessions.lastPlayed(for: game.id), + sessions: playSessions.sessions, libraryGames: visible, playtimeByGame: playtimes) + return SpotlightPageModel(presentation: presentation, + rows: BuildSourcesRow.steamRows(for: game, presentation: presentation, viewModel: viewModel), + more: [], buildBlocked: false) + } func page(for game: PortedGame) -> SpotlightPageModel { let library = viewModel.libraryGames @@ -892,18 +946,86 @@ public struct SpotlightView: View { if viewModel.isBuilding { router.openAddGame() } } + /// About this game with the activity column and the source's panel beside it: the same + /// geometry, focus blocks and excerpt for every source. + private func aboutAndColumn(_ page: SpotlightPageModel, state: SpotlightState, + @ViewBuilder panel: () -> Panel) -> some View { + HStack(alignment: .top, spacing: 0) { + SpotlightDescription(text: page.presentation.description, languageNote: page.presentation.descriptionLanguageNote, excerpt: aboutExcerpt(page), + expanded: aboutExpanded, focused: state.focus == .about, + onToggle: { aboutExpanded.toggle() }) + .spotlightBlock(.about) + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: canvas.u(40)) { + DetailsActivityColumn(presentation: page.presentation) + panel() + } + .spotlightBlock(.column) + } + .frame(width: canvas.u(1700), alignment: .topLeading) + .padding(.top, canvas.u(44)) + .spotlightBlock(.description) + } + + /// A Steam game's page under the head: About, activity and its installation panel. + private func steamPageBelow(_ page: SpotlightPageModel, state: SpotlightState) -> some View { + aboutAndColumn(page, state: state) { + SteamInstallationPanel( + presentation: page.presentation, rows: page.rows, + focusedRow: state.focus == .actions ? state.actionIndex : nil, + onHoverRow: { index in + guard state.focus == .actions, !isGliding else { return } + mutate { $0.actionIndex = index } + }, + onPressRow: { index in + mutate { $0.focus = .actions; $0.actionIndex = index } + if index < page.rows.count { page.rows[index].action() } + }) + } + } + + /// The screenshot strip under the head, for any source: the same tiles, fade and arrival. + private func screenshotStrip(id: UUID, titleID: String, urls: [URL], known: Bool, state: SpotlightState) -> some View { + SpotlightScreenshotStrip( + titleID: titleID, + urls: urls, + known: known, + focusedIndex: state.focus == .strip ? state.stripIndex : nil, + scrollIndex: state.stripIndex, + artwork: artwork, + onSelect: { index in + guard !state.isLifted else { return } + mutate { $0.focus = .strip; $0.stripIndex = index } + openViewer(at: index) + } + ) + // On the row the screenshots dissolve downwards (Selene's home): the + // hints sit on the dark below them, and the fade says there is more below. + // Gradient stops do not animate, so the fade lifts by a solid layer + // fading in over it, along with the glide. The mask reaches past the + // strip's edges so the focus ring outside a tile shows. + .mask(ZStack { + LinearGradient(stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: 0.3), + .init(color: .black.opacity(0.35), location: 0.62), + .init(color: .black.opacity(0), location: 0.86) + ], startPoint: .top, endPoint: .bottom) + Color.black.opacity(stripFull ? 1 : 0) + } + .padding(-canvas.u(16))) + // They arrive after the logo, not before it. + .seleneReveal(delay: 0.6) + .id(id) + .padding(.top, canvas.u(30)) + .spotlightBlock(.strip) + } + /// Below the screenshots: About this game, with Your activity and Build & /// Sources in a column on its right (beside it however long it is), then More like this. private func pageBelow(_ game: PortedGame, page: SpotlightPageModel, state: SpotlightState) -> some View { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 0) { - SpotlightDescription(text: page.presentation.description, languageNote: page.presentation.descriptionLanguageNote, excerpt: aboutExcerpt(page), - expanded: aboutExpanded, focused: state.focus == .about, - onToggle: { aboutExpanded.toggle() }) - .spotlightBlock(.about) - Spacer(minLength: 0) - VStack(alignment: .leading, spacing: canvas.u(40)) { - DetailsActivityColumn(presentation: page.presentation) + aboutAndColumn(page, state: state) { BuildSourcesPanel( presentation: page.presentation, appFileName: game.url.lastPathComponent, @@ -920,12 +1042,7 @@ public struct SpotlightView: View { if index < page.rows.count { page.rows[index].action() } }, width: BuildSourcesPanel.narrowWidth) - } - .spotlightBlock(.column) } - .frame(width: canvas.u(1700), alignment: .topLeading) - .padding(.top, canvas.u(44)) - .spotlightBlock(.description) if let mods = modsHost.localMods, modsHost.game?.id == game.id { DetailsModsBand( @@ -966,6 +1083,6 @@ public struct SpotlightView: View { private func loadLogo(_ game: PortedGame?) async { await art.loadLogo(game: game, metadata: game.flatMap { viewModel.storeMetadata[$0.id]}, - artwork: artwork) + artwork: artwork, personalizations: viewModel.personalizations) } } diff --git a/Sources/NXLibrary/Views/Theme/CatalogPersonalizationsEnvironment.swift b/Sources/NXLibrary/Views/Theme/CatalogPersonalizationsEnvironment.swift new file mode 100644 index 0000000..2fc86e6 --- /dev/null +++ b/Sources/NXLibrary/Views/Theme/CatalogPersonalizationsEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI +import NXLibraryCore + +private struct CatalogPersonalizationsKey: EnvironmentKey { + static let defaultValue = CatalogPersonalizations.empty +} + +extension EnvironmentValues { + var catalogPersonalizations: CatalogPersonalizations { + get { self[CatalogPersonalizationsKey.self] } + set { self[CatalogPersonalizationsKey.self] = newValue } + } +} diff --git a/Sources/NXLibrary/Views/Theme/MoonlightRenderer.swift b/Sources/NXLibrary/Views/Theme/MoonlightRenderer.swift index 4b4bbe3..d3319ab 100644 --- a/Sources/NXLibrary/Views/Theme/MoonlightRenderer.swift +++ b/Sources/NXLibrary/Views/Theme/MoonlightRenderer.swift @@ -125,7 +125,13 @@ final class MoonlightEngine: @unchecked Sendable { /// Renders one frame into the layer's next drawable and presents it. Safe /// from any thread: the render thread while moving, the main thread otherwise. func render(into layer: CAMetalLayer, uniforms: MoonlightUniforms) { - guard let drawable = layer.nextDrawable(), let buffer = queue.makeCommandBuffer() else { return } + guard let drawable = layer.nextDrawable() else { return } + render(into: drawable, uniforms: uniforms) + } + + /// Renders one frame into a drawable the caller already has (a `CAMetalDisplayLink` update) and presents it. + func render(into drawable: CAMetalDrawable, uniforms: MoonlightUniforms) { + guard let buffer = queue.makeCommandBuffer() else { return } let pass = MTLRenderPassDescriptor() pass.colorAttachments[0].texture = drawable.texture pass.colorAttachments[0].loadAction = .clear @@ -187,10 +193,18 @@ struct MoonlightMetalView: NSViewRepresentable { /// A Metal layer drawn from a display link on its own thread, so a busy main /// thread (a game's art arriving, the page laying out) never stalls the motes. +/// +/// On macOS 14 and later the link is a `CAMetalDisplayLink` asking for 60 fps: it tells the +/// system how often the motes change, so a variable-refresh display (ProMotion) keeps its +/// rate while nothing else on screen moves, and it hands over each drawable when the screen +/// can take it. A `CVDisplayLink` says neither: idle for ~10 s, the display slowed down and +/// `nextDrawable` waited up to 80 ms (measured: 60 fps fell to ~30). macOS 13 keeps it. final class MoonlightLayerView: NSView { private let engine: MoonlightEngine private let metalLayer = CAMetalLayer() private var link: CVDisplayLink? + /// The `CAMetalDisplayLink` (macOS 14+), held untyped for the macOS 13 build. + private var metalLink: AnyObject? private var occlusion: NSObjectProtocol? /// Shared with the render thread, under `lock`. @@ -217,6 +231,7 @@ final class MoonlightLayerView: NSView { static let blend: CFTimeInterval = 1.2 /// The frame rate's ceiling (a 120 Hz display draws every other refresh). private static let frameInterval = 1.0 / 60 - 0.002 + static let framesPerSecond: Float = 60 init(engine: MoonlightEngine) { self.engine = engine @@ -276,6 +291,7 @@ final class MoonlightLayerView: NSView { func stop() { if let link { CVDisplayLinkStop(link) } link = nil + stopMetalLink() if let occlusion { NotificationCenter.default.removeObserver(occlusion) } occlusion = nil } @@ -319,7 +335,8 @@ final class MoonlightLayerView: NSView { metalLayer.contentsScale = scale if metalLayer.drawableSize != size { metalLayer.drawableSize = size - renderFrame() + // A running `CAMetalDisplayLink` owns the layer's drawables and draws the new size next. + if metalLink == nil { renderFrame() } } } @@ -348,6 +365,10 @@ final class MoonlightLayerView: NSView { let run = ((state.moving && !isAbsent(at: now)) || blending(at: now)) && state.visible && window != nil lock.unlock() updateVisibility() + if #available(macOS 14.0, *) { + if run { startMetalLink() } else { stopMetalLink() } + return + } if run { if link == nil { var created: CVDisplayLink? @@ -366,6 +387,31 @@ final class MoonlightLayerView: NSView { } } + @available(macOS 14.0, *) + private func startMetalLink() { + guard metalLink == nil else { return } + let link = CAMetalDisplayLink(metalLayer: metalLayer) + link.preferredFrameRateRange = CAFrameRateRange(minimum: Self.framesPerSecond / 2, + maximum: Self.framesPerSecond, + preferred: Self.framesPerSecond) + let target = MoonlightLinkTarget(view: self) + link.delegate = target + metalLink = MoonlightLinkHandle(link: link, target: target) + MoonlightRenderThread.shared.perform { link.add(to: .current, forMode: .default) } + } + + /// Stops the link before anything else asks the layer for a drawable (a still frame). + private func stopMetalLink() { + guard let handle = metalLink as? MoonlightLinkHandle else { return } + metalLink = nil + if #available(macOS 14.0, *), let link = handle.link as? CAMetalDisplayLink { link.invalidate() } + } + + /// The render thread, on the `CAMetalDisplayLink` (macOS 14+): `presentation` is when the frame shows. + fileprivate func render(_ drawable: CAMetalDrawable, presentation: CFTimeInterval) { + renderFrame(at: presentation, drawable: drawable) + } + /// The display link's thread. private func tick() { let now = CACurrentMediaTime() @@ -378,7 +424,7 @@ final class MoonlightLayerView: NSView { renderFrame(at: now) } - private func renderFrame(at now: CFTimeInterval = CACurrentMediaTime()) { + private func renderFrame(at now: CFTimeInterval = CACurrentMediaTime(), drawable: CAMetalDrawable? = nil) { lock.lock() if state.moving { // A late frame (the window was hidden or the link paused) never skips ahead. @@ -396,9 +442,55 @@ final class MoonlightLayerView: NSView { let size = metalLayer.drawableSize guard size.width >= 1, size.height >= 1 else { return } let breath = Float(0.5 + 0.5 * sin(2 * .pi * clock / MoonlightEngine.breathPeriod)) - engine.render(into: metalLayer, uniforms: MoonlightEngine.uniforms( - width: Int(size.width), height: Int(size.height), - time: Float(clock), breath: breath, colors: colors)) + let uniforms = MoonlightEngine.uniforms(width: Int(size.width), height: Int(size.height), + time: Float(clock), breath: breath, colors: colors) + if let drawable { engine.render(into: drawable, uniforms: uniforms) } else { engine.render(into: metalLayer, uniforms: uniforms) } + } +} + +/// The link and its delegate, kept together (the link holds its delegate weakly). +private final class MoonlightLinkHandle { + let link: AnyObject + let target: AnyObject + init(link: AnyObject, target: AnyObject) { self.link = link; self.target = target } +} + +@available(macOS 14.0, *) +private final class MoonlightLinkTarget: NSObject, CAMetalDisplayLinkDelegate { + weak var view: MoonlightLayerView? + init(view: MoonlightLayerView) { self.view = view } + func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { + view?.render(update.drawable, presentation: update.targetPresentationTimestamp) + } +} + +/// One thread with its own run loop for the moonlight's display links, so the motes never wait on the main thread. +private final class MoonlightRenderThread: Thread { + static let shared: MoonlightRenderThread = { + let thread = MoonlightRenderThread() + thread.name = "Moonlight render" + thread.qualityOfService = .userInteractive + thread.start() + thread.ready.wait() + return thread + }() + + private let ready = DispatchSemaphore(value: 0) + private var loop: CFRunLoop? + + override func main() { + loop = CFRunLoopGetCurrent() + // A port keeps the run loop alive while no link is attached. + RunLoop.current.add(NSMachPort(), forMode: .default) + ready.signal() + while true { RunLoop.current.run(mode: .default, before: .distantFuture) } + } + + /// Runs `work` on this thread's run loop. + func perform(_ work: @escaping () -> Void) { + guard let loop else { return } + CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue, work) + CFRunLoopWakeUp(loop) } } diff --git a/Sources/NXLibrary/Views/Theme/SeleneBackdrop.swift b/Sources/NXLibrary/Views/Theme/SeleneBackdrop.swift index 3525505..8c67bec 100644 --- a/Sources/NXLibrary/Views/Theme/SeleneBackdrop.swift +++ b/Sources/NXLibrary/Views/Theme/SeleneBackdrop.swift @@ -151,7 +151,7 @@ struct SeleneBackdrop: View { /// theme's accent falling from the top left onto a pool of light on the floor, /// breathing; from the pool a band of thousands of motes rises to the right, far /// ones fine and sharp, near ones blurred into bokeh. Drawn by Metal -/// (`MoonlightEngine`, `MoonlightShaders`; the field is `MoonlightField`), 30 fps +/// (`MoonlightEngine`, `MoonlightShaders`; the field is `MoonlightField`), 60 fps /// or one still frame. Without Metal, the beam and pool hold still in SwiftUI. struct MoonlightBackdrop: View { let accent: Color diff --git a/Sources/NXLibraryCore/Models/AppSettings.swift b/Sources/NXLibraryCore/Models/AppSettings.swift index 448a1d1..3e2c076 100644 --- a/Sources/NXLibraryCore/Models/AppSettings.swift +++ b/Sources/NXLibraryCore/Models/AppSettings.swift @@ -169,6 +169,8 @@ public struct AppSettings: Codable, Equatable { public var keepWallpaperOnGames = false /// A card with this session's and the total playtime when a game closes. public var showsSessionCard = true + /// Steam starts silently and stays out of sight while a Steam game starts; it comes back if it needs the player. + public var steamInBackground = true public init(backgroundShade: BackgroundShade = .balanced, readableText: Bool = true, @@ -256,6 +258,7 @@ public struct AppSettings: Codable, Equatable { case wallpaperID, backgroundSizing, animatedBackground, wallpaperBrightness, launchStyle, launchMotion, launchShade case notifyPortingEnds, checkAppUpdatesAtLaunch case settingsWallpaperID, keepWallpaperOnGames, showsSessionCard + case steamInBackground } public init(from decoder: Decoder) throws { @@ -303,5 +306,6 @@ public struct AppSettings: Codable, Equatable { 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 + self.steamInBackground = try container.decodeIfPresent(Bool.self, forKey: .steamInBackground) ?? true } } diff --git a/Sources/NXLibraryCore/Models/CatalogBuildRecoveries.swift b/Sources/NXLibraryCore/Models/CatalogBuildRecoveries.swift new file mode 100644 index 0000000..5ebf814 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogBuildRecoveries.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Session-only outcomes whose file effects happened but whose catalog save did +/// not. Never choose an older persisted pending app over a retained generation. +public struct CatalogBuildRecoveries { + private var records: [UUID: PortedGame] = [:] + public init() {} + public subscript(id: UUID) -> PortedGame? { records[id] } + public var values: [PortedGame] { Array(records.values) } + public var isEmpty: Bool { records.isEmpty } + + public mutating func retain(_ outcome: PortedGame) { records[outcome.id] = outcome } + public mutating func acknowledge(id: UUID, matching expected: PortedGame?) { + if records[id] == expected { records[id] = nil } + } + public func pendingCandidate(persisted: PortedGame, explicitRetry: Bool) throws -> PortedGame? { + guard let retained = records[persisted.id] else { return persisted } + guard explicitRetry else { return nil } + var result = persisted + try result.applyCatalogBuildOutcome(retained) + return result + } +} + +extension PortedGame { + public mutating func applyCatalogBuildOutcome(_ outcome: PortedGame) throws { + guard id == outcome.id else { throw CatalogModelError.identityMismatch } + url = outcome.url + sizeBytes = outcome.sizeBytes + modificationDate = outcome.modificationDate + bundleId = outcome.bundleId + versionString = outcome.versionString + status = outcome.status + toolchain = outcome.toolchain + lastBuild = outcome.lastBuild + portingFailure = outcome.portingFailure + portingFailureReason = outcome.portingFailureReason + pendingInstall = outcome.pendingInstall + appIconPending = outcome.appIconPending + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogDocument.swift b/Sources/NXLibraryCore/Models/CatalogDocument.swift new file mode 100644 index 0000000..e961f74 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogDocument.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct CatalogSourceConfiguration: Codable, Equatable, Sendable { + public let integration: CatalogIntegration + public var isEnabled: Bool + public var rootURL: URL? + public internal(set) var generation: UUID + + public init(integration: CatalogIntegration, isEnabled: Bool = false, rootURL: URL? = nil, generation: UUID = UUID()) { + self.integration = integration; self.isEnabled = isEnabled; self.rootURL = rootURL; self.generation = generation + } + private enum CodingKeys: String, CodingKey { case integration, isEnabled, rootURL, generation } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + integration = try c.decode(CatalogIntegration.self, forKey: .integration) + isEnabled = try c.decode(Bool.self, forKey: .isEnabled) + rootURL = try c.decodeIfPresent(URL.self, forKey: .rootURL) + // Older schema-one files predate source generations. Use a stable + // sentinel until the first explicit source configuration transaction. + generation = try c.decodeIfPresent(UUID.self, forKey: .generation) + ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + } + +} + +/// Fingerprint of the untouched legacy input, not another writable catalog. +public struct CatalogLegacyOrigin: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { case absent, primary, backup } + public let kind: Kind + public let sha256: String? + public static let absent = Self(kind: .absent, sha256: nil) +} + +public struct CatalogDocument: Codable, Equatable { + public let schemaVersion: Int + public internal(set) var revision: UUID + public let legacyOrigin: CatalogLegacyOrigin + public var sources: [CatalogSourceConfiguration] + public var games: [CatalogGame] + + public init(schemaVersion: Int = 1, revision: UUID = UUID(), legacyOrigin: CatalogLegacyOrigin = .absent, + sources: [CatalogSourceConfiguration] = [.init(integration: .nxnative), .init(integration: .steam)], + games: [CatalogGame] = []) { + self.schemaVersion = schemaVersion; self.revision = revision; self.legacyOrigin = legacyOrigin + self.sources = sources; self.games = games + } + + public func source(_ integration: CatalogIntegration) -> CatalogSourceConfiguration? { + sources.first { $0.integration == integration } + } + + public static func converting(_ legacy: LibraryFile, origin: CatalogLegacyOrigin = .absent) throws -> Self { + guard legacy.schemaVersion == 1 else { throw CatalogModelError.unsupportedSchema(legacy.schemaVersion) } + let result = Self(legacyOrigin: origin, sources: [.init(integration: .nxnative, isEnabled: true), .init(integration: .steam)], + games: legacy.games.map { .init(nxnative: $0) }) + try result.validate() + return result + } + + public func validate() throws { + guard schemaVersion == 1 else { throw CatalogModelError.unsupportedSchema(schemaVersion) } + guard Set(sources.map(\.integration)).count == sources.count else { throw CatalogModelError.invalidRecord } + switch legacyOrigin.kind { + case .absent: guard legacyOrigin.sha256 == nil else { throw CatalogModelError.invalidRecord } + case .primary, .backup: + guard let digest = legacyOrigin.sha256, digest.utf8.count == 64, + digest.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { throw CatalogModelError.invalidRecord } + } + for source in sources { + if let root = source.rootURL, !root.isFileURL { throw CatalogModelError.invalidRecord } + if source.integration == .steam && source.isEnabled && source.rootURL == nil { throw CatalogModelError.invalidRecord } + } + var ids = Set(), appIDs = Set(), installationIDs = Set() + for game in games { + try game.validateRecord() + guard ids.insert(game.id).inserted else { throw CatalogModelError.duplicateGameID } + if let steam = game.steamGame { + guard appIDs.insert(steam.appID).inserted else { throw CatalogModelError.duplicateSteamAppID } + for installation in steam.installations { + guard installationIDs.insert(installation.id).inserted else { throw CatalogModelError.duplicateInstallationID } + } + } + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogGame.swift b/Sources/NXLibraryCore/Models/CatalogGame.swift new file mode 100644 index 0000000..bfb3c48 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogGame.swift @@ -0,0 +1,200 @@ +import Foundation + +public enum CatalogIntegration: String, Codable, Hashable, Sendable { case nxnative, steam } + +public struct CatalogProviderIdentity: Codable, Hashable, Sendable { + public let integration: CatalogIntegration + public let externalID: String +} + +/// Namespace and key travel together: a legacy free-form key cannot collide with Steam storage. +public struct CatalogPersonalizationReference: Codable, Equatable, Sendable { + public let namespace: CatalogIntegration + public let key: String +} + +public enum CatalogModelError: Error, Equatable { + case wrongIntegration, identityMismatch, invalidRecord, duplicateGameID, duplicateSteamAppID, duplicateInstallationID + case unsupportedSchema(Int) +} + +public enum CatalogInstallationAvailability: String, Codable, Sendable { + case available, unavailable, requiresReview, notDetected, sourceUnavailable +} + +public struct CatalogNativeApplication: Codable, Equatable, Sendable { + public let url: URL + public let executableURL: URL + public let bundleIdentifier: String? + public let name: String + public let architectures: [MacExecutableArchitecture] + public let isDeclaredGame: Bool + + public init(_ observed: NativeMacApplication) { + url = observed.url + executableURL = observed.executableURL + bundleIdentifier = observed.bundleIdentifier + name = observed.name + architectures = observed.architectures.sorted { $0.rawValue < $1.rawValue } + isDeclaredGame = observed.isDeclaredGame + } +} + +public struct SteamCatalogInstallation: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public var libraryURL: URL + public var contentURL: URL + public var applications: [CatalogNativeApplication] + public var availability: CatalogInstallationAvailability + public var reportedSizeBytes: Int64? + public var buildID: String? + public var lastObservedAt: Date + + public init(id: UUID = UUID(), libraryURL: URL, contentURL: URL, applications: [CatalogNativeApplication] = [], + availability: CatalogInstallationAvailability = .requiresReview, reportedSizeBytes: Int64? = nil, + buildID: String? = nil, lastObservedAt: Date = Date()) { + self.id = id + self.libraryURL = libraryURL + self.contentURL = contentURL + self.applications = applications + self.availability = availability + self.reportedSizeBytes = reportedSizeBytes + self.buildID = buildID + self.lastObservedAt = lastObservedAt + } + + public var primaryApplication: CatalogNativeApplication? { + let games = applications.filter(\.isDeclaredGame) + return games.count == 1 ? games[0] : nil + } +} + +public struct SteamCatalogGame: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let appID: UInt32 + public var name: String + public var isFavorite = false + public var isHidden = false + public var hasBeenRecognized = false + /// Ambiguity survives missing/partial sources; only complete evidence clears it. + public var needsInstallationReview = false + public var installations: [SteamCatalogInstallation] = [] + + public init(id: UUID = UUID(), appID: UInt32, name: String) { self.id = id; self.appID = appID; self.name = name } +} + +/// One catalog entry, with one owner for each field. The old record remains a +/// lossless nxnative payload; a Steam entry never needs its build/settings defaults. +public struct CatalogGame: Identifiable, Codable, Equatable { + enum Payload: Equatable { case nxnative(PortedGame), steam(SteamCatalogGame) } + private(set) var payload: Payload + public let personalizationReference: CatalogPersonalizationReference + + public init(nxnative game: PortedGame) { + payload = .nxnative(game) + personalizationReference = .init(namespace: .nxnative, key: game.titleId ?? game.uniqueKey) + } + + public init(steam game: SteamCatalogGame) { + payload = .steam(game) + personalizationReference = .init(namespace: .steam, key: "steam-\(game.appID)") + } + + public var integration: CatalogIntegration { if case .nxnative = payload { return .nxnative }; return .steam } + public var id: UUID { switch payload { case .nxnative(let g): return g.id; case .steam(let g): return g.id } } + public var name: String { switch payload { case .nxnative(let g): return g.name; case .steam(let g): return g.name } } + public var isFavorite: Bool { switch payload { case .nxnative(let g): return g.isFavorite; case .steam(let g): return g.isFavorite } } + public var isHidden: Bool { switch payload { case .nxnative(let g): return g.isHidden; case .steam(let g): return g.isHidden } } + public var personalizationKey: String { personalizationReference.key } + public var nxnativeGame: PortedGame? { if case .nxnative(let g) = payload { return g }; return nil } + public var steamGame: SteamCatalogGame? { if case .steam(let g) = payload { return g }; return nil } + + public var providerIdentity: CatalogProviderIdentity { + switch payload { + case .nxnative(let game): + let title = game.titleId.flatMap { $0.isEmpty ? nil : $0.uppercased() } + return .init(integration: .nxnative, externalID: title ?? "legacy-\(game.id.uuidString.lowercased())") + case .steam(let game): return .init(integration: .steam, externalID: String(game.appID)) + } + } + + public mutating func setFavorite(_ value: Bool) { + switch payload { + case .nxnative(var game): game.isFavorite = value; payload = .nxnative(game) + case .steam(var game): game.isFavorite = value; payload = .steam(game) + } + } + + public mutating func setHidden(_ value: Bool) { + switch payload { + case .nxnative(var game): game.isHidden = value; payload = .nxnative(game) + case .steam(var game): game.isHidden = value; payload = .steam(game) + } + } + + public mutating func updateNXNative(_ game: PortedGame) throws { + guard integration == .nxnative else { throw CatalogModelError.wrongIntegration } + guard id == game.id else { throw CatalogModelError.identityMismatch } + payload = .nxnative(game) + } + + mutating func updateSteam(_ game: SteamCatalogGame) throws { + guard let old = steamGame else { throw CatalogModelError.wrongIntegration } + guard old.id == game.id, old.appID == game.appID else { throw CatalogModelError.identityMismatch } + payload = .steam(game) + } + + func validateRecord() throws { + guard personalizationReference.namespace == integration, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CatalogModelError.invalidRecord + } + switch payload { + case .nxnative(let game): + guard game.url.isFileURL, game.sizeBytes >= 0 else { throw CatalogModelError.invalidRecord } + case .steam(let game): + guard game.appID > 0, personalizationKey == "steam-\(game.appID)" else { throw CatalogModelError.invalidRecord } + var contentsByLibrary: [URL: Set] = [:] + for installation in game.installations { + guard contentsByLibrary[installation.libraryURL.standardizedFileURL, default: []] + .insert(installation.contentURL.standardizedFileURL).inserted else { throw CatalogModelError.invalidRecord } + guard installation.libraryURL.isFileURL, installation.contentURL.isFileURL, + installation.reportedSizeBytes.map({ $0 >= 0 }) != false, + installation.applications.allSatisfy({ $0.url.isFileURL && $0.executableURL.isFileURL }) else { + throw CatalogModelError.invalidRecord + } + let common = installation.libraryURL.appendingPathComponent("steamapps/common").standardizedFileURL + guard NativeMacApplication.isWithin(installation.contentURL.standardizedFileURL, root: common), + installation.contentURL.standardizedFileURL != common, + installation.applications.allSatisfy({ + $0.url.pathExtension.lowercased() == "app" && !$0.architectures.isEmpty && + NativeMacApplication.isWithin($0.url.standardizedFileURL, root: installation.contentURL.standardizedFileURL) && + NativeMacApplication.isWithin($0.executableURL.standardizedFileURL, root: $0.url.standardizedFileURL) + }) else { throw CatalogModelError.invalidRecord } + if installation.availability == .available, installation.primaryApplication == nil { + throw CatalogModelError.invalidRecord + } + } + } + } + + private enum CodingKeys: String, CodingKey { case integration, data, personalization } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(CatalogIntegration.self, forKey: .integration) { + case .nxnative: payload = .nxnative(try c.decode(PortedGame.self, forKey: .data)) + case .steam: payload = .steam(try c.decode(SteamCatalogGame.self, forKey: .data)) + } + personalizationReference = try c.decode(CatalogPersonalizationReference.self, forKey: .personalization) + try validateRecord() + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(integration, forKey: .integration) + try c.encode(personalizationReference, forKey: .personalization) + switch payload { + case .nxnative(let game): try c.encode(game, forKey: .data) + case .steam(let game): try c.encode(game, forKey: .data) + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogGameOptions.swift b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift new file mode 100644 index 0000000..29b9f5e --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Shared library operations are ordered once. What a game's source can do is fixed by its record's +/// payload: `CatalogRepository` and `CatalogGame` refuse an nxnative change on a Steam game (`wrongIntegration`). +public enum CatalogGameOptions { + /// The main list's rows every source offers, in the order nxnative's Game options shows them. + /// The store page sits in Edit details for every source. + public static func commonRows(for integration: CatalogIntegration, origin: GameOptionsOrigin) -> [GameOptionsRow] { + var rows: [GameOptionsRow] = [.play, .editDetails] + if origin == .spotlight { rows.append(.move) } + rows += [.favorite, .hidden, .showInFinder] + return rows + } +} + +/// A Steam game's Options: only what NX Library can do for it. The shared rows keep +/// nxnative's order and look; nothing about engines, builds, saves or sources. +public struct SteamOptionsPresentation: Equatable, Sendable { + /// One available, recognized installation in an enabled source. + public let canPlay: Bool + /// The installation's app was found, so Finder has something to reveal. + public let hasApp: Bool + + public init(canPlay: Bool, hasApp: Bool) { + self.canPlay = canPlay + self.hasApp = hasApp + } + + public func entries(level: GameOptionsLevel, origin: GameOptionsOrigin) -> [GameOptionsEntry] { + switch level { + case .main: + return CatalogGameOptions.commonRows(for: .steam, origin: origin).map(GameOptionsEntry.row) + case .editDetails: + return [.heading("Edit details"), .row(.changeArtwork), .row(.changeMetadata), .row(.storePage)] + } + } + + public func focusableRows(level: GameOptionsLevel, origin: GameOptionsOrigin) -> [GameOptionsRow] { + entries(level: level, origin: origin).compactMap { entry in + if case .row(let row) = entry { return row } + return nil + } + } + + /// Dim rows still take focus; their action explains itself (Play shows why in the head). + public func isDimmed(_ row: GameOptionsRow) -> Bool { + switch row { + case .play: return !canPlay + case .showInFinder: return !hasApp + default: return false + } + } + + public func about(_ row: GameOptionsRow) -> String? { + switch row { + case .play: return L10n.text("Start the game through Steam.") + case .editDetails: return L10n.text("Artwork, metadata and the store page.") + case .changeArtwork: return L10n.text("Cover, background, icon and logo, from Steam, online sources or a file.") + case .storePage: return L10n.text("Open the game’s Steam store page.") + case .move, .favorite, .hidden, .showInFinder, .changeMetadata: + return GameOptionsPresentation(supportsRenderScale: false, isPlayable: false, sourcesDetached: false).about(row) + default: return nil + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift new file mode 100644 index 0000000..18bfd6f --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift @@ -0,0 +1,64 @@ +import Foundation + +public enum CatalogLaunchTarget: Equatable, Sendable { case nxnative(appURL: URL), steam(appID: UInt32) } + +public enum CatalogInstallationSize: Equatable, Sendable { case cached(Int64), steamReported(Int64), unknown } + +public struct CatalogInstallationPresentation: Identifiable, Equatable, Sendable { + public let id: String + public let contentURL: URL + public let applicationURL: URL? + public let executableURL: URL? + public let bundleIdentifier: String? + public let availability: CatalogInstallationAvailability + public let size: CatalogInstallationSize +} + +/// A value projection. No engine service, filesystem probe or view initialization +/// is needed to decide which integration owns an operation. +public struct CatalogGamePresentation { + public let id: UUID + public let name: String + public let integration: CatalogIntegration + public let personalizationReference: CatalogPersonalizationReference + public let isFavorite: Bool + public let isHidden: Bool + public let isListedNormally: Bool + public let requiresReview: Bool + public let installations: [CatalogInstallationPresentation] + public let launchTarget: CatalogLaunchTarget? + + public init(game: CatalogGame, sourceEnabled: Bool) { + id = game.id + name = game.name + integration = game.integration + personalizationReference = game.personalizationReference + isFavorite = game.isFavorite + isHidden = game.isHidden + switch game.payload { + case .nxnative(let legacy): + requiresReview = false + isListedNormally = sourceEnabled && !legacy.isHidden + launchTarget = sourceEnabled && legacy.isPlayableLocally ? .nxnative(appURL: legacy.url) : nil + installations = legacy.url.pathExtension.lowercased() == "app" ? [ + .init(id: "nxnative-\(legacy.id.uuidString.lowercased())", contentURL: legacy.url, applicationURL: legacy.url, + executableURL: nil, bundleIdentifier: legacy.effectiveBundleId, + availability: legacy.isPlayableLocally ? .available : (legacy.status == .appMissing ? .notDetected : .unavailable), + size: .cached(legacy.sizeBytes)) + ] : [] + case .steam(let steam): + requiresReview = !steam.hasBeenRecognized || steam.needsInstallationReview || steam.installations.contains { $0.availability == .requiresReview } + || steam.installations.filter { $0.availability == .available }.count > 1 + isListedNormally = sourceEnabled && !steam.isHidden && steam.hasBeenRecognized + let available = steam.installations.filter { $0.availability == .available && $0.primaryApplication != nil } + launchTarget = sourceEnabled && steam.appID > 0 && steam.hasBeenRecognized && available.count == 1 && !requiresReview ? .steam(appID: steam.appID) : nil + installations = steam.installations.map { installation in + let app = installation.primaryApplication + return .init(id: "steam-\(installation.id.uuidString.lowercased())", contentURL: installation.contentURL, + applicationURL: app?.url, executableURL: app?.executableURL, bundleIdentifier: app?.bundleIdentifier, + availability: installation.availability, + size: installation.reportedSizeBytes.map(CatalogInstallationSize.steamReported) ?? .unknown) + } + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogLibraryProjection.swift b/Sources/NXLibraryCore/Models/CatalogLibraryProjection.swift new file mode 100644 index 0000000..1fe4e3c --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogLibraryProjection.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum CatalogLibraryProjection { + public static func visible(games: [CatalogGame], sources: [CatalogSourceConfiguration]) -> [CatalogGame] { + let enabled = Set(sources.filter(\.isEnabled).map(\.integration)) + return games.filter { CatalogGamePresentation(game: $0, sourceEnabled: enabled.contains($0.integration)).isListedNormally } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogPersonalizations.swift b/Sources/NXLibraryCore/Models/CatalogPersonalizations.swift new file mode 100644 index 0000000..02aa4c8 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogPersonalizations.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Immutable lookup map captured by the presentation. Physical nxnative paths +/// remain compatible; each other integration has its own personal-data root. +public struct CatalogPersonalizations: Equatable, Sendable { + private let references: [UUID: CatalogPersonalizationReference] + public static let empty = Self(references: [:]) + private init(references: [UUID: CatalogPersonalizationReference]) { self.references = references } + public init(document: CatalogDocument) { + references = Dictionary(document.games.map { ($0.id, $0.personalizationReference) }, uniquingKeysWith: { first, _ in first }) + } + public func nxnativeKey(for game: PortedGame) -> String { + guard let reference = references[game.id], reference.namespace == .nxnative else { return game.titleId ?? game.uniqueKey } + return reference.key + } +} + +extension CatalogPersonalizationReference { + public func supportRoot(in launcherRoot: URL) -> URL { + switch namespace { + case .nxnative: return launcherRoot + case .steam: return launcherRoot.appendingPathComponent("integrations/steam", isDirectory: true) + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogRecovery.swift b/Sources/NXLibraryCore/Models/CatalogRecovery.swift new file mode 100644 index 0000000..2fd03c5 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogRecovery.swift @@ -0,0 +1,9 @@ +import Foundation + +/// An offer, not a repair. Re-check both fingerprints when the player accepts. +public struct CatalogRecoveryCandidate: Equatable, Sendable { + public let gameCount: Int + let directory: URL + let primaryDigest: String? + let backupDigest: String +} diff --git a/Sources/NXLibraryCore/Models/GameDetailPresentation+Steam.swift b/Sources/NXLibraryCore/Models/GameDetailPresentation+Steam.swift new file mode 100644 index 0000000..3b6cb6d --- /dev/null +++ b/Sources/NXLibraryCore/Models/GameDetailPresentation+Steam.swift @@ -0,0 +1,55 @@ +import Foundation + +extension GameDetailPresentation { + /// A Steam game's page facts, for the same About, activity and stats views as an nxnative game. + /// Facts delivered for another AppID are ignored; personal fields win. Nothing nxnative is + /// claimed: no engine, build, sources or save folder, and Play waits for the Steam launch step. + public init(steamRecord game: CatalogGame, metadata: SteamMetadata?, + overrides: [MetadataField: String] = [:], + totalPlaytime: TimeInterval, lastPlayed: Date?, now: Date = Date(), + sessions: [PlaySession] = [], libraryGames: [CatalogGame] = [], + playtimeByGame: [UUID: TimeInterval] = [:], calendar: Calendar = .current) { + let appID = game.steamGame?.appID + let facts = metadata.flatMap { $0.appID == appID ? $0 : nil } + title = game.name + console = "Mac" + eyebrow = "" + description = overrides[.description] ?? facts?.description ?? "" + descriptionIsEnglishFallback = false + descriptionLanguageNote = nil + let genreNames = overrides[.genres].map { $0.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } } + ?? facts?.genres ?? [] + genres = GameGenre.list(genreNames) + // Steam's dates are display text in the store's language: shown as given, never re-parsed. + let release = (overrides[.releaseDate] ?? facts?.releaseDate)?.trimmingCharacters(in: .whitespaces) + released = release.flatMap { $0.isEmpty ? nil : $0 } + releaseYear = release.flatMap { text in + text.range(of: #"\b(?:19|20)\d{2}\b"#, options: .regularExpression).map { String(text[$0]) } + } + let developers = overrides[.developer].map { [$0] } ?? facts?.developers ?? [] + let publishers = overrides[.publisher].map { [$0] } ?? facts?.publishers ?? [] + developerLine = [developers, publishers] + .map { $0.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } } + .first { !$0.isEmpty }?.joined(separator: ", ") + publisher = publishers.first + let week = Activity.summary(sessions: sessions.filter { $0.gameID == game.id }, now: now, calendar: calendar) + thisWeek = LibraryPresentation.duration(week.thisWeek) + rank = Self.rank(id: game.id, name: game.name, total: totalPlaytime, + others: libraryGames.filter { !$0.isHidden }.map { ($0.id, $0.name) }, playtimes: playtimeByGame) + playtime = LibraryPresentation.duration(totalPlaytime) + self.lastPlayed = LibraryPresentation.relative(lastPlayed, now: now) + version = "—" + size = "—" + engineCommitShort = "—" + toolchainVerified = false + toolchainNote = nil + canPlay = false + canRebuild = false + sourcesDetached = false + hasBaseSource = false + storeURL = appID.flatMap { URL(string: "https://store.steampowered.com/app/\($0)/") } + hltbURL = Self.hltbURL(for: game.name) + appPath = game.steamGame?.installations.first { $0.availability == .available }?.primaryApplication?.url.path ?? "" + savePath = "" + } +} diff --git a/Sources/NXLibraryCore/Models/GameDetailPresentation.swift b/Sources/NXLibraryCore/Models/GameDetailPresentation.swift index 9afe36c..96d8f2c 100644 --- a/Sources/NXLibraryCore/Models/GameDetailPresentation.swift +++ b/Sources/NXLibraryCore/Models/GameDetailPresentation.swift @@ -109,11 +109,19 @@ public struct GameDetailPresentation: Equatable { private static func rank(game: PortedGame, total: TimeInterval, library: [PortedGame], playtimes: [UUID: TimeInterval]) -> String? { + rank(id: game.id, name: game.name, total: total, + others: library.filter { !$0.isHidden }.map { ($0.id, $0.name) }, playtimes: playtimes) + } + + /// "#2 of 5" among the visible games with play time, whatever their source. + static func rank(id: UUID, name: String, total: TimeInterval, others: [(id: UUID, name: String)], + playtimes: [UUID: TimeInterval]) -> String? { guard total > 0 else { return nil } - var entries: [(id: UUID, name: String, time: TimeInterval)] = [(game.id, game.name, total)] - for other in library where other.id != game.id && !other.isHidden { + var entries: [(id: UUID, name: String, time: TimeInterval)] = [(id, name, total)] + for other in others where other.id != id { if let t = playtimes[other.id], t > 0 { entries.append((other.id, other.name, t)) } } + let game = (id: id, name: name) entries.sort { a, b in if a.time != b.time { return a.time > b.time } let c = a.name.localizedCaseInsensitiveCompare(b.name) @@ -138,7 +146,7 @@ public struct GameDetailPresentation: Equatable { return "" } - private static func hltbURL(for name: String) -> URL? { + static func hltbURL(for name: String) -> URL? { var components = URLComponents(string: "https://howlongtobeat.com/") components?.queryItems = [URLQueryItem(name: "q", value: name)] return components?.url diff --git a/Sources/NXLibraryCore/Models/GameRuntimeConfig.swift b/Sources/NXLibraryCore/Models/GameRuntimeConfig.swift index 690c27e..6496cbd 100644 --- a/Sources/NXLibraryCore/Models/GameRuntimeConfig.swift +++ b/Sources/NXLibraryCore/Models/GameRuntimeConfig.swift @@ -111,7 +111,7 @@ public struct GameRuntimeConfig: Codable, Equatable { } public static func load(for game: PortedGame) -> GameRuntimeConfig { - if let data = UserDefaults.standard.data(forKey: storageKey(for: game)), + if let data = AppData.preferences.data(forKey: storageKey(for: game)), let config = try? JSONDecoder().decode(GameRuntimeConfig.self, from: data) { return config } @@ -134,7 +134,7 @@ public struct GameRuntimeConfig: Codable, Equatable { public func save(for game: PortedGame, baseDirectory: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/nxnative"), - preferences: UserDefaults = .standard) throws { + preferences: UserDefaults = AppData.preferences) throws { let data = try JSONEncoder().encode(self) try applyToDisk(for: game, baseDirectory: baseDirectory) preferences.set(data, forKey: Self.storageKey(for: game)) diff --git a/Sources/NXLibraryCore/Models/LibraryArtworkRole.swift b/Sources/NXLibraryCore/Models/LibraryArtworkRole.swift new file mode 100644 index 0000000..3d1eb13 --- /dev/null +++ b/Sources/NXLibraryCore/Models/LibraryArtworkRole.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Layout roles stay separate from the provider. A square never requests/crops +/// the portrait cover; the renderer can keep the original Finder app artwork. +public enum LibraryArtworkRole: String, CaseIterable, Sendable { + case square, cover, hero, logo + public var slot: ArtworkSlot { + switch self { + case .square: return .icon + case .cover: return .cover + case .hero: return .background + case .logo: return .logo + } + } +} diff --git a/Sources/NXLibraryCore/Models/LibraryFilter.swift b/Sources/NXLibraryCore/Models/LibraryFilter.swift index 51b89d1..7a93c79 100644 --- a/Sources/NXLibraryCore/Models/LibraryFilter.swift +++ b/Sources/NXLibraryCore/Models/LibraryFilter.swift @@ -28,15 +28,18 @@ public struct LibraryQuery: Equatable { self.searchText = searchText } - public func apply(to games: [PortedGame], history: PlayHistory) -> [PortedGame] { + public func apply(to games: [PortedGame], history: PlayHistory) -> [PortedGame] { applyRecords(games, history: history) } + public func applyCatalog(to games: [CatalogGame], history: PlayHistory) -> [CatalogGame] { applyRecords(games, history: history) } + + private func applyRecords(_ games: [Game], history: PlayHistory) -> [Game] { let visible = games.filter { !$0.isHidden } let dates = Dictionary(uniqueKeysWithValues: visible.map { ($0.id, history.lastPlayed(for: $0.id)) }) - func played(_ game: PortedGame) -> Date? { dates[game.id] ?? nil } + func played(_ game: Game) -> Date? { dates[game.id] ?? nil } - let filtered: [PortedGame] + let filtered: [Game] switch filter { case .all: filtered = visible - case .installed: filtered = visible.filter(\.isPlayableLocally) + case .installed: filtered = visible.filter(\.isInstalledForLibrary) case .recentlyPlayed: filtered = visible.filter { played($0) != nil } case .favorites: filtered = visible.filter(\.isFavorite) case .notPlayed: filtered = visible.filter { played($0) == nil } @@ -45,8 +48,7 @@ public struct LibraryQuery: Equatable { let query = normalized(searchText) let searched = query.isEmpty ? filtered : filtered.filter { game in normalized(game.name).contains(query) - || normalized(game.titleId ?? "").contains(query) - || normalized(game.bundleId ?? "").contains(query) + || game.librarySearchTerms.contains { normalized($0).contains(query) } } return searched.sorted { lhs, rhs in @@ -66,3 +68,30 @@ public struct LibraryQuery: Equatable { .trimmingCharacters(in: .whitespacesAndNewlines) } } + + +private protocol LibraryQueryRecord { + var id: UUID { get } + var name: String { get } + var isFavorite: Bool { get } + var isHidden: Bool { get } + var isInstalledForLibrary: Bool { get } + var librarySearchTerms: [String] { get } +} +extension PortedGame: LibraryQueryRecord { + fileprivate var isInstalledForLibrary: Bool { isPlayableLocally } + fileprivate var librarySearchTerms: [String] { [titleId, bundleId].compactMap { $0 } } +} +extension CatalogGame: LibraryQueryRecord { + fileprivate var isInstalledForLibrary: Bool { + if let game = nxnativeGame { return game.isPlayableLocally } + guard let game = steamGame, game.hasBeenRecognized else { return false } + return game.installations.contains { $0.availability == .available || ($0.availability == .requiresReview && $0.primaryApplication != nil) } + } + fileprivate var librarySearchTerms: [String] { + switch payload { + case .nxnative(let game): return game.librarySearchTerms + case .steam(let game): return ["Steam", String(game.appID)] + game.installations.flatMap { $0.applications.compactMap(\.bundleIdentifier) } + } + } +} diff --git a/Sources/NXLibraryCore/Models/LibraryPresentation.swift b/Sources/NXLibraryCore/Models/LibraryPresentation.swift index 02fd523..52d9bfd 100644 --- a/Sources/NXLibraryCore/Models/LibraryPresentation.swift +++ b/Sources/NXLibraryCore/Models/LibraryPresentation.swift @@ -31,6 +31,40 @@ public struct LibraryPresentation: Equatable { .trimmingCharacters(in: .whitespacesAndNewlines) } + /// Common display boundary; provider facts never become a fabricated NX game. + public init(game: CatalogGame, steamMetadata: SteamMetadata?, + nxnativeMetadata: NintendoStoreMetadata? = nil, + overrides: [MetadataField: String] = [:], totalPlaytime: TimeInterval, + lastPlayed: Date?, now: Date = Date()) { + if let native = game.nxnativeGame { + self = Self(game: native, metadata: nxnativeMetadata, totalPlaytime: totalPlaytime, lastPlayed: lastPlayed, now: now) + return + } + self = Self(steamRecord: game, metadata: steamMetadata, overrides: overrides, + totalPlaytime: totalPlaytime, lastPlayed: lastPlayed, now: now) + } + + private init(steamRecord game: CatalogGame, metadata steamMetadata: SteamMetadata?, + overrides: [MetadataField: String], totalPlaytime: TimeInterval, lastPlayed: Date?, now: Date) { + // Reject facts accidentally delivered for another external identity. + let facts = steamMetadata.flatMap { $0.appID == game.steamGame?.appID ? $0 : nil } + title = game.name + eyebrow = Self.eyebrow(from: game.name) + let selectedGenres = overrides[.genres].map { $0.split(separator: ",").map(String.init) } ?? facts?.genres ?? [] + genres = Array(GameGenre.list(selectedGenres).prefix(3)) + publisher = overrides[.publisher] ?? facts?.publishers.first + let release = overrides[.releaseDate] ?? facts?.releaseDate ?? "" + if let range = release.range(of: #"\b(?:19|20)\d{2}\b"#, options: .regularExpression) { + releaseYear = String(release[range]) + } else { releaseYear = nil } + playtime = Self.duration(totalPlaytime) + self.lastPlayed = Self.relative(lastPlayed, now: now) + platform = "Steam" + console = "Mac" + sourcesDetached = false + summary = overrides[.description] ?? facts?.description ?? "" + } + private static func eyebrow(from name: String) -> String { let separators = [":", " – "] for separator in separators { diff --git a/Sources/NXLibraryCore/Models/LibrarySelectionState.swift b/Sources/NXLibraryCore/Models/LibrarySelectionState.swift index 1b94eca..ca2fafe 100644 --- a/Sources/NXLibraryCore/Models/LibrarySelectionState.swift +++ b/Sources/NXLibraryCore/Models/LibrarySelectionState.swift @@ -1,51 +1,52 @@ import Foundation -/// The library filter and the game selected on the Collection screen. The -/// filter is shared with Spotlight (both read `query.filter`). +/// The shared home filter and stable UUID selection, independent of integration. public struct LibrarySelectionState: Equatable { public var query: LibraryQuery public var selectedGameID: UUID? - - public init(query: LibraryQuery = LibraryQuery(), - selectedGameID: UUID? = nil) { - self.query = query - self.selectedGameID = selectedGameID + public init(query: LibraryQuery = LibraryQuery(), selectedGameID: UUID? = nil) { + self.query = query; self.selectedGameID = selectedGameID } - @discardableResult - public mutating func reconcile(with games: [PortedGame], history: PlayHistory, - preferredGameID: UUID? = nil) -> [PortedGame] { + public mutating func reconcile(with games: [PortedGame], history: PlayHistory, preferredGameID: UUID? = nil) -> [PortedGame] { let filtered = query.apply(to: games, history: history) - if let selectedGameID, - filtered.contains(where: { $0.id == selectedGameID }) { - return filtered - } - if let preferredGameID, filtered.contains(where: { $0.id == preferredGameID }) { - selectedGameID = preferredGameID - } else { - selectedGameID = filtered.first?.id - } + reconcileIDs(filtered.map(\.id), preferred: preferredGameID) + return filtered + } + @discardableResult + public mutating func reconcileCatalog(with games: [CatalogGame], history: PlayHistory, preferredGameID: UUID? = nil) -> [CatalogGame] { + let filtered = query.applyCatalog(to: games, history: history) + reconcileIDs(filtered.map(\.id), preferred: preferredGameID) return filtered } - @discardableResult public mutating func move(by delta: Int, in games: [PortedGame], history: PlayHistory) -> UUID? { - let filtered = query.apply(to: games, history: history) - guard !filtered.isEmpty else { - selectedGameID = nil - return nil - } - let current = filtered.firstIndex { $0.id == selectedGameID } ?? 0 - let next = min(max(current + delta, 0), filtered.count - 1) - selectedGameID = filtered[next].id - return selectedGameID + moveIDs(query.apply(to: games, history: history).map(\.id), by: delta) + } + @discardableResult + public mutating func moveCatalog(by delta: Int, in games: [CatalogGame], history: PlayHistory) -> UUID? { + moveIDs(query.applyCatalog(to: games, history: history).map(\.id), by: delta) } - - /// Selects the first or last game of the filtered list (LT / RT). @discardableResult public mutating func jump(toEnd: Bool, in games: [PortedGame], history: PlayHistory) -> UUID? { - let filtered = query.apply(to: games, history: history) - selectedGameID = (toEnd ? filtered.last : filtered.first)?.id + jumpIDs(query.apply(to: games, history: history).map(\.id), toEnd: toEnd) + } + @discardableResult + public mutating func jumpCatalog(toEnd: Bool, in games: [CatalogGame], history: PlayHistory) -> UUID? { + jumpIDs(query.applyCatalog(to: games, history: history).map(\.id), toEnd: toEnd) + } + private mutating func reconcileIDs(_ ids: [UUID], preferred: UUID?) { + if let selectedGameID, ids.contains(selectedGameID) { return } + selectedGameID = preferred.flatMap { ids.contains($0) ? $0 : nil } ?? ids.first + } + private mutating func moveIDs(_ ids: [UUID], by delta: Int) -> UUID? { + guard !ids.isEmpty else { selectedGameID = nil; return nil } + let current = ids.firstIndex { $0 == selectedGameID } ?? 0 + selectedGameID = ids[min(max(current + delta, 0), ids.count - 1)] + return selectedGameID + } + private mutating func jumpIDs(_ ids: [UUID], toEnd: Bool) -> UUID? { + selectedGameID = toEnd ? ids.last : ids.first return selectedGameID } } diff --git a/Sources/NXLibraryCore/Models/MenuSelectionModel.swift b/Sources/NXLibraryCore/Models/MenuSelectionModel.swift index c4503df..315ee19 100644 --- a/Sources/NXLibraryCore/Models/MenuSelectionModel.swift +++ b/Sources/NXLibraryCore/Models/MenuSelectionModel.swift @@ -2,7 +2,7 @@ import Foundation /// The control-center row, left to right (Selene's menu.png). public enum MenuAction: String, CaseIterable, Identifiable, Equatable { - case addGame, refreshLibrary, settings, theme, console, setupCheck, power + case addGame, refreshLibrary, settings, theme, console, setupCheck, manageSources, power public var id: String { rawValue } public var title: String { @@ -10,6 +10,7 @@ public enum MenuAction: String, CaseIterable, Identifiable, Equatable { case .addGame: return L10n.text("Add Game") case .refreshLibrary: return L10n.text("Library refresh") case .settings: return L10n.text("Settings") + case .manageSources: return L10n.text("Manage sources") case .theme: return "Theme" case .console: return "Console" case .setupCheck: return L10n.text("Setup check") @@ -22,6 +23,7 @@ public enum MenuAction: String, CaseIterable, Identifiable, Equatable { case .addGame: return .add case .refreshLibrary: return .sync case .settings: return .tune + case .manageSources: return .collections case .theme: return .palette case .console: return .terminal case .setupCheck: return .checklist diff --git a/Sources/NXLibraryCore/Models/SettingsCatalog.swift b/Sources/NXLibraryCore/Models/SettingsCatalog.swift index e9fa9da..de61110 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -34,7 +34,16 @@ public enum SettingsCatalog { SettingDescriptor(id: id, group: group, title: title, about: about) } - public static let all: [SettingDescriptor] = [ + /// In sidebar order (`SettingsSidebarGroup`), each section's rows as listed. + public static let all: [SettingDescriptor] = { + let order = SettingsSection.allCases + return listed.enumerated().sorted { + (order.firstIndex(of: $0.element.group) ?? order.count, $0.offset) + < (order.firstIndex(of: $1.element.group) ?? order.count, $1.offset) + }.map(\.element) + }() + + private static let listed: [SettingDescriptor] = [ // Start-up row("appLanguage", .startUp, "App language", "Menus, genres and available game descriptions. The games’ own language stays separate.") { $0.appLanguage }, row("appRegion", .startUp, "Regional formats", "Dates, numbers and sizes only. Game descriptions follow the app language.") { $0.appRegion }, @@ -44,9 +53,9 @@ public enum SettingsCatalog { "Opens with the game you had selected last time.") { $0.restoreLastGame }, row("startInFullscreen", .startUp, "Start in fullscreen", "Open NX Library in fullscreen. Applies at the next launch.") { $0.startInFullscreen }, - row("checkEngineAtLaunch", .startUp, "Check engine at launch", + row("checkEngineAtLaunch", .engine, "Check engine at launch", "Checks the engine and toolchain each time NX Library starts.") { $0.checkEngineAtLaunch }, - row("refreshLibraryAtLaunch", .startUp, "Refresh library at launch", + row("refreshLibraryAtLaunch", .library, "Refresh library at launch", "Scans your library folders for new or removed games at start.") { $0.refreshLibraryAtLaunch }, row("checkAppUpdatesAtLaunch", .startUp, "Check for app updates", "Looks for a newer NX Library release at launch, at most once a day.") { $0.checkAppUpdatesAtLaunch }, @@ -103,7 +112,7 @@ public enum SettingsCatalog { row("reduceMotion", .look, "Reduce motion", "Replaces slides and zooms with fades.") { $0.reduceMotion }, action("iconAppearance", .look, "App icon", "The icon appearance in the Dock."), - action("boxCovers", .look, "Box covers", "Open GameTDB for box covers."), + action("boxCovers", .library, "Box covers", "Open GameTDB for box covers."), action("steamGridDB", .look, "SteamGridDB", "Add your API key to find covers, backgrounds, logos and icons in Change artwork."), // Games @@ -145,9 +154,9 @@ public enum SettingsCatalog { action("controllerDeadzone", .controllers, "Stick navigation deadzone", "Thumbstick threshold for library navigation; does not alter game input."), row("gameDefaults.rumble", .controllers, "Rumble", - "Default controller vibration for new games.") { $0.gameDefaults.rumble }, + "Default controller vibration for new Switch games.") { $0.gameDefaults.rumble }, row("gameDefaults.rumbleGain", .controllers, "Rumble strength", - "Default vibration strength for new games.") { $0.gameDefaults.rumbleGain }, + "Default vibration strength for new Switch games.") { $0.gameDefaults.rumbleGain }, // Library action("gameFolder", .library, GameFoldersCopy.sourcesTitle, "Game sources: NSP, XCI, NSZ and XCZ files, or Switch 2 NUP files and extracted folders, for builds. Optional for existing apps."), @@ -155,6 +164,12 @@ public enum SettingsCatalog { action("libraryRefresh", .library, "Scan game apps", "Find existing nxnative builds and refresh the library."), row("notifyPortingEnds", .library, "Notify when a porting ends", "A notification when a porting finishes while NX Library is in the background.") { $0.notifyPortingEnds }, + // Steam + action("steamSource", .sources, "Steam", "Find installed native Mac games in your Steam libraries."), + action("steamRefresh", .sources, "Refresh Steam", "Check enabled Steam libraries without changing your game files."), + action("steamFolder", .sources, "Steam folder", "Choose the Steam folder containing steamapps."), + row("steamInBackground", .sources, "Keep Steam in the background", + "Steam stays out of sight while a game starts, and comes back if it needs you.") { $0.steamInBackground }, // Storage action("storageUsage", .storage, "Storage usage", "See per-game app, cache and log sizes and free disk space."), @@ -167,14 +182,13 @@ public enum SettingsCatalog { // Engine action("engineFolder", .engine, "Engine repository", "The upstream checkout used for porting; updates stay manual."), - action("engineStatus", .engine, "Compatibility", "Engine commit, Metal compiler, toolchain gate and required flags."), + action("engineStatus", .engine, "Compatibility", "Engine commit, Metal compiler, build environment, toolchain gate and required flags."), action("toolchainOverride", .engine, "Untested toolchain override", "Explicitly allow unverified new portings."), action("upstream", .engine, "Upstream", "Check newer engine commits without installing them."), action("newestEngine", .engine, "Newest engine", "Build with nxnative main before NX Library has tested it, or go back to the tested engine."), action("compare", .engine, "Compare", "Open the commit comparison on GitHub."), action("keys", .engine, "prod.keys", "Import, replace or detect private keys."), - action("setup", .startUp, "Setup", "Open the setup checklist."), - action("environment", .startUp, "Environment", "Re-check the environment and build requirements."), + action("setup", .engine, "Setup", "Open the setup checklist."), action("diagnostics", .about, "Copy diagnostics", "Copy a scrubbed support report."), action("appUpdates", .about, "App updates", "Check now, update or read release notes."), action("projectDocs", .about, "Open project docs", "Open the project documentation."), diff --git a/Sources/NXLibraryCore/Models/SettingsSection.swift b/Sources/NXLibraryCore/Models/SettingsSection.swift index 097c3c4..1b06f53 100644 --- a/Sources/NXLibraryCore/Models/SettingsSection.swift +++ b/Sources/NXLibraryCore/Models/SettingsSection.swift @@ -3,8 +3,11 @@ import Foundation /// The groups on the Settings screen (spec §5.7). `search` and `changed` are /// computed lists; `about` is informational and has no catalog rows. public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { - case search, changed, startUp, navigation, spotlight, soundMusic - case look, games, controllers, library, storage, engine, about + // Sidebar order (`SettingsSidebarGroup`). Raw values are identities: `sources` is Steam's page. + case search, changed, startUp, navigation, spotlight, soundMusic, look, controllers + case library, games, engine + case sources + case storage, about public var id: String { rawValue } @@ -26,6 +29,7 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { case .games: return "Games" case .controllers: return "Controllers" case .library: return "Library" + case .sources: return "Library" case .storage: return "Storage" case .engine: return "Engine" case .about: return "About" @@ -36,16 +40,17 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { switch self { case .search: return L10n.text("Find any setting by name or description.") case .changed: return L10n.text("Settings that differ from the defaults.") - case .startUp: return L10n.text("Startup, setup and readiness.") + case .startUp: return L10n.text("Language, the first screen and what runs at launch.") case .navigation: return L10n.text("Button hints, button names and the View button.") case .spotlight: return L10n.text("Which cards show on Spotlight and where it opens.") case .soundMusic: return L10n.text("Interface sounds, volume and the launch screen.") case .look: return L10n.text("How NX Library looks and moves.") case .games: return L10n.text("Launch screen, hidden games and defaults for new games.") - case .controllers: return L10n.text("Library controls and new-game rumble.") - case .library: return L10n.text("Folders, imports and artwork caches.") + case .controllers: return L10n.text("Library controls and rumble for new Switch games.") + case .library: return L10n.text("Folders, scans and box covers.") + case .sources: return L10n.text("Steam libraries, refresh and how Play opens Steam.") case .storage: return L10n.text("Caches, logs, saves and the console.") - case .engine: return L10n.text("Engine pin, toolchain gate and keys.") + case .engine: return L10n.text("Engine pin, toolchain gate, keys and setup.") case .about: return L10n.text("Version, credits and licenses.") } } @@ -63,9 +68,49 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { case .games: return .play case .controllers: return .gamepad case .library: return .collections + case .sources: return .collections case .storage: return .storage case .engine: return .build case .about: return .info } } + + public var group: SettingsSidebarGroup { + SettingsSidebarGroup.all.first { $0.sections.contains(self) } ?? SettingsSidebarGroup.all[0] + } + + /// The name Search and Changed show: a platform's section carries its platform ("Steam · Library"). + public var place: String { place(in: L10n.context) } + + public func place(in context: AppLocale) -> String { + guard group.isPlatform, let heading = group.title(in: context) else { return title(in: context) } + return "\(heading) · \(title(in: context))" + } +} + +/// The sidebar's groups, in order: app-wide settings first, then one group per platform, +/// so Switch and Steam settings never share a section. A heading is a label, never a stop. +public struct SettingsSidebarGroup: Identifiable, Equatable { + /// Localization key ("Settings group: Switch"); the first group has none. + public let heading: String? + public let sections: [SettingsSection] + /// A platform's group: its settings act only on that platform's games. + public let isPlatform: Bool + + public var id: String { heading ?? "top" } + + /// The heading as drawn: "Switch", never the verb a bare "Switch" key translates to. + public var title: String? { title(in: L10n.context) } + + public func title(in context: AppLocale) -> String? { + heading.map { L10n.text($0, context: context) } + } + + public static let all: [SettingsSidebarGroup] = [ + .init(heading: nil, sections: [.search, .changed], isPlatform: false), + .init(heading: "Settings group: General", sections: [.startUp, .navigation, .spotlight, .soundMusic, .look, .controllers], isPlatform: false), + .init(heading: "Settings group: Switch", sections: [.library, .games, .engine], isPlatform: true), + .init(heading: "Settings group: Steam", sections: [.sources], isPlatform: true), + .init(heading: "Settings group: System", sections: [.storage, .about], isPlatform: false), + ] } diff --git a/Sources/NXLibraryCore/Models/SpotlightRow.swift b/Sources/NXLibraryCore/Models/SpotlightRow.swift index dd595a2..f6c4b63 100644 --- a/Sources/NXLibraryCore/Models/SpotlightRow.swift +++ b/Sources/NXLibraryCore/Models/SpotlightRow.swift @@ -53,10 +53,14 @@ public enum SpotlightRow { enabledCards: [SpotlightCard], order: [String], front: UUID? = nil) -> [SpotlightItem] { + build(gameIDs: games.map(\.id), enabledCards: enabledCards, order: order, front: front) + } + + public static func build(gameIDs: [UUID], enabledCards: [SpotlightCard], order: [String], front: UUID? = nil) -> [SpotlightItem] { let enabled = Set(enabledCards) let defaults: [SpotlightItem] = SpotlightCard.allCases.filter(enabled.contains).map { .card($0) } - + games.map { .game($0.id) } + + gameIDs.map { .game($0) } var rank: [String: Int] = [:] for (i, key) in order.enumerated() where rank[key] == nil { rank[key] = i } @@ -108,6 +112,13 @@ public enum SpotlightRow { } } + /// Save a visible move without losing hidden/disabled games or cards. + /// `gameIDs` must include the whole catalog, not only its visible projection. + public static func saving(visibleOrder: [String], gameIDs: [UUID], previousOrder: [String]) -> [String] { + let full = build(gameIDs: gameIDs, enabledCards: SpotlightCard.allCases, order: previousOrder).map(\.key) + return merging(visibleOrder: visibleOrder, into: full) + } + /// Moves `item` by `delta` positions, clamped to the row's ends. /// Returns `items` unchanged if the item is absent. public static func moving(_ item: SpotlightItem, by delta: Int, diff --git a/Sources/NXLibraryCore/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index de61c48..9698d79 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -3240,6 +3240,232 @@ } } }, + "%lld Steam games need review": { + "comment": "Steam source discovery count.", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam game needs review" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam games need review" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld gioco Steam da controllare" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld giochi Steam da controllare" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld jeu Steam à vérifier" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld jeux Steam à vérifier" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam-Spiel muss geprüft werden" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam-Spiele müssen geprüft werden" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld juego de Steam requiere revisión" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld juegos de Steam requieren revisión" + } + } + } + } + }, + "pt-BR": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld jogo Steam requer revisão" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld jogos Steam requerem revisão" + } + } + } + } + } + } + }, + "%lld Steam installations found": { + "comment": "Steam source discovery count.", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam installation found" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam installations found" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld installazione Steam trovata" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld installazioni Steam trovate" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld installation Steam trouvée" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld installations Steam trouvées" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam-Installation gefunden" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Steam-Installationen gefunden" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld instalación de Steam encontrada" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld instalaciones de Steam encontradas" + } + } + } + } + }, + "pt-BR": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld instalação Steam encontrada" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld instalações Steam encontradas" + } + } + } + } + } + } + }, "%lld commits ahead": { "comment": "Quantity-sensitive UI message; preserve %lld count and other placeholders.", "localizations": { @@ -7022,6 +7248,47 @@ } } }, + "Another version changed your previous library. Both versions have been kept. Close the other copy and review the library folder.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Another version changed your previous library. Both versions have been kept. Close the other copy and review the library folder." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un’altra versione ha modificato la libreria precedente. Entrambe le versioni sono state conservate. Chiudi l’altra copia e controlla la cartella della libreria." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une autre version a modifié votre ancienne bibliothèque. Les deux versions ont été conservées. Fermez l’autre copie et examinez le dossier de la bibliothèque." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine andere Version hat deine bisherige Bibliothek geändert. Beide Versionen wurden aufbewahrt. Schließe die andere Kopie und prüfe den Bibliotheksordner." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otra versión ha modificado tu biblioteca anterior. Se han conservado ambas versiones. Cierra la otra copia y revisa la carpeta de la biblioteca." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Outra versão alterou sua biblioteca anterior. Ambas as versões foram preservadas. Feche a outra cópia e verifique a pasta da biblioteca." + } + } + } + }, "App": { "comment": "AddGameView+Rows.swift", "localizations": { @@ -7965,6 +8232,47 @@ } } }, + "Artwork, metadata and the store page.": { + "comment": "CatalogGameOptions.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artwork, metadata and the store page." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Immagini, metadati e pagina dello store." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Illustrations, métadonnées et page du magasin." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder, Metadaten und Store-Seite." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Imágenes, metadatos y página de la tienda." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Imagens, metadados e página da loja." + } + } + } + }, "Artwork, metadata, store page, sources and the build log.": { "comment": "GameOptionsPresentation.swift", "localizations": { @@ -12475,6 +12783,47 @@ } } }, + "Check enabled Steam libraries without changing your game files.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check enabled Steam libraries without changing your game files." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla le librerie Steam attive senza modificare i file dei giochi." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifiez les bibliothèques Steam activées sans modifier les fichiers des jeux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfe aktive Steam-Bibliotheken, ohne Spieldateien zu ändern." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisa las bibliotecas de Steam activas sin cambiar los archivos de tus juegos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verifique as bibliotecas Steam ativas sem alterar os arquivos dos jogos." + } + } + } + }, "Check engine at launch": { "comment": "SettingsCatalog.swift", "localizations": { @@ -12803,6 +13152,47 @@ } } }, + "Check these installations in Steam, then refresh this source. Only recognized native Mac games appear in the library.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check these installations in Steam, then refresh this source. Only recognized native Mac games appear in the library." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla queste installazioni in Steam, poi aggiorna la fonte. Nella libreria compaiono solo i giochi Mac nativi riconosciuti." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifiez ces installations dans Steam, puis actualisez cette source. Seuls les jeux Mac natifs reconnus apparaissent dans la bibliothèque." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfe diese Installationen in Steam und aktualisiere dann diese Quelle. Nur erkannte native Mac-Spiele erscheinen in der Bibliothek." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisa estas instalaciones en Steam y después actualiza esta fuente. Solo aparecen en la biblioteca los juegos nativos de Mac reconocidos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verifique estas instalações no Steam e atualize esta fonte. Apenas jogos nativos para Mac reconhecidos aparecem na biblioteca." + } + } + } + }, "Check what you need to build. Your games come next.": { "comment": "SetupCheckView.swift", "localizations": { @@ -12926,6 +13316,47 @@ } } }, + "Checking Steam libraries…": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking Steam libraries…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controllo delle librerie Steam…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification des bibliothèques Steam…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam-Bibliotheken werden geprüft…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisando bibliotecas de Steam…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando bibliotecas Steam…" + } + } + } + }, "Checking download…": { "comment": "SettingsAboutSection.swift:229", "localizations": { @@ -14238,6 +14669,47 @@ } } }, + "Choose the Steam folder containing steamapps.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the Steam folder containing steamapps." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli la cartella Steam che contiene steamapps." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez le dossier Steam contenant steamapps." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle den Steam-Ordner, der steamapps enthält." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige la carpeta de Steam que contiene steamapps." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha a pasta do Steam que contém steamapps." + } + } + } + }, "Choose the folder containing your game apps.": { "comment": "SetupCheck.swift", "localizations": { @@ -15345,6 +15817,47 @@ } } }, + "Close the other NX Library copy, then try again. Your original library has not been changed.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close the other NX Library copy, then try again. Your original library has not been changed." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi l’altra copia di NX Library e riprova. La libreria originale non è stata modificata." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermez l’autre copie de NX Library, puis réessayez. Votre bibliothèque d’origine n’a pas été modifiée." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließe die andere Kopie von NX Library und versuche es erneut. Deine Originalbibliothek wurde nicht verändert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cierra la otra copia de NX Library e inténtalo de nuevo. Tu biblioteca original no se ha modificado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Feche a outra cópia do NX Library e tente novamente. Sua biblioteca original não foi alterada." + } + } + } + }, "Cloud on": { "comment": "SavesPanel.swift:136", "localizations": { @@ -17846,6 +18359,47 @@ } } }, + "Cover, background, icon and logo, from Steam, online sources or a file.": { + "comment": "CatalogGameOptions.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cover, background, icon and logo, from Steam, online sources or a file." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copertina, sfondo, icona e logo, da Steam, fonti online o un file." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jaquette, fond, icône et logo, depuis Steam, des sources en ligne ou un fichier." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cover, Hintergrund, Symbol und Logo, von Steam, Online-Quellen oder aus einer Datei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Portada, fondo, icono y logotipo, de Steam, fuentes en línea o un archivo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Capa, fundo, ícone e logotipo, do Steam, de fontes online ou de um arquivo." + } + } + } + }, "Credits": { "comment": "SettingsAboutSection.swift", "localizations": { @@ -18092,43 +18646,43 @@ } } }, - "Default controller vibration for new games.": { + "Default controller vibration for new Switch games.": { "comment": "SettingsCatalog.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default controller vibration for new games." + "value": "Default controller vibration for new Switch games." } }, "it": { "stringUnit": { "state": "translated", - "value": "Vibrazione predefinita del controller per i nuovi giochi." + "value": "Vibrazione predefinita del controller per i nuovi giochi Switch." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Vibrations de la manette par défaut pour les nouveaux jeux." + "value": "Vibrations de la manette par défaut pour les nouveaux jeux Switch." } }, "de": { "stringUnit": { "state": "translated", - "value": "Standard-Controller-Vibration für neue Spiele." + "value": "Standard-Controller-Vibration für neue Switch-Spiele." } }, "es": { "stringUnit": { "state": "translated", - "value": "Vibración predeterminada del mando para juegos nuevos." + "value": "Vibración predeterminada del mando para juegos nuevos de Switch." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Vibração padrão do controle para novos jogos." + "value": "Vibração padrão do controle para novos jogos de Switch." } } } @@ -18215,43 +18769,43 @@ } } }, - "Default vibration strength for new games.": { + "Default vibration strength for new Switch games.": { "comment": "SettingsCatalog.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default vibration strength for new games." + "value": "Default vibration strength for new Switch games." } }, "it": { "stringUnit": { "state": "translated", - "value": "Intensità predefinita della vibrazione per i nuovi giochi." + "value": "Intensità predefinita della vibrazione per i nuovi giochi Switch." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Intensité des vibrations par défaut pour les nouveaux jeux." + "value": "Intensité des vibrations par défaut pour les nouveaux jeux Switch." } }, "de": { "stringUnit": { "state": "translated", - "value": "Standard-Vibrationsstärke für neue Spiele." + "value": "Standard-Vibrationsstärke für neue Switch-Spiele." } }, "es": { "stringUnit": { "state": "translated", - "value": "Intensidad de vibración predeterminada para juegos nuevos." + "value": "Intensidad de vibración predeterminada para juegos nuevos de Switch." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Intensidade padrão da vibração para novos jogos." + "value": "Intensidade padrão da vibração para novos jogos de Switch." } } } @@ -19742,6 +20296,47 @@ } } }, + "Development library unavailable": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Development library unavailable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Libreria di sviluppo non disponibile" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bibliothèque de développement indisponible" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwicklungsbibliothek nicht verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca de desarrollo no disponible" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca de desenvolvimento indisponível" + } + } + } + }, "Device save": { "comment": "Save safety and presentation; user content stays unchanged.", "localizations": { @@ -21218,6 +21813,47 @@ } } }, + "Enable Steam to find its installed Mac games.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Steam to find its installed Mac games." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva Steam per trovare i suoi giochi Mac installati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez Steam pour trouver ses jeux Mac installés." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiviere Steam, um seine installierten Mac-Spiele zu finden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activa Steam para encontrar sus juegos de Mac instalados." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ative o Steam para encontrar seus jogos de Mac instalados." + } + } + } + }, "Enable untested toolchain?": { "comment": "SettingsEngineSection.swift", "localizations": { @@ -21423,47 +22059,6 @@ } } }, - "Engine %@ · %@ · %@ · %@": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Engine %@ · %@ · %@ · %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Motore %@ · %@ · %@ · %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Moteur %@ · %@ · %@ · %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Engine %@ · %@ · %@ · %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Motor %@ · %@ · %@ · %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Engine %@ · %@ · %@ · %@" - } - } - } - }, "Engine back to the tested %@.": { "comment": "AppViewModel+NewestEngine.swift", "localizations": { @@ -21546,84 +22141,84 @@ } } }, - "Engine commit, Metal compiler, toolchain gate and required flags.": { + "Engine commit, Metal compiler, build environment, toolchain gate and required flags.": { "comment": "SettingsCatalog.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Engine commit, Metal compiler, toolchain gate and required flags." + "value": "Engine commit, Metal compiler, build environment, toolchain gate and required flags." } }, "it": { "stringUnit": { "state": "translated", - "value": "Commit del motore, compilatore Metal, controllo della toolchain e flag richiesti." + "value": "Commit del motore, compilatore Metal, ambiente di build, controllo della toolchain e flag richiesti." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Commit du moteur, compilateur Metal, contrôle de la chaîne d’outils et options requises." + "value": "Commit du moteur, compilateur Metal, environnement de compilation, contrôle de la chaîne d’outils et options requises." } }, "de": { "stringUnit": { "state": "translated", - "value": "Engine-Commit, Metal-Compiler, Toolchain-Prüfung und erforderliche Optionen." + "value": "Engine-Commit, Metal-Compiler, Build-Umgebung, Toolchain-Prüfung und erforderliche Optionen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Commit del motor, compilador Metal, control de la cadena de herramientas y opciones necesarias." + "value": "Commit del motor, compilador Metal, entorno de compilación, control de la cadena de herramientas y opciones necesarias." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Commit da engine, compilador Metal, verificação do conjunto de ferramentas e opções obrigatórias." + "value": "Commit da engine, compilador Metal, ambiente de build, verificação do conjunto de ferramentas e opções obrigatórias." } } } }, - "Engine pin, toolchain gate and keys.": { + "Engine pin, toolchain gate, keys and setup.": { "comment": "SettingsSection.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Engine pin, toolchain gate and keys." + "value": "Engine pin, toolchain gate, keys and setup." } }, "it": { "stringUnit": { "state": "translated", - "value": "Versione fissata del motore, controllo della toolchain e chiavi." + "value": "Versione fissata del motore, controllo della toolchain, chiavi e configurazione." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Version épinglée du moteur, contrôle de la chaîne d’outils et clés." + "value": "Version épinglée du moteur, contrôle de la chaîne d’outils, clés et configuration." } }, "de": { "stringUnit": { "state": "translated", - "value": "Festgelegte Engine-Version, Toolchain-Prüfung und Schlüssel." + "value": "Festgelegte Engine-Version, Toolchain-Prüfung, Schlüssel und Einrichtung." } }, "es": { "stringUnit": { "state": "translated", - "value": "Versión fijada del motor, control de la cadena de herramientas y claves." + "value": "Versión fijada del motor, control de la cadena de herramientas, claves y configuración." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Versão fixada da engine, verificação do conjunto de ferramentas e chaves." + "value": "Versão fixada da engine, verificação do conjunto de ferramentas, chaves e configuração." } } } @@ -21874,47 +22469,6 @@ } } }, - "Environment": { - "comment": "SettingsCatalog.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Environment" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Ambiente" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Environnement" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Umgebung" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Entorno" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Ambiente" - } - } - } - }, "Errors": { "comment": "ConsoleView.swift:16", "localizations": { @@ -23472,6 +24026,47 @@ } } }, + "Find installed native Mac games in your Steam libraries.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find installed native Mac games in your Steam libraries." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova i giochi Mac nativi installati nelle tue librerie Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recherchez les jeux Mac natifs installés dans vos bibliothèques Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finde installierte native Mac-Spiele in deinen Steam-Bibliotheken." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Encuentra los juegos nativos de Mac instalados en tus bibliotecas de Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Encontre jogos nativos para Mac instalados nas suas bibliotecas Steam." + } + } + } + }, "Find the game": { "comment": "DetailsBands.swift", "localizations": { @@ -23677,43 +24272,43 @@ } } }, - "Folders, imports and artwork caches.": { + "Folders, scans and box covers.": { "comment": "SettingsSection.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Folders, imports and artwork caches." + "value": "Folders, scans and box covers." } }, "it": { "stringUnit": { "state": "translated", - "value": "Cartelle, importazioni e cache delle immagini." + "value": "Cartelle, scansioni e copertine." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Dossiers, importations et caches d’illustrations." + "value": "Dossiers, analyses et jaquettes." } }, "de": { "stringUnit": { "state": "translated", - "value": "Ordner, Importe und Artwork-Caches." + "value": "Ordner, Scans und Cover." } }, "es": { "stringUnit": { "state": "translated", - "value": "Carpetas, importaciones y cachés de imágenes." + "value": "Carpetas, búsquedas y carátulas." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Pastas, importações e caches de artes." + "value": "Pastas, buscas e capas." } } } @@ -26506,6 +27101,47 @@ } } }, + "Game sources": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Game sources" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fonti" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sources de jeux" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spielequellen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuentes de juegos" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fontes de jogos" + } + } + } + }, "Game sources: NSP, XCI, NSZ and XCZ files, or Switch 2 NUP files and extracted folders, for builds. Optional for existing apps.": { "comment": "SettingsCatalog.swift", "localizations": { @@ -27941,6 +28577,88 @@ } } }, + "Hidden Steam games": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hidden Steam games" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Giochi Steam nascosti" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jeux Steam masqués" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeblendete Steam-Spiele" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Juegos de Steam ocultos" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Jogos Steam ocultos" + } + } + } + }, + "Hidden from the library": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hidden from the library" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nascosto nella libreria" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masqué dans la bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In der Bibliothek ausgeblendet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oculto en la biblioteca" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Oculto na biblioteca" + } + } + } + }, "Hidden games": { "comment": "SettingsCatalog.swift", "localizations": { @@ -30155,6 +30873,47 @@ } } }, + "Installation": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installazione" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalación" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalação" + } + } + } + }, "Installation failed": { "comment": "AppShellView.swift", "localizations": { @@ -30196,6 +30955,47 @@ } } }, + "Installation not detected": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installation not detected" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installazione non rilevata" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installation non détectée" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installation nicht gefunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalación no detectada" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalação não detectada" + } + } + } + }, "Installed": { "comment": "Language/region preferences and existing library filters.", "localizations": { @@ -31098,6 +31898,47 @@ } } }, + "Keep Steam in the background": { + "comment": "SettingsSourcesSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Steam in the background" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tieni Steam in secondo piano" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Garder Steam en arrière-plan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam im Hintergrund halten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener Steam en segundo plano" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Manter o Steam em segundo plano" + } + } + } + }, "Keep a copy of your current progress.": { "comment": "SavesPanel.swift", "localizations": { @@ -31754,6 +32595,47 @@ } } }, + "Language, the first screen and what runs at launch.": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language, the first screen and what runs at launch." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lingua, schermata iniziale e cosa avviene all’apertura." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Langue, premier écran et ce qui se lance au démarrage." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache, erster Bildschirm und was beim Start passiert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Idioma, primera pantalla y lo que se ejecuta al abrir." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Idioma, primeira tela e o que acontece ao abrir." + } + } + } + }, "Largest build caches first. Sizes describe file contents; the space recovered can differ. Shared folders count once in totals.": { "comment": "SettingsStorageSection.swift", "localizations": { @@ -32574,43 +33456,43 @@ } } }, - "Library controls and new-game rumble.": { + "Library controls and rumble for new Switch games.": { "comment": "SettingsSection.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Library controls and new-game rumble." + "value": "Library controls and rumble for new Switch games." } }, "it": { "stringUnit": { "state": "translated", - "value": "Comandi della libreria e vibrazione dei nuovi giochi." + "value": "Comandi della libreria e vibrazione dei nuovi giochi Switch." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Commandes de la bibliothèque et vibrations des nouveaux jeux." + "value": "Commandes de la bibliothèque et vibrations des nouveaux jeux Switch." } }, "de": { "stringUnit": { "state": "translated", - "value": "Bibliothekssteuerung und Vibration für neue Spiele." + "value": "Bibliothekssteuerung und Vibration für neue Switch-Spiele." } }, "es": { "stringUnit": { "state": "translated", - "value": "Controles de la biblioteca y vibración de juegos nuevos." + "value": "Controles de la biblioteca y vibración de juegos nuevos de Switch." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Controles da biblioteca e vibração para novos jogos." + "value": "Controles da biblioteca e vibração para novos jogos de Switch." } } } @@ -32656,6 +33538,47 @@ } } }, + "Library not saved": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Library not saved" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Libreria non salvata" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bibliothèque non enregistrée" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bibliothek nicht gespeichert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca sin guardar" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca não salva" + } + } + } + }, "Library refresh": { "comment": "MenuSelectionModel.swift", "localizations": { @@ -32779,6 +33702,47 @@ } } }, + "Library unavailable": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Library unavailable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Libreria non disponibile" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bibliothèque indisponible" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bibliothek nicht verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca no disponible" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Biblioteca indisponível" + } + } + } + }, "Library · Launch": { "comment": "ChangeArtworkView.swift:310", "localizations": { @@ -33025,47 +33989,6 @@ } } }, - "Locally cached game metadata such as title IDs, publisher names, release dates, and category tags fetched during library scans.": { - "comment": "SettingsLibrarySection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Locally cached game metadata such as title IDs, publisher names, release dates, and category tags fetched during library scans." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Metadati dei giochi conservati nella cache locale, come title ID, nomi degli editori, date di uscita e tag delle categorie, recuperati durante le scansioni della libreria." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Métadonnées de jeu mises en cache localement, telles que les title ID, les noms d’éditeur, les dates de sortie et les catégories, récupérées lors des analyses de la bibliothèque." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Lokal zwischengespeicherte Spielmetadaten wie title IDs, Publisher-Namen, Veröffentlichungsdaten und Kategorien, die beim Durchsuchen der Bibliothek abgerufen werden." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Metadatos de juegos guardados en la caché local, como title IDs, nombres de editoras, fechas de lanzamiento y etiquetas de categorías, obtenidos al analizar la biblioteca." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Metadados de jogos em cache local, como title IDs, nomes de publicadoras, datas de lançamento e categorias, obtidos durante as buscas na biblioteca." - } - } - } - }, "Log": { "comment": "AddGameStepThreeView.swift", "localizations": { @@ -33886,6 +34809,47 @@ } } }, + "Mac game": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac game" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gioco Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jeu Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mac-Spiel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Juego de Mac" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Jogo de Mac" + } + } + } + }, "Manage mods": { "comment": "DetailsBands.swift", "localizations": { @@ -33927,6 +34891,47 @@ } } }, + "Manage sources": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage sources" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci fonti" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les sources" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quellen verwalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestionar fuentes" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar fontes" + } + } + } + }, "Manual backup": { "comment": "Save safety and presentation; user content stays unchanged.", "localizations": { @@ -34337,47 +35342,6 @@ } } }, - "Metal unknown": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Metal unknown" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Metal sconosciuto" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Metal inconnu" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Metal unbekannt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Metal desconocido" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Metal desconhecido" - } - } - } - }, "MetalFX": { "comment": "Engine setting option display label.", "localizations": { @@ -36592,6 +37556,47 @@ } } }, + "Needs review": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs review" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Da controllare" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À vérifier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfung erforderlich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Requiere revisión" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Requer revisão" + } + } + } + }, "Never": { "comment": "LibraryPresentation.swift", "localizations": { @@ -37535,47 +38540,6 @@ } } }, - "Nintendo Store descriptions, genres and links.": { - "comment": "SettingsLibrarySection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Nintendo Store descriptions, genres and links." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Descrizioni, generi e link del Nintendo Store." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Descriptions, genres et liens du Nintendo Store." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Beschreibungen, Genres und Links aus dem Nintendo Store." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descripciones, géneros y enlaces de Nintendo Store." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Descrições, gêneros e links da Nintendo Store." - } - } - } - }, "Nintendo Store returned a response this app could not read.": { "comment": "NintendoStoreMetadata.swift", "localizations": { @@ -39544,47 +40508,6 @@ } } }, - "Not checked": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not checked" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Non verificato" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Non vérifié" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Nicht geprüft" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sin comprobar" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Não verificado" - } - } - } - }, "Not connected": { "comment": "SettingsControllersSection.swift", "localizations": { @@ -41717,6 +42640,47 @@ } } }, + "Open library folder": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open library folder" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella della libreria" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le dossier de la bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bibliotheksordner öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta de la biblioteca" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir pasta da biblioteca" + } + } + } + }, "Open log": { "comment": "GameOptionsPresentation.swift", "localizations": { @@ -42168,6 +43132,47 @@ } } }, + "Open the game’s Steam store page.": { + "comment": "CatalogGameOptions.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the game’s Steam store page." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la pagina del gioco nello store di Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir la page du jeu sur le magasin Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffne die Seite des Spiels im Steam-Shop." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abre la página del juego en la tienda de Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abra a página do jogo na loja Steam." + } + } + } + }, "Open the log, or copy diagnostics for a bug report.": { "comment": "PortingFailureReason.swift", "localizations": { @@ -44422,6 +45427,47 @@ } } }, + "Preparing a separate copy of your launcher data. Games stay in their current locations.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing a separate copy of your launcher data. Games stay in their current locations." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione di una copia separata dei dati del launcher. I giochi restano nei loro percorsi attuali." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation d’une copie séparée des données du lanceur. Les jeux restent à leur emplacement actuel." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine separate Kopie der Launcher-Daten wird vorbereitet. Die Spiele bleiben an ihren bisherigen Speicherorten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando una copia separada de los datos del launcher. Los juegos permanecen en sus ubicaciones actuales." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preparando uma cópia separada dos dados do launcher. Os jogos permanecem nos locais atuais." + } + } + } + }, "Preparing the app icon. The game will start when it is ready. This can take a minute for a large game.": { "comment": "AppViewModel+AppIcon.swift", "localizations": { @@ -44463,6 +45509,88 @@ } } }, + "Preparing your development library": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing your development library" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione della libreria di sviluppo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation de votre bibliothèque de développement" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwicklungsbibliothek wird vorbereitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando tu biblioteca de desarrollo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preparando sua biblioteca de desenvolvimento" + } + } + } + }, + "Preparing your library": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing your library" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione della libreria" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation de votre bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Bibliothek wird vorbereitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando tu biblioteca" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preparando sua biblioteca" + } + } + } + }, "Press a button to join": { "comment": "Engine game setting presentation; never translate env keys or values.", "localizations": { @@ -44914,88 +46042,6 @@ } } }, - "Python included": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Python included" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Python incluso" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Python intégré" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Python enthalten" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Python incluido" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Python incluído" - } - } - } - }, - "Python missing": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Python missing" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Python non disponibile" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Python manquant" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Python fehlt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Falta Python" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Python ausente" - } - } - } - }, "Python venv": { "comment": "CompatibilityChecker.swift", "localizations": { @@ -45037,47 +46083,6 @@ } } }, - "Python venv ready": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Python venv ready" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Venv Python pronto" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Environnement venv Python prêt" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Python-venv bereit" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Entorno virtual de Python listo" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "venv do Python pronto" - } - } - } - }, "Q": { "comment": "ButtonPrompts.swift:39", "localizations": { @@ -45406,88 +46411,6 @@ } } }, - "Re-check the environment and build requirements.": { - "comment": "SettingsCatalog.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-check the environment and build requirements." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Ricontrolla l’ambiente e i requisiti di compilazione." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Revérifier l’environnement et les prérequis de compilation." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Umgebung und Build-Voraussetzungen erneut prüfen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Vuelve a comprobar el entorno y los requisitos de compilación." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verifique novamente o ambiente e os requisitos de compilação." - } - } - } - }, - "Re-run the environment and compatibility checks.": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-run the environment and compatibility checks." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Ripeti i controlli dell’ambiente e della compatibilità." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Relancer les vérifications de l’environnement et de la compatibilité." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Umgebungs- und Kompatibilitätsprüfung erneut ausführen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Vuelve a ejecutar las comprobaciones del entorno y de compatibilidad." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Execute novamente as verificações de ambiente e compatibilidade." - } - } - } - }, "Read more": { "comment": "SpotlightPageSections.swift", "localizations": { @@ -45529,47 +46452,6 @@ } } }, - "Readiness": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Readiness" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Stato di preparazione" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "État de préparation" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Bereitschaft" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Estado de preparación" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Prontidão" - } - } - } - }, "Reading the folder…": { "comment": "AddGameView+Rows.swift", "localizations": { @@ -45652,43 +46534,43 @@ } } }, - "Ready to build and play": { - "comment": "SettingsStartUpSection.swift", + "Ready to check Steam.": { + "comment": "Steam source management and native Mac library presentation.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ready to build and play" + "value": "Ready to check Steam." } }, "it": { "stringUnit": { "state": "translated", - "value": "Pronto per compilare e giocare" + "value": "Pronto per controllare Steam." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Prêt à compiler et à jouer" + "value": "Prêt à vérifier Steam." } }, "de": { "stringUnit": { "state": "translated", - "value": "Bereit zum Erstellen und Spielen" + "value": "Bereit, Steam zu prüfen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Listo para compilar y jugar" + "value": "Listo para revisar Steam." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Pronto para compilar e jogar" + "value": "Pronto para verificar o Steam." } } } @@ -46267,6 +47149,47 @@ } } }, + "Refresh Steam": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh Steam" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiorna Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam aktualisieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizar Steam" + } + } + } + }, "Refresh library at launch": { "comment": "SettingsCatalog.swift", "localizations": { @@ -46308,6 +47231,47 @@ } } }, + "Refresh the Steam source or check the installation in Steam.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh the Steam source or check the installation in Steam." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiorna la fonte Steam o controlla l’installazione in Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualisez la source Steam ou vérifiez l’installation dans Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiere die Steam-Quelle oder prüfe die Installation in Steam." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualiza la fuente de Steam o revisa la instalación en Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualize a fonte Steam ou verifique a instalação no Steam." + } + } + } + }, "Region": { "comment": "Language/region preferences and existing library filters.", "localizations": { @@ -47773,6 +48737,119 @@ } } }, + "Restore backup (%lld games)": { + "comment": "Explicit restore of the offered catalog backup; original damaged file is retained.", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Restore backup (%lld game)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Restore backup (%lld games)" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Ripristina backup (%lld gioco)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Ripristina backup (%lld giochi)" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Restaurer la sauvegarde (%lld jeu)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Restaurer la sauvegarde (%lld jeux)" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sicherung wiederherstellen (%lld Spiel)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sicherung wiederherstellen (%lld Spiele)" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Restaurar copia (%lld juego)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Restaurar copia (%lld juegos)" + } + } + } + } + }, + "pt-BR": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Restaurar backup (%lld jogo)" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Restaurar backup (%lld jogos)" + } + } + } + } + } + } + }, "Restore last game": { "comment": "SettingsCatalog.swift", "localizations": { @@ -48101,6 +49178,47 @@ } } }, + "Retry saving library": { + "comment": "Available while a finished build outcome is waiting for its catalog save.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry saving library" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova a salvare la libreria" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer d’enregistrer la bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bibliothek erneut speichern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar guardar la biblioteca" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar salvar a biblioteca novamente" + } + } + } + }, "Return / Space · ": { "comment": "ControlsView.swift:47", "localizations": { @@ -48511,6 +49629,47 @@ } } }, + "Review installations": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review installations" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla le installazioni" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier les installations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installationen prüfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisar instalaciones" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Revisar instalações" + } + } + } + }, "Right stick": { "comment": "ControlsView.swift:37", "localizations": { @@ -49536,6 +50695,47 @@ } } }, + "Scan stopped. Refresh when you are ready.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan stopped. Refresh when you are ready." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scansione interrotta. Aggiorna quando vuoi." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse arrêtée. Actualisez quand vous le souhaitez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche angehalten. Aktualisiere, wenn du bereit bist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exploración detenida. Actualiza cuando quieras." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Busca interrompida. Atualize quando quiser." + } + } + } + }, "Scanning game apps…": { "comment": "SettingsLibrarySection.swift", "localizations": { @@ -49659,6 +50859,47 @@ } } }, + "Screen-shaped art, then Steam": { + "comment": "ChangeArtworkView.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Screen-shaped art, then Steam" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Immagini in formato schermo, poi Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Images au format écran, puis Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder im Bildschirmformat, dann Steam" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Imágenes con forma de pantalla, luego Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Imagens no formato da tela, depois o Steam" + } + } + } + }, "Screenshot %@": { "comment": "SpotlightScreenshotStrip.swift", "localizations": { @@ -50725,6 +51966,170 @@ } } }, + "Settings group: General": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Generali" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Geral" + } + } + } + }, + "Settings group: Steam": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + } + } + }, + "Settings group: Switch": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + } + } + }, + "Settings group: System": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + } + } + }, "Settings not saved": { "comment": "AppShellView.swift", "localizations": { @@ -51094,47 +52499,6 @@ } } }, - "Setup needed or builds blocked": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Setup needed or builds blocked" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Configurazione necessaria o compilazioni bloccate" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Configuration requise ou compilations bloquées" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Einrichtung erforderlich oder Builds gesperrt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Falta configurar o la compilación está bloqueada" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Configuração necessária ou compilações bloqueadas" - } - } - } - }, "Several files could be these cheats: add the one you want.": { "comment": "CheatCopy.swift", "localizations": { @@ -52160,6 +53524,88 @@ } } }, + "Some installations could not be checked. Your library has been kept.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some installations could not be checked. Your library has been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile controllare alcune installazioni. La tua libreria è stata conservata." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certaines installations n’ont pas pu être vérifiées. Votre bibliothèque a été conservée." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Installationen konnten nicht geprüft werden. Deine Bibliothek bleibt erhalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron revisar algunas instalaciones. Tu biblioteca se ha conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Algumas instalações não puderam ser verificadas. Sua biblioteca foi mantida." + } + } + } + }, + "Some launcher data could not be read. The original data has not been changed.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some launcher data could not be read. The original data has not been changed." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile leggere alcuni dati del launcher. I dati originali non sono stati modificati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certaines données du lanceur n’ont pas pu être lues. Les données d’origine n’ont pas été modifiées." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Launcher-Daten konnten nicht gelesen werden. Die Originaldaten wurden nicht verändert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron leer algunos datos del launcher. Los datos originales no se han modificado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler alguns dados do launcher. Os dados originais não foram alterados." + } + } + } + }, "Some shaders broke in this engine": { "comment": "PortingFailureReason.swift", "localizations": { @@ -52980,6 +54426,47 @@ } } }, + "Start the game through Steam.": { + "comment": "CatalogGameOptions.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start the game through Steam." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvia il gioco tramite Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lancer le jeu via Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Starte das Spiel über Steam." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia el juego a través de Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inicie o jogo pelo Steam." + } + } + } + }, "Start-up": { "comment": "SettingsSection.swift", "localizations": { @@ -53185,43 +54672,412 @@ } } }, - "Startup, setup and readiness.": { + "Steam": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Steam" + } + } + } + }, + "Steam could not be refreshed. Your library has been kept.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam could not be refreshed. Your library has been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile aggiornare Steam. La tua libreria è stata conservata." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam n’a pas pu être actualisé. Votre bibliothèque a été conservée." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam konnte nicht aktualisiert werden. Deine Bibliothek bleibt erhalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo actualizar Steam. Tu biblioteca se ha conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível atualizar o Steam. Sua biblioteca foi mantida." + } + } + } + }, + "Steam did not receive the request. Try again.": { + "comment": "CatalogSteamInfo.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam did not receive the request. Try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam non ha ricevuto la richiesta. Riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam n’a pas reçu la demande. Réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam hat die Anfrage nicht erhalten. Versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam no recibió la solicitud. Inténtalo de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Steam não recebeu a solicitação. Tente novamente." + } + } + } + }, + "Steam folder": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam folder" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cartella Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dossier Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam-Ordner" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpeta de Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pasta do Steam" + } + } + } + }, + "Steam installation data could not be verified. Your library has been kept.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam installation data could not be verified. Your library has been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile verificare i dati delle installazioni Steam. La tua libreria è stata conservata." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les données des installations Steam n’ont pas pu être vérifiées. Votre bibliothèque a été conservée." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Steam-Installationsdaten konnten nicht geprüft werden. Deine Bibliothek bleibt erhalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron verificar los datos de las instalaciones de Steam. Tu biblioteca se ha conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível verificar os dados das instalações Steam. Sua biblioteca foi mantida." + } + } + } + }, + "Steam is not installed on this Mac.": { + "comment": "CatalogSteamInfo.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam is not installed on this Mac." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam non è installato su questo Mac." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam n’est pas installé sur ce Mac." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam ist auf diesem Mac nicht installiert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam no está instalado en este Mac." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Steam não está instalado neste Mac." + } + } + } + }, + "Steam libraries, refresh and how Play opens Steam.": { "comment": "SettingsSection.swift", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Startup, setup and readiness." + "value": "Steam libraries, refresh and how Play opens Steam." } }, "it": { "stringUnit": { "state": "translated", - "value": "Avvio, configurazione e stato di preparazione." + "value": "Librerie Steam, aggiornamento e come Gioca apre Steam." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Démarrage, configuration et état de préparation." + "value": "Bibliothèques Steam, actualisation et ouverture de Steam par Jouer." } }, "de": { "stringUnit": { "state": "translated", - "value": "Start, Einrichtung und Bereitschaft." + "value": "Steam-Bibliotheken, Aktualisieren und wie „Spielen“ Steam öffnet." } }, "es": { "stringUnit": { "state": "translated", - "value": "Inicio, configuración y estado de preparación." + "value": "Bibliotecas de Steam, actualización y cómo Jugar abre Steam." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Inicialização, configuração e prontidão." + "value": "Bibliotecas do Steam, atualização e como Jogar abre o Steam." + } + } + } + }, + "Steam may need you. Check Steam, the game starts after.": { + "comment": "CatalogSteamInfo.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam may need you. Check Steam, the game starts after." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam potrebbe aver bisogno di te. Controlla Steam, poi il gioco si avvia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam a peut-être besoin de vous. Vérifiez Steam, le jeu démarrera ensuite." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam braucht dich vielleicht. Sieh in Steam nach, danach startet das Spiel." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puede que Steam te necesite. Revisa Steam y luego se iniciará el juego." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Steam pode precisar de você. Confira o Steam; depois o jogo inicia." + } + } + } + }, + "Steam stays out of sight while a game starts, and comes back if it needs you.": { + "comment": "SettingsSourcesSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam stays out of sight while a game starts, and comes back if it needs you." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam resta nascosto mentre il gioco si avvia e torna se ha bisogno di te." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam reste caché pendant le lancement d’un jeu et revient s’il a besoin de vous." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam bleibt beim Spielstart verborgen und kommt zurück, wenn es dich braucht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam permanece oculto mientras se inicia un juego y vuelve si te necesita." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Steam fica oculto enquanto um jogo inicia e volta se precisar de você." + } + } + } + }, + "Steam: %@": { + "comment": "ChangeMetadataView.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Steam: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Steam : %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Steam: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Steam: %@" } } } @@ -53431,6 +55287,88 @@ } } }, + "Steam’s library cover": { + "comment": "ChangeArtworkView.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam’s library cover" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La copertina della libreria di Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La jaquette de la bibliothèque Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Bibliothekscover von Steam" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La portada de la biblioteca de Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A capa da biblioteca do Steam" + } + } + } + }, + "Steam’s logo": { + "comment": "ChangeArtworkView.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam’s logo" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il logo di Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le logo de Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Logo von Steam" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El logotipo de Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O logotipo do Steam" + } + } + } + }, "Step %@ of 3": { "comment": "AddGameView.swift", "localizations": { @@ -53923,47 +55861,6 @@ } } }, - "Summarizes the 4 essential gates: valid prod.keys, cloned nxnative repository, Python build environment, and certified Metal compiler version.": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Summarizes the 4 essential gates: valid prod.keys, cloned nxnative repository, Python build environment, and certified Metal compiler version." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riepiloga i 4 requisiti essenziali: prod.keys valido, repository nxnative clonato, ambiente di compilazione Python e versione certificata del compilatore Metal." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Résume les 4 contrôles essentiels : prod.keys valide, dépôt nxnative cloné, environnement de compilation Python et version du compilateur Metal certifiée." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Fasst die 4 grundlegenden Voraussetzungen zusammen: gültige prod.keys, geklontes nxnative-Repository, Python-Build-Umgebung und zertifizierte Metal-Compiler-Version." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Resume los 4 requisitos esenciales: prod.keys válido, repositorio de nxnative clonado, entorno de compilación Python y versión del compilador Metal certificada." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Resume as 4 verificações essenciais: prod.keys válido, repositório nxnative clonado, ambiente Python de compilação e versão certificada do compilador Metal." - } - } - } - }, "Surprise me": { "comment": "SpotlightRow.swift", "localizations": { @@ -54620,6 +56517,47 @@ } } }, + "That game is no longer in the library. Refresh it and try again.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That game is no longer in the library. Refresh it and try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il gioco non è più nella libreria. Aggiornala e riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce jeu n’est plus dans la bibliothèque. Actualisez-la et réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Spiel ist nicht mehr in der Bibliothek. Aktualisiere sie und versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ese juego ya no está en la biblioteca. Actualízala y vuelve a intentarlo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esse jogo não está mais na biblioteca. Atualize-a e tente novamente." + } + } + } + }, "That is not a SteamGridDB API key. Copy it from Preferences → API in your SteamGridDB profile.": { "comment": "SteamGridDBArt.swift", "localizations": { @@ -54702,6 +56640,47 @@ } } }, + "The Mac app’s own icon": { + "comment": "ChangeArtworkView.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Mac app’s own icon" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’icona dell’app Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’icône de l’app Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das eigene Symbol der Mac-App" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El icono propio de la app de Mac" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O ícone do próprio app para Mac" + } + } + } + }, "The Nintendo Store URL is invalid.": { "comment": "NintendoStoreMetadata.swift", "localizations": { @@ -54743,6 +56722,47 @@ } } }, + "The Steam library changed while it was being checked. Refresh it again.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Steam library changed while it was being checked. Refresh it again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La libreria Steam è cambiata durante il controllo. Aggiornala di nuovo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La bibliothèque Steam a changé pendant la vérification. Actualisez-la à nouveau." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Steam-Bibliothek hat sich während der Prüfung geändert. Aktualisiere sie erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La biblioteca de Steam cambió durante la revisión. Actualízala de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A biblioteca Steam mudou durante a verificação. Atualize-a novamente." + } + } + } + }, "The app bundle is missing: %@": { "comment": "NXLibraryError.swift", "localizations": { @@ -55317,6 +57337,47 @@ } } }, + "The build result is kept for this session. Free space if needed, then retry saving before closing NX Library.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The build result is kept for this session. Free space if needed, then retry saving before closing NX Library." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il risultato della build viene conservato per questa sessione. Libera spazio se necessario, poi ritenta il salvataggio prima di chiudere NX Library." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le résultat de la compilation est conservé pour cette session. Libérez de l’espace si nécessaire, puis réessayez d’enregistrer avant de fermer NX Library." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Build-Ergebnis bleibt für diese Sitzung erhalten. Gib bei Bedarf Speicherplatz frei und versuche das Speichern erneut, bevor du NX Library schließt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El resultado de la compilación se conserva durante esta sesión. Libera espacio si es necesario y vuelve a guardarlo antes de cerrar NX Library." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O resultado da compilação será mantido nesta sessão. Libere espaço se necessário e tente salvar novamente antes de fechar o NX Library." + } + } + } + }, "The built app is invalid: %@": { "comment": "BuildOutputInstaller.swift", "localizations": { @@ -55563,6 +57624,129 @@ } } }, + "The development data folder already exists without a completed import. It has been preserved.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The development data folder already exists without a completed import. It has been preserved." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La cartella dei dati di sviluppo esiste già, ma l’importazione non è completa. È stata conservata." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le dossier des données de développement existe déjà, mais l’importation est incomplète. Il a été conservé." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Entwicklungsdatenordner ist bereits vorhanden, aber der Import ist nicht abgeschlossen. Er wurde beibehalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La carpeta de datos de desarrollo ya existe, pero la importación no está completa. Se ha conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A pasta de dados de desenvolvimento já existe, mas a importação não foi concluída. Ela foi mantida." + } + } + } + }, + "The development preferences could not be saved. Try again.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The development preferences could not be saved. Try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile salvare le preferenze di sviluppo. Riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les préférences de développement n’ont pas pu être enregistrées. Réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Entwicklungseinstellungen konnten nicht gespeichert werden. Versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron guardar las preferencias de desarrollo. Inténtalo de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível salvar as preferências de desenvolvimento. Tente novamente." + } + } + } + }, + "The development profile configuration is invalid.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The development profile configuration is invalid." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La configurazione del profilo di sviluppo non è valida." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La configuration du profil de développement est invalide." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Konfiguration des Entwicklungsprofils ist ungültig." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La configuración del perfil de desarrollo no es válida." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A configuração do perfil de desenvolvimento é inválida." + } + } + } + }, "The disk filled up during the porting.": { "comment": "PortingFailureReason.swift", "localizations": { @@ -56465,6 +58649,47 @@ } } }, + "The game did not start. Check Steam, then try again.": { + "comment": "CatalogSteamInfo.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The game did not start. Check Steam, then try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il gioco non si è avviato. Controlla Steam, poi riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le jeu n’a pas démarré. Vérifiez Steam, puis réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Spiel wurde nicht gestartet. Prüfe Steam und versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El juego no se inició. Revisa Steam y vuelve a intentarlo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O jogo não iniciou. Verifique o Steam e tente novamente." + } + } + } + }, "The game does not have this language": { "comment": "PortingFailureReason.swift", "localizations": { @@ -57244,6 +59469,293 @@ } } }, + "The latest save could not be confirmed. Reload the library before trying again.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The latest save could not be confirmed. Reload the library before trying again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile confermare l’ultimo salvataggio. Ricarica la libreria prima di riprovare." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le dernier enregistrement n’a pas pu être confirmé. Rechargez la bibliothèque avant de réessayer." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das letzte Speichern konnte nicht bestätigt werden. Lade die Bibliothek neu, bevor du es erneut versuchst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo confirmar el último guardado. Vuelve a cargar la biblioteca antes de intentarlo de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível confirmar o último salvamento. Recarregue a biblioteca antes de tentar novamente." + } + } + } + }, + "The launcher file %@ could not be read. The original data has not been changed.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The launcher file %@ could not be read. The original data has not been changed." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile leggere il file del launcher %@. I dati originali non sono stati modificati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le fichier du lanceur %@ n’a pas pu être lu. Les données d’origine n’ont pas été modifiées." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Launcher-Datei %@ konnte nicht gelesen werden. Die Originaldaten wurden nicht verändert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer el archivo del launcher %@. Los datos originales no se han modificado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler o arquivo do launcher %@. Os dados originais não foram alterados." + } + } + } + }, + "The library changed. Reload it and try again.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The library changed. Reload it and try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La libreria è cambiata. Ricaricala e riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La bibliothèque a changé. Rechargez-la et réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Bibliothek hat sich geändert. Lade sie neu und versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La biblioteca ha cambiado. Vuelve a cargarla e inténtalo de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A biblioteca mudou. Recarregue-a e tente novamente." + } + } + } + }, + "The library contains conflicting or unsupported records. Its files have been kept.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The library contains conflicting or unsupported records. Its files have been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La libreria contiene dati in conflitto o non supportati. I file sono stati conservati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La bibliothèque contient des données en conflit ou non prises en charge. Ses fichiers ont été conservés." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Bibliothek enthält widersprüchliche oder nicht unterstützte Einträge. Die Dateien wurden aufbewahrt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La biblioteca contiene datos en conflicto o no compatibles. Sus archivos se han conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A biblioteca contém dados conflitantes ou não compatíveis. Os arquivos foram preservados." + } + } + } + }, + "The library copy contains a symbolic link or an unsupported file. The original data has not been changed.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The library copy contains a symbolic link or an unsupported file. The original data has not been changed." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La copia della libreria contiene un collegamento simbolico o un file non supportato. I dati originali non sono stati modificati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La copie de la bibliothèque contient un lien symbolique ou un fichier non pris en charge. Les données d’origine n’ont pas été modifiées." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Bibliothekskopie enthält einen symbolischen Link oder eine nicht unterstützte Datei. Die Originaldaten wurden nicht verändert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La copia de la biblioteca contiene un enlace simbólico o un archivo no compatible. Los datos originales no se han modificado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A cópia da biblioteca contém um link simbólico ou um arquivo não compatível. Os dados originais não foram alterados." + } + } + } + }, + "The library folder could not be used safely. Its files have been kept.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The library folder could not be used safely. Its files have been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile usare la cartella della libreria in modo sicuro. I file sono stati conservati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’utiliser le dossier de la bibliothèque en toute sécurité. Ses fichiers ont été conservés." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Bibliotheksordner konnte nicht sicher verwendet werden. Die Dateien wurden aufbewahrt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo usar la carpeta de la biblioteca de forma segura. Sus archivos se han conservado." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível usar a pasta da biblioteca com segurança. Os arquivos foram preservados." + } + } + } + }, + "The library is busy. Try again shortly.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The library is busy. Try again shortly." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La libreria è occupata. Riprova tra poco." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La bibliothèque est occupée. Réessayez dans un instant." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Bibliothek ist beschäftigt. Versuche es gleich erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La biblioteca está ocupada. Vuelve a intentarlo en un momento." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A biblioteca está ocupada. Tente novamente em instantes." + } + } + } + }, "The library is in a cloud folder": { "comment": "PortingFailureReason.swift", "localizations": { @@ -57736,6 +60248,47 @@ } } }, + "The original library changed during the copy. Close the other launcher copy and try again.": { + "comment": "Development profile bootstrap; do not expose credential values.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The original library changed during the copy. Close the other launcher copy and try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La libreria originale è cambiata durante la copia. Chiudi l’altra copia del launcher e riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La bibliothèque d’origine a changé pendant la copie. Fermez l’autre copie du lanceur et réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Originalbibliothek wurde während des Kopierens geändert. Schließe die andere Launcher-Kopie und versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La biblioteca original cambió durante la copia. Cierra la otra copia del launcher e inténtalo de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A biblioteca original mudou durante a cópia. Feche a outra cópia do launcher e tente novamente." + } + } + } + }, "The original sources are attached. Rebuild writes a new app and keeps the previous one if it fails.": { "comment": "BuildSourcesPanel.swift", "localizations": { @@ -58679,6 +61232,47 @@ } } }, + "The source changed during the scan. Refresh it again.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The source changed during the scan. Refresh it again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fonte è cambiata durante la scansione. Aggiornala di nuovo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La source a changé pendant l’analyse. Actualisez-la à nouveau." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Quelle hat sich während der Suche geändert. Aktualisiere sie erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La fuente cambió durante la exploración. Actualízala de nuevo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A fonte mudou durante a busca. Atualize-a novamente." + } + } + } + }, "The source files are missing. Attach or locate the original base, update and DLC files first.": { "comment": "NXLibraryError.swift", "localizations": { @@ -59745,6 +62339,47 @@ } } }, + "This action is not available for this game.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This action is not available for this game." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa azione non è disponibile per il gioco." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette action n’est pas disponible pour ce jeu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Aktion ist für dieses Spiel nicht verfügbar." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta acción no está disponible para este juego." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta ação não está disponível para este jogo." + } + } + } + }, "This app is not affiliated with Nintendo.": { "comment": "SettingsAboutSection.swift", "localizations": { @@ -60688,6 +63323,47 @@ } } }, + "This source is disabled. Enable it in Game sources.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This source is disabled. Enable it in Game sources." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa fonte è disattivata. Attivala in Fonti." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette source est désactivée. Activez-la dans Sources de jeux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Quelle ist deaktiviert. Aktiviere sie unter Spielequellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta fuente está desactivada. Actívala en Fuentes de juegos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta fonte está desativada. Ative-a em Fontes de jogos." + } + } + } + }, "This version's cheat files go to the Trash.": { "comment": "CheatsPanel.swift:298", "localizations": { @@ -63189,6 +65865,47 @@ } } }, + "Vibration in new Switch games": { + "comment": "SettingsControllersSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vibration in new Switch games" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vibrazione nei nuovi giochi Switch" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vibrations dans les nouveaux jeux Switch" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vibration in neuen Switch-Spielen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vibración en juegos nuevos de Switch" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Vibração em novos jogos de Switch" + } + } + } + }, "Vibration strength": { "comment": "GameOptionsPresentation.swift", "localizations": { @@ -63681,6 +66398,47 @@ } } }, + "Waiting for Steam to start the game…": { + "comment": "CatalogSteamInfo.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for Steam to start the game…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In attesa che Steam avvii il gioco…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente du lancement du jeu par Steam…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warte, bis Steam das Spiel startet …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando a que Steam inicie el juego…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguardando o Steam iniciar o jogo…" + } + } + } + }, "Waiting for build output…": { "comment": "BuildLiveLogsDrawer.swift", "localizations": { @@ -64132,6 +66890,47 @@ } } }, + "When Play opens Steam, it starts without its window. An open Steam is hidden until the game appears. If the game takes longer than 15 seconds, Steam comes back so you can answer it.": { + "comment": "SettingsSourcesSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When Play opens Steam, it starts without its window. An open Steam is hidden until the game appears. If the game takes longer than 15 seconds, Steam comes back so you can answer it." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando Gioca apre Steam, si avvia senza finestra. Uno Steam già aperto viene nascosto finché non compare il gioco. Se il gioco impiega più di 15 secondi, Steam torna così puoi rispondergli." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quand Jouer ouvre Steam, il démarre sans sa fenêtre. Un Steam déjà ouvert est masqué jusqu’à l’apparition du jeu. Si le jeu met plus de 15 secondes, Steam revient pour que vous puissiez lui répondre." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn „Spielen“ Steam öffnet, startet es ohne Fenster. Ein offenes Steam wird ausgeblendet, bis das Spiel erscheint. Dauert das Spiel länger als 15 Sekunden, kommt Steam zurück, damit du antworten kannst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando Jugar abre Steam, se inicia sin su ventana. Un Steam abierto se oculta hasta que aparece el juego. Si el juego tarda más de 15 segundos, Steam vuelve para que puedas responderle." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quando Jogar abre o Steam, ele inicia sem a janela. Um Steam aberto fica oculto até o jogo aparecer. Se o jogo levar mais de 15 segundos, o Steam volta para você responder." + } + } + } + }, "When you close a game, a card shows how long you played this time and in total.": { "comment": "SettingsCatalog.swift", "localizations": { @@ -65034,47 +67833,6 @@ } } }, - "Xcode unknown": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Xcode unknown" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Xcode sconosciuto" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Xcode inconnu" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Xcode unbekannt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Xcode desconocido" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Xcode desconhecido" - } - } - } - }, "Y": { "comment": "ButtonPrompts.swift:38", "localizations": { @@ -65403,6 +68161,47 @@ } } }, + "Your Steam files stay where they are. Disabling this source keeps its games and personal choices.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Steam files stay where they are. Disabling this source keeps its games and personal choices." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I file di Steam restano al loro posto. Disattivando questa fonte, i giochi e le tue scelte vengono conservati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vos fichiers Steam restent à leur place. Désactiver cette source conserve ses jeux et vos choix personnels." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Steam-Dateien bleiben an ihrem Platz. Beim Deaktivieren dieser Quelle bleiben ihre Spiele und deine Einstellungen erhalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tus archivos de Steam permanecen donde están. Al desactivar esta fuente se conservan sus juegos y tus preferencias." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Seus arquivos Steam ficam onde estão. Desativar esta fonte mantém seus jogos e suas preferências." + } + } + } + }, "Your activity": { "comment": "DetailsBands.swift:63", "localizations": { @@ -65813,6 +68612,88 @@ } } }, + "Your library could not be read. Its files have been kept. Open the library folder, then try again.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your library could not be read. Its files have been kept. Open the library folder, then try again." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile leggere la libreria. I file sono stati conservati. Apri la cartella della libreria, poi riprova." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire votre bibliothèque. Ses fichiers ont été conservés. Ouvrez le dossier de la bibliothèque, puis réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Bibliothek konnte nicht gelesen werden. Die Dateien wurden aufbewahrt. Öffne den Bibliotheksordner und versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer tu biblioteca. Sus archivos se han conservado. Abre la carpeta de la biblioteca y vuelve a intentarlo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler sua biblioteca. Os arquivos foram preservados. Abra a pasta da biblioteca e tente novamente." + } + } + } + }, + "Your library could not be read. Restore its backup or open the library folder to review the files.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your library could not be read. Restore its backup or open the library folder to review the files." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile leggere la libreria. Ripristina il backup oppure apri la cartella della libreria per controllare i file." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire votre bibliothèque. Restaurez sa sauvegarde ou ouvrez son dossier pour examiner les fichiers." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Bibliothek konnte nicht gelesen werden. Stelle die Sicherung wieder her oder öffne den Bibliotheksordner, um die Dateien zu prüfen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer tu biblioteca. Restaura su copia de seguridad o abre la carpeta de la biblioteca para revisar los archivos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler sua biblioteca. Restaure o backup ou abra a pasta da biblioteca para verificar os arquivos." + } + } + } + }, "Your own": { "comment": "WallpaperCatalog.swift", "localizations": { @@ -66018,6 +68899,47 @@ } } }, + "Your previous library could not be read. The original files have been kept.": { + "comment": "Catalog activation and recovery", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your previous library could not be read. The original files have been kept." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile leggere la libreria precedente. I file originali sono stati conservati." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire votre ancienne bibliothèque. Les fichiers originaux ont été conservés." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine bisherige Bibliothek konnte nicht gelesen werden. Die Originaldateien wurden aufbewahrt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer tu biblioteca anterior. Se han conservado los archivos originales." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler sua biblioteca anterior. Os arquivos originais foram preservados." + } + } + } + }, "You’re up to date": { "comment": "AppUpdateDialog.swift", "localizations": { @@ -66223,47 +69145,6 @@ } } }, - "checked by the build command": { - "comment": "SettingsEngineSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "checked by the build command" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "verificato dal comando di compilazione" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "vérifié par la commande de compilation" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "wird vom Build-Befehl geprüft" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "se comprueba en el comando de compilación" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "verificado pelo comando de compilação" - } - } - } - }, "current %@, expected %@": { "comment": "CompatibilityChecker.swift", "localizations": { diff --git a/Sources/NXLibraryCore/Services/AppDataProfile.swift b/Sources/NXLibraryCore/Services/AppDataProfile.swift new file mode 100644 index 0000000..79fed69 --- /dev/null +++ b/Sources/NXLibraryCore/Services/AppDataProfile.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Immutable location policy. The build declares its profile; filenames and branch names do not select it. +public struct AppDataProfile: Sendable { + public let home: URL + public let isDevelopment: Bool + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser, isDevelopment: Bool) { + self.home = home + self.isDevelopment = isDevelopment + } + /// Folders are built as folders (`isDirectory: true`), never by asking the disk, so a root is the same URL + /// before and after it is created: equal to `file.deletingLastPathComponent()` on a fresh Mac too. + public var stableSupportRoot: URL { home.appendingPathComponent("Library/Application Support/NXLibrary", isDirectory: true) } + public var supportRoot: URL { + isDevelopment ? home.appendingPathComponent("Library/Application Support/NXLibrary-Development", isDirectory: true) : stableSupportRoot + } + public var cacheRoot: URL { home.appendingPathComponent("Library/Caches/" + (isDevelopment ? "NXLibrary-Development" : "NXLibrary"), isDirectory: true) } + public func automaticStagingFolders(appFolders: [URL]) -> [URL] { + BuildScratch.tempFolders(core: supportRoot, appFolders: isDevelopment ? [] : appFolders) + } + public var preferencesDomain: String { isDevelopment ? "local.nxlibrary.development" : "local.nxlibrary" } +} + +/// All app-owned stores use this composition-root policy, including their default initializers. +/// Game installations, their runtime preferences and saves are separate, explicitly operated-on resources. +public enum AppData { + public static let profile = AppDataProfile(isDevelopment: Bundle.main.object(forInfoDictionaryKey: "NXLibraryDevelopmentProfile") as? Bool == true) + public static var supportRoot: URL { profile.supportRoot } + public static var cacheRoot: URL { profile.cacheRoot } + // Each packaged profile has its own bundle ID. Foundation already binds + // standard preferences to that ID and rejects reopening it as a suite. + public static var preferences: UserDefaults { .standard } + public static func validateBuildIdentity(_ info: [String: Any]) throws { + let development = info["NXLibraryDevelopmentProfile"] as? Bool == true + let release = info["NXLibraryRelease"] as? Bool == true + let identifier = info["CFBundleIdentifier"] as? String + guard (development && !release && identifier == "local.nxlibrary.development") + || (release && !development && identifier == "local.nxlibrary") else { + throw DevelopmentProfileError.invalidConfiguration + } + } + public static func prepare() throws { + // An unmarked/mispackaged executable must not silently open the live profile. + try validateBuildIdentity(Bundle.main.infoDictionary ?? [:]) + guard profile.isDevelopment else { return } + let defaults = preferences + _ = try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { + UserDefaults.standard.persistentDomain(forName: "local.nxlibrary") ?? [:] + }, savePreferences: { values in + defaults.setPersistentDomain(values, forName: profile.preferencesDomain) + guard CFPreferencesAppSynchronize(profile.preferencesDomain as CFString) else { + throw DevelopmentProfileError.preferencesUnavailable + } + }) + } +} diff --git a/Sources/NXLibraryCore/Services/AppIconWriter.swift b/Sources/NXLibraryCore/Services/AppIconWriter.swift index 4b4ec1d..fdc6f70 100644 --- a/Sources/NXLibraryCore/Services/AppIconWriter.swift +++ b/Sources/NXLibraryCore/Services/AppIconWriter.swift @@ -40,8 +40,7 @@ public struct AppIconWriter { private let iconutil: URL private let xattr: URL - public init(backups: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/app-icons"), + public init(backups: URL = AppData.supportRoot.appendingPathComponent("app-icons"), runner: CommandRunning = SystemCommandRunner(), fileManager: FileManager = .default, codesign: URL = URL(fileURLWithPath: "/usr/bin/codesign"), iconutil: URL = URL(fileURLWithPath: "/usr/bin/iconutil"), diff --git a/Sources/NXLibraryCore/Services/AppSettingsStore.swift b/Sources/NXLibraryCore/Services/AppSettingsStore.swift index 2892469..8072bc9 100644 --- a/Sources/NXLibraryCore/Services/AppSettingsStore.swift +++ b/Sources/NXLibraryCore/Services/AppSettingsStore.swift @@ -24,8 +24,7 @@ public final class AppSettingsStore: ObservableObject { } public static func defaultURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/settings.json") + AppData.supportRoot.appendingPathComponent("settings.json") } @discardableResult public func save() -> Bool { diff --git a/Sources/NXLibraryCore/Services/AppUpdate.swift b/Sources/NXLibraryCore/Services/AppUpdate.swift index bc9e595..a33abc5 100644 --- a/Sources/NXLibraryCore/Services/AppUpdate.swift +++ b/Sources/NXLibraryCore/Services/AppUpdate.swift @@ -28,10 +28,12 @@ public enum AppUpdate { /// of the version in progress, so it never reads like a published release. public static func versionLabel(_ info: [String: Any]?) -> String { let short = info?["CFBundleShortVersionString"] as? String ?? "?" - return info?[releaseMarker] as? Bool == true ? short : "\(short) (development build)" + if info?[releaseMarker] as? Bool == true { return short } + if info?["NXLibraryDevelopmentProfile"] as? Bool == true { return "\(short) (Dev · Launcher)" } + return "\(short) (development build)" } - public static func repository(defaults: UserDefaults = .standard) -> String? { + public static func repository(defaults: UserDefaults = AppData.preferences) -> String? { if let value = defaults.string(forKey: repositoryOverrideKey)?.trimmingCharacters(in: .whitespaces), value.range(of: #"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"#, options: .regularExpression) != nil { return value @@ -236,7 +238,7 @@ public enum AppUpdate { FileManager.default.isExecutableFile(atPath: app.appendingPathComponent("Contents/MacOS/" + exe).path) else { return .noExecutable } guard info["CFBundleShortVersionString"] as? String == version else { return .wrongVersion } - guard info[releaseMarker] as? Bool == true else { return .notRelease } + guard info[releaseMarker] as? Bool == true, info["NXLibraryDevelopmentProfile"] as? Bool != true else { return .notRelease } if let text = info["LSMinimumSystemVersion"] as? String, let minimum = numbers(text) { let running = [runningOS.majorVersion, runningOS.minorVersion, runningOS.patchVersion] let padded = minimum + Array(repeating: 0, count: max(0, 3 - minimum.count)) @@ -256,7 +258,7 @@ public enum AppUpdate { } public static func installability(_ bundle: URL) -> Installability { - guard bundle.pathExtension == "app", info(bundle)?[releaseMarker] as? Bool == true else { return .developer } + guard bundle.pathExtension == "app", let metadata = info(bundle), metadata[releaseMarker] as? Bool == true, metadata["NXLibraryDevelopmentProfile"] as? Bool != true else { return .developer } let standardized = bundle.standardizedFileURL if standardized.path.contains("/AppTranslocation/") { return .readOnly } if !FileManager.default.isWritableFile(atPath: standardized.deletingLastPathComponent().path) { return .readOnly } diff --git a/Sources/NXLibraryCore/Services/ArtworkBrowse.swift b/Sources/NXLibraryCore/Services/ArtworkBrowse.swift index 73792ba..704633b 100644 --- a/Sources/NXLibraryCore/Services/ArtworkBrowse.swift +++ b/Sources/NXLibraryCore/Services/ArtworkBrowse.swift @@ -45,13 +45,14 @@ public enum ArtworkBrowseSource: String, CaseIterable, Sendable { /// Searches the source. A name the player searches is taken loosely: the first Switch result (LaunchBox) or the /// first result (SteamGridDB) when no title matches it exactly. - public func search(_ query: String, gameNames: [String], switch2: Bool, key: String?, + /// `computer`: a native Mac game (Steam), looked for among LaunchBox's Mac and Windows entries. + public func search(_ query: String, gameNames: [String], switch2: Bool, computer: Bool = false, key: String?, client: ArtworkHTTPClient = URLSessionArtworkClient()) async -> Found { let names = Self.names(for: query, gameNames: gameNames) guard !names.isEmpty else { return Found(page: nil, candidates: []) } switch self { case .launchBox: - let game = await LaunchBoxArt.game(names: names, switch2: switch2, loose: true, client: client) + let game = await LaunchBoxArt.game(names: names, switch2: switch2, computer: computer, loose: true, client: client) return Found(page: game?.page, candidates: game?.candidates ?? []) case .steamGridDB: guard let key else { return Found(page: nil, candidates: []) } diff --git a/Sources/NXLibraryCore/Services/ArtworkPicker.swift b/Sources/NXLibraryCore/Services/ArtworkPicker.swift index 89800bb..a53eb0d 100644 --- a/Sources/NXLibraryCore/Services/ArtworkPicker.swift +++ b/Sources/NXLibraryCore/Services/ArtworkPicker.swift @@ -195,6 +195,27 @@ public enum ArtworkCandidates { return list } + /// A Steam game's images for `slot`: Steam's own art for the exact AppID first, its store + /// screenshots as backgrounds, then everything found online (`OnlineArtwork`), without repeats. + /// Steam has no icon art: the Icon tab offers only online images and files. + public static func steamList(for slot: ArtworkSlot, appID: UInt32, screenshots: [URL], + online: [ArtworkCandidate] = []) -> [ArtworkCandidate] { + // Named for where Steam shows it, so the library hero is recognizable among the backgrounds. + var list = (appID > 0 ? SteamStoreArt.candidates(appID: Int(appID), slot: slot) : []).map { + ArtworkCandidate(slot: $0.slot, source: "Steam library", url: $0.url, fallback: $0.fallback) + } + if slot == .background { + for shot in OnlineArtwork.screenshots(screenshots.map(\.absoluteString), source: "Steam screenshot", stripQuery: true) + where !list.contains(where: { $0.url == shot.url }) { + list.append(shot) + } + } + for candidate in online where candidate.slot == slot && !list.contains(where: { $0.url == candidate.url }) { + list.append(candidate) + } + return list + } + /// Downloads a candidate (or its fallback) and reads its size; nil when both are missing or not images. public static func load(_ candidate: ArtworkCandidate, client: ArtworkHTTPClient = URLSessionArtworkClient()) async -> LoadedArtwork? { @@ -244,8 +265,7 @@ public final class ArtworkPicks: ObservableObject { /// Bumps on every change, so screens reload their art. @Published public private(set) var revision = 0 - public init(directory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/artwork-picks")) { + public init(directory: URL = AppData.supportRoot.appendingPathComponent("artwork-picks")) { self.directory = directory } diff --git a/Sources/NXLibraryCore/Services/ArtworkStore.swift b/Sources/NXLibraryCore/Services/ArtworkStore.swift index a8fd574..8da4765 100644 --- a/Sources/NXLibraryCore/Services/ArtworkStore.swift +++ b/Sources/NXLibraryCore/Services/ArtworkStore.swift @@ -55,8 +55,7 @@ public final class ArtworkStore: ObservableObject { } public static func defaultDirectory() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/artwork") + AppData.supportRoot.appendingPathComponent("artwork") } public func cachedURL(titleID: String, kind: ArtworkKind) -> URL? { diff --git a/Sources/NXLibraryCore/Services/Catalog/CatalogErrorCopy.swift b/Sources/NXLibraryCore/Services/Catalog/CatalogErrorCopy.swift new file mode 100644 index 0000000..4667164 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/CatalogErrorCopy.swift @@ -0,0 +1,43 @@ +import Foundation + +extension CatalogScanError: LocalizedError { + public var errorDescription: String? { + switch self { + case .sourceDisabled: return L10n.text("This source is disabled. Enable it in Game sources.") + case .staleScan: return L10n.text("The source changed during the scan. Refresh it again.") + case .wrongSourceRoot: return L10n.text("The Steam library changed while it was being checked. Refresh it again.") + case .invalidSnapshot: return L10n.text("Steam installation data could not be verified. Your library has been kept.") + } + } +} + +extension CatalogStoreError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidLegacy: return L10n.text("Your previous library could not be read. The original files have been kept.") + case .invalidCatalog(let available): + return available + ? L10n.text("Your library could not be read. Restore its backup or open the library folder to review the files.") + : L10n.text("Your library could not be read. Its files have been kept. Open the library folder, then try again.") + case .legacyChanged: return L10n.text("Another version changed your previous library. Both versions have been kept. Close the other copy and review the library folder.") + case .staleRevision: return L10n.text("The library changed. Reload it and try again.") + case .busy: return L10n.text("The library is busy. Try again shortly.") + case .unsafeDirectory: return L10n.text("The library folder could not be used safely. Its files have been kept.") + case .commitUncertain: return L10n.text("The latest save could not be confirmed. Reload the library before trying again.") + } + } +} + +extension CatalogRepositoryError: LocalizedError { + public var errorDescription: String? { L10n.text("That game is no longer in the library. Refresh it and try again.") } +} + +extension CatalogModelError: LocalizedError { + public var errorDescription: String? { + switch self { + case .wrongIntegration: return L10n.text("This action is not available for this game.") + case .identityMismatch: return L10n.text("The library changed. Reload it and try again.") + default: return L10n.text("The library contains conflicting or unsupported records. Its files have been kept.") + } + } +} diff --git a/Sources/NXLibraryCore/Services/Catalog/CatalogPublication.swift b/Sources/NXLibraryCore/Services/Catalog/CatalogPublication.swift new file mode 100644 index 0000000..55bae74 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/CatalogPublication.swift @@ -0,0 +1,46 @@ +import Foundation +import Darwin + +/// Only the commit boundary is replaceable; staging, decoding, locking and +/// source/revision checks remain real in failure and concurrency tests. +struct CatalogPublication { + let publish: (_ staged: URL, _ destination: URL, _ replacing: Bool, _ validate: () throws -> Void) throws -> Void + + let syncDirectory: (URL) throws -> Void + + init(syncDirectory: @escaping (URL) throws -> Void = Self.synchronize, + publish: @escaping (URL, URL, Bool, () throws -> Void) throws -> Void) { + self.syncDirectory = syncDirectory + self.publish = publish + } + + private static func synchronize(_ directory: URL) throws { + let fd = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(fd) } + guard fsync(fd) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + } + + static let atomic = Self { staged, destination, replacing, validate in + try validate() + let result = replacing ? rename(staged.path, destination.path) : link(staged.path, destination.path) + guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + } + + static func stage(_ data: Data, in directory: URL) throws -> URL { + let url = directory.appendingPathComponent(".catalog-stage-" + UUID().uuidString) + let fd = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + do { + try handle.write(contentsOf: data) + guard fsync(fd) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + try handle.close() + return url + } catch { + try? handle.close() + try? FileManager.default.removeItem(at: url) + throw error + } + } +} diff --git a/Sources/NXLibraryCore/Services/Catalog/CatalogRepository.swift b/Sources/NXLibraryCore/Services/Catalog/CatalogRepository.swift new file mode 100644 index 0000000..c215afd --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/CatalogRepository.swift @@ -0,0 +1,155 @@ +import Foundation + +public enum CatalogRepositoryError: Error, Equatable { case gameNotFound } + +extension CatalogDocument { + /// A projection for the still-nxnative screens, never another writable store. + public var nxnativeGames: [PortedGame] { games.compactMap(\.nxnativeGame) } +} + +/// Typed transactions against one authoritative catalog. No cached second +/// document and no automatic replay of closures with game-file side effects. +public struct CatalogRepository { + private let store: CatalogStore + public init(store: CatalogStore) { self.store = store } + public func load() throws -> CatalogDocument { try store.loadOrMigrate() } + + @discardableResult + public func updateNXNative(id: UUID, _ change: (inout PortedGame) throws -> Void) throws -> CatalogDocument { + try transaction { document in + guard let index = document.games.firstIndex(where: { $0.id == id }) else { throw CatalogRepositoryError.gameNotFound } + guard var game = document.games[index].nxnativeGame else { throw CatalogModelError.wrongIntegration } + try change(&game) + try document.games[index].updateNXNative(game) + } + } + + @discardableResult + public func updateNXNativeGames(_ change: (inout [PortedGame]) throws -> Void) throws -> CatalogDocument { + try mutateNXNativeGames(enablingSource: false, requiringEnabledSource: false, change) + } + + private func mutateNXNativeGames(enablingSource: Bool, requiringEnabledSource: Bool, + _ change: (inout [PortedGame]) throws -> Void) throws -> CatalogDocument { + try transaction { document in + if requiringEnabledSource && !enablingSource && document.source(.nxnative)?.isEnabled != true { throw CatalogScanError.sourceDisabled } + var games = document.nxnativeGames + try change(&games) + guard Set(games.map(\.id)).count == games.count else { throw CatalogModelError.duplicateGameID } + var updates = Dictionary(uniqueKeysWithValues: games.map { ($0.id, $0) }) + document.games = try document.games.compactMap { record in + guard record.integration == .nxnative else { return record } + guard let updated = updates.removeValue(forKey: record.id) else { return nil } + var result = record + try result.updateNXNative(updated) + return result + } + for game in games where updates[game.id] != nil { document.games.append(.init(nxnative: game)) } + if enablingSource { + var config = document.source(.nxnative) ?? .init(integration: .nxnative) + if !config.isEnabled { config.generation = UUID() } + config.isEnabled = true + if let index = document.sources.firstIndex(where: { $0.integration == .nxnative }) { document.sources[index] = config } + else { document.sources.append(config) } + } + } + } + + @discardableResult + public func mergeNXNative(scanned: [PortedGame], replacingSources: Bool = false, enableSource: Bool = false, + observedFrom baseline: [PortedGame]? = nil, + reconcile: (inout [PortedGame], Set) throws -> Void = { _, _ in }) throws -> CatalogDocument { + try mutateNXNativeGames(enablingSource: enableSource, requiringEnabledSource: true) { games in + let baselineByID = baseline.map { Dictionary($0.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) } + let protected = Set(games.filter { current in + if current.status == .building { return true } + guard let baselineByID else { return false } + guard let earlier = baselineByID[current.id] else { return true } + return !Self.sameObservationGeneration(earlier, current) + }.map(\.id)) + let accepted = scanned.filter { observation in + let current = games.first { candidate in + candidate.id == observation.id || (observation.titleId.map { + candidate.titleId?.caseInsensitiveCompare($0) == .orderedSame + } ?? false) + } + return current.map { !protected.contains($0.id) } ?? true + } + games = NXNativeLibraryMerge.merge(scanned: accepted, into: games, replacingSources: replacingSources) + try reconcile(&games, Set(games.map(\.id)).subtracting(protected)) + } + } + + private static func sameObservationGeneration(_ earlier: PortedGame, _ current: PortedGame) -> Bool { + earlier.url == current.url && earlier.status == current.status && earlier.modificationDate == current.modificationDate + && earlier.lastBuild == current.lastBuild && earlier.pendingInstall == current.pendingInstall + && earlier.toolchain == current.toolchain && earlier.sources == current.sources + && earlier.workspacePath == current.workspacePath && earlier.buildCachePath == current.buildCachePath + && earlier.appIconPending == current.appIconPending + } + + private func transaction(_ change: (inout CatalogDocument) throws -> Void) throws -> CatalogDocument { + let current = try load() + var draft = current + try change(&draft) + return try store.save(draft, expectedRevision: current.revision) + } +} + +extension CatalogRepository { + /// Apply only fields owned by the finished build. In-flight personal/runtime + /// changes must not be replaced by the build's earlier payload snapshot. + @discardableResult + public func recordNXNativeBuild(_ outcome: PortedGame) throws -> CatalogDocument { + try updateNXNative(id: outcome.id) { game in + try game.applyCatalogBuildOutcome(outcome) + } + } +} + + +extension CatalogRepository { + @discardableResult + public func configureSource(_ integration: CatalogIntegration, enabled: Bool, root: URL?) throws -> CatalogDocument { + try transaction { document in + var config = document.source(integration) ?? .init(integration: integration) + let nextRoot = root.map { $0.resolvingSymlinksInPath().standardizedFileURL } ?? config.rootURL + if config.isEnabled != enabled || config.rootURL != nextRoot { config.generation = UUID() } + config.isEnabled = enabled; config.rootURL = nextRoot + if let index = document.sources.firstIndex(where: { $0.integration == integration }) { document.sources[index] = config } + else { document.sources.append(config) } + } + } + + @discardableResult + public func toggleFavorite(for id: UUID) throws -> CatalogDocument { + try transaction { document in + guard let index = document.games.firstIndex(where: { $0.id == id }) else { throw CatalogRepositoryError.gameNotFound } + document.games[index].setFavorite(!document.games[index].isFavorite) + } + } + + @discardableResult + public func setFavorite(_ value: Bool, for id: UUID) throws -> CatalogDocument { + try transaction { document in + guard let index = document.games.firstIndex(where: { $0.id == id }) else { throw CatalogRepositoryError.gameNotFound } + document.games[index].setFavorite(value) + } + } + @discardableResult + public func setHidden(_ value: Bool, for id: UUID) throws -> CatalogDocument { + try transaction { document in + guard let index = document.games.firstIndex(where: { $0.id == id }) else { throw CatalogRepositoryError.gameNotFound } + document.games[index].setHidden(value) + } + } + + @discardableResult + public func applySteam(_ snapshot: SteamLibrarySnapshot, expectedSource: CatalogSourceConfiguration) throws -> CatalogDocument { + let current = try load() + guard expectedSource.integration == .steam, expectedSource.isEnabled, + current.source(.steam) == expectedSource else { throw CatalogScanError.staleScan } + let draft = try SteamCatalogReconciler.applying(snapshot, ticket: current.steamScanTicket(), to: current) + return try store.save(draft, expectedRevision: current.revision) + } +} diff --git a/Sources/NXLibraryCore/Services/Catalog/CatalogSourcesModel.swift b/Sources/NXLibraryCore/Services/Catalog/CatalogSourcesModel.swift new file mode 100644 index 0000000..3d9a648 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/CatalogSourcesModel.swift @@ -0,0 +1,101 @@ +import Foundation +import Combine + +public enum CatalogSourceStatus: Equatable, Sendable { case disabled, idle, scanning, ready, partial, cancelled, failed } + +/// Source UI state and a cancellable reader. CatalogRepository alone owns the +/// persisted source configuration and game records; no competing catalog cache. +@MainActor +public final class CatalogSourcesModel: ObservableObject { + public typealias Scanner = @Sendable (URL, @escaping () -> Bool) throws -> SteamLibrarySnapshot + @Published public private(set) var configuration: CatalogSourceConfiguration + @Published public private(set) var status: CatalogSourceStatus + @Published public private(set) var observedGameCount = 0 + @Published public private(set) var reviewCount = 0 + @Published public private(set) var issues: [SteamScanIssue] = [] + @Published public private(set) var errorMessage: String? + private let repository: CatalogRepository + private let scanner: Scanner + private let onCommit: (CatalogDocument) -> Void + private var requestID = UUID() + private var task: Task? + + public init(repository: CatalogRepository, initialConfiguration: CatalogSourceConfiguration, + scanner: @escaping Scanner = { root, cancelled in try SteamLibrarySource(root: root).scan(cancelled: cancelled) }, + onCommit: @escaping (CatalogDocument) -> Void) { + self.repository = repository; configuration = initialConfiguration + self.scanner = scanner; self.onCommit = onCommit + status = initialConfiguration.isEnabled ? .idle : .disabled + } + public func startIfEnabled() { if configuration.isEnabled && status == .idle { refresh() } } + public func configure(enabled: Bool, root: URL?) { + invalidate() + do { + let document = try repository.configureSource(.steam, enabled: enabled, root: root) + configuration = document.source(.steam)! + errorMessage = nil; issues = []; observedGameCount = 0; reviewCount = 0 + status = enabled ? .idle : .disabled + onCommit(document) + if enabled { refresh() } + } catch { failed(error) } + } + public func cancel() { + invalidate() + status = configuration.isEnabled ? .cancelled : .disabled + } + public func refresh() { + invalidate() + let expected: CatalogSourceConfiguration + do { + let current = try repository.load() + guard let source = current.source(.steam), source.isEnabled, let _ = source.rootURL else { + configuration = current.source(.steam) ?? .init(integration: .steam) + status = .disabled + onCommit(current) + return + } + expected = source; configuration = source + } catch { failed(error); return } + let id = requestID + status = .scanning; errorMessage = nil + let scan = scanner, root = expected.rootURL! + let worker = Task.detached(priority: .userInitiated) { + try scan(root) { Task.isCancelled } + } + task = Task { [weak self] in + do { + let snapshot = try await withTaskCancellationHandler(operation: { try await worker.value }, onCancel: { worker.cancel() }) + try Task.checkCancellation() + guard let self, self.requestID == id else { return } + let document = try self.repository.applySteam(snapshot, expectedSource: expected) + self.configuration = document.source(.steam)! + self.observedGameCount = Set(snapshot.games.map(\.appID)).count + let seen = Set(snapshot.games.map(\.appID)) + self.reviewCount = document.games.compactMap(\.steamGame).filter { + seen.contains($0.appID) && (!$0.hasBeenRecognized || $0.needsInstallationReview) + }.count + self.issues = snapshot.issues + self.status = snapshot.isComplete ? .ready : .partial + self.task = nil + self.onCommit(document) + } catch { + guard let self, self.requestID == id else { return } + self.task = nil + if error is CancellationError { self.status = .cancelled } + else { self.failed(error) } + } + } + } + private func invalidate() { + requestID = UUID(); task?.cancel(); task = nil + } + private func failed(_ error: Error) { + // Publication uncertainty/stale config may have committed a newer + // source. Read it, if possible, before presenting a recoverable error. + if let actual = try? repository.load() { + configuration = actual.source(.steam) ?? .init(integration: .steam) + onCommit(actual) + } + errorMessage = error.localizedDescription; status = .failed + } +} diff --git a/Sources/NXLibraryCore/Services/Catalog/CatalogStore.swift b/Sources/NXLibraryCore/Services/Catalog/CatalogStore.swift new file mode 100644 index 0000000..1817e81 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/CatalogStore.swift @@ -0,0 +1,223 @@ +import Foundation +import CryptoKit +import Darwin + +public enum CatalogStoreError: Error, Equatable { + case invalidLegacy, invalidCatalog(backupAvailable: Bool), legacyChanged, staleRevision, busy, unsafeDirectory + /// Main may already contain the new revision. Reload before deciding whether to retry. + case commitUncertain +} + +/// Versioned persistence for the generalization branch. CatalogRepository owns +/// all app game-record mutations; legacy files remain recovery inputs. +public struct CatalogStore { + public let directory: URL + private let publication: CatalogPublication + private static let maximumBytes = 16 * 1024 * 1024 + + public init(directory: URL) { self.init(directory: directory, publication: .atomic) } + init(directory: URL, publication: CatalogPublication) { + self.directory = directory.standardizedFileURL + self.publication = publication + } + private func file(_ name: String) -> URL { directory.appendingPathComponent(name) } + + static func decode(_ data: Data) throws -> CatalogDocument { + // Deferred-to-Date keeps the exact Date value; the legacy decoder below + // alone uses ISO8601. This is part of catalog schema 1. + let document = try JSONDecoder().decode(CatalogDocument.self, from: data) + try document.validate() + return document + } + private static func encode(_ document: CatalogDocument) throws -> Data { + try document.validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let data = try encoder.encode(document) + guard data.count <= maximumBytes else { throw InstallationFileReadError.tooLarge } + guard try decode(data) == document else { throw CatalogModelError.invalidRecord } + return data + } + + public func loadOrMigrate() throws -> CatalogDocument { + try locked { + let current = try currentCatalog() + if let document = current.document { + try verifyLegacy(document.legacyOrigin) + return document + } + let (document, observations) = try convertedLegacy() + let data = try Self.encode(document) + try commit(data, previous: nil, current: current.state) { + for observation in observations { try observation.verifyUnchanged() } + try self.verifyLegacy(document.legacyOrigin) + } + return document + } + } + + public func save(_ draft: CatalogDocument, expectedRevision: UUID) throws -> CatalogDocument { + try locked { + let current = try currentCatalog() + guard let old = current.document, old.revision == expectedRevision, draft.revision == expectedRevision, + old.legacyOrigin == draft.legacyOrigin else { throw CatalogStoreError.staleRevision } + try verifyLegacy(old.legacyOrigin) + var committed = draft + committed.revision = UUID() + let data = try Self.encode(committed) + try commit(data, previous: current.state.data, current: current.state) { try self.verifyLegacy(old.legacyOrigin) } + return committed + } + } + + private struct FileState { + let data: Data? + let observation: InstallationFileObservation + func verify() throws { try observation.verifyUnchanged() } + } + private func read(_ name: String) throws -> FileState { + let target = file(name) + do { + let snapshot = try InstallationFileSnapshot.read(at: target, maximumBytes: Self.maximumBytes) + return FileState(data: snapshot.data, observation: snapshot.observation) + } catch let error as POSIXError where error.code == .ENOENT { + return FileState(data: nil, observation: try .absent(at: target)) + } + } + + private func currentCatalog() throws -> (document: CatalogDocument?, state: FileState) { + let backup = try? read("catalog.json.bak") + let backupAvailable = backup?.data.flatMap { try? Self.decode($0) } != nil + do { + let main = try read("catalog.json") + if let data = main.data { return (try Self.decode(data), main) } + // An existing backup, valid or damaged, is evidence of an older + // catalog. Never interpret a missing main as a fresh profile. + guard backup?.data == nil else { throw CatalogStoreError.invalidCatalog(backupAvailable: backupAvailable) } + // Unsafe/unreadable backups also require explicit recovery. + if backup == nil { throw CatalogStoreError.invalidCatalog(backupAvailable: false) } + return (nil, main) + } catch { throw CatalogStoreError.invalidCatalog(backupAvailable: backupAvailable) } + } + + private static func digest(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } + private func convertedLegacy() throws -> (CatalogDocument, [InstallationFileObservation]) { + do { + let main = try read("library.json") + let selected: FileState, origin: CatalogLegacyOrigin + if let data = main.data { + selected = main; origin = .init(kind: .primary, sha256: Self.digest(data)) + } else { + let backup = try read("library.json.bak") + guard let bytes = backup.data else { + return (CatalogDocument(), [main.observation, backup.observation]) + } + selected = backup; origin = .init(kind: .backup, sha256: Self.digest(bytes)) + } + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let legacy = try decoder.decode(LibraryFile.self, from: selected.data!) + return (try CatalogDocument.converting(legacy, origin: origin), [main.observation, selected.observation]) + } catch { throw CatalogStoreError.invalidLegacy } + } + + private func verifyLegacy(_ origin: CatalogLegacyOrigin) throws { + do { + let main = try read("library.json") + switch origin.kind { + case .primary: + guard let data = main.data, Self.digest(data) == origin.sha256 else { throw CatalogStoreError.legacyChanged } + case .backup: + guard main.data == nil, let backup = try read("library.json.bak").data, + Self.digest(backup) == origin.sha256 else { throw CatalogStoreError.legacyChanged } + case .absent: + guard main.data == nil, try read("library.json.bak").data == nil else { throw CatalogStoreError.legacyChanged } + } + } catch { throw CatalogStoreError.legacyChanged } + } + + private func commit(_ data: Data, previous: Data?, current: FileState, validate: () throws -> Void) throws { + let candidate = try CatalogPublication.stage(data, in: directory) + defer { try? FileManager.default.removeItem(at: candidate) } + // Re-read the actual staged bytes, not just the in-memory draft. + _ = try Self.decode(InstallationFileSnapshot.read(at: candidate, maximumBytes: Self.maximumBytes).data) + try current.verify(); try validate() + let backup = try read("catalog.json.bak") + let backupStage = try CatalogPublication.stage(previous ?? data, in: directory) + defer { try? FileManager.default.removeItem(at: backupStage) } + // A valid old main is the new restore point. Initial create also has a + // restore point before the main is exposed. Original legacy files stay put. + try CatalogPublication.atomic.publish(backupStage, file("catalog.json.bak"), backup.data != nil) { + try backup.verify(); try current.verify(); try validate() + } + try publication.publish(candidate, file("catalog.json"), previous != nil) { + try current.verify(); try validate() + } + // Persist directory entries after both renames/links. File data was + // fsynced before publication; a valid restore point precedes the main. + do { try publication.syncDirectory(directory) } + catch { throw CatalogStoreError.commitUncertain } + } + + private func locked(_ operation: () throws -> T) throws -> T { + guard directory.isFileURL else { throw CatalogStoreError.unsafeDirectory } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var status = stat() + guard lstat(directory.path, &status) == 0, status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { + throw CatalogStoreError.unsafeDirectory + } + let fd = open(file(".catalog.lock").path, O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK, 0o600) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(fd) } + _ = try InstallationFileObservation.capture(fileDescriptor: fd, url: file(".catalog.lock")) + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { throw CatalogStoreError.busy } + defer { _ = flock(fd, LOCK_UN) } + return try operation() + } +} + +extension CatalogStore { + /// Read-only validation used on an import stage, before making it visible. + func validateImportedOrigin(_ document: CatalogDocument) throws { try verifyLegacy(document.legacyOrigin) } + + public func recoveryCandidate() throws -> CatalogRecoveryCandidate? { + try locked { + let main = try read("catalog.json") + if let bytes = main.data, (try? Self.decode(bytes)) != nil { return nil } + let backup = try read("catalog.json.bak") + guard let bytes = backup.data else { return nil } + let document = try Self.decode(bytes) + try verifyLegacy(document.legacyOrigin) + return .init(gameCount: document.games.count, directory: directory, + primaryDigest: main.data.map(Self.digest), backupDigest: Self.digest(bytes)) + } + } + + public func restoreBackup(_ candidate: CatalogRecoveryCandidate) throws -> CatalogDocument { + try locked { + guard candidate.directory == directory else { throw CatalogStoreError.staleRevision } + let main = try read("catalog.json"), backup = try read("catalog.json.bak") + guard main.data.map(Self.digest) == candidate.primaryDigest, + let bytes = backup.data, Self.digest(bytes) == candidate.backupDigest else { throw CatalogStoreError.staleRevision } + var restored = try Self.decode(bytes) + try verifyLegacy(restored.legacyOrigin) + restored.revision = UUID() // A restore never revives an old writer's revision. + let staged = try CatalogPublication.stage(Self.encode(restored), in: directory) + defer { try? FileManager.default.removeItem(at: staged) } + _ = try Self.decode(InstallationFileSnapshot.read(at: staged, maximumBytes: Self.maximumBytes).data) + func validate() throws { + try main.verify(); try backup.verify(); try verifyLegacy(restored.legacyOrigin) + } + try validate() + if let damaged = main.data { + let kept = try CatalogPublication.stage(damaged, in: directory) + defer { try? FileManager.default.removeItem(at: kept) } + let archive = file("catalog.json.recovered-" + UUID().uuidString + ".json") + try CatalogPublication.atomic.publish(kept, archive, false, validate) + } + try publication.publish(staged, file("catalog.json"), main.data != nil, validate) + do { try publication.syncDirectory(directory) } + catch { throw CatalogStoreError.commitUncertain } + return restored + } + } +} diff --git a/Sources/NXLibraryCore/Services/Catalog/SteamCatalogReconciler.swift b/Sources/NXLibraryCore/Services/Catalog/SteamCatalogReconciler.swift new file mode 100644 index 0000000..bf1669f --- /dev/null +++ b/Sources/NXLibraryCore/Services/Catalog/SteamCatalogReconciler.swift @@ -0,0 +1,107 @@ +import Foundation + +public enum CatalogScanError: Error, Equatable { case sourceDisabled, staleScan, wrongSourceRoot, invalidSnapshot } + +public struct CatalogScanTicket: Equatable, Sendable { + public let revision: UUID + public let rootURL: URL + let configuredRootURL: URL +} + +extension CatalogDocument { + /// Capture this after committing source configuration, before starting I/O. + public func steamScanTicket() throws -> CatalogScanTicket { + try validate() + guard let config = source(.steam), config.isEnabled, let root = config.rootURL else { throw CatalogScanError.sourceDisabled } + return .init(revision: revision, rootURL: root.resolvingSymlinksInPath().standardizedFileURL, + configuredRootURL: root.standardizedFileURL) + } +} + +/// Pure reconciliation: no scans, game launches, settings writes or disk probes. +/// The caller commits the returned draft through CatalogStore's revision check. +public enum SteamCatalogReconciler { + private struct InstallationKey: Hashable { + let library: URL + let content: URL + init(_ library: URL, _ content: URL) { + self.library = library.standardizedFileURL; self.content = content.standardizedFileURL + } + } + + public static func applying(_ snapshot: SteamLibrarySnapshot, ticket: CatalogScanTicket, to document: CatalogDocument, + observedAt: Date = Date()) throws -> CatalogDocument { + try document.validate() + guard let source = document.source(.steam), source.isEnabled, let root = source.rootURL else { throw CatalogScanError.sourceDisabled } + guard document.revision == ticket.revision, root.standardizedFileURL == ticket.configuredRootURL else { throw CatalogScanError.staleScan } + let libraries = snapshot.libraries.map { $0.root.standardizedFileURL } + guard libraries.contains(ticket.rootURL) else { throw CatalogScanError.wrongSourceRoot } + guard Set(libraries).count == libraries.count, libraries.allSatisfy(\.isFileURL) else { throw CatalogScanError.invalidSnapshot } + let unreadable = Set(snapshot.libraries.filter { !$0.isReadable }.map { $0.root.standardizedFileURL }) + let grouped = Dictionary(grouping: snapshot.games, by: \.appID) + for observation in snapshot.games { + guard observation.appID > 0, libraries.contains(observation.libraryURL.standardizedFileURL), + !unreadable.contains(observation.libraryURL.standardizedFileURL) else { throw CatalogScanError.invalidSnapshot } + } + var result = document + var indices = Dictionary(uniqueKeysWithValues: result.games.enumerated().compactMap { index, record in + record.steamGame.map { ($0.appID, index) } + }) + let seenByAppID = grouped.mapValues { Set($0.map { InstallationKey($0.libraryURL, $0.installationURL) }) } + // Absence is meaningful only for a complete source. A source error can + // report unavailability, but can never erase an entry or its identity. + for index in result.games.indices { + guard var game = result.games[index].steamGame else { continue } + if game.installations.contains(where: { $0.availability == .requiresReview }) + || game.installations.filter({ $0.availability == .available }).count > 1 { + game.needsInstallationReview = true + } + if snapshot.isComplete && grouped[game.appID] == nil { game.needsInstallationReview = false } + for i in game.installations.indices { + let installation = game.installations[i] + let key = InstallationKey(installation.libraryURL, installation.contentURL) + if seenByAppID[game.appID]?.contains(key) != true { + if unreadable.contains(key.library) { game.installations[i].availability = .sourceUnavailable } + else if snapshot.isComplete { game.installations[i].availability = .notDetected } + } + } + try result.games[index].updateSteam(game) + } + for appID in grouped.keys.sorted() { + let observations = grouped[appID]!.sorted { + ($0.libraryURL.path, $0.installationURL.path) < ($1.libraryURL.path, $1.installationURL.path) + } + guard Set(observations.map { InstallationKey($0.libraryURL, $0.installationURL) }).count == observations.count else { + throw CatalogScanError.invalidSnapshot + } + let index = indices[appID] + var game = index.flatMap { result.games[$0].steamGame } ?? SteamCatalogGame(appID: appID, name: observations[0].name) + game.name = observations[0].name + var installs = Dictionary(uniqueKeysWithValues: game.installations.enumerated().map { + (InstallationKey($0.element.libraryURL, $0.element.contentURL), $0.offset) + }) + var observedAmbiguity = false + for observed in observations { + let key = InstallationKey(observed.libraryURL, observed.installationURL) + let apps = observed.nativeApps.map(CatalogNativeApplication.init) + let ready = observed.state == .nativeReady && apps.filter(\.isDeclaredGame).count == 1 + let availability: CatalogInstallationAvailability = observations.count > 1 ? .requiresReview + : (ready ? .available : (observed.state == .unavailable ? .unavailable : .requiresReview)) + if availability == .requiresReview { observedAmbiguity = true } + if ready && observations.count == 1 { game.hasBeenRecognized = true } + let installIndex = installs[key] + let installation = SteamCatalogInstallation(id: installIndex.map { game.installations[$0].id } ?? UUID(), + libraryURL: key.library, contentURL: key.content, applications: apps, availability: availability, + reportedSizeBytes: observed.reportedSizeBytes, buildID: observed.buildID, lastObservedAt: observedAt) + if let installIndex { game.installations[installIndex] = installation } + else { installs[key] = game.installations.count; game.installations.append(installation) } + } + if observedAmbiguity { game.needsInstallationReview = true } + else if snapshot.isComplete { game.needsInstallationReview = false } + if let index { try result.games[index].updateSteam(game) } + else { indices[appID] = result.games.count; result.games.append(.init(steam: game)) } + } + try result.validate() + return result + } +} diff --git a/Sources/NXLibraryCore/Services/DevelopmentOperationStore.swift b/Sources/NXLibraryCore/Services/DevelopmentOperationStore.swift new file mode 100644 index 0000000..2a254f0 --- /dev/null +++ b/Sources/NXLibraryCore/Services/DevelopmentOperationStore.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Only explicit operations created in this Dev profile may resume automatically after a restart. +/// This file is deliberately absent from the stable-to-development import allowlist. +public final class DevelopmentOperationStore { + public enum Kind: String, Codable { case build, icon } + private let file: URL + private let isDevelopment: Bool + private var requested: [String: Set] = [:] + public private(set) var loadError: Error? + + public init(file: URL = AppData.supportRoot.appendingPathComponent(".development-operations.json"), + isDevelopment: Bool = AppData.profile.isDevelopment) { + self.file = file + self.isDevelopment = isDevelopment + guard isDevelopment, FileManager.default.fileExists(atPath: file.path) else { return } + do { requested = try JSONDecoder().decode([String: Set].self, from: Data(contentsOf: file)) } + catch { loadError = error } + } + public func permits(_ kind: Kind, gameID: UUID) -> Bool { + !isDevelopment || (loadError == nil && requested[kind.rawValue]?.contains(gameID) == true) + } + public func authorize(_ kind: Kind, gameID: UUID) throws { + guard isDevelopment else { return } + if let loadError { throw loadError } + var next = requested + next[kind.rawValue, default: []].insert(gameID) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(next).write(to: file, options: .atomic) + requested = next + } +} diff --git a/Sources/NXLibraryCore/Services/DevelopmentProfileImporter.swift b/Sources/NXLibraryCore/Services/DevelopmentProfileImporter.swift new file mode 100644 index 0000000..f8ea261 --- /dev/null +++ b/Sources/NXLibraryCore/Services/DevelopmentProfileImporter.swift @@ -0,0 +1,169 @@ +import Foundation +import CryptoKit + +public enum DevelopmentProfileError: Error, LocalizedError { + case invalidConfiguration, destinationExists, unsafeFile, invalidData, sourceChanged, preferencesUnavailable + case unreadableFile(String) + public var errorDescription: String? { + let context = AppLocale(language: .automatic, region: nil) + switch self { + case .unreadableFile(let name): return L10n.format("The launcher file %@ could not be read. The original data has not been changed.", [name], context: context) + case .invalidConfiguration: return L10n.text("The development profile configuration is invalid.", context: context) + case .destinationExists: return L10n.text("The development data folder already exists without a completed import. It has been preserved.", context: context) + case .unsafeFile: return L10n.text("The library copy contains a symbolic link or an unsupported file. The original data has not been changed.", context: context) + case .invalidData: return L10n.text("Some launcher data could not be read. The original data has not been changed.", context: context) + case .sourceChanged: return L10n.text("The original library changed during the copy. Close the other launcher copy and try again.", context: context) + case .preferencesUnavailable: return L10n.text("The development preferences could not be saved. Try again.", context: context) + } + } +} + +/// An allowlisted, one-time snapshot. Reads source bytes directly: source-store load/recovery hooks never run. +public struct DevelopmentProfileImporter { + private let fm: FileManager + public init(fileManager: FileManager = .default) { fm = fileManager } + private static let files = ["library.json", "library.json.bak", "catalog.json", "catalog.json.bak", "settings.json", "play-sessions.json", "play-sessions-totals.json", "metadata-picks.json", "integrations/steam/metadata-picks.json", "app-sizes.json"] + private static let folders = ["artwork-picks", "integrations/steam/artwork-picks", "artwork", "metadata", "SeleneImports", "app-icons", "mods", "gamebanana"] + private static let marker = ".development-import.json" + private static let preferenceFile = ".import-preferences.plist" + private struct ImportState: Codable { + let version: Int + var ready: Bool + let hashes: [String: String] + } + public func prepare(profile: AppDataProfile, sourcePreferences: () throws -> [String: Any], + savePreferences: ([String: Any]) throws -> Void) throws -> Bool { + guard profile.isDevelopment else { return false } + let source = profile.stableSupportRoot, destination = profile.supportRoot + guard source.resolvingSymlinksInPath() != destination.resolvingSymlinksInPath() else { throw DevelopmentProfileError.invalidConfiguration } + if try exists(destination) { + try regularDirectory(destination) + let marker = destination.appendingPathComponent(Self.marker) + guard (try? fm.attributesOfItem(atPath: marker.path)[.type] as? FileAttributeType) == .typeRegular else { throw DevelopmentProfileError.destinationExists } + guard let data = try? Data(contentsOf: marker), let state = try? JSONDecoder().decode(ImportState.self, from: data), state.version == 1 else { + throw DevelopmentProfileError.destinationExists + } + if state.ready { return false } + try finish(destination, state: state, savePreferences: savePreferences) + return true + } + if try exists(source) { try regularDirectory(source) } + let before = try snapshot(source) + let preferences = try sourcePreferences().filter { Self.ownedPreference($0.key) } + let preferencesData = try PropertyListSerialization.data(fromPropertyList: preferences, format: .binary, options: 0) + let parent = destination.deletingLastPathComponent() + try fm.createDirectory(at: parent, withIntermediateDirectories: true) + let stage = parent.appendingPathComponent(".NXLibrary-Development-import-" + UUID().uuidString) + try fm.createDirectory(at: stage, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? fm.removeItem(at: stage) } // Only the staging folder created by this invocation. + for relative in before.keys.sorted() { + let from = source.appendingPathComponent(relative), to = stage.appendingPathComponent(relative) + try fm.createDirectory(at: to.deletingLastPathComponent(), withIntermediateDirectories: true) + guard try fm.attributesOfItem(atPath: from.path)[.type] as? FileAttributeType == .typeRegular else { throw DevelopmentProfileError.sourceChanged } + try fm.copyItem(at: from, to: to) + guard try digest(to) == before[relative] else { throw DevelopmentProfileError.sourceChanged } + if relative.hasSuffix(".json") { + do { _ = try JSONSerialization.jsonObject(with: Data(contentsOf: to), options: [.fragmentsAllowed]) } + catch { throw DevelopmentProfileError.unreadableFile(relative) } + } + } + try validateRequiredData(stage) + guard try snapshot(source) == before, + NSDictionary(dictionary: try sourcePreferences().filter { Self.ownedPreference($0.key) }).isEqual(to: preferences) else { throw DevelopmentProfileError.sourceChanged } + try preferencesData.write(to: stage.appendingPathComponent(Self.preferenceFile), options: .atomic) + // End a copied unfinished session at its last recorded heartbeat, never at the current time. + let sessionFile = stage.appendingPathComponent("play-sessions.json") + if try exists(sessionFile) { + var sessions = try JSONDecoder().decode([PlaySession].self, from: Data(contentsOf: sessionFile)) + if sessions.contains(where: \.isActive) { + for i in sessions.indices where sessions[i].isActive { + sessions[i].endedAt = max(sessions[i].startedAt, sessions[i].lastHeartbeatAt) + } + try JSONEncoder().encode(sessions).write(to: sessionFile, options: .atomic) + } + } + let state = ImportState(version: 1, ready: false, hashes: try snapshot(stage)) + try JSONEncoder().encode(state).write(to: stage.appendingPathComponent(Self.marker), options: .atomic) + // Moving into place never replaces an existing destination. A racing import fails safely. + try fm.moveItem(at: stage, to: destination) + try finish(destination, state: state, savePreferences: savePreferences) + return true + } + private func finish(_ destination: URL, state: ImportState, savePreferences: ([String: Any]) throws -> Void) throws { + guard try snapshot(destination) == state.hashes else { throw DevelopmentProfileError.invalidData } + let preferenceFile = destination.appendingPathComponent(Self.preferenceFile) + guard try fm.attributesOfItem(atPath: preferenceFile.path)[.type] as? FileAttributeType == .typeRegular else { throw DevelopmentProfileError.unsafeFile } + let data = try Data(contentsOf: preferenceFile) + guard let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { throw DevelopmentProfileError.invalidData } + try savePreferences(values.filter { Self.ownedPreference($0.key) }) + var ready = state + ready.ready = true + try JSONEncoder().encode(ready).write(to: destination.appendingPathComponent(Self.marker), options: .atomic) + } + // Version-one legacy launcher preference schema. Do not copy future secrets just because they share a prefix. + private static let preferenceKeys: Set = [ + "NXLibrary.lastPortingFinishSeconds", "NXLibrary_AllowUntestedToolchain", "NXLibrary_EngineFolder", + "NXLibrary_NewestEngineChoice", "NXLibrary_SetupCompleted", "NXLibrary_SetupLastStates", "NXLibrary_SetupPage", + "NXLibrary_UpdateLastCheck", "NXLibrary_UpdateLaterAt", "NXLibrary_UpdateLaterVersion", "NXLibrary_UpdateRepository", + "NXLibrary_UpstreamStatus", "NXNative_ControllerDeadzone", "NXNative_SavedGameFolder", "NXNative_SavedIconAppearanceMode", + "NXNative_SavedLibraryFolder", "NXNative_SavedProdKeysPath" + ] + public static func ownedPreference(_ key: String) -> Bool { + preferenceKeys.contains(key) || key.hasPrefix("NXNative_GameConfig_") + } + private func validateRequiredData(_ root: URL) throws { + for name in ["library.json", "catalog.json", "catalog.json.bak", "settings.json", "play-sessions.json"] { + let file = root.appendingPathComponent(name) + guard fm.fileExists(atPath: file.path) else { continue } + let data = try Data(contentsOf: file) + do { + switch name { + case "library.json": + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + _ = try decoder.decode(LibraryFile.self, from: data) + case "catalog.json", "catalog.json.bak": + let document = try CatalogStore.decode(data) + try CatalogStore(directory: root).validateImportedOrigin(document) + case "settings.json": _ = try JSONDecoder().decode(AppSettings.self, from: data) + default: _ = try JSONDecoder().decode([PlaySession].self, from: data) + } + } catch { throw DevelopmentProfileError.unreadableFile(name) } + } + } + private func snapshot(_ root: URL) throws -> [String: String] { + var result: [String: String] = [:] + func visit(_ relative: String, depth: Int) throws { + guard depth < 32, result.count < 100_000 else { throw DevelopmentProfileError.unsafeFile } + let url = root.appendingPathComponent(relative) + let attrs = try fm.attributesOfItem(atPath: url.path) + switch attrs[.type] as? FileAttributeType { + case .typeRegular: result[relative] = try digest(url) + case .typeDirectory: + for name in try fm.contentsOfDirectory(atPath: url.path).sorted() { try visit(relative + "/" + name, depth: depth + 1) } + default: throw DevelopmentProfileError.unsafeFile + } + } + for name in Self.files + Self.folders { + let url = root.appendingPathComponent(name) + // attributes also detects broken symlinks, which fileExists would silently skip. + if try exists(url) { try visit(name, depth: 0) } + } + return result + } + private func exists(_ url: URL) throws -> Bool { + do { _ = try fm.attributesOfItem(atPath: url.path); return true } + catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { return false } + } + private func regularDirectory(_ url: URL) throws { + guard try fm.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType == .typeDirectory else { throw DevelopmentProfileError.unsafeFile } + } + private func digest(_ url: URL) throws -> String { + guard try fm.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType == .typeRegular else { throw DevelopmentProfileError.unsafeFile } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hash = SHA256() + while let bytes = try handle.read(upToCount: 1024 * 1024), !bytes.isEmpty { hash.update(data: bytes) } + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/NXLibraryCore/Services/LaunchBoxArt.swift b/Sources/NXLibraryCore/Services/LaunchBoxArt.swift index 994bc7e..cc5ea0a 100644 --- a/Sources/NXLibraryCore/Services/LaunchBoxArt.swift +++ b/Sources/NXLibraryCore/Services/LaunchBoxArt.swift @@ -11,6 +11,8 @@ public enum LaunchBoxArt { public static let site = URL(string: "https://gamesdb.launchbox-app.com")! static let images = URL(string: "https://images.launchbox-app.com/")! static let gameplay = "Screenshot - Gameplay" + /// How a fan art candidate's source starts ("LaunchBox · Fanart - Background (region)"). + public static let fanartSource = "LaunchBox · Fanart - Background" /// Screenshots Details shows when Title DB has none. public static let screenshotLimit = 6 @@ -55,12 +57,22 @@ public enum LaunchBoxArt { /// `loose` (a name the player typed) falls back to the first Switch result when no title matches the name. public static func pick(_ results: [Result], names: [String], switch2: Bool, loose: Bool = false) -> Result? { let own = switch2 ? "Nintendo Switch 2" : "Nintendo Switch" + return pick(results, names: names, loose: loose, preferring: [{ $0 == own }, { $0.hasPrefix("Nintendo Switch") }]) + } + + /// The result that is this game on a computer, for a native Mac game (Steam): its Mac entry first, then + /// Windows, where LaunchBox keeps most PC art. A console entry is never taken. + public static func pick(_ results: [Result], names: [String], computer: Bool, loose: Bool = false) -> Result? { + pick(results, names: names, loose: loose, preferring: [{ $0 == "Apple Mac OS" }, { $0 == "Windows" }]) + } + + private static func pick(_ results: [Result], names: [String], loose: Bool, + preferring platforms: [(String) -> Bool]) -> Result? { let named = results.filter { ArtworkNames.matches($0.title, names) } - if let found = named.first(where: { $0.platform == own }) ?? named.first(where: { $0.platform.hasPrefix("Nintendo Switch") }) { - return found - } + for wanted in platforms { if let found = named.first(where: { wanted($0.platform) }) { return found } } guard loose else { return nil } - return results.first { $0.platform == own } ?? results.first { $0.platform.hasPrefix("Nintendo Switch") } + for wanted in platforms { if let found = results.first(where: { wanted($0.platform) }) { return found } } + return nil } /// The images a game page's payload lists, each in its slot with its preview, full file and size, credited @@ -129,12 +141,14 @@ public enum LaunchBoxArt { } /// Searches each name until one finds this game on a Switch, then reads its page. - public static func game(names: [String], switch2: Bool, loose: Bool = false, + /// `computer`: the game is a native Mac game (Steam), so its Mac or Windows entry is looked for, not Switch. + public static func game(names: [String], switch2: Bool, computer: Bool = false, loose: Bool = false, client: ArtworkHTTPClient = URLSessionArtworkClient()) async -> Game? { for name in names { guard let url = searchURL(for: name), let data = await OnlineArtworkRequest.data(url, client: client), let html = String(data: data, encoding: .utf8), - let result = pick(results(in: html), names: names, switch2: switch2, loose: loose), + let result = computer ? pick(results(in: html), names: names, computer: true, loose: loose) + : pick(results(in: html), names: names, switch2: switch2, loose: loose), let page = URL(string: result.path, relativeTo: site)?.absoluteURL else { continue } guard let body = await OnlineArtworkRequest.data(page, client: client), let text = String(data: body, encoding: .utf8) else { return nil } diff --git a/Sources/NXLibraryCore/Services/LibraryStore.swift b/Sources/NXLibraryCore/Services/LibraryStore.swift index 5e3b250..0c4e517 100644 --- a/Sources/NXLibraryCore/Services/LibraryStore.swift +++ b/Sources/NXLibraryCore/Services/LibraryStore.swift @@ -77,64 +77,8 @@ public final class LibraryStore { public func merge(scanned: [PortedGame], replacingSources: Bool = false) throws -> LibraryFile { var library = try load() - for game in scanned { - if let index = library.games.firstIndex(where: { existing in - guard let target = game.titleId else { return existing.titleId == nil } - return existing.titleId?.caseInsensitiveCompare(target) == .orderedSame - }) { - let old = library.games[index] - // Scans refresh disk observations. Application-owned state survives a scan. - var merged = old - merged.name = game.name - merged.url = game.url - merged.platform = game.platform - merged.sizeBytes = game.sizeBytes - merged.modificationDate = game.modificationDate - merged.bundleId = game.bundleId - merged.titleId = game.titleId - merged.versionString = game.versionString - merged.status = game.status - merged.toolchain = game.toolchain - merged.lastBuild = game.lastBuild - let explicitSources = replacingSources && !game.sources.isEmpty - if explicitSources || old.sources.isEmpty { - merged.sources = game.sources - } else { - let oldSourcesExist = old.sources.allSatisfy { - fm.fileExists(atPath: $0.url.path) - } - merged.sources = (oldSourcesExist || game.sources.isEmpty) - ? old.sources : game.sources - } - merged.workspacePath = explicitSources ? game.workspacePath ?? old.workspacePath : old.workspacePath ?? game.workspacePath - merged.buildCachePath = explicitSources ? game.buildCachePath ?? old.buildCachePath : old.buildCachePath ?? game.buildCachePath - if (old.status == .built || old.status == .builtSourcesMissing - || old.status == .needsRebuild), - old.url.pathExtension.lowercased() == "app", - fm.fileExists(atPath: old.url.path) { - merged.url = old.url - merged.sizeBytes = old.sizeBytes - merged.bundleId = old.bundleId ?? merged.bundleId - merged.versionString = old.versionString ?? merged.versionString - } - if old.status == .built || old.status == .builtSourcesMissing - || old.status == .needsRebuild { - if merged.sources.isEmpty { - merged.status = game.status == .built ? .built : .builtSourcesMissing - } else { - let sourcesExist = merged.sources.allSatisfy { - fm.fileExists(atPath: $0.url.path) - } - merged.status = sourcesExist ? .built : .builtSourcesMissing - } - merged.toolchain = old.toolchain - merged.lastBuild = old.lastBuild - } - library.games[index] = merged - } else { - library.games.append(game) - } - } + library.games = NXNativeLibraryMerge.merge(scanned: scanned, into: library.games, + replacingSources: replacingSources, fileManager: fm) try save(library) return library } diff --git a/Sources/NXLibraryCore/Services/MetadataPicker.swift b/Sources/NXLibraryCore/Services/MetadataPicker.swift index 0f14263..d097f1c 100644 --- a/Sources/NXLibraryCore/Services/MetadataPicker.swift +++ b/Sources/NXLibraryCore/Services/MetadataPicker.swift @@ -84,8 +84,7 @@ public final class MetadataPicks: ObservableObject { private var fileURL: URL { directory.appendingPathComponent("metadata-picks.json") } - public init(directory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary")) { + public init(directory: URL = AppData.supportRoot) { self.directory = directory let url = directory.appendingPathComponent("metadata-picks.json") table = (try? Data(contentsOf: url)) diff --git a/Sources/NXLibraryCore/Services/ModStore.swift b/Sources/NXLibraryCore/Services/ModStore.swift index b6f7057..dfa380f 100644 --- a/Sources/NXLibraryCore/Services/ModStore.swift +++ b/Sources/NXLibraryCore/Services/ModStore.swift @@ -59,14 +59,16 @@ public struct ModStore { public let recordsFile: URL public let titleID: String private let canWrite: () -> Bool + private let allowsAutomaticMaintenance: Bool private let trash: (URL) throws -> Void public init(supportFolder: URL, recordsFile: URL, titleID: String, - canWrite: @escaping () -> Bool, trash: @escaping (URL) throws -> Void) { + canWrite: @escaping () -> Bool, trash: @escaping (URL) throws -> Void, allowsAutomaticMaintenance: Bool = true) { self.supportFolder = supportFolder self.recordsFile = recordsFile self.titleID = titleID.uppercased() self.canWrite = canWrite + self.allowsAutomaticMaintenance = allowsAutomaticMaintenance self.trash = trash } @@ -86,8 +88,33 @@ public struct ModStore { let present = Set(names) let recorded = loadRecords().filter { present.contains($0.folderName) } let known = Set(recorded.map(\.folderName)) - return recorded.map { mod($0.folderName, on: $0.isOn) } + let list = recorded.map { mod($0.folderName, on: $0.isOn) } + names.filter { !known.contains($0) }.map { mod($0, on: false) } + return allowsAutomaticMaintenance ? list : readingLiveOverlay(list) + } + + /// A copied record orders metadata; the actual game's overlay determines what is active. + /// Reading a separate profile must neither write the overlay nor display stale enabled states. + private func readingLiveOverlay(_ list: [LocalMod]) -> [LocalMod] { + guard let text = try? String(contentsOf: envFile, encoding: .utf8) else { return list } + let value = text.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) } + .last { $0.hasPrefix(Self.overlayKey + "=") } + .map { String($0.dropFirst(Self.overlayKey.count + 1)) } ?? "" + let paths = value.split(separator: ":").map { URL(fileURLWithPath: String($0)).standardizedFileURL.resolvingSymlinksInPath().path } + var order: [String: Int] = [:] + for (index, path) in paths.enumerated() where order[path] == nil { order[path] = index } + var observed: [(rank: Int, index: Int, item: LocalMod)] = [] + for (index, original) in list.enumerated() { + var item = original + let rank: Int? = item.romfs.flatMap { order[$0.standardizedFileURL.resolvingSymlinksInPath().path] } + item.isOn = rank != nil + observed.append((rank: rank ?? Int.max, index: index, item: item)) + } + observed.sort { first, second in + if first.rank == second.rank { return first.index < second.index } + return first.rank < second.rank + } + return observed.map { $0.item } } /// The overlay value for the mods that are on, top first; nil when none is. @@ -119,7 +146,7 @@ public struct ModStore { /// staging folders and `.installing-.download` archives. Mods and other files stay; nothing happens while /// the game runs. public func removeLeftoverInstalls() { - guard canWrite() else { return } + guard allowsAutomaticMaintenance, canWrite() else { return } let fm = FileManager.default for name in (try? fm.contentsOfDirectory(atPath: modsFolder.path)) ?? [] where name.hasPrefix(".installing-") { try? fm.removeItem(at: modsFolder.appendingPathComponent(name)) @@ -195,7 +222,8 @@ public struct ModStore { /// Drops records whose folder is gone and brings the overlay line in line with the list. Does nothing while the /// game runs, or before NX Library has managed this game's mods (no records yet: a hand-written overlay stays). A /// list whose overlay no longer fits the launcher's line leaves `env.txt` as it is and throws `overlayTooLong`. - public func sync() throws { + public func sync(automatic: Bool = true) throws { + guard !automatic || allowsAutomaticMaintenance else { return } guard canWrite(), FileManager.default.fileExists(atPath: recordsFile.path) else { return } let list = mods() let value = overlay(for: list) diff --git a/Sources/NXLibraryCore/Services/NXNativeGameSettings.swift b/Sources/NXLibraryCore/Services/NXNativeGameSettings.swift index 6d57c84..26f9eb3 100644 --- a/Sources/NXLibraryCore/Services/NXNativeGameSettings.swift +++ b/Sources/NXLibraryCore/Services/NXNativeGameSettings.swift @@ -56,7 +56,7 @@ public struct NXNativeGameSettings { store: SavedChoicesStore = SystemSavedChoices(), supportDirectory: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/nxnative"), - preferences: UserDefaults = .standard) -> NXNativeGameSettings? { + preferences: UserDefaults = AppData.preferences) -> NXNativeGameSettings? { guard let titleId = game.titleId, !titleId.isEmpty else { return nil } let plist = game.url.appendingPathComponent("Contents/Info.plist") guard let data = try? Data(contentsOf: plist), diff --git a/Sources/NXLibraryCore/Services/NXNativeLibraryMerge.swift b/Sources/NXLibraryCore/Services/NXNativeLibraryMerge.swift new file mode 100644 index 0000000..52f4ac9 --- /dev/null +++ b/Sources/NXLibraryCore/Services/NXNativeLibraryMerge.swift @@ -0,0 +1,70 @@ +import Foundation + +/// The established nxnative scan merge, shared with the legacy store. It only +/// handles nxnative payloads; other catalog integrations never enter this path. +enum NXNativeLibraryMerge { + static func merge(scanned: [PortedGame], into existing: [PortedGame], replacingSources: Bool = false, + fileManager fm: FileManager = .default) -> [PortedGame] { + var games = existing + for game in scanned { + if let index = games.firstIndex(where: { existing in + if existing.id == game.id { return true } + guard let target = game.titleId else { return false } + return existing.titleId?.caseInsensitiveCompare(target) == .orderedSame + }) { + let old = games[index] + // Scans refresh disk observations. Application-owned state survives a scan. + var merged = old + merged.name = game.name + merged.url = game.url + merged.platform = game.platform + merged.sizeBytes = game.sizeBytes + merged.modificationDate = game.modificationDate + merged.bundleId = game.bundleId + merged.titleId = game.titleId + merged.versionString = game.versionString + merged.status = game.status + merged.toolchain = game.toolchain + merged.lastBuild = game.lastBuild + let explicitSources = replacingSources && !game.sources.isEmpty + if explicitSources || old.sources.isEmpty { + merged.sources = game.sources + } else { + let oldSourcesExist = old.sources.allSatisfy { + fm.fileExists(atPath: $0.url.path) + } + merged.sources = (oldSourcesExist || game.sources.isEmpty) + ? old.sources : game.sources + } + merged.workspacePath = explicitSources ? game.workspacePath ?? old.workspacePath : old.workspacePath ?? game.workspacePath + merged.buildCachePath = explicitSources ? game.buildCachePath ?? old.buildCachePath : old.buildCachePath ?? game.buildCachePath + if (old.status == .built || old.status == .builtSourcesMissing + || old.status == .needsRebuild), + old.url.pathExtension.lowercased() == "app", + fm.fileExists(atPath: old.url.path) { + merged.url = old.url + merged.sizeBytes = old.sizeBytes + merged.bundleId = old.bundleId ?? merged.bundleId + merged.versionString = old.versionString ?? merged.versionString + } + if old.status == .built || old.status == .builtSourcesMissing + || old.status == .needsRebuild { + if merged.sources.isEmpty { + merged.status = game.status == .built ? .built : .builtSourcesMissing + } else { + let sourcesExist = merged.sources.allSatisfy { + fm.fileExists(atPath: $0.url.path) + } + merged.status = sourcesExist ? .built : .builtSourcesMissing + } + merged.toolchain = old.toolchain + merged.lastBuild = old.lastBuild + } + games[index] = merged + } else { + games.append(game) + } + } + return games + } +} diff --git a/Sources/NXLibraryCore/Services/NintendoStoreMetadata.swift b/Sources/NXLibraryCore/Services/NintendoStoreMetadata.swift index 9315284..ada0a65 100644 --- a/Sources/NXLibraryCore/Services/NintendoStoreMetadata.swift +++ b/Sources/NXLibraryCore/Services/NintendoStoreMetadata.swift @@ -132,8 +132,7 @@ public final class NintendoStoreMetadataService: @unchecked Sendable { public init( client: NintendoStoreHTTPClient = URLSessionNintendoStoreClient(), - cacheDirectory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/metadata"), + cacheDirectory: URL = AppData.supportRoot.appendingPathComponent("metadata"), fileManager: FileManager = .default ) { self.client = client diff --git a/Sources/NXLibraryCore/Services/PlaySessionStore.swift b/Sources/NXLibraryCore/Services/PlaySessionStore.swift index d4ab5d8..ae1f022 100644 --- a/Sources/NXLibraryCore/Services/PlaySessionStore.swift +++ b/Sources/NXLibraryCore/Services/PlaySessionStore.swift @@ -31,8 +31,7 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { } public static func defaultURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/play-sessions.json") + AppData.supportRoot.appendingPathComponent("play-sessions.json") } @discardableResult @@ -52,6 +51,20 @@ public final class PlaySessionStore: ObservableObject, PlayHistory { return true } + /// A session for a game of another integration, which observes its own process. `sourceID` is the + /// qualified identity ("steam-570"), never a made-up Nintendo title ID. Without a bundle ID the + /// session is ended by its owner, and a restart closes it at its last heartbeat. + @discardableResult + public func start(gameID: UUID, sourceID: String, bundleID: String? = nil) -> Bool { + if isRunning(gameID: gameID) { return false } + let date = now() + sessions.append(PlaySession(gameID: gameID, titleID: sourceID, bundleID: bundleID, + startedAt: date, lastHeartbeatAt: date)) + let folded = trim() + save(totals: folded) + return true + } + public func heartbeat(for gameID: UUID) { guard let index = sessions.lastIndex(where: { $0.gameID == gameID && $0.isActive }) else { return } sessions[index].lastHeartbeatAt = now() diff --git a/Sources/NXLibraryCore/Services/ScreenshotFallback.swift b/Sources/NXLibraryCore/Services/ScreenshotFallback.swift index bb6b413..7f76cd0 100644 --- a/Sources/NXLibraryCore/Services/ScreenshotFallback.swift +++ b/Sources/NXLibraryCore/Services/ScreenshotFallback.swift @@ -19,8 +19,7 @@ public struct ScreenshotFallbackStore: Sendable { public let directory: URL - public init(directory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/metadata/launchbox-screenshots")) { + public init(directory: URL = AppData.supportRoot.appendingPathComponent("metadata/launchbox-screenshots")) { self.directory = directory } diff --git a/Sources/NXLibraryCore/Services/SeleneImporter.swift b/Sources/NXLibraryCore/Services/SeleneImporter.swift index 496b85f..13f7b12 100644 --- a/Sources/NXLibraryCore/Services/SeleneImporter.swift +++ b/Sources/NXLibraryCore/Services/SeleneImporter.swift @@ -70,8 +70,7 @@ public struct SeleneImporter { "activation", "CloseAdditionalView", "Alert"] public static var defaultDestination: URL { - URL(fileURLWithPath: NSHomeDirectory()) - .appendingPathComponent("Library/Application Support/NXLibrary/SeleneImports") + AppData.supportRoot.appendingPathComponent("SeleneImports") } public let destination: URL diff --git a/Sources/NXLibraryCore/Services/Steam/InstallationDirectorySnapshot.swift b/Sources/NXLibraryCore/Services/Steam/InstallationDirectorySnapshot.swift new file mode 100644 index 0000000..cf74591 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/InstallationDirectorySnapshot.swift @@ -0,0 +1,50 @@ +import Foundation +import Darwin + +typealias InstallationDirectoryReader = @Sendable (URL, Int, () -> Bool) throws -> InstallationDirectorySnapshot + +/// One directory-read boundary shared by library inventory and app discovery. +struct InstallationDirectorySnapshot: Sendable { + let entries: [URL] + let isComplete: Bool + let observation: InstallationFileObservation + + static func read(at url: URL, maximumEntries: Int, cancelled: () -> Bool) throws -> Self { + if cancelled() { throw CancellationError() } + let fd = Darwin.open(url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | O_DIRECTORY) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + guard let directory = fdopendir(fd) else { + let failure = errno + Darwin.close(fd) + throw POSIXError(POSIXErrorCode(rawValue: failure) ?? .EIO) + } + defer { closedir(directory) } + let observation = try InstallationFileObservation.capture(fileDescriptor: fd, url: url, expectedType: mode_t(S_IFDIR)) + let budget = min(max(0, maximumEntries), 10_000) + var entries: [URL] = [] + var examined = 0 + var complete = true + while true { + if cancelled() { throw CancellationError() } + errno = 0 + guard let entry = readdir(directory) else { + guard errno == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + break + } + let count = Int(entry.pointee.d_namlen) + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: count) { + String(bytes: UnsafeBufferPointer(start: $0, count: count), encoding: .utf8) + } + } + guard let name, !name.isEmpty else { throw InstallationFileReadError.invalidText } + if name == "." || name == ".." { continue } + guard examined < budget else { complete = false; break } + examined += 1 + if name.hasPrefix(".") { continue } + entries.append(url.appendingPathComponent(name)) + } + try observation.verifyUnchanged() + return Self(entries: entries, isComplete: complete, observation: observation) + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/InstallationFileSnapshot.swift b/Sources/NXLibraryCore/Services/Steam/InstallationFileSnapshot.swift new file mode 100644 index 0000000..ae0d5de --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/InstallationFileSnapshot.swift @@ -0,0 +1,101 @@ +import Foundation +import Darwin + +enum InstallationFileReadError: Error { case tooLarge, unsafeFile, sourceChanged, invalidText } + +/// Retain only this small identity after parsing, not all source-file contents. +struct InstallationFileObservation: Equatable, Sendable { + let url: URL + private let identity: Identity? + private let fileType: mode_t + + fileprivate struct Identity: Equatable, Sendable { + let device: Int32 + let inode: UInt64 + let size: Int64 + let modifiedSeconds: Int + let modifiedNanoseconds: Int + let changedSeconds: Int + let changedNanoseconds: Int + + init(_ status: stat) { + device = status.st_dev + inode = status.st_ino + size = status.st_size + modifiedSeconds = status.st_mtimespec.tv_sec + modifiedNanoseconds = status.st_mtimespec.tv_nsec + changedSeconds = status.st_ctimespec.tv_sec + changedNanoseconds = status.st_ctimespec.tv_nsec + } + } + + private init(url: URL, status: stat) { + self.url = url + identity = Identity(status) + fileType = status.st_mode & mode_t(S_IFMT) + } + private init(absentURL: URL) { url = absentURL; identity = nil; fileType = 0 } + var byteCount: Int64 { identity?.size ?? 0 } + + static func capture(fileDescriptor: Int32, url: URL, expectedType: mode_t = mode_t(S_IFREG)) throws -> Self { + var status = stat() + guard fstat(fileDescriptor, &status) == 0, status.st_mode & mode_t(S_IFMT) == expectedType else { + throw InstallationFileReadError.unsafeFile + } + return Self(url: url, status: status) + } + + static func absent(at url: URL) throws -> Self { + var status = stat() + if lstat(url.path, &status) == 0 { throw InstallationFileReadError.sourceChanged } + guard errno == ENOENT else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + return Self(absentURL: url) + } + + func verifyUnchanged() throws { + var current = stat() + let result = lstat(url.path, ¤t) + if identity == nil { + guard result == -1, errno == ENOENT else { throw InstallationFileReadError.sourceChanged } + return + } + guard result == 0, current.st_mode & mode_t(S_IFMT) == fileType, Identity(current) == identity else { + throw InstallationFileReadError.sourceChanged + } + } +} + +/// Bounded read from a regular file with no final symlink following. +/// The same identity is checked after the read and again before scan publication. +struct InstallationFileSnapshot { + let data: Data + let observation: InstallationFileObservation + + static func read(at url: URL, maximumBytes: Int) throws -> Self { + guard maximumBytes >= 0, maximumBytes < Int.max else { throw InstallationFileReadError.tooLarge } + let handle = try openReadOnly(url) + defer { try? handle.close() } + let observation = try InstallationFileObservation.capture(fileDescriptor: handle.fileDescriptor, url: url) + guard observation.byteCount >= 0, observation.byteCount <= Int64(maximumBytes) else { throw InstallationFileReadError.tooLarge } + let bytes = try handle.read(upToCount: maximumBytes + 1) ?? Data() + guard bytes.count <= maximumBytes else { throw InstallationFileReadError.tooLarge } + try observation.verifyUnchanged() + guard Int64(bytes.count) == observation.byteCount else { throw InstallationFileReadError.sourceChanged } + return Self(data: bytes, observation: observation) + } + + static func openReadOnly(_ url: URL) throws -> FileHandle { + let fd = Darwin.open(url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + + func verifyUnchanged() throws { try observation.verifyUnchanged() } + + var text: String { + get throws { + guard let result = String(data: data, encoding: .utf8) else { throw InstallationFileReadError.invalidText } + return result + } + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/NativeMacApplication.swift b/Sources/NXLibraryCore/Services/Steam/NativeMacApplication.swift new file mode 100644 index 0000000..81abcef --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/NativeMacApplication.swift @@ -0,0 +1,144 @@ +import Foundation + +public enum MacExecutableArchitecture: String, Hashable, Codable, Sendable { case arm64, x86_64 } + +public enum NativeMacApplicationError: Error, Equatable { + case invalidBundle, unsafePath, invalidExecutable, metadataTooLarge +} + +/// Evidence about an installed app, independent of Steam and of any launch operation. +public struct NativeMacApplication: Equatable, Sendable { + public let url: URL + public let executableURL: URL + public let bundleIdentifier: String? + public let name: String + public let architectures: Set + public let isDeclaredGame: Bool + let fileObservations: [InstallationFileObservation] + + public static func inspect(at app: URL, within root: URL) throws -> Self { + let fm = FileManager.default + let root = root.resolvingSymlinksInPath().standardizedFileURL + let app = app.resolvingSymlinksInPath().standardizedFileURL + guard app.pathExtension.lowercased() == "app", isWithin(app, root: root) else { + throw NativeMacApplicationError.unsafePath + } + let plist = app.appendingPathComponent("Contents/Info.plist").resolvingSymlinksInPath().standardizedFileURL + guard isWithin(plist, root: app) else { throw NativeMacApplicationError.unsafePath } + let metadata: InstallationFileSnapshot + do { metadata = try InstallationFileSnapshot.read(at: plist, maximumBytes: 1024 * 1024) } + catch InstallationFileReadError.tooLarge { throw NativeMacApplicationError.metadataTooLarge } + let data = metadata.data + guard let info = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + info["CFBundlePackageType"] as? String == "APPL", + info["LSRequiresIPhoneOS"] as? Bool != true, + let executable = info["CFBundleExecutable"] as? String, + !executable.isEmpty, executable != ".", executable != "..", + !executable.contains("/"), !executable.contains("\\") else { throw NativeMacApplicationError.invalidBundle } + if let platforms = info["CFBundleSupportedPlatforms"] as? [String], + !platforms.isEmpty, !platforms.contains("MacOSX") { throw NativeMacApplicationError.invalidBundle } + let executableURL = app.appendingPathComponent("Contents/MacOS").appendingPathComponent(executable) + .resolvingSymlinksInPath().standardizedFileURL + guard isWithin(executableURL, root: app) else { throw NativeMacApplicationError.unsafePath } + guard fm.isExecutableFile(atPath: executableURL.path) else { throw NativeMacApplicationError.invalidExecutable } + let executableEvidence = try MacExecutableHeader.inspect(at: executableURL) + let bundleID = (info["CFBundleIdentifier"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let name = [info["CFBundleDisplayName"] as? String, info["CFBundleName"] as? String] + .compactMap { $0 }.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + ?? app.deletingPathExtension().lastPathComponent + let category = info["LSApplicationCategoryType"] as? String ?? "" + try metadata.verifyUnchanged() + return Self(url: app, executableURL: executableURL, bundleIdentifier: bundleID, name: name, + architectures: executableEvidence.architectures, isDeclaredGame: gameCategories.contains(category), + fileObservations: [metadata.observation, executableEvidence.observation]) + } + + static func isWithin(_ url: URL, root: URL) -> Bool { + url.path == root.path || url.path.hasPrefix(root.path.hasSuffix("/") ? root.path : root.path + "/") + } + + private static let gameCategories: Set = Set([ + "games", "action-games", "adventure-games", "arcade-games", "board-games", "card-games", + "casino-games", "dice-games", "educational-games", "family-games", "kids-games", "music-games", + "puzzle-games", "racing-games", "role-playing-games", "simulation-games", "sports-games", + "strategy-games", "trivia-games", "word-games" + ].map { "public.app-category." + $0 }) +} + +/// Reads only bounded Mach-O headers and fat architecture tables, never executable contents. +private enum MacExecutableHeader { + static func inspect(at url: URL) throws -> (architectures: Set, observation: InstallationFileObservation) { + let file = try InstallationFileSnapshot.openReadOnly(url) + defer { try? file.close() } + let observation = try InstallationFileObservation.capture(fileDescriptor: file.fileDescriptor, url: url) + let size = try file.seekToEnd() + let result = try architectures(file: file, size: size) + try observation.verifyUnchanged() + return (result, observation) + } + + private static func architectures(file: FileHandle, size: UInt64) throws -> Set { + func read(_ offset: UInt64, _ count: Int) throws -> Data { + guard offset <= size, UInt64(count) <= size - offset else { throw NativeMacApplicationError.invalidExecutable } + try file.seek(toOffset: offset) + guard let bytes = try file.read(upToCount: count), bytes.count == count else { + throw NativeMacApplicationError.invalidExecutable + } + return bytes + } + let first = try read(0, 8) + let magic = word(first, 0, bigEndian: true) + let isFat64 = magic == 0xcafebabf || magic == 0xbfbafeca + let isFat = isFat64 || magic == 0xcafebabe || magic == 0xbebafeca + if !isFat { return [try thin(try read(0, 32), expectedCPU: nil)] } + let bigEndian = magic == 0xcafebabe || magic == 0xcafebabf + let count = Int(word(first, 4, bigEndian: bigEndian)) + guard (1...64).contains(count) else { throw NativeMacApplicationError.invalidExecutable } + let stride = isFat64 ? 32 : 20 + let table = try read(8, count * stride) + let tableEnd = UInt64(8 + count * stride) + var result: Set = [] + for index in 0..= tableEnd, offset <= size, length >= 32, length <= size - offset else { + throw NativeMacApplicationError.invalidExecutable + } + if cpu == 0x01000007 || cpu == 0x0100000c { + result.insert(try thin(try read(offset, 32), expectedCPU: cpu)) + } + } + guard !result.isEmpty else { throw NativeMacApplicationError.invalidExecutable } + return result + } + + private static func thin(_ data: Data, expectedCPU: UInt32?) throws -> MacExecutableArchitecture { + let magic = word(data, 0, bigEndian: true) + guard magic == 0xcffaedfe || magic == 0xfeedfacf else { throw NativeMacApplicationError.invalidExecutable } + let bigEndian = magic == 0xfeedfacf + let cpu = word(data, 4, bigEndian: bigEndian) + guard word(data, 12, bigEndian: bigEndian) == 2, expectedCPU == nil || cpu == expectedCPU else { + throw NativeMacApplicationError.invalidExecutable + } + switch cpu { + case 0x01000007: return .x86_64 + case 0x0100000c: return .arm64 + default: throw NativeMacApplicationError.invalidExecutable + } + } + + private static func word(_ data: Data, _ offset: Int, bigEndian: Bool) -> UInt32 { + let shifts = bigEndian ? [24, 16, 8, 0] : [0, 8, 16, 24] + return (0..<4).reduce(0) { $0 | UInt32(data[offset + $1]) << shifts[$1] } + } + + private static func longWord(_ data: Data, _ offset: Int, bigEndian: Bool) -> UInt64 { + let first = UInt64(word(data, offset, bigEndian: bigEndian)) + let second = UInt64(word(data, offset + 4, bigEndian: bigEndian)) + return bigEndian ? (first << 32) | second : first | (second << 32) + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift new file mode 100644 index 0000000..ca8c1ec --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift @@ -0,0 +1,54 @@ +import Foundation + +/// What a Steam game's tile asks for in one layout role. Personal picks always come +/// first. A square then uses the installed app's own icon file, never the portrait +/// cover; other roles have no local default and go to the Steam store art. +public struct SteamArtworkRequest: Equatable { + public let game: CatalogGame + public let role: LibraryArtworkRole + + public init(game: CatalogGame, role: LibraryArtworkRole) { + self.game = game; self.role = role + } + + public var slot: ArtworkSlot { role.slot } + public var appID: UInt32? { game.steamGame?.appID } + /// Distinct per game and role, so a square never shows a cover still loading for the same game. + public var key: String { "\(game.id.uuidString)|\(role.rawValue)" } + + /// The installed app this game launches, as the rest of the library reads it. + public var application: CatalogNativeApplication? { + game.steamGame?.installations.first { $0.availability == .available }?.primaryApplication + } + + /// A file on this Mac for the role: the player's pick, else (square only) the app's own icon. + public func localURL(picks: SteamPersonalizationStore) -> URL? { + if let picked = picks.url(titleID: game.personalizationReference.key, slot: slot), + FileManager.default.fileExists(atPath: picked.path) { return picked } + return automaticLocalURL + } + + /// What the role shows with no pick: the app's own icon for the square, nothing else on this Mac. + public var automaticLocalURL: URL? { + guard role == .square, let application else { return nil } + return Self.bundleIcon(of: application.url) + } + + /// `CFBundleIconFile` inside the bundle's own Resources folder. A name that points + /// elsewhere (a path, or a link that leaves the folder) is not followed. + static func bundleIcon(of app: URL) -> URL? { + let plist = app.appendingPathComponent("Contents/Info.plist") + guard let data = try? InstallationFileSnapshot.read(at: plist, maximumBytes: 1_048_576).data, + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let name = (info["CFBundleIconFile"] as? String)?.trimmingCharacters(in: .whitespaces), + !name.isEmpty, !name.hasPrefix("."), name == URL(fileURLWithPath: name).lastPathComponent else { return nil } + let resources = app.appendingPathComponent("Contents/Resources") + let file = resources.appendingPathComponent(name.hasSuffix(".icns") ? name : name + ".icns") + let folder = resources.resolvingSymlinksInPath().standardizedFileURL.path + "/" + let resolved = file.resolvingSymlinksInPath().standardizedFileURL + var isDirectory: ObjCBool = false + guard resolved.path.hasPrefix(folder), + FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), !isDirectory.boolValue else { return nil } + return file + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift new file mode 100644 index 0000000..fd5ea6c --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift @@ -0,0 +1,74 @@ +import Foundation +import ImageIO + +/// The automatic full-screen background of a Steam game. Steam's library hero is a wide banner +/// (about 3:1) made for the top of Steam's own page: filling a 16:9 screen enlarges and crops it. +/// So clean 16:9 art comes first: the stores' hero backgrounds found by the game's name (Xbox, +/// GOG Galaxy, TheGamesDB fan art, each matched by `ArtworkNames`), then Steam's own first +/// screenshot for the exact AppID, and the library hero only when nothing else fits. +/// The player's own pick always comes before all of this, outside this type. +public enum SteamBackdropChoice { + /// Screen-shaped: between 3:2 and 2:1, and wide enough not to be blown up on a large display. + public static func suits(width: Int, height: Int) -> Bool { + guard width >= 1280, height > 0 else { return false } + let ratio = Double(width) / Double(height) + return ratio >= 1.5 && ratio <= 2.0 + } + + /// Candidates in trial order. Only each store's own clean hero is taken (not titled heroes, + /// wide covers or their screenshots); Steam's art is the exact AppID's, never a name match. + public static func ordered(storeArt: [ArtworkCandidate], screenshots: [URL], libraryHero: URL?) -> [URL] { + func first(_ source: String) -> [URL] { + storeArt.first { $0.slot == .background && $0.source == source }.map { [$0.url] } ?? [] + } + // LaunchBox's fan art for the game's computer entry, at full size: only one whose stated size suits a + // screen (a size it does not state is checked by `choose`). + let fanart = storeArt.first { candidate in + guard candidate.slot == .background, candidate.source.hasPrefix(LaunchBoxArt.fanartSource) else { return false } + guard let width = candidate.width, let height = candidate.height else { return true } + return suits(width: width, height: height) + }.map { $0.full ?? $0.url } + var urls = first("Xbox Store") + first("GOG") + first("TheGamesDB fan art") + (fanart.map { [$0] } ?? []) + urls += screenshots.prefix(1) + if let libraryHero { urls.append(libraryHero) } + var seen: Set = [] + return urls.filter { seen.insert($0).inserted } + } + + /// The first candidate whose image suits a full screen. When none does: `fallback` (Steam's library + /// banner, shown cropped), else the first candidate that loaded at all. Nothing is kept here: the caller + /// caches the choice. + public static func choose(_ urls: [URL], fallback: URL?, data: (URL) async -> Data?) async -> URL? { + var loaded: URL? + for url in urls { + guard !Task.isCancelled else { return nil } + guard let bytes = await data(url), let size = pixelSize(bytes) else { continue } + if suits(width: size.width, height: size.height) { return url } + if loaded == nil { loaded = url } + } + return fallback ?? loaded + } + + /// Store art for the name, from the sources that publish clean 16:9 heroes, and LaunchBox's fan art for + /// the game's Mac or Windows entry (never a console's). + public static func storeArt(names: [String], client: ArtworkHTTPClient = URLSessionArtworkClient()) async -> [ArtworkCandidate] { + async let xbox = XboxStoreArt.find(names: names, client: client) + async let gog = GOGArt.find(names: names, client: client) + async let gamesDB = TheGamesDBArt.find(names: names, client: client) + async let launchBox = LaunchBoxArt.game(names: names, switch2: false, computer: true, client: client) + return await xbox + gog + gamesDB + (launchBox?.candidates ?? []) + } + + /// Fetches a candidate for `choose`. + public static func download(_ url: URL, client: ArtworkHTTPClient = URLSessionArtworkClient()) async -> Data? { + await OnlineArtworkRequest.data(url, client: client) + } + + static func pixelSize(_ data: Data) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { return nil } + return (width, height) + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamBackgroundLaunch.swift b/Sources/NXLibraryCore/Services/Steam/SteamBackgroundLaunch.swift new file mode 100644 index 0000000..b086190 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamBackgroundLaunch.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Keeping Steam out of sight while a Steam game starts ("Keep Steam in the background"). +/// Valve documents `-silent` (no window at startup, only when Steam is not running yet) and +/// `-nofriendsui`; nothing stops an open Steam from coming forward when it gets `steam://run`, +/// so NX Library hides it for a short while instead. Past `attentionDelay` without the game, +/// Steam may be asking for something (sign-in, an update, a cloud conflict): it is shown again. +public enum SteamBackgroundLaunch { + public static let attentionDelay: TimeInterval = 15 + + /// Arguments for a Steam that Play itself starts; an open Steam ignores launch arguments. + public static func arguments(enabled: Bool, steamRunning: Bool) -> [String] { + enabled && !steamRunning ? ["-silent", "-nofriendsui"] : [] + } + + /// Steam is hidden only while this game is on its way and not yet late. + public static func keepsSteamAway(enabled: Bool, phase: SteamPlayState.Phase, now: Date) -> Bool { + guard enabled, case .starting(let since, _) = phase else { return false } + return now.timeIntervalSince(since) <= attentionDelay + } + + /// The game is late: Steam is back in view and the head says to check it. + public static func mayNeedPlayer(enabled: Bool, phase: SteamPlayState.Phase, now: Date) -> Bool { + guard enabled, case .starting(let since, _) = phase else { return false } + return now.timeIntervalSince(since) > attentionDelay + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamKeyValues.swift b/Sources/NXLibraryCore/Services/Steam/SteamKeyValues.swift new file mode 100644 index 0000000..6b2b56a --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamKeyValues.swift @@ -0,0 +1,145 @@ +import Foundation + +public enum KeyValuesError: Error, Equatable { + case malformed, limitExceeded + case duplicateField(String), unsupportedDirective(String) +} + +public enum KeyValuesValue: Equatable, Sendable { + case string(String) + case object([KeyValuesEntry]) +} + +public struct KeyValuesEntry: Equatable, Sendable { + public let key: String + public let value: KeyValuesValue +} + +public extension Array where Element == KeyValuesEntry { + func uniqueValue(for key: String) throws -> KeyValuesValue? { + var result: KeyValuesValue? + for entry in self where entry.key.caseInsensitiveCompare(key) == .orderedSame { + guard result == nil else { throw KeyValuesError.duplicateField(key) } + result = entry.value + } + return result + } +} + +/// The bounded text subset used by Steam's local indexes and manifests. +/// Entries retain order and duplicates; querying an ambiguous field fails. +/// Directives never cause filesystem access. +public struct KeyValuesDocument: Sendable { + public let entries: [KeyValuesEntry] + + public init(text: String, maximumDepth: Int = 32, maximumEntries: Int = 20_000) throws { + guard text.utf8.count <= 8 * 1024 * 1024, maximumDepth >= 0, maximumEntries >= 0 else { + throw KeyValuesError.limitExceeded + } + var reader = Reader(bytes: Array(text.utf8), maximumDepth: maximumDepth, maximumEntries: maximumEntries) + entries = try reader.object(depth: 0) + } + + public func uniqueValue(for key: String) throws -> KeyValuesValue? { + try entries.uniqueValue(for: key) + } +} + +private struct Reader { + let bytes: [UInt8] + let maximumDepth: Int + let maximumEntries: Int + private var offset = 0 + private var count = 0 + + init(bytes: [UInt8], maximumDepth: Int, maximumEntries: Int) { + self.bytes = bytes + self.maximumDepth = maximumDepth + self.maximumEntries = maximumEntries + if bytes.starts(with: [0xef, 0xbb, 0xbf]) { offset = 3 } + } + + private enum Token { case scalar(String), open, close } + + mutating func object(depth: Int) throws -> [KeyValuesEntry] { + var entries: [KeyValuesEntry] = [] + while let token = try readToken() { + if case .close = token { + guard depth > 0 else { throw KeyValuesError.malformed } + return entries + } + guard case .scalar(let key) = token, !key.isEmpty else { throw KeyValuesError.malformed } + guard !key.hasPrefix("#") else { throw KeyValuesError.unsupportedDirective(key) } + guard count < maximumEntries else { throw KeyValuesError.limitExceeded } + count += 1 + let value: KeyValuesValue + switch try readToken() { + case .scalar(let text): value = .string(text) + case .open: + guard depth < maximumDepth else { throw KeyValuesError.limitExceeded } + value = .object(try object(depth: depth + 1)) + default: throw KeyValuesError.malformed + } + entries.append(KeyValuesEntry(key: key, value: value)) + } + guard depth == 0 else { throw KeyValuesError.malformed } + return entries + } + + private mutating func readToken() throws -> Token? { + skipTrivia() + guard offset < bytes.count else { return nil } + let first = bytes[offset] + offset += 1 + if first == 123 { return .open } + if first == 125 { return .close } + if first == 34 { return .scalar(try quoted()) } + guard first != 0 else { throw KeyValuesError.malformed } + let start = offset - 1 + while offset < bytes.count { + let byte = bytes[offset] + if isWhitespace(byte) || byte == 123 || byte == 125 || isComment { break } + guard byte != 0, byte != 34 else { throw KeyValuesError.malformed } + offset += 1 + } + return .scalar(String(decoding: bytes[start.. String { + var result: [UInt8] = [] + while offset < bytes.count { + let byte = bytes[offset] + offset += 1 + if byte == 34 { return String(decoding: result, as: UTF8.self) } + guard byte != 0 else { throw KeyValuesError.malformed } + if byte == 92 { + guard offset < bytes.count else { throw KeyValuesError.malformed } + let escaped = bytes[offset] + offset += 1 + switch escaped { + case 34, 92: result.append(escaped) + case 110: result.append(10) + case 116: result.append(9) + case 114: result.append(13) + default: throw KeyValuesError.malformed + } + } else { result.append(byte) } + } + throw KeyValuesError.malformed + } + + private var isComment: Bool { + offset + 1 < bytes.count && bytes[offset] == 47 && bytes[offset + 1] == 47 + } + + private func isWhitespace(_ byte: UInt8) -> Bool { byte == 32 || (9...13).contains(byte) } + + private mutating func skipTrivia() { + while offset < bytes.count { + if isWhitespace(bytes[offset]) { offset += 1 } + else if isComment { + while offset < bytes.count && bytes[offset] != 10 && bytes[offset] != 13 { offset += 1 } + } else { return } + } + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamLibrarySource.swift b/Sources/NXLibraryCore/Services/Steam/SteamLibrarySource.swift new file mode 100644 index 0000000..ff2df9e --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamLibrarySource.swift @@ -0,0 +1,310 @@ +import Foundation + +public struct SteamScanLimits: Sendable { + public var maximumTextBytes = 8 * 1024 * 1024 + public var maximumLibraries = 64 + public var maximumManifests = 10_000 + public var maximumDirectoriesPerInstall = 256 + public var maximumInstallDepth = 4 + public var maximumDirectoryEntries = 10_000 + public static let standard = Self() + public init() {} +} + +public struct SteamScanIssue: Equatable, Sendable { + public enum Reason: Equatable, Sendable { + case unavailableLibrary, invalidIndex, oversizedIndex, invalidManifest, oversizedManifest + case conflictingIdentity, unsafePath, directoryLimit, unreadableAppMetadata, libraryLimit, manifestLimit + } + public let url: URL + public let appID: UInt32? + public let reason: Reason +} + +public struct SteamLibraryObservation: Equatable, Sendable { + public let root: URL + public let isReadable: Bool + public var isComplete: Bool +} + +public struct SteamGameObservation: Equatable, Sendable { + public enum State: Equatable, Sendable { case nativeReady, requiresReview, unavailable } + public let appID: UInt32 + public let name: String + public let installationURL: URL + public let libraryURL: URL + public let reportedSizeBytes: Int64? + public let buildID: String? + public let nativeApps: [NativeMacApplication] + public var state: State +} + +public struct SteamLibrarySnapshot: Sendable { + public let libraries: [SteamLibraryObservation] + public let games: [SteamGameObservation] + public let issues: [SteamScanIssue] + /// A partial source must never authorize pruning unseen libraries or games. + public var isComplete: Bool { issues.isEmpty && libraries.allSatisfy(\.isComplete) } +} + +public enum SteamSourceError: Error, Equatable { case sourceChanged } + +/// Reads only Steam's declared libraries. No scan starts until explicitly requested; +/// this source owns no persistence, process launch, credentials or user opt-in. +public struct SteamLibrarySource: Sendable { + private let root: URL + private let limits: SteamScanLimits + private let directoryReader: InstallationDirectoryReader + public init(root: URL, limits: SteamScanLimits = .standard) { + self.init(root: root, limits: limits, directoryReader: { url, budget, cancelled in + try InstallationDirectorySnapshot.read(at: url, maximumEntries: budget, cancelled: cancelled) + }) + } + + init(root: URL, limits: SteamScanLimits = .standard, directoryReader: @escaping InstallationDirectoryReader) { + self.root = root + self.limits = limits + self.directoryReader = directoryReader + } + + public func scan(cancelled: @escaping () -> Bool = { false }) throws -> SteamLibrarySnapshot { + var scan = Scan(root: root, limits: limits, cancelled: cancelled, directoryReader: directoryReader) + do { return try scan.run() } + catch InstallationFileReadError.sourceChanged { throw SteamSourceError.sourceChanged } + } +} + +private struct Scan { + let root: URL + let limits: SteamScanLimits + let cancelled: () -> Bool + let directoryReader: InstallationDirectoryReader + private let fm = FileManager.default + private var observations: [InstallationFileObservation] = [] + private var issues: [SteamScanIssue] = [] + private var games: [SteamGameObservation] = [] + + init(root: URL, limits: SteamScanLimits, cancelled: @escaping () -> Bool, directoryReader: @escaping InstallationDirectoryReader) { + self.root = root.resolvingSymlinksInPath().standardizedFileURL + self.limits = limits + self.cancelled = cancelled + self.directoryReader = directoryReader + } + + mutating func run() throws -> SteamLibrarySnapshot { + try checkCancellation() + let paths = try libraryPaths() + var libraries: [SteamLibraryObservation] = [] + var manifestsSeen = 0 + for library in paths { + try checkCancellation() + let before = issues.count + let steamapps = library.appendingPathComponent("steamapps").resolvingSymlinksInPath().standardizedFileURL + guard NativeMacApplication.isWithin(steamapps, root: library) else { + issue(.unsafePath, at: steamapps) + libraries.append(.init(root: library, isReadable: false, isComplete: false)) + continue + } + let files: [URL] + do { files = try readDirectory(steamapps) } + catch is CancellationError { throw CancellationError() } + catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch { + issue(.unavailableLibrary, at: library) + libraries.append(.init(root: library, isReadable: false, isComplete: false)) + continue + } + let manifests = files.filter { $0.lastPathComponent.hasPrefix("appmanifest_") && $0.pathExtension == "acf" } + .sorted { $0.path < $1.path } + for file in manifests { + try checkCancellation() + guard manifestsSeen < max(0, limits.maximumManifests) else { + issue(.manifestLimit, at: library) + break + } + manifestsSeen += 1 + try readManifest(file, library: library) + } + libraries.append(.init(root: library, isReadable: true, isComplete: issues.count == before)) + } + let conflicting = Dictionary(grouping: games.indices, by: { games[$0].appID }).values.filter { $0.count > 1 } + for indices in conflicting { + for index in indices { + games[index].state = .requiresReview + issue(.conflictingIdentity, at: games[index].installationURL, appID: games[index].appID) + if let library = libraries.firstIndex(where: { $0.root.path == games[index].libraryURL.path }) { + libraries[library].isComplete = false + } + } + } + // Errors reading the index may hide entire libraries, so source-level completeness matters too. + if issues.contains(where: { [.invalidIndex, .oversizedIndex, .libraryLimit].contains($0.reason) }) { + for index in libraries.indices { libraries[index].isComplete = false } + } + for observation in observations { try checkCancellation(); try observation.verifyUnchanged() } + games.sort { $0.appID == $1.appID ? $0.libraryURL.path < $1.libraryURL.path : $0.appID < $1.appID } + return SteamLibrarySnapshot(libraries: libraries, games: games, issues: issues) + } + + private mutating func libraryPaths() throws -> [URL] { + var paths = [root] + let steamapps = root.appendingPathComponent("steamapps").resolvingSymlinksInPath().standardizedFileURL + guard NativeMacApplication.isWithin(steamapps, root: root) else { + issue(.unsafePath, at: steamapps) + return paths + } + let index = root.appendingPathComponent("steamapps/libraryfolders.vdf") + if fm.fileExists(atPath: index.path) { + do { + let document = try readDocument(index) + guard case .object(let entries)? = try document.uniqueValue(for: "libraryfolders") else { throw KeyValuesError.malformed } + for entry in entries where Self.numeric(entry.key) != nil || entry.key == "0" { + let path: String + switch entry.value { + case .string(let value): path = value + case .object(let children): + guard case .string(let value)? = try children.uniqueValue(for: "path") else { throw KeyValuesError.malformed } + path = value + } + guard path.hasPrefix("/"), !path.contains("\0") else { issue(.unsafePath, at: index); continue } + let url = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + if !paths.contains(where: { $0.path == url.path }) { + guard paths.count < max(1, limits.maximumLibraries) else { issue(.libraryLimit, at: index); continue } + paths.append(url) + } + } + } catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch InstallationFileReadError.tooLarge { issue(.oversizedIndex, at: index) } + catch { issue(.invalidIndex, at: index) } + } else { + do { observations.append(try InstallationFileObservation.absent(at: index)) } + catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch { issue(.invalidIndex, at: index) } + } + return paths + } + + private mutating func readManifest(_ file: URL, library: URL) throws { + let stem = file.deletingPathExtension().lastPathComponent + let filenameID = Self.numeric(String(stem.dropFirst("appmanifest_".count))) + do { + guard let filenameID else { throw KeyValuesError.malformed } + let document = try readDocument(file) + guard case .object(let fields)? = try document.uniqueValue(for: "AppState"), + let idText = try Self.string("appid", in: fields), let id = Self.numeric(idText) else { throw KeyValuesError.malformed } + guard id == filenameID else { issue(.conflictingIdentity, at: file, appID: filenameID); return } + guard let name = try Self.string("name", in: fields), !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let directory = try Self.string("installdir", in: fields), !directory.isEmpty else { throw KeyValuesError.malformed } + let components = directory.split(separator: "/", omittingEmptySubsequences: false) + guard !directory.hasPrefix("/"), !directory.contains("\\"), !directory.contains("\0"), + !components.contains(where: { $0 == ".." || $0 == "." || $0.isEmpty }) else { + issue(.unsafePath, at: file, appID: id); return + } + let common = library.appendingPathComponent("steamapps/common").resolvingSymlinksInPath().standardizedFileURL + let install = common.appendingPathComponent(directory).resolvingSymlinksInPath().standardizedFileURL + guard NativeMacApplication.isWithin(common, root: library), NativeMacApplication.isWithin(install, root: common), install.path != common.path else { + issue(.unsafePath, at: file, appID: id); return + } + let before = issues.count + let apps = try nativeApps(in: install, appID: id) + let mainApps = apps.filter(\.isDeclaredGame) + let state: SteamGameObservation.State = mainApps.count == 1 && issues.count == before ? .nativeReady + : (apps.isEmpty && issues.count == before ? .unavailable : .requiresReview) + let size = try Self.string("SizeOnDisk", in: fields).flatMap(Self.nonnegativeSize) + let build = try Self.string("buildid", in: fields) + games.append(.init(appID: id, name: name, installationURL: install, libraryURL: library, + reportedSizeBytes: size, buildID: build, nativeApps: apps, state: state)) + } catch is CancellationError { throw CancellationError() } + catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch InstallationFileReadError.tooLarge { issue(.oversizedManifest, at: file, appID: filenameID) } + catch { issue(.invalidManifest, at: file, appID: filenameID) } + } + + private mutating func nativeApps(in install: URL, appID: UInt32) throws -> [NativeMacApplication] { + guard fm.fileExists(atPath: install.path) else { return [] } + var level = [install] + var depth = 0 + var visited = 0 + var apps: [NativeMacApplication] = [] + var seen: Set = [] + let keys: Set = [.isDirectoryKey, .isSymbolicLinkKey, .isPackageKey] + while !level.isEmpty { + try checkCancellation() + var nextLevel: [URL] = [] + var truncated = false + let remaining = max(0, max(1, limits.maximumDirectoriesPerInstall) - visited - level.count) + for directory in level { + let files: [URL] + do { files = try readDirectory(directory, appID: appID) } + catch is CancellationError { throw CancellationError() } + catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch { issue(.unreadableAppMetadata, at: directory, appID: appID); continue } + for file in files.sorted(by: { $0.path < $1.path }) { + try checkCancellation() + if file.pathExtension.lowercased() == "app" { + do { + let app = try NativeMacApplication.inspect(at: file, within: install) + observations.append(contentsOf: app.fileObservations) + if seen.insert(app.url.path).inserted { apps.append(app) } + } catch InstallationFileReadError.sourceChanged { throw InstallationFileReadError.sourceChanged } + catch { issue(.unreadableAppMetadata, at: file, appID: appID) } + continue // Every bundle is a boundary, including an invalid one. + } + let values: URLResourceValues + do { values = try file.resourceValues(forKeys: keys) } + catch { issue(.unreadableAppMetadata, at: file, appID: appID); continue } + guard values.isDirectory == true, values.isSymbolicLink != true, values.isPackage != true else { continue } + if nextLevel.count < remaining { nextLevel.append(file) } + else { truncated = true } + } + } + visited += level.count + // Compare all apps at this depth. Once game candidates exist, deeper + // loose-data trees do not decide which top-level application is primary. + if apps.contains(where: \.isDeclaredGame) { return apps } + if truncated || (!nextLevel.isEmpty && depth >= max(0, limits.maximumInstallDepth)) { + issue(.directoryLimit, at: level[0], appID: appID) + return apps + } + level = nextLevel + depth += 1 + } + return apps + } + + private mutating func readDocument(_ url: URL) throws -> KeyValuesDocument { + let snapshot = try InstallationFileSnapshot.read(at: url, maximumBytes: min(max(0, limits.maximumTextBytes), 8 * 1024 * 1024)) + let document = try KeyValuesDocument(text: snapshot.text) + observations.append(snapshot.observation) + return document + } + + private mutating func readDirectory(_ url: URL, appID: UInt32? = nil) throws -> [URL] { + let snapshot = try directoryReader(url, limits.maximumDirectoryEntries, cancelled) + observations.append(snapshot.observation) + if !snapshot.isComplete { issue(.directoryLimit, at: url, appID: appID) } + return snapshot.entries + } + + private mutating func issue(_ reason: SteamScanIssue.Reason, at url: URL, appID: UInt32? = nil) { + issues.append(SteamScanIssue(url: url, appID: appID, reason: reason)) + } + + private func checkCancellation() throws { if cancelled() { throw CancellationError() } } + + private static func numeric(_ text: String) -> UInt32? { + guard !text.isEmpty, text.utf8.allSatisfy({ (48...57).contains($0) }), let value = UInt32(text), value > 0 else { return nil } + return value + } + + private static func nonnegativeSize(_ text: String) -> Int64? { + guard !text.isEmpty, text.utf8.allSatisfy({ (48...57).contains($0) }), let value = Int64(text) else { return nil } + return value + } + + private static func string(_ key: String, in fields: [KeyValuesEntry]) throws -> String? { + guard let value = try fields.uniqueValue(for: key) else { return nil } + guard case .string(let result) = value else { throw KeyValuesError.malformed } + return result + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamMetadata.swift b/Sources/NXLibraryCore/Services/Steam/SteamMetadata.swift new file mode 100644 index 0000000..9912b8d --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamMetadata.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Store facts are distinct from installation observations and personal choices. +public struct SteamMetadata: Codable, Equatable, Sendable { + public let appID: UInt32 + public let language: String + public let description: String + public let genres: [String] + public let developers: [String] + public let publishers: [String] + public let releaseDate: String? + public let screenshots: [URL] + public let fetchedAt: Date + + public static func language(_ language: AppLanguage) -> String { + switch language { + case .italian: return "italian" + case .french: return "french" + case .german: return "german" + case .spanish: return "spanish" + case .portugueseBrazil: return "brazilian" + default: return "english" + } + } + public static func safeImage(_ url: URL?) -> URL? { + guard let url, url.scheme == "https", url.user == nil, url.password == nil, + url.port == nil || url.port == 443, let host = url.host?.lowercased(), + ["steamstatic.com", "steamusercontent.com"].contains(where: { host == $0 || host.hasSuffix("." + $0) }) else { return nil } + return url + } + public static func decode(_ data: Data, appID: UInt32, language: String) throws -> Self { + struct Envelope: Decodable { + struct Body: Decodable { + struct Genre: Decodable { let description: String } + struct Shot: Decodable { let path_full: URL } + struct Release: Decodable { let date: String? } + let steam_appid: UInt32 + let short_description: String? + let genres: [Genre]? + let developers: [String]? + let publishers: [String]? + let release_date: Release? + let screenshots: [Shot]? + } + let success: Bool + let data: Body? + } + guard data.count <= 2_097_152, appID > 0, + let envelope = try JSONDecoder().decode([String: Envelope].self, from: data)[String(appID)], + envelope.success, let body = envelope.data, body.steam_appid == appID else { throw URLError(.cannotParseResponse) } + return Self(appID: appID, language: language, + description: GameBananaAPI.plainText(fromHTML: body.short_description ?? ""), + genres: Array((body.genres ?? []).prefix(24)).map(\.description), + developers: Array((body.developers ?? []).prefix(12)), publishers: Array((body.publishers ?? []).prefix(12)), + releaseDate: body.release_date?.date, + screenshots: Array((body.screenshots ?? []).compactMap { safeImage($0.path_full) }.prefix(12)), fetchedAt: Date()) + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift b/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift new file mode 100644 index 0000000..fb85795 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift @@ -0,0 +1,127 @@ +import Foundation +import CryptoKit + +/// Bounded, source-specific enrichment. Failures preserve disk cache; no catalog +/// writes, credentials, game-content reads or Nintendo lookups. +public actor SteamMetadataService { + public typealias Transport = @Sendable (URL) async throws -> Data + public static let shared = SteamMetadataService() + private let directory: URL + private let transport: Transport + private struct Flight { + let id: UUID + let task: Task + var waiters: Set + } + private var pending: [URL: Flight] = [:] + private var failedAt: [URL: Date] = [:] + public static let resourceDeadline: TimeInterval = 20 + final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } + } + private static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForResource = resourceDeadline + config.timeoutIntervalForRequest = 10 + config.httpMaximumConnectionsPerHost = 4 + return URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil) + }() + public static func allowed(_ url: URL) -> Bool { + guard url.scheme == "https", url.user == nil, url.password == nil, url.port == nil || url.port == 443 else { return false } + return url.host == "store.steampowered.com" || SteamMetadata.safeImage(url) != nil + } + + public init(directory: URL = AppData.supportRoot.appendingPathComponent("integrations/steam/enrichment"), + transport: @escaping Transport = { try await SteamMetadataService.download($0) }) { + self.directory = directory; self.transport = transport + } + public static func download(_ url: URL) async throws -> Data { + guard allowed(url) else { throw URLError(.badURL) } + var request = URLRequest(url: url); request.timeoutInterval = 15 + request.setValue("NXLibrary/0.1 (macOS)", forHTTPHeaderField: "User-Agent") + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), + let final = http.url, allowed(final) else { throw URLError(.badServerResponse) } + let limit = url.host == "store.steampowered.com" ? 2_097_152 : 12_582_912 + if response.expectedContentLength > limit { throw URLError(.dataLengthExceedsMaximum) } + var data = Data() + for try await byte in bytes { + guard data.count < limit else { throw URLError(.dataLengthExceedsMaximum) } + data.append(byte) + } + return data + } + private func release(_ url: URL, flightID: UUID, waiter: UUID) { + guard var flight = pending[url], flight.id == flightID else { return } + flight.waiters.remove(waiter) + if flight.waiters.isEmpty { flight.task.cancel(); pending[url] = nil } + else { pending[url] = flight } + } + private func fetch(_ url: URL) async throws -> Data { + try Task.checkCancellation() + if let failed = failedAt[url], Date().timeIntervalSince(failed) < 60 { throw URLError(.resourceUnavailable) } + let waiter = UUID() + var flight: Flight + if let existing = pending[url] { flight = existing; flight.waiters.insert(waiter) } + else { + guard pending.count < 6 else { throw URLError(.resourceUnavailable) } + let transport = transport + flight = Flight(id: UUID(), task: Task { try await transport(url) }, waiters: [waiter]) + } + pending[url] = flight + let id = flight.id, task = flight.task + return try await withTaskCancellationHandler(operation: { + defer { release(url, flightID: id, waiter: waiter) } + do { let data = try await task.value; try Task.checkCancellation(); return data } + catch { + if !Task.isCancelled && !(error is CancellationError) { failedAt[url] = Date() } + throw error + } + }, onCancel: { Task { await self.release(url, flightID: id, waiter: waiter) } }) + } + public func metadata(appID: UInt32, language: String, refresh: Bool = false) async -> SteamMetadata? { + guard appID > 0, ["english","italian","french","german","spanish","brazilian"].contains(language) else { return nil } + let file = directory.appendingPathComponent("\(appID)/\(language).json") + let cached = (try? InstallationFileSnapshot.read(at: file, maximumBytes: 2_097_152).data).flatMap { try? JSONDecoder().decode(SteamMetadata.self, from: $0) } + let valid = cached.flatMap { $0.appID == appID && $0.language == language ? $0 : nil } + if let valid, !refresh, Date().timeIntervalSince(valid.fetchedAt) < 604800 { return valid } + let url = URL(string: "https://store.steampowered.com/api/appdetails?appids=\(appID)&l=\(language)")! + do { + let bytes = try await fetch(url) + try Task.checkCancellation() + let result: SteamMetadata + do { result = try SteamMetadata.decode(bytes, appID: appID, language: language) } + catch { failedAt[url] = Date(); throw error } + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(result).write(to: file, options: .atomic) + return result + } catch { return valid } + } + public func image(appID: UInt32, slot: ArtworkSlot, remote: URL? = nil) async -> URL? { + guard appID > 0, !Task.isCancelled else { return nil } + // Screenshots use their full URL in the cache key; no basename collisions. + let token = remote.map { SHA256.hash(data: Data($0.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined() } + let file = directory.appendingPathComponent("\(appID)/images/\(token ?? slot.rawValue).png") + let cached = try? InstallationFileSnapshot.read(at: file, maximumBytes: 12_582_912).data + let validCache = cached.flatMap { ArtworkStore.downsample($0, maxPixelSize: slot.kind.maxPixelSize, png: true) } != nil + let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + if validCache, let modified, Date().timeIntervalSince(modified) < 604800 { return file } + let candidates = remote.map { [ArtworkCandidate(slot: slot, source: "Steam", url: $0)] } + ?? SteamStoreArt.candidates(appID: Int(appID), slot: slot) + for candidate in candidates { + for url in [candidate.url, candidate.fallback].compactMap({ $0 }).compactMap(SteamMetadata.safeImage) { + guard !Task.isCancelled else { return validCache ? file : nil } + guard let data = try? await fetch(url) else { continue } + guard !Task.isCancelled else { return validCache ? file : nil } + guard data.count <= 12_582_912, + let image = ArtworkStore.downsample(data, maxPixelSize: slot.kind.maxPixelSize, png: true) else { failedAt[url] = Date(); continue } + do { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try image.write(to: file, options: .atomic); return file + } catch { return nil } + } + } + return validCache ? file : nil + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift b/Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift new file mode 100644 index 0000000..378fe4d --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift @@ -0,0 +1,63 @@ +import Foundation +import Combine + +/// One manifest publishes a complete title draft. New image files are prepared +/// first; a failed manifest commit leaves every previous reference untouched. +public final class SteamPersonalizationStore: ObservableObject { + private struct Entry: Codable { var fields: [String: String]; var art: [String: String] } + @Published public private(set) var revision = 0 + private let directory: URL + private let publish: (Data, URL) throws -> Void + private let invalidManifest: Bool + private var entries: [String: Entry] + public init(directory: URL, publish: @escaping (Data, URL) throws -> Void = { try $0.write(to: $1, options: .atomic) }) { + self.directory = directory; self.publish = publish + let decoded = (try? InstallationFileSnapshot.read(at: directory.appendingPathComponent("personal.json"), maximumBytes: 16_777_216).data) + .flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } + entries = decoded ?? [:] + invalidManifest = FileManager.default.fileExists(atPath: directory.appendingPathComponent("personal.json").path) && decoded == nil + } + public func value(titleID: String, field: MetadataField) -> String? { entries[titleID]?.fields[field.rawValue] } + public func url(titleID: String, slot: ArtworkSlot) -> URL? { + guard let name = entries[titleID]?.art[slot.rawValue], name == URL(fileURLWithPath: name).lastPathComponent else { return nil } + return directory.appendingPathComponent("assets").appendingPathComponent(name) + } + public func save(fields: [MetadataField: String], images: [ArtworkSlot: Data], automatic: Set, titleID: String) throws { + guard !invalidManifest else { throw URLError(.cannotParseResponse) } + var draft = entries + var entry = entries[titleID] ?? Entry(fields: [:], art: [:]) + var created: [URL] = [] + do { + for (field, raw) in fields { + guard raw.utf8.count <= 100_000 else { throw URLError(.dataLengthExceedsMaximum) } + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + entry.fields[field.rawValue] = text.isEmpty ? nil : text + } + for (slot, data) in images { + guard data.count <= 12_582_912, + let normalized = ArtworkStore.downsample(data, maxPixelSize: slot.kind.maxPixelSize, png: true) else { throw URLError(.cannotDecodeContentData) } + let name = UUID().uuidString + ".png" + let file = directory.appendingPathComponent("assets").appendingPathComponent(name) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try normalized.write(to: file, options: .atomic); created.append(file); entry.art[slot.rawValue] = name + } + for slot in automatic { entry.art[slot.rawValue] = nil } + draft[titleID] = entry + let data = try JSONEncoder().encode(draft) + guard data.count <= 16_777_216 else { throw URLError(.dataLengthExceedsMaximum) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try publish(data, directory.appendingPathComponent("personal.json")) + } catch { + for file in created { try? FileManager.default.removeItem(at: file) } + throw error + } + let retained = Set(draft.values.flatMap { $0.art.values }) + let old = Set(entries.values.flatMap { $0.art.values }).subtracting(retained) + entries = draft; revision += 1 + // Only obsolete generated copies, after successful publication. Automatic + // is removal of a manifest reference, so a file deletion cannot undo it. + for name in old where name == URL(fileURLWithPath: name).lastPathComponent { + try? FileManager.default.removeItem(at: directory.appendingPathComponent("assets").appendingPathComponent(name)) + } + } +} diff --git a/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift new file mode 100644 index 0000000..e008bc2 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift @@ -0,0 +1,155 @@ +import Foundation + +/// One live process as the system reports it, reduced to what identifies an installed app. +public struct NativeProcessObservation: Equatable, Sendable { + public let pid: Int32 + public let bundleURL: URL? + public let executableURL: URL? + public init(pid: Int32, bundleURL: URL?, executableURL: URL?) { + self.pid = pid; self.bundleURL = bundleURL; self.executableURL = executableURL + } + + /// The same process with its paths resolved (symlinks, `..`), the form `SteamPlayState` compares. + /// A process's paths don't change while it lives, so callers resolve each pid once. + public func canonical() -> NativeProcessObservation { + NativeProcessObservation(pid: pid, bundleURL: bundleURL.map(SteamPlayState.canonical), + executableURL: executableURL.map(SteamPlayState.canonical)) + } +} + +public enum SteamPlayFailure: Equatable, Sendable { + /// No single available, recognized installation in an enabled source. + case unavailable + /// No Steam client registered on this Mac. + case clientMissing + /// The system did not hand the request to Steam. + case handoffRejected + /// Steam took the request but the game's own app never appeared in time. + case notStarted +} + +/// A Steam game's Play and its real session, as pure state. Handing the request to Steam +/// starts nothing: the session starts when the installation's own app is seen running and +/// ends when that process goes, whatever Steam itself does. A game started from Steam +/// directly is followed the same way. Requests carry a generation, so a late answer to an +/// older Play never changes a newer one. +public struct SteamPlayState: Equatable, Sendable { + public enum Phase: Equatable, Sendable { + case idle + /// Waiting for the game's app; `accepted` once the system handed the request to Steam. + case starting(since: Date, accepted: Bool) + case running(pid: Int32) + case failed(SteamPlayFailure) + } + + public enum Request: Equatable, Sendable { + case handOff(URL, generation: Int) + case resume(pid: Int32) + /// Play pressed again while the first request is still on its way: nothing new is sent. + case wait + case refuse(SteamPlayFailure) + } + + public enum Change: Equatable, Sendable { + case started(pid: Int32) + case ended(pid: Int32) + } + + /// Steam may install an update or show its own window before the game starts. + public static let startDeadline: TimeInterval = 300 + + public private(set) var phase: Phase = .idle + public private(set) var generation = 0 + /// The app the running session saw start: it lasts while that process lives, whatever the catalog + /// says about the installation since (removed in a refresh, or now named at another path). + private var runningBundle: URL? + + public init() {} + + /// The only URL NX Library sends: built from a validated numeric AppID, nothing else. + public static func launchURL(appID: UInt32) -> URL? { + appID > 0 ? URL(string: "steam://run/\(appID)") : nil + } + + public mutating func requestPlay(target: CatalogLaunchTarget?, clientAvailable: Bool, now: Date) -> Request { + switch phase { + case .running(let pid): return .resume(pid: pid) + case .starting: return .wait + case .idle, .failed: break + } + guard case .steam(let appID)? = target, let url = Self.launchURL(appID: appID) else { + phase = .failed(.unavailable); return .refuse(.unavailable) + } + guard clientAvailable else { phase = .failed(.clientMissing); return .refuse(.clientMissing) } + generation += 1 + phase = .starting(since: now, accepted: false) + return .handOff(url, generation: generation) + } + + /// The system's answer to the hand-off. Only the current request's answer counts. + public mutating func handoffFinished(accepted: Bool, generation: Int) { + guard generation == self.generation, case .starting(let since, _) = phase else { return } + phase = accepted ? .starting(since: since, accepted: true) : .failed(.handoffRejected) + } + + /// The player gave up waiting, or dismissed a failure. + public mutating func cancel() { + switch phase { + case .starting, .failed: phase = .idle; generation += 1 + case .idle, .running: break + } + } + + /// Reads the process list, given in `canonical()` form. A running session ends when its process is + /// gone; otherwise the installation's own app, once seen, starts one. Waiting too long fails without a session. + public mutating func observe(_ processes: [NativeProcessObservation], application: CatalogNativeApplication?, + now: Date) -> Change? { + if case .running(let pid) = phase { + // Same pid and still the app it started as: a pid the system reused for another program is not the game. + if processes.contains(where: { process in + process.pid == pid && (runningBundle.map { bundle in process.bundleURL.map { Self.same($0, bundle) } ?? false } ?? true) + }) { return nil } + phase = .idle + runningBundle = nil + return .ended(pid: pid) + } + // The installation's paths are resolved once here, not once per process. + if let target = application.map(Target.init), let match = processes.first(where: target.matches) { + phase = .running(pid: match.pid) + runningBundle = match.bundleURL + return .started(pid: match.pid) + } + if case .starting(let since, _) = phase, now.timeIntervalSince(since) > Self.startDeadline { + phase = .failed(.notStarted) + } + return nil + } + + /// The process is this installation's app: the same bundle, and its executable when the + /// system names one. Steam itself, helpers and another copy of the game never match. + /// `process` is in `canonical()` form; the application is resolved here. + public static func matches(_ process: NativeProcessObservation, _ application: CatalogNativeApplication) -> Bool { + Target(application).matches(process) + } + + /// An installation's app with its paths resolved, for comparing against canonical processes. + private struct Target { + let bundle: String + let executable: String + init(_ application: CatalogNativeApplication) { + bundle = SteamPlayState.canonical(application.url).path + executable = SteamPlayState.canonical(application.executableURL).path + } + func matches(_ process: NativeProcessObservation) -> Bool { + guard let url = process.bundleURL, url.standardizedFileURL.path == bundle else { return false } + guard let executableURL = process.executableURL else { return true } + return executableURL.standardizedFileURL.path == executable + } + } + + /// Symlinks and `..` resolved: two names for one app compare equal. + public static func canonical(_ url: URL) -> URL { url.resolvingSymlinksInPath().standardizedFileURL } + + /// Both URLs are canonical already: no filesystem access. + static func same(_ a: URL, _ b: URL) -> Bool { a.standardizedFileURL.path == b.standardizedFileURL.path } +} diff --git a/Sources/NXLibraryCore/Services/SteamGridDBArt.swift b/Sources/NXLibraryCore/Services/SteamGridDBArt.swift index b351181..bebe625 100644 --- a/Sources/NXLibraryCore/Services/SteamGridDBArt.swift +++ b/Sources/NXLibraryCore/Services/SteamGridDBArt.swift @@ -9,8 +9,7 @@ public struct SteamGridDBKey { public init(url: URL = SteamGridDBKey.defaultURL()) { self.url = url } public static func defaultURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary/steamgriddb-api-key") + AppData.supportRoot.appendingPathComponent("steamgriddb-api-key") } /// A key as SteamGridDB writes it: letters and digits only (its keys are 32 hex digits). diff --git a/Sources/NXLibraryCore/Services/StorageManager.swift b/Sources/NXLibraryCore/Services/StorageManager.swift index 608f086..670928a 100644 --- a/Sources/NXLibraryCore/Services/StorageManager.swift +++ b/Sources/NXLibraryCore/Services/StorageManager.swift @@ -34,8 +34,7 @@ public final class StorageManager { private let fm: FileManager public init( - supportRoot: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/NXLibrary"), + supportRoot: URL = AppData.supportRoot, applicationsRoot: URL = URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent("Applications"), logsRoot: URL = FileManager.default.homeDirectoryForCurrentUser diff --git a/Tests/CatalogAppChecks/NoticeHost.swift b/Tests/CatalogAppChecks/NoticeHost.swift new file mode 100644 index 0000000..a230b4d --- /dev/null +++ b/Tests/CatalogAppChecks/NoticeHost.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import NXLibraryCore + +struct SettingsNotice { var retry: (() -> Void)?; var onDismiss: (() -> Void)? = nil } +final class AppViewModel { + let catalogRepository: CatalogRepository + var personalizations = CatalogPersonalizations.empty + var catalogBuildRecoveries = CatalogBuildRecoveries() + var logs: [LogEntry] = [] + var alertMessage: String? + var settingsNotice: SettingsNotice? + var isBuilding = false + init(_ repository: CatalogRepository) { catalogRepository = repository } + func reportLibrarySaveFailure(_ error: Error) { alertMessage = error.localizedDescription } + func showSettingsNotice(title: String, message: String, actionTitle: String = "Retry", cancelTitle: String = "Back", retry: (() -> Void)? = nil) { + settingsNotice = SettingsNotice(retry: retry) + } + func applyCatalog(_ document: CatalogDocument) {} + func presentInstallFailure(for: PortedGame, error: String) {} +} +func require(_ ok: @autoclosure () -> Bool, _ message: String) throws { + if !ok() { throw NSError(domain: "NoticeProbe", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) } +} +func run() throws { +let root = FileManager.default.temporaryDirectory.appendingPathComponent("CatalogNotice-" + UUID().uuidString) +try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: root) } +let store = CatalogStore(directory: root) +var catalog = try store.loadOrMigrate() +let game = PortedGame(name: "Generated fixture", url: root.appendingPathComponent("Game.app"), platform: .macApp, sizeBytes: 1, modificationDate: Date()) +catalog.games = [.init(nxnative: game)] +_ = try store.save(catalog, expectedRevision: catalog.revision) +let failing = CatalogStore(directory: root, publication: .init { _, _, _, _ in throw POSIXError(.ENOSPC) }) +let model = AppViewModel(CatalogRepository(store: failing)) +model.reportCatalogFailure(POSIXError(.ENOSPC)) +try require(model.alertMessage == nil, "Catalog failure published legacy alert and can replace its Retry notice") +try require(model.settingsNotice?.retry != nil, "Catalog reload Retry missing") +_ = model.persistBuildOutcome(game) +try require(model.alertMessage == nil, "Build failure published legacy alert and can replace its save Retry") +try require(model.settingsNotice?.retry != nil, "Build save Retry missing") +try require(model.catalogBuildRecoveries[game.id] != nil, "Build recovery missing") +// Deliver a previously queued legacy alert after the retry notice exists. +model.alertMessage = "An earlier, unrelated alert" +let host = LegacyNoticeHost(viewModel: model) +host.deliver() +try require(model.settingsNotice?.retry != nil, "Queued legacy alert replaced the structured save Retry") +try require(model.alertMessage != nil, "Queued legacy alert was lost") +host.dismiss() +try require(model.alertMessage == nil, "Deferred legacy alert was not presented after dismissal") +try require(model.settingsNotice?.retry == nil, "Expected the deferred generic notice after dismissal") +print("Actual catalog helpers and legacy bridge preserve Retry and defer unrelated alerts.") + +} +do { try run() } catch { FileHandle.standardError.write(Data((error.localizedDescription + "\n").utf8)); exit(1) } diff --git a/Tests/CatalogAppChecks/run_game_header.py b/Tests/CatalogAppChecks/run_game_header.py new file mode 100644 index 0000000..5d1653c --- /dev/null +++ b/Tests/CatalogAppChecks/run_game_header.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Evaluate actual common header bodies without a window or app stores.""" +from pathlib import Path +import subprocess,tempfile,os +ROOT=Path(__file__).resolve().parents[2] +BIN=ROOT/'.build/debug' +files=[ROOT/'Sources/NXLibrary/Views/Components/GameLogoArea.swift', ROOT/'Sources/NXLibrary/Views/Components/GamePrimaryActions.swift'] +actual='\n'.join(p.read_text() for p in files) +host='''import SwiftUI +import NXLibraryCore +struct HeaderCanvas { func u(_ value: CGFloat) -> CGFloat { value } } +private struct CanvasKey: EnvironmentKey { static let defaultValue = HeaderCanvas() } +extension EnvironmentValues { var seleneCanvas: HeaderCanvas { get { self[CanvasKey.self] } set { self[CanvasKey.self] = newValue } } } +extension View { func seleneFont(_ size: CGFloat, _ weight: Font.Weight = .regular) -> some View { font(.system(size: size, weight: weight)) } } +enum SeleneTheme { static let ink = Color.white } +struct SelenePillButton: View { + let title: String?; var icon: MaterialIcon? = nil; var iconSize: CGFloat = 26 + var minWidth: CGFloat = 180; var height: CGFloat = 60; var horizontalPadding: CGFloat = 36 + let focused: Bool; var enabled: Bool = true; let action: () -> Void + var body: some View { Color.clear } +} +'''+actual+''' +@main struct Check { + @MainActor static func main() { + for collection in [true,false] { + withExtendedLifetime(GameLogoArea(title: "Fixture", logo: nil, collection: collection).body) {} + } + for enabled in [true,false] { + withExtendedLifetime(GamePrimaryActions(primaryTitle: "Play", secondaryTitle: "Options", isFavorite: true, focused: 0, primaryEnabled: enabled, onAction: {_ in}).body) {} + } + print("Actual shared game header bodies work without injected stores, for both layouts/action states.") + } +} +''' +with tempfile.TemporaryDirectory(prefix='game-header-check-') as folder: + folder=Path(folder);swift=folder/'check.swift';swift.write_text(host);binary=folder/'check' + subprocess.run(['xcrun','swiftc','-parse-as-library','-I',str(BIN),str(swift),str(BIN/'NXLibraryCore.o'),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True,env=dict(os.environ,PACKAGE_RESOURCE_BUNDLE_PATH=str(BIN))) diff --git a/Tests/CatalogAppChecks/run_notices.py b/Tests/CatalogAppChecks/run_notices.py new file mode 100644 index 0000000..293d6ce --- /dev/null +++ b/Tests/CatalogAppChecks/run_notices.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Exercise actual app notice helpers/bridge without constructing SwiftUI or AppKit. + +Run after swift test on macOS. Core I/O uses generated temporary data only. +The host substitutes the surrounding view model, not the methods being checked. +""" +from pathlib import Path +import os +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +BIN = ROOT / '.build/debug' + +def method(source, signature): + start = source.index(signature) + opening = source.index('{', start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + +with tempfile.TemporaryDirectory(prefix='catalog-notice-check-') as scratch: + scratch = Path(scratch) + view = (ROOT / 'Sources/NXLibrary/Views/Shell/AppShellView.swift').read_text() + bridge = '\n'.join(method(view, signature) for signature in ( + ' private func finishSettingsNotice(choice: Int)', ' private func presentLegacyNotice()')) + host = (ROOT / 'Tests/CatalogAppChecks/NoticeHost.swift').read_text() + host += '\nstruct LegacyNoticeHost {\n let viewModel: AppViewModel\n' + host += ' func deliver() { presentLegacyNotice() }\n func dismiss() { finishSettingsNotice(choice: 0) }\n' + host += bridge + '\n}\n' + main = scratch / 'main.swift' + main.write_text(host) + executable = scratch / 'notices' + command = ['xcrun', 'swiftc', '-I', str(BIN), str(main), + str(ROOT / 'Sources/NXLibrary/Models/LogEntry.swift'), + str(ROOT / 'Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift'), + str(BIN / 'NXLibraryCore.o'), '-o', str(executable)] + subprocess.run(command, cwd=ROOT, check=True) + env = dict(os.environ, PACKAGE_RESOURCE_BUNDLE_PATH=str(BIN)) + subprocess.run([str(executable)], env=env, check=True) diff --git a/Tests/NXLibraryCoreTests/AppRouterTests.swift b/Tests/NXLibraryCoreTests/AppRouterTests.swift index 9cc1294..74ab46f 100644 --- a/Tests/NXLibraryCoreTests/AppRouterTests.swift +++ b/Tests/NXLibraryCoreTests/AppRouterTests.swift @@ -78,7 +78,7 @@ final class AppRouterTests: XCTestCase { router.movePowerMenu(by: 1) XCTAssertEqual(router.menuSelection.power, .hide) router.moveMenu(by: -1) - XCTAssertEqual(router.menuSelection.selected, .setupCheck) + XCTAssertEqual(router.menuSelection.selected, .manageSources) } func testCloseMenuClosesPowerPills() { @@ -94,9 +94,9 @@ final class AppRouterTests: XCTestCase { final class MenuSelectionModelTests: XCTestCase { func testMenuOrder() { XCTAssertEqual(MenuAction.allCases, - [.addGame, .refreshLibrary, .settings, .theme, .console, .setupCheck, .power]) + [.addGame, .refreshLibrary, .settings, .theme, .console, .setupCheck, .manageSources, .power]) XCTAssertEqual(MenuAction.allCases.map(\.title), - ["Add Game", "Library refresh", "Settings", "Theme", "Console", "Setup check", "Power"]) + ["Add Game", "Library refresh", "Settings", "Theme", "Console", "Setup check", "Manage sources", "Power"]) XCTAssertEqual(PowerAction.allCases, [.quit, .hide, .relaunch]) XCTAssertEqual(PowerAction.allCases.map(\.title), ["Quit", "Hide", "Restart app"]) } diff --git a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift index 166d311..c70c046 100644 --- a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift +++ b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift @@ -235,11 +235,33 @@ final class SettingsSectionTests: XCTestCase { func testAllSectionsArePresent() { XCTAssertEqual(SettingsSection.allCases.map(\.title), [ "Search", "Changed", "Start-up", "Navigation", "Spotlight", "Sound & music", - "Look", "Games", "Controllers", "Library", "Storage", "Engine", "About" + "Look", "Controllers", "Library", "Games", "Engine", "Library", "Storage", "About" ]) XCTAssertTrue(SettingsSection.allCases.allSatisfy { !$0.icon.rawValue.isEmpty }) } + func testSidebarGroupsKeepSwitchAndSteamApartInScreenOrder() { + XCTAssertEqual(SettingsSidebarGroup.all.map(\.title), [nil, "General", "Switch", "Steam", "System"]) + XCTAssertEqual(SettingsSidebarGroup.all.map { $0.sections.map(\.rawValue) }, [ + ["search", "changed"], + ["startUp", "navigation", "spotlight", "soundMusic", "look", "controllers"], + ["library", "games", "engine"], + ["sources"], + ["storage", "about"], + ]) + // The sidebar's Up/Down follows the groups as drawn, and every section is in exactly one group. + XCTAssertEqual(SettingsSidebarGroup.all.flatMap(\.sections), SettingsSection.allCases) + XCTAssertEqual(SettingsSection.sources.group.title, "Steam") + } + + func testPlatformSectionsAreNamedWithTheirPlatformWhereSearchListsThem() { + XCTAssertEqual(SettingsSection.sources.place, "Steam · Library") + XCTAssertEqual(SettingsSection.library.place, "Switch · Library") + XCTAssertEqual(SettingsSection.games.place, "Switch · Games") + XCTAssertEqual(SettingsSection.look.place, "Look") + XCTAssertEqual(SettingsSection.storage.place, "Storage") + } + func testStartScreenDefaultsAndDecode() throws { let settings = AppSettings() XCTAssertEqual(settings.startOn, .spotlight) diff --git a/Tests/NXLibraryCoreTests/AppUpdateTests.swift b/Tests/NXLibraryCoreTests/AppUpdateTests.swift index c2a2fd2..563ed14 100644 --- a/Tests/NXLibraryCoreTests/AppUpdateTests.swift +++ b/Tests/NXLibraryCoreTests/AppUpdateTests.swift @@ -240,6 +240,16 @@ final class AppUpdateTests: XCTestCase { version: "0.2.0", runningOS: os), .noInfo) } + func testDevelopmentDataMarkerCannotShipAsAnUpdate() throws { + let app = try makeApp("Mixed.app") + let file = app.appendingPathComponent("Contents/Info.plist") + var info = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(contentsOf: file), format: nil) as? [String: Any]) + info["NXLibraryDevelopmentProfile"] = true + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: file) + XCTAssertEqual(AppUpdate.bundleProblem(app, expectedID: "local.nxlibrary", version: "0.2.0", runningOS: os), .notRelease) + XCTAssertEqual(AppUpdate.installability(app), .developer) + } + func testBundleWithoutMarkerIsRefused() throws { let dev = try makeApp("Dev.app", release: false) XCTAssertEqual(AppUpdate.bundleProblem(dev, expectedID: "local.nxlibrary", version: "0.2.0", runningOS: os), diff --git a/Tests/NXLibraryCoreTests/CatalogBuildRecoveryTests.swift b/Tests/NXLibraryCoreTests/CatalogBuildRecoveryTests.swift new file mode 100644 index 0000000..668786c --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogBuildRecoveryTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogBuildRecoveryTests: XCTestCase { + private func generation(_ name: String, basedOn original: PortedGame) -> PortedGame { + var result = original + var record = original.lastBuild! + record.outputPath = "/tmp/" + name + ".app" + result.pendingInstall = PendingInstall(tempApp: URL(fileURLWithPath: "/tmp/held/" + name + ".app"), finalApp: original.url, + record: record, toolchain: original.toolchain) + result.lastBuild = record + return result + } + + func testRetainedNewerBuildSuspendsAutomaticInstallAndExplicitRetryUsesIt() throws { + let a = generation("A", basedOn: CatalogTestSupport.completeLegacyGame()) + let b = generation("B", basedOn: a) + var recoveries = CatalogBuildRecoveries() + recoveries.retain(b) + XCTAssertNil(try recoveries.pendingCandidate(persisted: a, explicitRetry: false)) + var latest = a; latest.isFavorite = false; latest.settings.language = "de" + let candidate = try XCTUnwrap(recoveries.pendingCandidate(persisted: latest, explicitRetry: true)) + XCTAssertEqual(candidate.pendingInstall?.tempApp.path, "/tmp/held/B.app") + XCTAssertEqual(candidate.lastBuild, b.lastBuild) + XCTAssertFalse(candidate.isFavorite) + XCTAssertEqual(candidate.settings.language, "de") + } + + func testCommittingOldGenerationCannotDiscardNewerRecovery() throws { + let a = generation("A", basedOn: CatalogTestSupport.completeLegacyGame()) + let b = generation("B", basedOn: a) + var recoveries = CatalogBuildRecoveries() + recoveries.retain(a); recoveries.retain(b) + recoveries.acknowledge(id: a.id, matching: a) + XCTAssertEqual(recoveries[a.id], b) + recoveries.acknowledge(id: a.id, matching: nil) + XCTAssertEqual(recoveries[a.id], b) + recoveries.acknowledge(id: a.id, matching: b) + XCTAssertNil(recoveries[a.id]) + } + + func testRetainedInstalledResultDoesNotResurrectOlderPendingInstallation() throws { + let a = generation("A", basedOn: CatalogTestSupport.completeLegacyGame()) + var b = generation("B", basedOn: a); b.pendingInstall = nil; b.status = .built + var recoveries = CatalogBuildRecoveries(); recoveries.retain(b) + let candidate = try XCTUnwrap(recoveries.pendingCandidate(persisted: a, explicitRetry: true)) + XCTAssertNil(candidate.pendingInstall) + XCTAssertEqual(candidate.lastBuild, b.lastBuild) + XCTAssertNil(try recoveries.pendingCandidate(persisted: a, explicitRetry: false)) + } +} diff --git a/Tests/NXLibraryCoreTests/CatalogLibraryQueryTests.swift b/Tests/NXLibraryCoreTests/CatalogLibraryQueryTests.swift new file mode 100644 index 0000000..cdabe95 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogLibraryQueryTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogLibraryQueryTests: XCTestCase { + private func games() -> [CatalogGame] { + var nx = CatalogTestSupport.completeLegacyGame(); nx.isHidden = false; nx.isFavorite = false + var steam = SteamCatalogGame(appID: 10, name: "Árena"); steam.hasBeenRecognized = true; steam.isFavorite = true + return [.init(nxnative: nx), .init(steam: steam)] + } + func testMixedProjectionRespectsEnabledHiddenAndRecognizedStates() throws { + var records = games() + let enabled: [CatalogSourceConfiguration] = [.init(integration: .nxnative, isEnabled: true), .init(integration: .steam, isEnabled: true, rootURL: URL(fileURLWithPath: "/tmp/Steam"))] + XCTAssertEqual(CatalogLibraryProjection.visible(games: records, sources: enabled).count, 2) + XCTAssertEqual(CatalogLibraryProjection.visible(games: records, sources: [.init(integration: .nxnative, isEnabled: true)]).count, 1) + records[1].setHidden(true) + XCTAssertEqual(CatalogLibraryProjection.visible(games: records, sources: enabled).count, 1) + records.append(.init(steam: SteamCatalogGame(appID: 20, name: "Unclassified"))) + XCTAssertEqual(CatalogLibraryProjection.visible(games: records, sources: enabled).count, 1) + } + func testSearchAndFavoriteFilterUseSharedCatalogSemantics() { + let records = games() + XCTAssertEqual(LibraryQuery(searchText: "arena").applyCatalog(to: records, history: NoPlayHistory()).map(\.integration), [.steam]) + XCTAssertEqual(LibraryQuery(filter: .favorites).applyCatalog(to: records, history: NoPlayHistory()).map(\.integration), [.steam]) + XCTAssertEqual(LibraryQuery(searchText: "10").applyCatalog(to: records, history: NoPlayHistory()).map(\.integration), [.steam, .nxnative]) // both external IDs contain 10 + } + func testCatalogSelectionAndSpotlightOrderKeepSteamIdentity() { + let records = games() + var selection = LibrarySelectionState(selectedGameID: records[1].id) + _ = selection.reconcileCatalog(with: records, history: NoPlayHistory()) + XCTAssertEqual(selection.selectedGameID, records[1].id) + let row = SpotlightRow.build(gameIDs: records.map(\.id), enabledCards: [], order: [SpotlightItem.game(records[1].id).key]) + XCTAssertEqual(Set(row), Set(records.map { SpotlightItem.game($0.id) })) + _ = selection.reconcileCatalog(with: [records[0]], history: NoPlayHistory()) + XCTAssertEqual(selection.selectedGameID, records[0].id) + } + func testInstalledAndHistoryFiltersDoNotInferSteamPlayFromDiscovery() { + let records = games() + struct History: PlayHistory { + let played: UUID + func lastPlayed(for id: UUID) -> Date? { id == played ? Date(timeIntervalSince1970: 100) : nil } + } + let history = History(played: records[1].id) + XCTAssertEqual(LibraryQuery(filter: .installed).applyCatalog(to: records, history: history).map(\.integration), [.nxnative]) + XCTAssertEqual(LibraryQuery(filter: .recentlyPlayed).applyCatalog(to: records, history: history).map(\.integration), [.steam]) + XCTAssertEqual(LibraryQuery(filter: .notPlayed).applyCatalog(to: records, history: history).map(\.integration), [.nxnative]) + XCTAssertTrue(LibraryQuery(filter: .recentlyPlayed).applyCatalog(to: records, history: NoPlayHistory()).isEmpty) + } + +} diff --git a/Tests/NXLibraryCoreTests/CatalogModelTests.swift b/Tests/NXLibraryCoreTests/CatalogModelTests.swift new file mode 100644 index 0000000..c024ec3 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogModelTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogModelTests: XCTestCase { + func testCompleteLegacyRecordRoundTripsWithoutLosingAnyField() throws { + let original = CatalogTestSupport.completeLegacyGame() + let game = CatalogGame(nxnative: original) + XCTAssertEqual(game.id, original.id) + XCTAssertEqual(game.nxnativeGame, original) + XCTAssertEqual(game.personalizationKey, "0100000000000001") + let data = try JSONEncoder().encode(game) + let loaded = try JSONDecoder().decode(CatalogGame.self, from: data) + XCTAssertEqual(loaded, game) + XCTAssertEqual(loaded.nxnativeGame, original) + XCTAssertNil(loaded.steamGame) + } + + func testLegacyKeyFallbackAndProviderNamespacesRemainDistinct() throws { + var legacy = CatalogTestSupport.completeLegacyGame() + legacy.titleId = nil + legacy.bundleId = "fixture.bundle" + let game = CatalogGame(nxnative: legacy) + XCTAssertEqual(game.personalizationKey, "fixture.bundle") + let steam = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Same name")) + XCTAssertNotEqual(game.personalizationReference.namespace, steam.personalizationReference.namespace) + XCTAssertNotEqual(game.providerIdentity, steam.providerIdentity) + XCTAssertNil(steam.nxnativeGame) + } + + func testCommonUserStateHasOneOwnerAndSurvivesSerialization() throws { + var game = CatalogGame(nxnative: CatalogTestSupport.completeLegacyGame()) + game.setFavorite(false) + game.setHidden(false) + XCTAssertFalse(try XCTUnwrap(game.nxnativeGame).isFavorite) + XCTAssertFalse(try XCTUnwrap(game.nxnativeGame).isHidden) + var steam = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "G")) + steam.setFavorite(true) + steam.setHidden(true) + let result = try JSONDecoder().decode(CatalogGame.self, from: JSONEncoder().encode(steam)) + XCTAssertTrue(result.isFavorite) + XCTAssertTrue(result.isHidden) + XCTAssertEqual(result.id, steam.id) + } + + func testUpdatesRejectIntegrationOrIdentityChangesAndKeepPersonalLookupReference() throws { + let original = CatalogTestSupport.completeLegacyGame() + var game = CatalogGame(nxnative: original) + var update = original + update.titleId = "0100000000000002" + try game.updateNXNative(update) + XCTAssertEqual(game.personalizationKey, "0100000000000001") + XCTAssertEqual(game.nxnativeGame?.titleId, "0100000000000002") + update.id = UUID() + XCTAssertThrowsError(try game.updateNXNative(update)) + var steam = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "G")) + XCTAssertThrowsError(try steam.updateNXNative(original)) + XCTAssertNil(steam.nxnativeGame) + } + + func testSteamDoesNotSupportEngineOrDestructiveOperations() throws { + let game = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Steam game")) + let shown = CatalogGamePresentation(game: game, sourceEnabled: true) + var record = game + XCTAssertThrowsError(try record.updateNXNative(CatalogTestSupport.completeLegacyGame())) { error in + XCTAssertEqual(error as? CatalogModelError, .wrongIntegration) + } + XCTAssertNil(shown.launchTarget) + XCTAssertFalse(shown.isListedNormally) + } + + func testLegacyPresentationPreservesPlaySemanticsWithoutConsultingTheFilesystem() throws { + var original = CatalogTestSupport.completeLegacyGame() + original.isHidden = false + let shown = CatalogGamePresentation(game: CatalogGame(nxnative: original), sourceEnabled: true) + XCTAssertEqual(shown.launchTarget, .nxnative(appURL: original.url)) + XCTAssertTrue(shown.isListedNormally) + XCTAssertEqual(shown.installations.first?.contentURL, original.url) + original.status = .appMissing + XCTAssertNil(CatalogGamePresentation(game: CatalogGame(nxnative: original), sourceEnabled: true).launchTarget) + original.status = .notBuilt + original.url = URL(fileURLWithPath: "/tmp/catalog-fixture/workspace") + XCTAssertTrue(CatalogGamePresentation(game: CatalogGame(nxnative: original), sourceEnabled: true).installations.isEmpty) + } + + func testUnknownIntegrationTagCannotBeDecodedAsAnEmptyOrLegacyGame() throws { + let game = CatalogGame(nxnative: CatalogTestSupport.completeLegacyGame()) + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(game)) as? [String: Any]) + json["integration"] = "unknown-future-source" + let data = try JSONSerialization.data(withJSONObject: json) + XCTAssertThrowsError(try JSONDecoder().decode(CatalogGame.self, from: data)) + } +} diff --git a/Tests/NXLibraryCoreTests/CatalogPersonalizationTests.swift b/Tests/NXLibraryCoreTests/CatalogPersonalizationTests.swift new file mode 100644 index 0000000..adb07e4 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogPersonalizationTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogPersonalizationTests: XCTestCase { + func testPersonalKeySurvivesRenameOfATitleWithoutTitleID() throws { + var payload = CatalogTestSupport.completeLegacyGame() + payload.titleId = nil; payload.bundleId = nil; payload.name = "Old name" + var entry = CatalogGame(nxnative: payload) + payload.name = "New name" + try entry.updateNXNative(payload) + let context = CatalogPersonalizations(document: CatalogDocument(games: [entry])) + XCTAssertEqual(context.nxnativeKey(for: payload), "Old name") + XCTAssertEqual(CatalogPersonalizations.empty.nxnativeKey(for: payload), "New name", "Unpersisted previews retain their existing fallback") + } + + func testSameTextKeyInDifferentIntegrationsCannotOverwritePersonalMetadata() throws { + let root = try SteamTestFixture.temporary(in: self).root + var payload = CatalogTestSupport.completeLegacyGame() + payload.titleId = nil; payload.bundleId = "steam-10" + let nx = CatalogGame(nxnative: payload).personalizationReference + let steam = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Steam")).personalizationReference + let nxStore = MetadataPicks(directory: nx.supportRoot(in: root)) + let steamStore = MetadataPicks(directory: steam.supportRoot(in: root)) + nxStore.set("Switch text", titleID: nx.key, field: .description) + steamStore.set("Steam text", titleID: steam.key, field: .description) + XCTAssertEqual(MetadataPicks(directory: root).value(titleID: "steam-10", field: .description), "Switch text") + XCTAssertEqual(MetadataPicks(directory: steam.supportRoot(in: root)).value(titleID: "steam-10", field: .description), "Steam text") + XCTAssertNotEqual(nx.supportRoot(in: root), steam.supportRoot(in: root)) + } +} diff --git a/Tests/NXLibraryCoreTests/CatalogRecoveryTests.swift b/Tests/NXLibraryCoreTests/CatalogRecoveryTests.swift new file mode 100644 index 0000000..4d3ec17 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogRecoveryTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogRecoveryTests: XCTestCase { + private func prepared() throws -> (URL, CatalogStore, CatalogDocument, Data) { + let root = try SteamTestFixture.temporary(in: self).root + let legacy = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let store = CatalogStore(directory: root) + return (root, store, try store.loadOrMigrate(), legacy) + } + private func file(_ root: URL, _ name: String) -> URL { root.appendingPathComponent(name) } + + func testExplicitRestorePreservesDamagedMainBackupAndOriginalLegacy() throws { + let (root, store, original, legacy) = try prepared() + let backup = try Data(contentsOf: file(root, "catalog.json.bak")) + let damaged = Data("incomplete document".utf8) + try damaged.write(to: file(root, "catalog.json")) + let candidate = try XCTUnwrap(store.recoveryCandidate()) + XCTAssertEqual(candidate.gameCount, 1) + XCTAssertEqual(try Data(contentsOf: file(root, "catalog.json")), damaged, "Offering recovery alone must not repair anything") + let restored = try store.restoreBackup(candidate) + XCTAssertEqual(restored.games, original.games) + XCTAssertNotEqual(restored.revision, original.revision) + XCTAssertEqual(try store.loadOrMigrate(), restored) + let archives = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("catalog.json.recovered-") } + XCTAssertEqual(archives.count, 1) + XCTAssertEqual(try Data(contentsOf: archives[0]), damaged) + XCTAssertEqual(try Data(contentsOf: file(root, "catalog.json.bak")), backup) + XCTAssertEqual(try Data(contentsOf: file(root, "library.json")), legacy) + } + + func testMissingMainCanBeRestoredExplicitlyAndHealthyMainHasNoOffer() throws { + let (root, store, original, _) = try prepared() + XCTAssertNil(try store.recoveryCandidate()) + try FileManager.default.removeItem(at: file(root, "catalog.json")) + let candidate = try XCTUnwrap(store.recoveryCandidate()) + XCTAssertFalse(FileManager.default.fileExists(atPath: file(root, "catalog.json").path)) + XCTAssertEqual(try store.restoreBackup(candidate).games, original.games) + XCTAssertNil(try store.recoveryCandidate()) + } + + func testStaleRecoveryOfferCannotReplaceChangedMainOrBackup() throws { + for changed in ["catalog.json", "catalog.json.bak"] { + let (root, store, _, _) = try prepared() + try Data("damaged".utf8).write(to: file(root, "catalog.json")) + let candidate = try XCTUnwrap(store.recoveryCandidate()) + let later = Data("later edit".utf8) + try later.write(to: file(root, changed)) + XCTAssertThrowsError(try store.restoreBackup(candidate)) { XCTAssertEqual($0 as? CatalogStoreError, .staleRevision) } + XCTAssertEqual(try Data(contentsOf: file(root, changed)), later) + } + } + + func testChangedLegacyAndUnsafeBackupCannotBeRestored() throws { + let (root, store, _, legacy) = try prepared() + try Data("damaged".utf8).write(to: file(root, "catalog.json")) + let candidate = try XCTUnwrap(store.recoveryCandidate()) + try (legacy + Data(" ".utf8)).write(to: file(root, "library.json")) + XCTAssertThrowsError(try store.restoreBackup(candidate)) + XCTAssertThrowsError(try store.recoveryCandidate()) + try FileManager.default.removeItem(at: file(root, "catalog.json.bak")) + try FileManager.default.createSymbolicLink(at: file(root, "catalog.json.bak"), withDestinationURL: file(root, "library.json")) + XCTAssertThrowsError(try store.recoveryCandidate()) + XCTAssertEqual(try Data(contentsOf: file(root, "catalog.json")), Data("damaged".utf8)) + } + + func testRecoveryPublicationFailureLeavesAllInputsAvailable() throws { + let (root, store, _, _) = try prepared() + let damaged = Data("damaged".utf8) + try damaged.write(to: file(root, "catalog.json")) + let backup = try Data(contentsOf: file(root, "catalog.json.bak")) + let candidate = try XCTUnwrap(store.recoveryCandidate()) + let failing = CatalogStore(directory: root, publication: .init { _, _, _, _ in throw POSIXError(.ENOSPC) }) + XCTAssertThrowsError(try failing.restoreBackup(candidate)) + XCTAssertEqual(try Data(contentsOf: file(root, "catalog.json")), damaged) + XCTAssertEqual(try Data(contentsOf: file(root, "catalog.json.bak")), backup) + } + + func testFreshDevCopiesTheMixedCatalogAndReadyDevIsNeverReimported() throws { + let home = try SteamTestFixture.temporary(in: self).root + let profile = AppDataProfile(home: home, isDevelopment: true) + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: profile.stableSupportRoot) + let store = CatalogStore(directory: profile.stableSupportRoot) + var mixed = try store.loadOrMigrate() + mixed.games.append(.init(steam: SteamCatalogGame(appID: 10, name: "Steam game"))) + mixed = try store.save(mixed, expectedRevision: mixed.revision) + let importer = DevelopmentProfileImporter() + XCTAssertTrue(try importer.prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in })) + XCTAssertEqual(try CatalogStore(directory: profile.supportRoot).loadOrMigrate(), mixed) + let sourceBytes = try Data(contentsOf: profile.stableSupportRoot.appendingPathComponent("catalog.json")) + try Data("later source damage".utf8).write(to: profile.stableSupportRoot.appendingPathComponent("catalog.json")) + XCTAssertFalse(try importer.prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in XCTFail() })) + XCTAssertEqual(try Data(contentsOf: profile.supportRoot.appendingPathComponent("catalog.json")), sourceBytes) + } + + func testFreshDevRejectsUnsupportedOrMalformedCatalogBeforeActivation() throws { + for bytes in [Data("{\"schemaVersion\":99}".utf8), Data("{\"valid_json_wrong_document\":true}".utf8)] { + let profile = AppDataProfile(home: try SteamTestFixture.temporary(in: self).root, isDevelopment: true) + try FileManager.default.createDirectory(at: profile.stableSupportRoot, withIntermediateDirectories: true) + try bytes.write(to: profile.stableSupportRoot.appendingPathComponent("catalog.json")) + XCTAssertThrowsError(try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in XCTFail() })) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.path)) + } + } +} diff --git a/Tests/NXLibraryCoreTests/CatalogRepositoryTests.swift b/Tests/NXLibraryCoreTests/CatalogRepositoryTests.swift new file mode 100644 index 0000000..972bc48 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogRepositoryTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogRepositoryTests: XCTestCase { + private func mixed() throws -> (SteamTestFixture, CatalogStore, CatalogDocument) { + let f = try SteamTestFixture.temporary(in: self) + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: f.root) + let store = CatalogStore(directory: f.root) + var document = try store.loadOrMigrate() + var steam = SteamCatalogGame(appID: 10, name: "Fixture Switch game") + steam.isFavorite = true; steam.isHidden = true + document.games.append(.init(steam: steam)) + return (f, store, try store.save(document, expectedRevision: document.revision)) + } + + func testNXNativeUpdateRetainsWholePayloadPersonalReferenceAndSteamRecord() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + let id = original.games[0].id + let updated = try repository.updateNXNative(id: id) { $0.titleId = "0100000000000002"; $0.isFavorite = false } + var expected = try XCTUnwrap(original.games[0].nxnativeGame) + expected.titleId = "0100000000000002"; expected.isFavorite = false + XCTAssertEqual(updated.games[0].nxnativeGame, expected) + XCTAssertEqual(updated.games[0].personalizationReference, original.games[0].personalizationReference) + XCTAssertEqual(updated.games[1], original.games[1]) + XCTAssertEqual(try repository.load(), updated) + } + + func testScanUsesLatestFlagsAndDoesNotReplaceSteamOrLegacyBytes() throws { + let (fixture, store, original) = try mixed() + let bytes = try Data(contentsOf: fixture.root.appendingPathComponent("library.json")) + let repository = CatalogRepository(store: store) + var scanned = try XCTUnwrap(original.games[0].nxnativeGame) + scanned.name = "Renamed"; scanned.isFavorite = true; scanned.isHidden = true + _ = try repository.updateNXNative(id: scanned.id) { $0.isFavorite = false; $0.isHidden = false; $0.appIconOnMac = true } + let result = try repository.mergeNXNative(scanned: [scanned], observedFrom: original.nxnativeGames) + XCTAssertEqual(result.games[0].name, "Renamed") + XCTAssertFalse(result.games[0].isFavorite); XCTAssertFalse(result.games[0].isHidden) + XCTAssertEqual(result.nxnativeGames[0].appIconOnMac, true) + XCTAssertEqual(result.games[1], original.games[1]) + XCTAssertEqual(try Data(contentsOf: fixture.root.appendingPathComponent("library.json")), bytes) + } + + func testWrongIntegrationAndMissingIDRejectBeforeOperationEffects() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + var calls = 0 + XCTAssertThrowsError(try repository.updateNXNative(id: original.games[1].id) { _ in calls += 1 }) + XCTAssertThrowsError(try repository.updateNXNative(id: UUID()) { _ in calls += 1 }) + XCTAssertEqual(calls, 0) + XCTAssertEqual(try repository.load(), original) + } + + func testMutationDoesNotReplayEffectsWhenPublicationFails() throws { + let (fixture, store, original) = try mixed() + let failing = CatalogStore(directory: fixture.root, publication: .init { _, _, _, _ in throw POSIXError(.ENOSPC) }) + let repository = CatalogRepository(store: failing) + var calls = 0 + XCTAssertThrowsError(try repository.updateNXNative(id: original.games[0].id) { game in calls += 1; game.isFavorite = false }) + XCTAssertEqual(calls, 1) + XCTAssertEqual(try store.loadOrMigrate(), original) + } + + func testConcurrentChangeIsNotOverwrittenAndOperationIsNotReplayed() throws { + let (fixture, store, original) = try mixed() + let repository = CatalogRepository(store: store) + var calls = 0 + XCTAssertThrowsError(try repository.updateNXNative(id: original.games[0].id) { game in + calls += 1 + var other = try CatalogStore(directory: fixture.root).loadOrMigrate() + other.games[1].setHidden(false) + _ = try CatalogStore(directory: fixture.root).save(other, expectedRevision: other.revision) + game.isFavorite = false + }) { XCTAssertEqual($0 as? CatalogStoreError, .staleRevision) } + XCTAssertEqual(calls, 1) + let actual = try repository.load() + XCTAssertFalse(actual.games[1].isHidden) + XCTAssertTrue(actual.games[0].isFavorite) + } + + func testSourceLessRecordsUseUUIDAndCannotMergeOnMissingTitleID() throws { + let (_, store, _) = try mixed() + let repository = CatalogRepository(store: store) + var a = CatalogTestSupport.completeLegacyGame(id: UUID()), b = CatalogTestSupport.completeLegacyGame(id: UUID()) + a.titleId = nil; b.titleId = nil + a.name = "A"; b.name = "B" + let added = try repository.mergeNXNative(scanned: [a, b]) + XCTAssertEqual(added.nxnativeGames.count, 3) + a.name = "A changed" + let refreshed = try repository.mergeNXNative(scanned: [a]) + XCTAssertEqual(refreshed.nxnativeGames.count, 3) + XCTAssertEqual(refreshed.nxnativeGames.first { $0.id == a.id }?.name, "A changed") + XCTAssertEqual(refreshed.nxnativeGames.first { $0.id == b.id }?.name, "B") + } + + func testBatchPatchPreservesOtherIntegrationAndRejectsDuplicateIDs() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + let updated = try repository.updateNXNativeGames { $0[0].status = .needsRebuild } + XCTAssertEqual(updated.games[1], original.games[1]) + XCTAssertEqual(updated.nxnativeGames[0].status, .needsRebuild) + XCTAssertThrowsError(try repository.updateNXNativeGames { $0.append($0[0]) }) + XCTAssertEqual(try repository.load(), updated) + } + func testBuildOutcomeUpdatesBuildOwnedFieldsAndKeepsCurrentPersonalAndSourceChoices() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + var outcome = try XCTUnwrap(original.games[0].nxnativeGame) + outcome.url = URL(fileURLWithPath: "/tmp/Finished.app") + outcome.status = .built; outcome.sizeBytes = 9000; outcome.versionString = "3.0" + outcome.appIconPending = true; outcome.pendingInstall = nil + outcome.portingFailure = nil; outcome.portingFailureReason = nil + _ = try repository.updateNXNative(id: outcome.id) { + $0.isFavorite = false; $0.isHidden = false; $0.appIconOnMac = true + $0.settings.language = "fr"; $0.name = "User choice" + } + let updated = try repository.recordNXNativeBuild(outcome) + let game = try XCTUnwrap(updated.games[0].nxnativeGame) + XCTAssertFalse(game.isFavorite); XCTAssertFalse(game.isHidden) + XCTAssertEqual(game.settings.language, "fr"); XCTAssertEqual(game.name, "User choice") + XCTAssertEqual(game.appIconOnMac, true) + XCTAssertEqual(game.url.path, "/tmp/Finished.app"); XCTAssertEqual(game.sizeBytes, 9000) + XCTAssertEqual(game.status, .built); XCTAssertEqual(game.versionString, "3.0") + XCTAssertNil(game.pendingInstall); XCTAssertNil(game.portingFailureReason) + XCTAssertEqual(game.sources, original.nxnativeGames[0].sources) + XCTAssertEqual(updated.games[1], original.games[1]) + } + + func testOldScanCannotResetAnInProgressBuild() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + let id = original.games[0].id + _ = try repository.updateNXNative(id: id) { $0.status = .building } + let refreshed = try repository.mergeNXNative(scanned: original.nxnativeGames, observedFrom: original.nxnativeGames) + XCTAssertEqual(refreshed.nxnativeGames[0].status, .building) + } + + func testOldScanCannotRevertACompletedBuildOrRunItsReconcileCallbackForThatGame() throws { + let (_, store, original) = try mixed() + let repository = CatalogRepository(store: store) + var outcome = original.nxnativeGames[0] + outcome.modificationDate = Date(timeIntervalSince1970: 1000) + outcome.versionString = "3.0"; outcome.sizeBytes = 9000 + outcome.pendingInstall = nil; outcome.status = .built + outcome.lastBuild = BuildRecord(startedAt: Date(timeIntervalSince1970: 900), endedAt: Date(timeIntervalSince1970: 1000), exitCode: 0) + _ = try repository.recordNXNativeBuild(outcome) + var accepted = Set() + let refreshed = try repository.mergeNXNative(scanned: original.nxnativeGames, observedFrom: original.nxnativeGames) { games, ids in + accepted = ids + for index in games.indices where ids.contains(games[index].id) { games[index].sizeBytes = 1234 } + } + XCTAssertFalse(accepted.contains(outcome.id)) + XCTAssertEqual(refreshed.nxnativeGames[0].versionString, "3.0") + XCTAssertEqual(refreshed.nxnativeGames[0].sizeBytes, 9000) + XCTAssertEqual(refreshed.nxnativeGames[0].modificationDate, Date(timeIntervalSince1970: 1000)) + XCTAssertEqual(refreshed.nxnativeGames[0].lastBuild, outcome.lastBuild) + } + +} diff --git a/Tests/NXLibraryCoreTests/CatalogSourcesModelTests.swift b/Tests/NXLibraryCoreTests/CatalogSourcesModelTests.swift new file mode 100644 index 0000000..61560c3 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogSourcesModelTests.swift @@ -0,0 +1,136 @@ +import XCTest +import Combine +@testable import NXLibraryCore + +@MainActor +final class CatalogSourcesModelTests: XCTestCase { + private func prepared() throws -> (SteamTestFixture, CatalogRepository, CatalogSourceConfiguration) { + let f = try SteamTestFixture.temporary(in: self) + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: f.root) + let repository = CatalogRepository(store: CatalogStore(directory: f.root)) + return (f, repository, try repository.load().source(.steam)!) + } + private func snapshot(_ root: URL, id: UInt32 = 10, partial: Bool = false) -> SteamLibrarySnapshot { + let content = root.appendingPathComponent("steamapps/common/Game") + let app = content.appendingPathComponent("Game.app") + let native = NativeMacApplication(url: app, executableURL: app.appendingPathComponent("Contents/MacOS/game"), + bundleIdentifier: "test.steam", name: "Steam game", architectures: [.arm64], + isDeclaredGame: true, fileObservations: []) + return .init(libraries: [.init(root: root, isReadable: true, isComplete: !partial)], + games: [.init(appID: id, name: "Steam game", installationURL: content, libraryURL: root, + reportedSizeBytes: 1, buildID: nil, nativeApps: [native], state: .nativeReady)], + issues: partial ? [.init(url: root, appID: nil, reason: .invalidManifest)] : []) + } + func testDisabledInitializationDoesNotScanAndEnableCommitsDiscovery() async throws { + let (f, repository, initial) = try prepared() + let done = expectation(description: "discovered") + let calls = SourceCallCounter() + let scan = snapshot(f.root) + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { _, _ in calls.increment(); return scan }) { doc in + if doc.games.contains(where: { $0.steamGame != nil }) { done.fulfill() } + } + XCTAssertEqual(calls.value, 0); XCTAssertEqual(model.status, .disabled) + model.configure(enabled: true, root: f.root) + await fulfillment(of: [done], timeout: 3) + XCTAssertEqual(calls.value, 1); XCTAssertEqual(model.status, .ready) + XCTAssertEqual(model.observedGameCount, 1) + XCTAssertEqual(try repository.load().source(.steam)?.isEnabled, true) + } + func testDisableCancelsAndIgnoredWorkerCancellationStillCannotCommit() async throws { + let (f, repository, initial) = try prepared() + let entered = expectation(description: "worker") + let gate = DispatchSemaphore(value: 0) + let scan = snapshot(f.root) + let rejected = expectation(description: "must not discover"); rejected.isInverted = true + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { _, _ in + entered.fulfill(); _ = gate.wait(timeout: .now() + 2); return scan + }) { if $0.games.contains(where: { $0.steamGame != nil }) { rejected.fulfill() } } + model.configure(enabled: true, root: f.root) + await fulfillment(of: [entered], timeout: 3) + model.configure(enabled: false, root: nil) + gate.signal() + await fulfillment(of: [rejected], timeout: 0.15) + XCTAssertEqual(model.status, .disabled) + XCTAssertEqual(try repository.load().games.count, 1) + } + func testCancelKeepsOptInAndExistingRecords() async throws { + let (f, repository, initial) = try prepared() + let entered = expectation(description: "worker") + let gate = DispatchSemaphore(value: 0) + let scan = snapshot(f.root) + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { _, _ in + entered.fulfill(); _ = gate.wait(timeout: .now() + 2); return scan + }, onCommit: { _ in }) + model.configure(enabled: true, root: f.root) + await fulfillment(of: [entered], timeout: 3) + model.cancel(); gate.signal() + XCTAssertEqual(model.status, .cancelled) + XCTAssertEqual(try repository.load().source(.steam)?.isEnabled, true) + XCTAssertEqual(try repository.load().games.count, 1) + } + func testNewRootWinsWhenOldWorkerReturnsLater() async throws { + let (f, repository, initial) = try prepared() + let entered = expectation(description: "old worker"), done = expectation(description: "new worker") + let gate = DispatchSemaphore(value: 0), other = f.root.appendingPathComponent("Other") + let oldCommitted = expectation(description: "old source must not commit"); oldCommitted.isInverted = true + let old = snapshot(f.root, id: 10), new = snapshot(other, id: 20) + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { root, _ in + if root.path == f.root.path { entered.fulfill(); _ = gate.wait(timeout: .now() + 2); return old } + return new + }) { + if $0.games.contains(where: { $0.steamGame?.appID == 20 }) { done.fulfill() } + if $0.games.contains(where: { $0.steamGame?.appID == 10 }) { oldCommitted.fulfill() } + } + model.configure(enabled: true, root: f.root) + await fulfillment(of: [entered], timeout: 3) + model.configure(enabled: true, root: other) + await fulfillment(of: [done], timeout: 3) + gate.signal() + await fulfillment(of: [oldCommitted], timeout: 0.15) + XCTAssertEqual(try repository.load().games.compactMap { $0.steamGame?.appID }, [20]) + XCTAssertEqual(model.configuration.rootURL?.path, other.path) + XCTAssertEqual(model.status, .ready) + } + func testFavoriteChangeWhileWorkerRunsIsRetained() async throws { + let (f, repository, initial) = try prepared() + let entered = expectation(description: "worker"), done = expectation(description: "committed") + let gate = DispatchSemaphore(value: 0), scan = snapshot(f.root) + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { _, _ in + entered.fulfill(); _ = gate.wait(timeout: .now() + 2); return scan + }) { if $0.games.contains(where: { $0.steamGame != nil }) { done.fulfill() } } + model.configure(enabled: true, root: f.root) + await fulfillment(of: [entered], timeout: 3) + let id = try repository.load().games[0].id + _ = try repository.setFavorite(false, for: id) + gate.signal() + await fulfillment(of: [done], timeout: 3) + XCTAssertFalse(try repository.load().games[0].isFavorite) + XCTAssertEqual(model.status, .ready) + } + func testFailedSourceCanRefreshIntoPartialSnapshotWithoutDroppingKnownData() async throws { + let (f, repository, initial) = try prepared() + let failed = expectation(description: "failure"), partial = expectation(description: "partial") + let counter = SourceCallCounter(), scan = snapshot(f.root, partial: true) + let model = CatalogSourcesModel(repository: repository, initialConfiguration: initial, scanner: { _, _ in + if counter.increment() == 1 { throw POSIXError(.EACCES) }; return scan + }, onCommit: { _ in }) + let observation = model.$status.sink { status in + if status == .failed { failed.fulfill() }; if status == .partial { partial.fulfill() } + } + model.configure(enabled: true, root: f.root) + await fulfillment(of: [failed], timeout: 3) + XCTAssertEqual(try repository.load().games.count, 1) + model.refresh() + await fulfillment(of: [partial], timeout: 3) + XCTAssertEqual(try repository.load().games.count, 2) + XCTAssertEqual(model.issues.count, 1) + withExtendedLifetime(observation) {} + } +} + +private final class SourceCallCounter: @unchecked Sendable { + private let lock = NSLock() + private var stored = 0 + @discardableResult func increment() -> Int { lock.lock(); defer { lock.unlock() }; stored += 1; return stored } + var value: Int { lock.lock(); defer { lock.unlock() }; return stored } +} diff --git a/Tests/NXLibraryCoreTests/CatalogSourcesRepositoryTests.swift b/Tests/NXLibraryCoreTests/CatalogSourcesRepositoryTests.swift new file mode 100644 index 0000000..f94244b --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogSourcesRepositoryTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import NXLibraryCore + +final class CatalogSourcesRepositoryTests: XCTestCase { + private func prepared() throws -> (SteamTestFixture, CatalogRepository) { + let fixture = try SteamTestFixture.temporary(in: self) + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: fixture.root) + return (fixture, CatalogRepository(store: CatalogStore(directory: fixture.root))) + } + private func snapshot(_ root: URL) -> SteamLibrarySnapshot { + let content = root.appendingPathComponent("steamapps/common/Fixture") + let app = content.appendingPathComponent("Fixture.app") + let native = NativeMacApplication(url: app, executableURL: app.appendingPathComponent("Contents/MacOS/game"), + bundleIdentifier: "test.steam", name: "Fixture Steam", architectures: [.arm64], + isDeclaredGame: true, fileObservations: []) + return .init(libraries: [.init(root: root, isReadable: true, isComplete: true)], + games: [.init(appID: 10, name: "Fixture Steam", installationURL: content, libraryURL: root, + reportedSizeBytes: 200, buildID: "1", nativeApps: [native], state: .nativeReady)], issues: []) + } + + func testOldSchemaOneWithoutSourceGenerationDecodesDeterministically() throws { + let (_, repository) = try prepared() + let document = try repository.load() + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(document)) as? [String: Any]) + var sources = try XCTUnwrap(json["sources"] as? [[String: Any]]) + for index in sources.indices { sources[index].removeValue(forKey: "generation") } + json["sources"] = sources + let old = try JSONSerialization.data(withJSONObject: json) + let a = try CatalogStore.decode(old), b = try CatalogStore.decode(old) + XCTAssertEqual(a, b) + XCTAssertEqual(try CatalogStore.decode(JSONEncoder().encode(a)), a) + XCTAssertEqual(a.games, document.games) + } + + func testPersonalEditsDuringAScanArePreservedWhenSnapshotCommits() throws { + let (fixture, repository) = try prepared() + let enabled = try repository.configureSource(.steam, enabled: true, root: fixture.root) + let source = try XCTUnwrap(enabled.source(.steam)) + _ = try repository.setFavorite(false, for: enabled.games[0].id) + let merged = try repository.applySteam(snapshot(fixture.root), expectedSource: source) + XCTAssertFalse(merged.games[0].isFavorite) + XCTAssertEqual(merged.games.count, 2) + XCTAssertEqual(merged.games[1].steamGame?.appID, 10) + let id = merged.games[1].id + _ = try repository.setFavorite(true, for: id) + _ = try repository.setHidden(true, for: id) + let again = try repository.applySteam(snapshot(fixture.root), expectedSource: source) + XCTAssertEqual(again.games[1].id, id) + XCTAssertTrue(again.games[1].isFavorite); XCTAssertTrue(again.games[1].isHidden) + } + + func testDisableReenableAtSameRootInvalidatesOlderSourceGeneration() throws { + let (fixture, repository) = try prepared() + let initial = try repository.configureSource(.steam, enabled: true, root: fixture.root) + let old = try XCTUnwrap(initial.source(.steam)) + _ = try repository.configureSource(.steam, enabled: false, root: fixture.root) + let enabled = try repository.configureSource(.steam, enabled: true, root: fixture.root) + XCTAssertNotEqual(enabled.source(.steam)?.generation, old.generation) + XCTAssertThrowsError(try repository.applySteam(snapshot(fixture.root), expectedSource: old)) + XCTAssertEqual(try repository.load(), enabled) + } + + func testChangingRootOrDisablingNeverAppliesOldSnapshot() throws { + let (fixture, repository) = try prepared() + let initial = try repository.configureSource(.steam, enabled: true, root: fixture.root) + let source = try XCTUnwrap(initial.source(.steam)) + _ = try repository.configureSource(.steam, enabled: true, root: fixture.root.appendingPathComponent("Other")) + XCTAssertThrowsError(try repository.applySteam(snapshot(fixture.root), expectedSource: source)) + _ = try repository.configureSource(.steam, enabled: false, root: fixture.root) + XCTAssertThrowsError(try repository.applySteam(snapshot(fixture.root), expectedSource: source)) + XCTAssertEqual(try repository.load().games.count, 1) + } + func testExplicitNXAdoptionEnablesItsExperienceWhilePassiveScanStaysDisabled() throws { + let root = try SteamTestFixture.temporary(in: self).root + let repository = CatalogRepository(store: CatalogStore(directory: root)) + let game = CatalogTestSupport.completeLegacyGame() + XCTAssertFalse(try repository.load().source(.nxnative)!.isEnabled) + XCTAssertThrowsError(try repository.mergeNXNative(scanned: [game])) + let adopted = try repository.mergeNXNative(scanned: [game], enableSource: true) + XCTAssertTrue(adopted.source(.nxnative)!.isEnabled) + XCTAssertEqual(adopted.games.first?.id, game.id) + XCTAssertFalse(adopted.source(.steam)!.isEnabled) + } + + func testRepeatedFavoriteCommandUsesLatestPersistedValue() throws { + let (fixture, repository) = try prepared() + let enabled = try repository.configureSource(.steam, enabled: true, root: fixture.root) + let scanned = try repository.applySteam(snapshot(fixture.root), expectedSource: enabled.source(.steam)!) + let captured = try XCTUnwrap(scanned.games.first { $0.integration == .steam }) + XCTAssertFalse(captured.isFavorite) + // The keyboard can retain one Details view/ID across both commands. + let favorite = try repository.toggleFavorite(for: captured.id) + XCTAssertTrue(favorite.games.first { $0.id == captured.id }!.isFavorite) + let unfavorite = try repository.toggleFavorite(for: captured.id) + XCTAssertFalse(unfavorite.games.first { $0.id == captured.id }!.isFavorite) + XCTAssertEqual(try repository.load(), unfavorite) + XCTAssertEqual(unfavorite.source(.steam), scanned.source(.steam)) + } + +} diff --git a/Tests/NXLibraryCoreTests/CatalogStoreTests.swift b/Tests/NXLibraryCoreTests/CatalogStoreTests.swift new file mode 100644 index 0000000..25b3491 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogStoreTests.swift @@ -0,0 +1,237 @@ +import XCTest +import Darwin +@testable import NXLibraryCore + +final class CatalogStoreTests: XCTestCase { + private func fixture() throws -> SteamTestFixture { try .temporary(in: self) } + private func url(_ root: URL, _ name: String) -> URL { root.appendingPathComponent(name) } + private func assertNoCatalog(_ root: URL, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertFalse(FileManager.default.fileExists(atPath: url(root, "catalog.json").path), file: file, line: line) + } + + func testMigrationPreservesAllLegacyFieldsKeysAndBytesAndIsNotRepeated() throws { + let root = try fixture().root + let original = CatalogTestSupport.completeLegacyGame() + let bytes = try CatalogTestSupport.writeLegacy([original], to: root) + let store = CatalogStore(directory: root) + let migrated = try store.loadOrMigrate() + XCTAssertEqual(migrated.games.map(\.nxnativeGame), [original]) + XCTAssertEqual(migrated.games[0].personalizationKey, "0100000000000001") + XCTAssertEqual(migrated.source(.nxnative)?.isEnabled, true) + XCTAssertEqual(migrated.source(.steam)?.isEnabled, false) + var draft = migrated + draft.games[0].setFavorite(false) + let saved = try store.save(draft, expectedRevision: migrated.revision) + XCTAssertNotEqual(saved.revision, migrated.revision) + XCTAssertEqual(try store.loadOrMigrate(), saved) + XCTAssertFalse(saved.games[0].isFavorite) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json")), bytes) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json.bak"))), migrated) + } + + func testNewProfileDoesNotEnableAnIntegration() throws { + let root = try fixture().root + let result = try CatalogStore(directory: root).loadOrMigrate() + XCTAssertTrue(result.games.isEmpty) + XCTAssertEqual(Set(result.sources.map(\.integration)), [.steam, .nxnative]) + XCTAssertTrue(result.sources.allSatisfy { !$0.isEnabled }) + XCTAssertFalse(FileManager.default.fileExists(atPath: url(root, "library.json").path)) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json.bak"))), result) + } + + func testOnlyAbsentLegacyMainAllowsItsValidBackup() throws { + let root = try fixture().root + let original = CatalogTestSupport.completeLegacyGame() + let bytes = try CatalogTestSupport.writeLegacy([original], to: root) + try FileManager.default.moveItem(at: url(root, "library.json"), to: url(root, "library.json.bak")) + let store = CatalogStore(directory: root) + let result = try store.loadOrMigrate() + XCTAssertEqual(result.games.first?.nxnativeGame, original) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json.bak")), bytes) + try bytes.write(to: url(root, "library.json")) + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .legacyChanged) } + } + + func testCorruptLegacyMainDoesNotSilentlyUseGoodBackup() throws { + let root = try fixture().root + let bytes = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + try bytes.write(to: url(root, "library.json.bak")) + let corrupt = Data("not-json".utf8) + try corrupt.write(to: url(root, "library.json")) + XCTAssertThrowsError(try CatalogStore(directory: root).loadOrMigrate()) + assertNoCatalog(root) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json")), corrupt) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json.bak")), bytes) + } + + func testUnknownLegacySchemaAndDuplicateUUIDsAreRejected() throws { + for (schema, count) in [(99, 1), (1, 2)] { + let root = try fixture().root + let bytes = try CatalogTestSupport.writeLegacy(Array(repeating: CatalogTestSupport.completeLegacyGame(), count: count), to: root, schemaVersion: schema) + XCTAssertThrowsError(try CatalogStore(directory: root).loadOrMigrate()) + assertNoCatalog(root) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json")), bytes) + } + } + + func testLegacySymlinkAndFIFOAreRejectedWithoutFollowingOrBlocking() throws { + for special in ["symlink", "fifo"] { + let root = try fixture().root + let target = url(root, "library.json") + if special == "symlink" { + let external = url(root, "external.json") + try Data("untouched".utf8).write(to: external) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: external) + } else { XCTAssertEqual(mkfifo(target.path, 0o600), 0) } + XCTAssertThrowsError(try CatalogStore(directory: root).loadOrMigrate()) + assertNoCatalog(root) + } + } + + func testOversizedLegacyFileIsRejected() throws { + let root = try fixture().root + try Data(repeating: 0x20, count: 16 * 1024 * 1024 + 1).write(to: url(root, "library.json")) + XCTAssertThrowsError(try CatalogStore(directory: root).loadOrMigrate()) + assertNoCatalog(root) + } + + func testChangedLegacyMainBlocksReadsAndWritesButBackupRotationDoesNot() throws { + let root = try fixture().root + let bytes = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let store = CatalogStore(directory: root) + let first = try store.loadOrMigrate() + try Data("an obsolete backup".utf8).write(to: url(root, "library.json.bak")) + XCTAssertEqual(try store.loadOrMigrate(), first) + try (bytes + Data(" ".utf8)).write(to: url(root, "library.json")) + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .legacyChanged) } + XCTAssertThrowsError(try store.save(first, expectedRevision: first.revision)) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json"))), first) + } + + func testNewLegacyFileAfterEmptyMigrationCannotBeIgnored() throws { + let root = try fixture().root + let store = CatalogStore(directory: root) + _ = try store.loadOrMigrate() + _ = try CatalogTestSupport.writeLegacy([], to: root) + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .legacyChanged) } + } + + func testTwoStoresCannotSaveTheSameRevisionTwice() throws { + let root = try fixture().root + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let a = CatalogStore(directory: root), b = CatalogStore(directory: root) + let old = try a.loadOrMigrate() + var draft = old + draft.games[0].setHidden(false) + let committed = try a.save(draft, expectedRevision: old.revision) + XCTAssertThrowsError(try b.save(old, expectedRevision: old.revision)) { XCTAssertEqual($0 as? CatalogStoreError, .staleRevision) } + XCTAssertEqual(try b.loadOrMigrate(), committed) + } + + func testAWriterHoldingTheLockRefusesAnotherWriterInsteadOfWaiting() throws { + let root = try fixture().root + let store = CatalogStore(directory: root) + let initial = try store.loadOrMigrate() + let competing = CatalogStore(directory: root, publication: .init { staged, target, replace, validate in + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .busy) } + try CatalogPublication.atomic.publish(staged, target, replace, validate) + }) + _ = try competing.save(initial, expectedRevision: initial.revision) + } + + func testPublicationFailurePreservesReadableMainBackupAndLegacyBytes() throws { + let root = try fixture().root + let bytes = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let initial = try CatalogStore(directory: root).loadOrMigrate() + let failing = CatalogStore(directory: root, publication: .init { _, _, _, _ in throw POSIXError(.ENOSPC) }) + XCTAssertThrowsError(try failing.save(initial, expectedRevision: initial.revision)) + XCTAssertEqual(try CatalogStore(directory: root).loadOrMigrate(), initial) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json.bak"))), initial) + XCTAssertEqual(try Data(contentsOf: url(root, "library.json")), bytes) + XCTAssertFalse(try FileManager.default.contentsOfDirectory(atPath: root.path).contains { $0.hasPrefix(".catalog-stage-") }) + } + + func testLegacyChangeDuringPublicationRejectsThePreparedCandidate() throws { + let root = try fixture().root + let legacy = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let initial = try CatalogStore(directory: root).loadOrMigrate() + let changing = CatalogStore(directory: root, publication: .init { staged, target, replace, validate in + try (legacy + Data(" ".utf8)).write(to: self.url(root, "library.json")) + try CatalogPublication.atomic.publish(staged, target, replace, validate) + }) + XCTAssertThrowsError(try changing.save(initial, expectedRevision: initial.revision)) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json"))), initial) + } + + func testDamagedOrUnsupportedCatalogNeverActivatesEmptyOrOverwritesGoodBackup() throws { + for replacement in [Data("broken".utf8), Data("{\"schemaVersion\":99}".utf8)] { + let root = try fixture().root + let store = CatalogStore(directory: root) + let initial = try store.loadOrMigrate() + let backup = try Data(contentsOf: url(root, "catalog.json.bak")) + try replacement.write(to: url(root, "catalog.json")) + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .invalidCatalog(backupAvailable: true)) } + XCTAssertThrowsError(try store.save(initial, expectedRevision: initial.revision)) + XCTAssertEqual(try Data(contentsOf: url(root, "catalog.json.bak")), backup) + XCTAssertEqual(try Data(contentsOf: url(root, "catalog.json")), replacement) + } + } + + func testMissingCatalogWithValidBackupRequiresRecoveryRatherThanNewMigration() throws { + let root = try fixture().root + let store = CatalogStore(directory: root) + _ = try store.loadOrMigrate() + try FileManager.default.removeItem(at: url(root, "catalog.json")) + XCTAssertThrowsError(try store.loadOrMigrate()) { XCTAssertEqual($0 as? CatalogStoreError, .invalidCatalog(backupAvailable: true)) } + assertNoCatalog(root) + } + + func testInvalidDraftCannotReplaceTheValidCatalog() throws { + let root = try fixture().root + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let store = CatalogStore(directory: root) + let initial = try store.loadOrMigrate() + var duplicate = initial + duplicate.games.append(initial.games[0]) + XCTAssertThrowsError(try store.save(duplicate, expectedRevision: initial.revision)) + var invalidSource = initial + invalidSource.sources = [.init(integration: .steam, isEnabled: true)] + XCTAssertThrowsError(try store.save(invalidSource, expectedRevision: initial.revision)) + XCTAssertEqual(try store.loadOrMigrate(), initial) + } + + func testFractionalDatesSurviveNewCatalogWrites() throws { + let root = try fixture().root + let store = CatalogStore(directory: root) + var draft = try store.loadOrMigrate() + var legacy = CatalogTestSupport.completeLegacyGame() + legacy.modificationDate = Date(timeIntervalSinceReferenceDate: 12345.678901) + draft.games = [.init(nxnative: legacy)] + let saved = try store.save(draft, expectedRevision: draft.revision) + XCTAssertEqual(try store.loadOrMigrate().games[0].nxnativeGame?.modificationDate, legacy.modificationDate) + XCTAssertEqual(saved.games[0].nxnativeGame, legacy) + } + func testFinalDirectorySyncFailureReportsUncertainCommitAndAllowsExplicitReload() throws { + let root = try fixture().root + _ = try CatalogTestSupport.writeLegacy([CatalogTestSupport.completeLegacyGame()], to: root) + let store = CatalogStore(directory: root) + let initial = try store.loadOrMigrate() + var draft = initial + draft.games[0].setFavorite(false) + let failing = CatalogStore(directory: root, publication: .init(syncDirectory: { _ in throw POSIXError(.EIO) }, + publish: CatalogPublication.atomic.publish)) + XCTAssertThrowsError(try failing.save(draft, expectedRevision: initial.revision)) { + XCTAssertEqual($0 as? CatalogStoreError, .commitUncertain) + } + // Publication already happened; the caller must reload, not assume + // the old revision still owns the file or retry as an empty library. + let reloaded = try store.loadOrMigrate() + XCTAssertNotEqual(reloaded.revision, initial.revision) + XCTAssertFalse(reloaded.games[0].isFavorite) + XCTAssertEqual(try CatalogStore.decode(Data(contentsOf: url(root, "catalog.json.bak"))), initial) + XCTAssertThrowsError(try store.save(draft, expectedRevision: initial.revision)) { + XCTAssertEqual($0 as? CatalogStoreError, .staleRevision) + } + } + +} diff --git a/Tests/NXLibraryCoreTests/CatalogTestSupport.swift b/Tests/NXLibraryCoreTests/CatalogTestSupport.swift new file mode 100644 index 0000000..1e46287 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogTestSupport.swift @@ -0,0 +1,41 @@ +import Foundation +import XCTest +@testable import NXLibraryCore + +enum CatalogTestSupport { + static func completeLegacyGame(id: UUID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!) -> PortedGame { + var game = PortedGame(id: id, name: "Fixture Switch game", url: URL(fileURLWithPath: "/tmp/catalog-fixture/Game.app"), + platform: .macApp, sizeBytes: 1234, modificationDate: Date(timeIntervalSince1970: 100), + bundleId: "org.nxnative.0100000000000001", titleId: "0100000000000001", versionString: "2.0") + game.status = .builtSourcesMissing + game.sources = [GameFileItem(url: URL(fileURLWithPath: "/tmp/catalog-fixture/base.nsp"), kind: .base, + titleId: "0100000000000001", sizeBytes: 456)] + game.workspacePath = "/tmp/catalog-fixture/workspace" + game.buildCachePath = "/tmp/catalog-fixture/workspace/cache" + game.settings.language = "it" + game.settings.metalHUD = true + game.toolchain = ToolchainInfo(engineCommit: "fixture123", metalVersion: "32023.922", xcodeVersion: "27.2", verified: true) + game.lastBuild = BuildRecord(startedAt: Date(timeIntervalSince1970: 10), endedAt: Date(timeIntervalSince1970: 20), + exitCode: 0, outputPath: game.url.path, toolchainVerified: true) + game.pendingInstall = PendingInstall(tempApp: URL(fileURLWithPath: "/tmp/catalog-fixture/held/Game.app"), finalApp: game.url, + record: game.lastBuild!, toolchain: game.toolchain) + game.portingFailure = .failed + game.portingFailureReason = PortingFailureReason(kind: .diskFull, detail: "Synthetic failure") + game.isFavorite = true + game.isHidden = true + game.usesPickedAppIcon = true + game.appIconOnMac = false + game.appIconPending = true + return game + } + + static func writeLegacy(_ games: [PortedGame], to directory: URL, schemaVersion: Int = 1) throws -> Data { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(LibraryFile(schemaVersion: schemaVersion, games: games)) + try data.write(to: directory.appendingPathComponent("library.json")) + return data + } +} diff --git a/Tests/NXLibraryCoreTests/DevelopmentProfileEffectTests.swift b/Tests/NXLibraryCoreTests/DevelopmentProfileEffectTests.swift new file mode 100644 index 0000000..36bbeac --- /dev/null +++ b/Tests/NXLibraryCoreTests/DevelopmentProfileEffectTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import NXLibraryCore + +final class DevelopmentProfileEffectTests: XCTestCase { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("NXEffects-\(UUID())") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + func testAutomaticModSyncCannotOverwriteRealOverlayFromCopiedRecords() throws { + let root = try root() + let game = root.appendingPathComponent("real-game") + try FileManager.default.createDirectory(at: game.appendingPathComponent("mods/Foo/romfs"), withIntermediateDirectories: true) + let env = game.appendingPathComponent("env.txt") + let original = "DM_ROMFS_OVERLAY=/current-stable-choice\n" + try Data(original.utf8).write(to: env) + let records = root.appendingPathComponent("development-records.json") + try JSONEncoder().encode([ModRecord(folderName: "Foo", isOn: true)]).write(to: records) + let store = ModStore(supportFolder: game, recordsFile: records, titleID: "0100000000000000", canWrite: { true }, trash: { _ in XCTFail() }, allowsAutomaticMaintenance: false) + try store.sync() + XCTAssertEqual(try String(contentsOf: env), original) + try store.sync(automatic: false) + XCTAssertNotEqual(try String(contentsOf: env), original, "Explicit changes must remain possible") + } + func testBrowsingUsesActualOverlayAndKeepsOtherProfilesInstallStaging() throws { + let root = try root() + let game = root.appendingPathComponent("game") + for name in ["Foo", "Bar", ".installing-other-profile"] { + try FileManager.default.createDirectory(at: game.appendingPathComponent("mods/" + name + "/romfs"), withIntermediateDirectories: true) + } + let env = game.appendingPathComponent("env.txt") + try Data(("DM_ROMFS_OVERLAY=" + game.appendingPathComponent("mods/Bar/romfs").path + "\n").utf8).write(to: env) + let records = root.appendingPathComponent("copied-records.json") + let original = try JSONEncoder().encode([ModRecord(folderName: "Foo", isOn: true), ModRecord(folderName: "Bar", isOn: false)]) + try original.write(to: records) + let store = ModStore(supportFolder: game, recordsFile: records, titleID: "0100000000000000", canWrite: { true }, trash: { _ in XCTFail() }, allowsAutomaticMaintenance: false) + XCTAssertEqual(store.mods().filter(\.isOn).map(\.folderName), ["Bar"]) + store.removeLeftoverInstalls() + XCTAssertTrue(FileManager.default.fileExists(atPath: game.appendingPathComponent("mods/.installing-other-profile").path)) + XCTAssertEqual(try Data(contentsOf: records), original) + } + + func testDevelopmentStartupDoesNotSweepSharedStaging() throws { + let home = try root() + let folder = home.appendingPathComponent("Games") + let dev = AppDataProfile(home: home, isDevelopment: true) + XCTAssertEqual(dev.automaticStagingFolders(appFolders: [folder]), [dev.supportRoot.appendingPathComponent("Temp")]) + let stable = AppDataProfile(home: home, isDevelopment: false) + XCTAssertTrue(stable.automaticStagingFolders(appFolders: [folder]).contains(folder.appendingPathComponent(".nxlibrary-build/Temp"))) + } + func testOnlyExplicitDevelopmentOperationsSurviveRestart() throws { + let root = try root() + let file = root.appendingPathComponent("operations.json") + let imported = UUID(), requested = UUID() + let first = DevelopmentOperationStore(file: file, isDevelopment: true) + XCTAssertFalse(first.permits(.build, gameID: imported)) + try first.authorize(.build, gameID: requested) + try first.authorize(.icon, gameID: requested) + let restarted = DevelopmentOperationStore(file: file, isDevelopment: true) + XCTAssertTrue(restarted.permits(.build, gameID: requested)) + XCTAssertTrue(restarted.permits(.icon, gameID: requested)) + XCTAssertFalse(restarted.permits(.build, gameID: imported)) + XCTAssertFalse(restarted.permits(.icon, gameID: imported)) + } + func testCorruptAuthorizationFailsClosedWithoutReplacingIt() throws { + let file = try root().appendingPathComponent("operations.json") + try Data("broken".utf8).write(to: file) + let store = DevelopmentOperationStore(file: file, isDevelopment: true) + XCTAssertFalse(store.permits(.icon, gameID: UUID())) + XCTAssertThrowsError(try store.authorize(.icon, gameID: UUID())) + XCTAssertEqual(try String(contentsOf: file), "broken") + } +} diff --git a/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift b/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift new file mode 100644 index 0000000..d2ed138 --- /dev/null +++ b/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import NXLibraryCore + +final class DevelopmentProfileTests: XCTestCase { + private func fixture() throws -> (URL, AppDataProfile) { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("NXProfile-\(UUID())") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: home) } + return (home, AppDataProfile(home: home, isDevelopment: true)) + } + private func put(_ data: String, _ name: String, profile: AppDataProfile) throws { + let file = profile.stableSupportRoot.appendingPathComponent(name) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(data.utf8).write(to: file) + } + func testBuildIdentityMustSelectExactlyOneMatchingProfile() throws { + XCTAssertNoThrow(try AppData.validateBuildIdentity(["CFBundleIdentifier": "local.nxlibrary", "NXLibraryRelease": true])) + XCTAssertNoThrow(try AppData.validateBuildIdentity(["CFBundleIdentifier": "local.nxlibrary.development", "NXLibraryDevelopmentProfile": true])) + for invalid: [String: Any] in [[:], ["CFBundleIdentifier": "local.nxlibrary.development"], + ["CFBundleIdentifier": "local.nxlibrary", "NXLibraryDevelopmentProfile": true], + ["CFBundleIdentifier": "local.nxlibrary.development", "NXLibraryRelease": true], + ["CFBundleIdentifier": "local.nxlibrary", "NXLibraryRelease": true, "NXLibraryDevelopmentProfile": true]] { + XCTAssertThrowsError(try AppData.validateBuildIdentity(invalid)) + } + } + func testProfileSeparatesSupportCachesAndPreferenceDomain() throws { + let (home, dev) = try fixture() + let stable = AppDataProfile(home: home, isDevelopment: false) + XCTAssertNotEqual(dev.supportRoot, stable.supportRoot) + XCTAssertNotEqual(dev.cacheRoot, stable.cacheRoot) + XCTAssertNotEqual(dev.preferencesDomain, stable.preferencesDomain) + XCTAssertEqual(stable.supportRoot, dev.stableSupportRoot) + } + func testCopiesOnlyLauncherDataAndPreservesSourceAndIdentity() throws { + let (_, profile) = try fixture() + let id = UUID() + let game = PortedGame(id: id, name: "Personal game", url: URL(fileURLWithPath: "/Applications/Game.app"), platform: .macApp, sizeBytes: 1, modificationDate: Date()) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let encoded = try encoder.encode(LibraryFile(games: [game])) + try FileManager.default.createDirectory(at: profile.stableSupportRoot, withIntermediateDirectories: true) + try encoded.write(to: profile.stableSupportRoot.appendingPathComponent("library.json")) + try put("{}", "settings.json", profile: profile) + try put("personal", "artwork-picks/own.png", profile: profile) + try put("do not copy", "keys/prod.keys", profile: profile) + try put("do not copy", "workspaces/game_build/content", profile: profile) + try put("do not copy", "steamgriddb-api-key", profile: profile) + var saved: [String: Any] = [:] + let importer = DevelopmentProfileImporter() + XCTAssertTrue(try importer.prepare(profile: profile, sourcePreferences: { ["NXLibrary_SetupCompleted": true, "NXNative_GameConfig_game": Data([1]), "AppleLanguages": ["en"], "NXLibrary_AuthToken": "synthetic"] }, savePreferences: { saved = $0 })) + XCTAssertEqual(saved["NXLibrary_SetupCompleted"] as? Bool, true) + XCTAssertNil(saved["AppleLanguages"]) + XCTAssertNil(saved["NXLibrary_AuthToken"]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let copied = try decoder.decode(LibraryFile.self, from: Data(contentsOf: profile.supportRoot.appendingPathComponent("library.json"))) + XCTAssertEqual(copied.games.first?.id, id) + XCTAssertEqual(copied.games.first?.url, game.url) + XCTAssertEqual(try Data(contentsOf: profile.stableSupportRoot.appendingPathComponent("library.json")), encoded) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.appendingPathComponent("keys").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.appendingPathComponent("workspaces").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.appendingPathComponent("steamgriddb-api-key").path)) + } + func testSecondImportDoesNotOverwriteDevelopmentChanges() throws { + let (_, profile) = try fixture() + try put("{}", "settings.json", profile: profile) + let importer = DevelopmentProfileImporter() + _ = try importer.prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in }) + let file = profile.supportRoot.appendingPathComponent("settings.json") + try Data(#"{"showClock":false}"#.utf8).write(to: file) + XCTAssertFalse(try importer.prepare(profile: profile, sourcePreferences: { XCTFail("must not reread source preferences"); return [:] }, savePreferences: { _ in XCTFail("must not reimport preferences") })) + XCTAssertEqual(try String(contentsOf: file), #"{"showClock":false}"#) + } + func testMalformedSourceIsNotRepairedOrActivated() throws { + let (_, profile) = try fixture() + try put("broken", "library.json", profile: profile) + XCTAssertThrowsError(try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in XCTFail() })) + XCTAssertEqual(try String(contentsOf: profile.stableSupportRoot.appendingPathComponent("library.json")), "broken") + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.path)) + } + func testSymlinkDoesNotCopyExternalGameData() throws { + let (home, profile) = try fixture() + let external = home.appendingPathComponent("actual-game") + try FileManager.default.createDirectory(at: external, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profile.stableSupportRoot, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: profile.stableSupportRoot.appendingPathComponent("artwork-picks"), withDestinationURL: external) + XCTAssertThrowsError(try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in })) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.path)) + } + func testPreferenceFailureCanResumeWithoutOverwritingSource() throws { + let (_, profile) = try fixture() + try put("{}", "settings.json", profile: profile) + let importer = DevelopmentProfileImporter() + XCTAssertThrowsError(try importer.prepare(profile: profile, sourcePreferences: { ["NXLibrary_SetupCompleted": true] }, savePreferences: { _ in throw CocoaError(.fileWriteNoPermission) })) + var saved: [String: Any] = [:] + XCTAssertTrue(try importer.prepare(profile: profile, sourcePreferences: { XCTFail("resume uses captured preferences"); return [:] }, savePreferences: { saved = $0 })) + XCTAssertEqual(saved["NXLibrary_SetupCompleted"] as? Bool, true) + XCTAssertEqual(try String(contentsOf: profile.stableSupportRoot.appendingPathComponent("settings.json")), "{}") + } + func testRefusesAnExistingUnownedDestination() throws { + let (_, profile) = try fixture() + try FileManager.default.createDirectory(at: profile.supportRoot, withIntermediateDirectories: true) + XCTAssertThrowsError(try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in XCTFail() })) + } + func testChangedSourceDoesNotActivatePartialCopy() throws { + let (_, profile) = try fixture() + try put("{}", "settings.json", profile: profile) + var reads = 0 + XCTAssertThrowsError(try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { + reads += 1 + return ["NXLibrary_SetupCompleted": reads == 1] + }, savePreferences: { _ in XCTFail("must not activate") })) + XCTAssertFalse(FileManager.default.fileExists(atPath: profile.supportRoot.path)) + } + func testUnfinishedCopiedSessionEndsAtLastHeartbeatWithoutChangingTheOriginal() throws { + let (_, profile) = try fixture() + let session = PlaySession(gameID: UUID(), titleID: "fixture", bundleID: nil, + startedAt: Date(timeIntervalSince1970: 100), lastHeartbeatAt: Date(timeIntervalSince1970: 160)) + let data = try JSONEncoder().encode([session]) + try FileManager.default.createDirectory(at: profile.stableSupportRoot, withIntermediateDirectories: true) + let original = profile.stableSupportRoot.appendingPathComponent("play-sessions.json") + try data.write(to: original) + _ = try DevelopmentProfileImporter().prepare(profile: profile, sourcePreferences: { [:] }, savePreferences: { _ in }) + let copied = try JSONDecoder().decode([PlaySession].self, from: Data(contentsOf: profile.supportRoot.appendingPathComponent("play-sessions.json"))) + XCTAssertEqual(copied.first?.id, session.id) + XCTAssertEqual(copied.first?.endedAt, session.lastHeartbeatAt) + XCTAssertEqual(copied.first?.duration, 60) + XCTAssertEqual(try Data(contentsOf: original), data) + } + func testRootsAreFoldersWhetherOrNotTheyExistYet() { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + for development in [false, true] { + let profile = AppDataProfile(home: home, isDevelopment: development) + for root in [profile.stableSupportRoot, profile.supportRoot, profile.cacheRoot] { + XCTAssertTrue(root.hasDirectoryPath, root.path) + XCTAssertEqual(root.appendingPathComponent("settings.json").deletingLastPathComponent(), root) + } + } + } + func testSharedAndScopedDefaultPathsAreConsistent() { + XCTAssertEqual(AppSettingsStore.defaultURL().deletingLastPathComponent(), AppData.supportRoot) + XCTAssertEqual(PlaySessionStore.defaultURL().deletingLastPathComponent(), AppData.supportRoot) + XCTAssertEqual(MetadataPicks().directory, AppData.supportRoot) + XCTAssertEqual(ArtworkStore.defaultDirectory(), AppData.supportRoot.appendingPathComponent("artwork")) + XCTAssertEqual(SteamGridDBKey.defaultURL(), AppData.supportRoot.appendingPathComponent("steamgriddb-api-key")) + } + func testReleaseProfileNeverImports() throws { + let (home, _) = try fixture() + XCTAssertFalse(try DevelopmentProfileImporter().prepare(profile: AppDataProfile(home: home, isDevelopment: false), sourcePreferences: { XCTFail(); return [:] }, savePreferences: { _ in XCTFail() })) + } +} diff --git a/Tests/NXLibraryCoreTests/LaunchBoxArtTests.swift b/Tests/NXLibraryCoreTests/LaunchBoxArtTests.swift index 9f8ebd4..85a9d17 100644 --- a/Tests/NXLibraryCoreTests/LaunchBoxArtTests.swift +++ b/Tests/NXLibraryCoreTests/LaunchBoxArtTests.swift @@ -86,6 +86,19 @@ final class LaunchBoxArtTests: XCTestCase { XCTAssertNil(LaunchBoxArt.pick(snes, names: ["Donkey Kong Bananza"], switch2: false, loose: true), "even typed") } + func testAMacGameTakesItsComputerEntryNeverAConsoleOne() { + let results = LaunchBoxArt.results(in: Self.card("3-ps", "The Witcher 3: Wild Hunt", "Sony Playstation 4") + + Self.card("2-sw", "The Witcher 3: Wild Hunt", "Nintendo Switch") + + Self.card("1-win", "The Witcher 3: Wild Hunt", "Windows")) + XCTAssertEqual(LaunchBoxArt.pick(results, names: ["The Witcher 3: Wild Hunt"], computer: true)?.path, "/games/details/1-win") + let both = LaunchBoxArt.results(in: Self.card("1-win", "Fixture", "Windows") + Self.card("4-mac", "Fixture", "Apple Mac OS")) + XCTAssertEqual(LaunchBoxArt.pick(both, names: ["Fixture"], computer: true)?.path, "/games/details/4-mac", "the Mac entry first") + let consoles = LaunchBoxArt.results(in: Self.card("2-sw", "Fixture", "Nintendo Switch")) + XCTAssertNil(LaunchBoxArt.pick(consoles, names: ["Fixture"], computer: true, loose: true)) + XCTAssertEqual(LaunchBoxArt.pick(results, names: ["The Witcher 3: Wild Hunt"], switch2: false)?.path, "/games/details/2-sw", + "a Switch game still takes the Switch entry") + } + func testATypedNameTakesTheFirstSwitchResult() { let results = LaunchBoxArt.results(in: Self.card("2-y", "Mario Kart 64", "Nintendo 64") + Self.card("1-x", "Super Mario Odyssey", "Nintendo Switch")) diff --git a/Tests/NXLibraryCoreTests/NativeMacApplicationTests.swift b/Tests/NXLibraryCoreTests/NativeMacApplicationTests.swift new file mode 100644 index 0000000..60e418e --- /dev/null +++ b/Tests/NXLibraryCoreTests/NativeMacApplicationTests.swift @@ -0,0 +1,106 @@ +import XCTest +import Darwin +@testable import NXLibraryCore + +final class NativeMacApplicationTests: XCTestCase { + func testIdentifiesArm64GameAndPreservesItsBundleIdentity() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let app = try fixture.app() + let result = try NativeMacApplication.inspect(at: app, within: fixture.root) + XCTAssertEqual(result.url.path, app.path) + XCTAssertEqual(result.bundleIdentifier, "test.game") + XCTAssertEqual(result.name, "Fixture game") + XCTAssertEqual(result.architectures, [.arm64]) + XCTAssertTrue(result.isDeclaredGame) + } + + func testRecognizesIntelAndUniversalExecutables() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let intel = try fixture.app(at: "Intel.app", cpuTypes: [0x01000007]) + let universal = try fixture.app(at: "Universal.app", cpuTypes: [0x01000007, 0x0100000c]) + XCTAssertEqual(try NativeMacApplication.inspect(at: intel, within: fixture.root).architectures, [.x86_64]) + XCTAssertEqual(try NativeMacApplication.inspect(at: universal, within: fixture.root).architectures, [.x86_64, .arm64]) + } + + func testDoesNotClassifyToolsOrUnclassifiedAppsAsGames() throws { + let fixture = try SteamTestFixture.temporary(in: self) + for (i, category) in ["public.app-category.developer-tools", nil, "public.app-category.not-games"].enumerated() { + let app = try fixture.app(at: "Tool\(i).app", category: category) + XCTAssertFalse(try NativeMacApplication.inspect(at: app, within: fixture.root).isDeclaredGame) + } + let action = try fixture.app(at: "Action.app", category: "public.app-category.action-games") + XCTAssertTrue(try NativeMacApplication.inspect(at: action, within: fixture.root).isDeclaredGame) + } + + func testRejectsNonAppsAndIosOnlyBundles() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let invalid: [[String: Any]] = [["CFBundlePackageType": "BNDL"], ["LSRequiresIPhoneOS": true], + ["CFBundleSupportedPlatforms": ["iPhoneOS"]]] + for (i, metadata) in invalid.enumerated() { + let app = try fixture.app(at: "Invalid\(i).app", metadata: metadata) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: app, within: fixture.root)) + } + } + + func testRejectsMissingNonExecutableAndUnsupportedCpuFiles() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let missing = try fixture.app(at: "Missing.app") + try FileManager.default.removeItem(at: missing.appendingPathComponent("Contents/MacOS/fixture")) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: missing, within: fixture.root)) + let wrong = try fixture.app(at: "Wrong.app") + try Data("not Mach-O".utf8).write(to: wrong.appendingPathComponent("Contents/MacOS/fixture")) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: wrong, within: fixture.root)) + let unsupported = try fixture.app(at: "Unsupported.app", cpuTypes: [12]) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: unsupported, within: fixture.root)) + let library = try fixture.app(at: "Library.app") + try SteamTestFixture.machHeader(cpu: 0x0100000c, fileType: 6) + .write(to: library.appendingPathComponent("Contents/MacOS/fixture")) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: library, within: fixture.root)) + } + + func testRejectsExecutableTraversalAndSymlinkEscape() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let app = try fixture.app(metadata: ["CFBundleExecutable": "../fixture"]) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: app, within: fixture.root)) + let valid = try fixture.app(at: "Valid.app") + let narrowRoot = fixture.root.appendingPathComponent("narrow") + try FileManager.default.createDirectory(at: narrowRoot, withIntermediateDirectories: true) + let link = narrowRoot.appendingPathComponent("Escaping.app") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: valid) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: link, within: narrowRoot)) + } + + func testRejectsOversizedMetadataAndTruncatedFatSlices() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let app = try fixture.app() + try Data(repeating: 65, count: 1024 * 1024 + 1).write(to: app.appendingPathComponent("Contents/Info.plist")) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: app, within: fixture.root)) + let fat = try fixture.app(at: "Truncated.app", cpuTypes: [0x01000007, 0x0100000c]) + let bytes = SteamTestFixture.fatHeader(cpus: [0x01000007, 0x0100000c]).dropLast(20) + try Data(bytes).write(to: fat.appendingPathComponent("Contents/MacOS/fixture")) + XCTAssertThrowsError(try NativeMacApplication.inspect(at: fat, within: fixture.root)) + } + + func testNonRegularMetadataDoesNotBlockWaitingForAWriter() throws { + let fixture = try SteamTestFixture.temporary(in: self) + let app = try fixture.app() + let plist = app.appendingPathComponent("Contents/Info.plist") + try FileManager.default.removeItem(at: plist) + XCTAssertEqual(mkfifo(plist.path, 0o600), 0) + let finished = expectation(description: "Inspection rejects a FIFO without blocking") + DispatchQueue.global().async { + do { + _ = try NativeMacApplication.inspect(at: app, within: fixture.root) + XCTFail("A FIFO is not app metadata") + } catch {} + finished.fulfill() + } + let result = XCTWaiter.wait(for: [finished], timeout: 1) + // Release an old blocking implementation so a failed test leaves no blocked worker behind. + if result != .completed { + let writer = Darwin.open(plist.path, O_WRONLY | O_NONBLOCK) + if writer >= 0 { Darwin.close(writer) } + } + XCTAssertEqual(result, .completed) + } +} diff --git a/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift b/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift index e09a6f8..c659534 100644 --- a/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift +++ b/Tests/NXLibraryCoreTests/PlaySessionStoreTests.swift @@ -49,6 +49,28 @@ final class PlaySessionStoreTests: XCTestCase { XCTAssertEqual(store.sessions.count, 1) } + func testSteamSessionIsQualifiedAndOnlyItsOwnerEndsIt() { + let store = store() + let steamID = UUID() + let nx = game() + XCTAssertTrue(store.start(gameID: steamID, sourceID: "steam-570")) + XCTAssertFalse(store.start(gameID: steamID, sourceID: "steam-570"), "one running session per game") + XCTAssertTrue(store.start(for: nx)) + now = now.addingTimeInterval(120) + // An nxnative app quitting ends its own session only. + XCTAssertEqual(store.end(bundleID: nx.bundleId).map(\.gameID), [nx.id]) + XCTAssertTrue(store.isRunning(gameID: steamID)) + store.heartbeat(for: steamID) + now = now.addingTimeInterval(60) + store.end(gameID: steamID) + XCTAssertEqual(store.totalDuration(for: steamID), 180, accuracy: 0.001) + XCTAssertEqual(store.sessions.first { $0.gameID == steamID }?.titleID, "steam-570") + XCTAssertNil(store.sessions.first { $0.gameID == steamID }?.bundleID) + // Reloaded from disk with the old decoder shape. + let reloaded = PlaySessionStore(fileURL: url, now: { [unowned self] in self.now }) + XCTAssertEqual(reloaded.totalDuration(for: steamID), 180, accuracy: 0.001) + } + func testDanglingSessionClosesAtHeartbeat() { let store = store() let game = game() diff --git a/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift b/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift index ee212c1..d3ac814 100644 --- a/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift +++ b/Tests/NXLibraryCoreTests/SettingsCatalogTests.swift @@ -21,6 +21,25 @@ final class SettingsCatalogTests: XCTestCase { XCTAssertTrue(changed.allSatisfy { $0.group == .controllers }) } + func testSwitchOnlyRowsLiveInTheSwitchSections() { + let groups = Dictionary(uniqueKeysWithValues: SettingsCatalog.all.map { ($0.id, $0.group) }) + XCTAssertEqual(groups["checkEngineAtLaunch"], .engine) + XCTAssertEqual(groups["setup"], .engine) + XCTAssertEqual(groups["refreshLibraryAtLaunch"], .library) + XCTAssertEqual(groups["boxCovers"], .library) + XCTAssertNil(groups["environment"], "Compatibility in Engine re-checks the environment") + XCTAssertFalse(SettingsCatalog.all.contains { $0.group == .startUp && !Self.generalStartUp.contains($0.id) }) + } + + func testEnvironmentIsFoundOnTheEnginePage() { + XCTAssertEqual(SettingsCatalog.search("environment").first { $0.id == "engineStatus" }?.group, .engine) + } + + private static let generalStartUp: Set = [ + "appLanguage", "appRegion", "startOn", "restoreLastGame", "startInFullscreen", + "checkAppUpdatesAtLaunch", "introAnimation" + ] + func testSearchMatchesAboutText() { let titles = SettingsCatalog.search("crops").map(\.title) XCTAssertTrue(titles.contains("Wallpaper sizing")) diff --git a/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift new file mode 100644 index 0000000..ba7f185 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import NXLibraryCore + +final class SharedGamePresentationTests: XCTestCase { + func testSteamPresentationKeepsIdentityPersonalOverridesAndHistory() throws { + let body = Data(""" + {"10":{"success":true,"data":{"steam_appid":10,"short_description":"Store text","genres":[{"description":"Strategy"}],"publishers":["Studio"],"release_date":{"date":"23 mag 2023"}}}} + """.utf8) + let facts = try SteamMetadata.decode(body, appID: 10, language: "italian") + let record = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Fixture")) + let result = LibraryPresentation(game: record, steamMetadata: facts, + overrides: [.description: "Personal", .genres: "Puzzle, Adventure"], + totalPlaytime: 7200, lastPlayed: nil) + XCTAssertEqual(result.title, "Fixture") + XCTAssertEqual(result.platform, "Steam") + XCTAssertEqual(result.console, "Mac") + XCTAssertEqual(result.summary, "Personal") + XCTAssertEqual(result.publisher, "Studio") + XCTAssertEqual(result.releaseYear, "2023") + XCTAssertEqual(result.genres.count, 2) + XCTAssertEqual(result.playtime, "2h 0m") + XCTAssertFalse(result.sourcesDetached) + } + func testNXDisplayRemainsEqualThroughCommonBoundary() { + let game = CatalogTestSupport.completeLegacyGame() + let direct = LibraryPresentation(game: game, metadata: nil, totalPlaytime: 100, lastPlayed: nil) + let common = LibraryPresentation(game: CatalogGame(nxnative: game), steamMetadata: nil, + totalPlaytime: 100, lastPlayed: nil) + XCTAssertEqual(common, direct) + } + func testPersonalFieldsRemainVisibleWithoutRemoteMetadata() { + let game = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Local title")) + let result = LibraryPresentation(game: game, steamMetadata: nil, overrides: [.description: "Offline choice", .publisher: "Mine"], + totalPlaytime: 0, lastPlayed: nil) + XCTAssertEqual(result.summary, "Offline choice") + XCTAssertEqual(result.publisher, "Mine") + XCTAssertEqual(result.title, "Local title") + } + func testSquareRoleDoesNotUsePortraitArtwork() { + XCTAssertEqual(LibraryArtworkRole.square.slot, .icon) + XCTAssertEqual(LibraryArtworkRole.cover.slot, .cover) + XCTAssertEqual(LibraryArtworkRole.hero.slot, .background) + XCTAssertEqual(LibraryArtworkRole.logo.slot, .logo) + } + func testCommonOptionsHaveSameOrderAndSourceActionsStaySeparate() { + XCTAssertEqual(CatalogGameOptions.commonRows(for: .steam, origin: .spotlight), + CatalogGameOptions.commonRows(for: .nxnative, origin: .spotlight)) + XCTAssertTrue(CatalogGameOptions.commonRows(for: .steam, origin: .spotlight).contains(.move)) + XCTAssertFalse(CatalogGameOptions.commonRows(for: .steam, origin: .collection).contains(.move)) + let options = SteamOptionsPresentation(canPlay: true, hasApp: true) + let steam = Set([GameOptionsLevel.main, .editDetails].flatMap { level in + options.entries(level: level, origin: .spotlight).compactMap { entry -> GameOptionsRow? in + if case .row(let row) = entry { return row } else { return nil } + } + }) + XCTAssertTrue(steam.isDisjoint(with: [.fullscreen, .frameRate, .consoleMode, .rebuild, .openSaves, .removeSources, + .cheats, .mods, .attachSources, .openLog])) + } + + func testSteamPagePresentationClaimsNothingFromNxnative() throws { + let body = Data(""" + {"10":{"success":true,"data":{"steam_appid":10,"short_description":"Store text","genres":[{"description":"Strategy"}],"developers":["Dev Studio"],"publishers":["Studio"],"release_date":{"date":"23 mag 2023"}}}} + """.utf8) + let facts = try SteamMetadata.decode(body, appID: 10, language: "italian") + let record = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Fixture")) + let other = CatalogGame(steam: SteamCatalogGame(appID: 20, name: "Other")) + let page = GameDetailPresentation(steamRecord: record, metadata: facts, overrides: [.publisher: "Mine"], + totalPlaytime: 60, lastPlayed: nil, + libraryGames: [record, other], playtimeByGame: [other.id: 120]) + XCTAssertEqual(page.description, "Store text") + XCTAssertEqual(page.released, "23 mag 2023") + XCTAssertEqual(page.releaseYear, "2023") + XCTAssertEqual(page.developerLine, "Dev Studio") + XCTAssertEqual(page.publisher, "Mine") + XCTAssertEqual(page.rank, "#2 of 2") + XCTAssertEqual(page.storeURL?.absoluteString, "https://store.steampowered.com/app/10/") + XCTAssertFalse(page.canPlay); XCTAssertFalse(page.canRebuild); XCTAssertFalse(page.sourcesDetached) + XCTAssertEqual(page.savePath, ""); XCTAssertEqual(page.engineCommitShort, "—") + // Facts delivered for another game never reach this page. + let stray = GameDetailPresentation(steamRecord: other, metadata: facts, totalPlaytime: 0, lastPlayed: nil) + XCTAssertEqual(stray.description, "") + XCTAssertNil(stray.rank) + } +} diff --git a/Tests/NXLibraryCoreTests/SpotlightRowTests.swift b/Tests/NXLibraryCoreTests/SpotlightRowTests.swift index 1c8e607..e45e3ba 100644 --- a/Tests/NXLibraryCoreTests/SpotlightRowTests.swift +++ b/Tests/NXLibraryCoreTests/SpotlightRowTests.swift @@ -120,6 +120,21 @@ final class SpotlightRowTests: XCTestCase { } final class SpotlightRowMergeTests: XCTestCase { + func testSavingVisibleMoveRetainsDisabledHiddenAndCardPositions() { + let a = UUID(), steam = UUID(), hidden = UUID(), b = UUID() + let key: (UUID) -> String = { SpotlightItem.game($0).key } + let saved = [key(a), key(steam), key(hidden), key(b), SpotlightItem.card(.mods).key] + // Only A and B are visible; Steam is disabled, another game hidden, + // and Mods disabled. All still belong to the saved layout. + let changed = SpotlightRow.saving(visibleOrder: [key(b), key(a)], + gameIDs: [a, steam, hidden, b], previousOrder: saved) + let restored = SpotlightRow.build(gameIDs: [a, steam, hidden, b], enabledCards: [], order: changed) + XCTAssertEqual(restored, [.game(b), .game(steam), .game(hidden), .game(a)]) + XCTAssertTrue(changed.contains(SpotlightItem.card(.mods).key)) + let back = SpotlightRow.saving(visibleOrder: [key(a), key(b)], gameIDs: [a, steam, hidden, b], previousOrder: changed) + XCTAssertEqual(SpotlightRow.build(gameIDs: [a, steam, hidden, b], enabledCards: [], order: back), + [.game(a), .game(steam), .game(hidden), .game(b)]) + } func testFilteredMoveKeepsHiddenSlots() { // Saved A…E as D,A,B,C,E; Installed shows A,C,E; C moved first. let merged = SpotlightRow.merging(visibleOrder: ["C", "A", "E"], diff --git a/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift b/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift new file mode 100644 index 0000000..61a1ef8 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift @@ -0,0 +1,72 @@ +import XCTest +import AppKit +@testable import NXLibraryCore + +final class SteamArtworkRequestTests: XCTestCase { + func testSquareUsesOriginalBundleAssetButPortraitNeverUsesIt() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let app = root.appendingPathComponent("Fixture.app") + let resources = app.appendingPathComponent("Contents/Resources") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleIconFile":"Original"], format: .xml, options: 0).write(to: app.appendingPathComponent("Contents/Info.plist")) + let icon = resources.appendingPathComponent("Original.icns") + try Data("fixture".utf8).write(to: icon) + let observed = NativeMacApplication(url: app, executableURL: app.appendingPathComponent("Contents/MacOS/main"), bundleIdentifier: "fixture", name: "Fixture", architectures: [.arm64], isDeclaredGame: true, fileObservations: []) + let native = CatalogNativeApplication(observed) + let install = SteamCatalogInstallation(libraryURL: root, contentURL: root, applications: [native], availability: .available) + var steam = SteamCatalogGame(appID: 10, name: "Fixture"); steam.installations = [install]; steam.hasBeenRecognized = true + let game = CatalogGame(steam: steam), picks = SteamPersonalizationStore(directory: root.appendingPathComponent("picks")) + XCTAssertEqual(SteamArtworkRequest(game: game, role: .square).localURL(picks: picks), icon) + XCTAssertNil(SteamArtworkRequest(game: game, role: .cover).localURL(picks: picks)) + XCTAssertNotEqual(SteamArtworkRequest(game: game, role: .square).key, SteamArtworkRequest(game: game, role: .cover).key) + // A malicious icon path is not a request to read outside the bundle. + try PropertyListSerialization.data(fromPropertyList: ["CFBundleIconFile":"../../outside"], format: .xml, options: 0).write(to: app.appendingPathComponent("Contents/Info.plist")) + XCTAssertNil(SteamArtworkRequest(game: game, role: .square).localURL(picks: picks)) + } + + func testPersonalPickComesBeforeTheAppIcon() throws { + let (root, game, icon) = try fixture(iconName: "Original") + defer { try? FileManager.default.removeItem(at: root) } + let picks = SteamPersonalizationStore(directory: root.appendingPathComponent("picks")) + XCTAssertEqual(SteamArtworkRequest(game: game, role: .square).localURL(picks: picks), icon) + let png = try XCTUnwrap(Self.png()) + try picks.save(fields: [:], images: [.icon: png, .cover: png], automatic: [], titleID: game.personalizationReference.key) + let square = try XCTUnwrap(SteamArtworkRequest(game: game, role: .square).localURL(picks: picks)) + XCTAssertNotEqual(square, icon) + XCTAssertTrue(square.path.hasPrefix(root.appendingPathComponent("picks").path)) + XCTAssertNotNil(SteamArtworkRequest(game: game, role: .cover).localURL(picks: picks)) + XCTAssertNil(SteamArtworkRequest(game: game, role: .hero).localURL(picks: picks)) + } + + func testIconLinkLeavingTheBundleIsNotFollowed() throws { + let (root, game, icon) = try fixture(iconName: "Original.icns") + defer { try? FileManager.default.removeItem(at: root) } + let outside = root.appendingPathComponent("outside.icns") + try Data("outside".utf8).write(to: outside) + try FileManager.default.removeItem(at: icon) + try FileManager.default.createSymbolicLink(at: icon, withDestinationURL: outside) + let picks = SteamPersonalizationStore(directory: root.appendingPathComponent("picks")) + XCTAssertNil(SteamArtworkRequest(game: game, role: .square).localURL(picks: picks)) + } + + private func fixture(iconName: String) throws -> (URL, CatalogGame, URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let app = root.appendingPathComponent("Fixture.app") + let resources = app.appendingPathComponent("Contents/Resources") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleIconFile": iconName], format: .xml, options: 0).write(to: app.appendingPathComponent("Contents/Info.plist")) + let icon = resources.appendingPathComponent("Original.icns") + try Data("fixture".utf8).write(to: icon) + let observed = NativeMacApplication(url: app, executableURL: app.appendingPathComponent("Contents/MacOS/main"), bundleIdentifier: "fixture", name: "Fixture", architectures: [.arm64], isDeclaredGame: true, fileObservations: []) + let install = SteamCatalogInstallation(libraryURL: root, contentURL: root, applications: [CatalogNativeApplication(observed)], availability: .available) + var steam = SteamCatalogGame(appID: 10, name: "Fixture"); steam.installations = [install]; steam.hasBeenRecognized = true + return (root, CatalogGame(steam: steam), icon) + } + + private static func png() -> Data? { + let image = NSImage(size: NSSize(width: 8, height: 8)) + image.lockFocus(); NSColor.red.setFill(); NSRect(x: 0, y: 0, width: 8, height: 8).fill(); image.unlockFocus() + return image.tiffRepresentation.flatMap { NSBitmapImageRep(data: $0)?.representation(using: .png, properties: [:]) } + } +} diff --git a/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift new file mode 100644 index 0000000..9ad5d13 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift @@ -0,0 +1,80 @@ +import XCTest +import AppKit +@testable import NXLibraryCore + +final class SteamBackdropChoiceTests: XCTestCase { + private let shot = URL(string: "https://shared.akamai.steamstatic.com/shot.jpg")! + private let hero = URL(string: "https://shared.akamai.steamstatic.com/library_hero.jpg")! + + func testScreenShapedArtOnly() { + XCTAssertTrue(SteamBackdropChoice.suits(width: 1920, height: 1080)) + XCTAssertTrue(SteamBackdropChoice.suits(width: 1920, height: 1200)) + XCTAssertFalse(SteamBackdropChoice.suits(width: 3840, height: 1240), "Steam's library hero is a banner") + XCTAssertFalse(SteamBackdropChoice.suits(width: 1024, height: 576), "too small for a full screen") + XCTAssertFalse(SteamBackdropChoice.suits(width: 600, height: 900)) + } + + func testCleanStoreHeroesComeFirstThenTheExactScreenshotThenTheBanner() { + let art = [ + ArtworkCandidate(slot: .background, source: "Xbox Store · titled", url: URL(string: "https://x/titled")!), + ArtworkCandidate(slot: .background, source: "TheGamesDB fan art", url: URL(string: "https://t/fan")!), + ArtworkCandidate(slot: .cover, source: "GOG", url: URL(string: "https://g/cover")!), + ArtworkCandidate(slot: .background, source: "GOG", url: URL(string: "https://g/galaxy")!), + ArtworkCandidate(slot: .background, source: "GOG", url: URL(string: "https://g/wide-cover")!), + ArtworkCandidate(slot: .background, source: "Xbox Store", url: URL(string: "https://x/hero")!) + ] + let order = SteamBackdropChoice.ordered(storeArt: art, screenshots: [shot, URL(string: "https://s/2")!], libraryHero: hero) + XCTAssertEqual(order.map(\.absoluteString), ["https://x/hero", "https://g/galaxy", "https://t/fan", shot.absoluteString, hero.absoluteString]) + XCTAssertEqual(SteamBackdropChoice.ordered(storeArt: [], screenshots: [], libraryHero: hero), [hero]) + } + + func testLaunchBoxComputerFanartComesAfterTheStoresAndBeforeScreenshots() { + let fanart = { (name: String, w: Int?, h: Int?) in + ArtworkCandidate(slot: .background, source: "LaunchBox · Fanart - Background", url: URL(string: "https://l/\(name)-preview")!, + full: URL(string: "https://l/\(name)"), width: w, height: h) + } + let art = [ + ArtworkCandidate(slot: .background, source: "LaunchBox · Screenshot - Gameplay", url: URL(string: "https://l/play")!, + full: URL(string: "https://l/play-full"), width: 1920, height: 1080), + fanart("banner", 3840, 1240), + fanart("small", 1024, 576), + fanart("wide", 2560, 1440), + ArtworkCandidate(slot: .background, source: "GOG", url: URL(string: "https://g/galaxy")!) + ] + let order = SteamBackdropChoice.ordered(storeArt: art, screenshots: [shot], libraryHero: hero) + // The full file, not its preview; only a screen-shaped one; gameplay shots are not fan art. + XCTAssertEqual(order.map(\.absoluteString), ["https://g/galaxy", "https://l/wide", shot.absoluteString, hero.absoluteString]) + let unsized = SteamBackdropChoice.ordered(storeArt: [fanart("unknown", nil, nil)], screenshots: [], libraryHero: nil) + XCTAssertEqual(unsized.map(\.absoluteString), ["https://l/unknown"], "a size the page does not state is checked when chosen") + } + + func testChoosesTheFirstScreenShapedImageAndFallsBackToTheBanner() async throws { + let banner = try XCTUnwrap(Self.png(width: 1500, height: 484)) + let wide = try XCTUnwrap(Self.png(width: 1600, height: 900)) + var asked: [URL] = [] + let chosen = await SteamBackdropChoice.choose([URL(string: "https://x/missing")!, hero, shot], fallback: hero) { url in + asked.append(url) + return url == hero ? banner : url == self.shot ? wide : nil + } + XCTAssertEqual(chosen, shot) + XCTAssertEqual(asked.count, 3) + let fallback = await SteamBackdropChoice.choose([shot, hero], fallback: hero) { _ in banner } + XCTAssertEqual(fallback, hero) + } + + func testWithoutAScreenShapedImageOrBannerTheFirstImageThatLoadsIsShown() async throws { + let small = try XCTUnwrap(Self.png(width: 1024, height: 576)) + let missing = URL(string: "https://x/missing")! + let chosen = await SteamBackdropChoice.choose([missing, shot], fallback: nil) { $0 == self.shot ? small : nil } + XCTAssertEqual(chosen, shot, "never the last URL just for being last: one that loads") + let none = await SteamBackdropChoice.choose([missing], fallback: nil) { _ in nil } + XCTAssertNil(none) + } + + private static func png(width: Int, height: Int) -> Data? { + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0) else { return nil } + return rep.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/NXLibraryCoreTests/SteamCatalogReconcilerTests.swift b/Tests/NXLibraryCoreTests/SteamCatalogReconcilerTests.swift new file mode 100644 index 0000000..1dd2195 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamCatalogReconcilerTests.swift @@ -0,0 +1,200 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamCatalogReconcilerTests: XCTestCase { + private let root = URL(fileURLWithPath: "/tmp/catalog-steam") + private let external = URL(fileURLWithPath: "/tmp/catalog-external") + private let observedAt = Date(timeIntervalSince1970: 1000.125) + + private func catalog() -> CatalogDocument { + CatalogDocument(sources: [.init(integration: .nxnative, isEnabled: true), + .init(integration: .steam, isEnabled: true, rootURL: root)], + games: [.init(nxnative: CatalogTestSupport.completeLegacyGame())]) + } + private func observation(_ id: UInt32 = 10, name: String = "Same name", library: URL? = nil, + directory: String = "Game", state: SteamGameObservation.State = .nativeReady, + size: Int64? = 100) throws -> SteamGameObservation { + let library = library ?? root + let content = library.appendingPathComponent("steamapps/common/" + directory) + let app = content.appendingPathComponent("Game.app") + let native = NativeMacApplication(url: app, executableURL: app.appendingPathComponent("Contents/MacOS/game"), + bundleIdentifier: "test.game", name: name, architectures: [.arm64], + isDeclaredGame: true, fileObservations: []) + return .init(appID: id, name: name, installationURL: content, + libraryURL: library, reportedSizeBytes: size, buildID: "42", nativeApps: [native], state: state) + } + private func snapshot(_ games: [SteamGameObservation], complete: Bool = true, + libraries: [SteamLibraryObservation]? = nil) -> SteamLibrarySnapshot { + .init(libraries: libraries ?? [.init(root: root, isReadable: true, isComplete: complete)], games: games, + issues: complete ? [] : [.init(url: root, appID: nil, reason: .invalidManifest)]) + } + private func apply(_ scan: SteamLibrarySnapshot, to document: CatalogDocument) throws -> CatalogDocument { + try SteamCatalogReconciler.applying(scan, ticket: document.steamScanTicket(), to: document, observedAt: observedAt) + } + + func testScansRequireEnabledSourceAndMatchingRevisionAndRoot() throws { + var document = catalog() + let ticket = try document.steamScanTicket() + document.sources[1].isEnabled = false + XCTAssertThrowsError(try document.steamScanTicket()) + XCTAssertThrowsError(try SteamCatalogReconciler.applying(snapshot([]), ticket: ticket, to: document)) + document = catalog(); document.revision = UUID() + XCTAssertThrowsError(try SteamCatalogReconciler.applying(snapshot([]), ticket: ticket, to: document)) + document = catalog(); document.sources[1].rootURL = external + XCTAssertThrowsError(try SteamCatalogReconciler.applying(snapshot([]), ticket: ticket, to: document)) + document = catalog() + XCTAssertThrowsError(try apply(snapshot([], libraries: [.init(root: external, isReadable: true, isComplete: true)]), to: document)) + } + + func testIdenticalNamesAcrossSourcesAndAppIDsStaySeparate() throws { + var first = try observation() + first = .init(appID: first.appID, name: "Fixture Switch game", installationURL: first.installationURL, + libraryURL: first.libraryURL, reportedSizeBytes: 100, buildID: "42", nativeApps: first.nativeApps, state: .nativeReady) + let original = catalog() + let result = try apply(snapshot([first, observation(20, name: "Fixture Switch game")]), to: original) + XCTAssertEqual(result.games.count, 3) + XCTAssertEqual(Set(result.games.map(\.id)).count, 3) + XCTAssertEqual(Set(result.games.compactMap { $0.steamGame?.appID }), [10, 20]) + XCTAssertEqual(result.games.filter { $0.integration == .steam }.map(\.personalizationKey), ["steam-10", "steam-20"]) + XCTAssertEqual(result.games[0].nxnativeGame, original.games[0].nxnativeGame) + } + + func testRefreshKeepsUUIDInstallationIDAndPersonalChoicesWhileUpdatingObservations() throws { + let game = try observation() + var document = try apply(snapshot([game]), to: catalog()) + document.games[1].setFavorite(true); document.games[1].setHidden(true) + let before = document.games[1] + let renamed = SteamGameObservation(appID: 10, name: "New name", installationURL: game.installationURL, + libraryURL: game.libraryURL, reportedSizeBytes: 200, buildID: "43", nativeApps: game.nativeApps, state: .nativeReady) + let result = try apply(snapshot([renamed]), to: document) + let after = result.games[1] + XCTAssertEqual(after.id, before.id) + XCTAssertEqual(after.steamGame?.installations.first?.id, before.steamGame?.installations.first?.id) + XCTAssertEqual(after.personalizationReference, before.personalizationReference) + XCTAssertTrue(after.isFavorite); XCTAssertTrue(after.isHidden) + XCTAssertEqual(after.name, "New name") + XCTAssertEqual(after.steamGame?.installations.first?.reportedSizeBytes, 200) + XCTAssertEqual(after.steamGame?.installations.first?.buildID, "43") + XCTAssertEqual(after.steamGame?.installations.first?.lastObservedAt, observedAt) + XCTAssertEqual(result.games[0], document.games[0]) + XCTAssertEqual(result.revision, document.revision, "Store alone assigns the committed revision") + } + + func testDuplicateAppIDRetainsBothInstallationsAndRequiresReview() throws { + let a = try observation(), b = try observation(library: external) + let scan = snapshot([a, b], libraries: [.init(root: root, isReadable: true, isComplete: true), .init(root: external, isReadable: true, isComplete: true)]) + let result = try apply(scan, to: catalog()) + XCTAssertEqual(result.games.count, 2) + XCTAssertEqual(result.games[1].steamGame?.installations.count, 2) + XCTAssertNil(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).launchTarget) + XCTAssertTrue(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).requiresReview) + let repeated = try apply(scan, to: result) + XCTAssertEqual(repeated.games[1].steamGame?.installations.map(\.id), result.games[1].steamGame?.installations.map(\.id)) + } + + func testCompleteAbsenceMarksNotDetectedWithoutDeletingGameOrHistoryIdentity() throws { + let document = try apply(snapshot([observation()]), to: catalog()) + let result = try apply(snapshot([]), to: document) + XCTAssertEqual(result.games.count, 2) + XCTAssertEqual(result.games[1].id, document.games[1].id) + XCTAssertEqual(result.games[1].steamGame?.installations.first?.availability, .notDetected) + XCTAssertTrue(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).isListedNormally) + XCTAssertNil(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).launchTarget) + } + + func testPartialScanPreservesUnseenStateAndUnreadableLibraryIsExplicit() throws { + let document = try apply(snapshot([observation()]), to: catalog()) + let partial = try apply(snapshot([], complete: false), to: document) + XCTAssertEqual(partial.games, document.games) + let unavailable = snapshot([], complete: false, libraries: [.init(root: root, isReadable: false, isComplete: false)]) + let missing = try apply(unavailable, to: document) + XCTAssertEqual(missing.games[1].steamGame?.installations.first?.availability, .sourceUnavailable) + XCTAssertEqual(missing.games[1].id, document.games[1].id) + XCTAssertNil(CatalogGamePresentation(game: missing.games[1], sourceEnabled: true).launchTarget) + } + + func testMovedInstallationKeepsGameIdentityAndOldObservation() throws { + let document = try apply(snapshot([observation()]), to: catalog()) + let result = try apply(snapshot([observation(directory: "Moved")]), to: document) + XCTAssertEqual(result.games[1].id, document.games[1].id) + let installs = try XCTUnwrap(result.games[1].steamGame?.installations) + XCTAssertEqual(installs.count, 2) + XCTAssertEqual(installs[0].availability, .notDetected) + XCTAssertEqual(installs[1].availability, .available) + XCTAssertEqual(installs[0].id, document.games[1].steamGame?.installations[0].id) + XCTAssertEqual(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).launchTarget, .steam(appID: 10)) + } + + func testUnrecognizedCandidatesDoNotBecomeNormalGamesOrLaunchTargets() throws { + for state: SteamGameObservation.State in [.unavailable, .requiresReview] { + let result = try apply(snapshot([observation(state: state)]), to: catalog()) + let presentation = CatalogGamePresentation(game: result.games[1], sourceEnabled: true) + XCTAssertFalse(presentation.isListedNormally) + XCTAssertTrue(presentation.requiresReview) + XCTAssertNil(presentation.launchTarget) + } + } + + func testSourceDisableHidesAndBlocksLaunchWithoutLosingRecords() throws { + let document = try apply(snapshot([observation()]), to: catalog()) + let steam = CatalogGamePresentation(game: document.games[1], sourceEnabled: false) + XCTAssertFalse(steam.isListedNormally) + XCTAssertNil(steam.launchTarget) + } + + func testInvalidAppIDAndUnlistedLibraryCannotBeAccepted() throws { + XCTAssertThrowsError(try apply(snapshot([observation(0)]), to: catalog())) + XCTAssertThrowsError(try apply(snapshot([observation(library: external)]), to: catalog())) + } + func testNativeApplicationOutsideObservedInstallationIsRejected() throws { + let original = try observation() + let unsafe = SteamGameObservation(appID: 10, name: original.name, + installationURL: external.appendingPathComponent("Outside"), libraryURL: root, + reportedSizeBytes: nil, buildID: nil, nativeApps: original.nativeApps, state: .nativeReady) + XCTAssertThrowsError(try apply(snapshot([unsafe]), to: catalog())) + } + + func testDifferentAppIDReusingAPathDoesNotKeepMissingOldGameAvailable() throws { + let document = try apply(snapshot([observation(10)]), to: catalog()) + let result = try apply(snapshot([observation(20)]), to: document) + XCTAssertEqual(result.games[1].steamGame?.appID, 10) + XCTAssertEqual(result.games[1].steamGame?.installations.first?.availability, .notDetected) + XCTAssertNil(CatalogGamePresentation(game: result.games[1], sourceEnabled: true).launchTarget) + } + + func testDuplicateInstallationPathsAreRejectedBeforeReconciliation() throws { + var document = try apply(snapshot([observation()]), to: catalog()) + var game = try XCTUnwrap(document.games[1].steamGame) + let original = game.installations[0] + game.installations.append(.init(libraryURL: original.libraryURL, contentURL: original.contentURL, + applications: original.applications, availability: .available)) + try document.games[1].updateSteam(game) + XCTAssertThrowsError(try document.validate()) + } + + func testMissingDiskCannotResolveDuplicateAmbiguityAndReconnectRemainsBlocked() throws { + let a = try observation(), b = try observation(library: external) + let both = snapshot([a, b], libraries: [.init(root: root, isReadable: true, isComplete: true), + .init(root: external, isReadable: true, isComplete: true)]) + let duplicate = try apply(both, to: catalog()) + let absentDisk = snapshot([a], complete: false, libraries: [.init(root: root, isReadable: true, isComplete: true), + .init(root: external, isReadable: false, isComplete: false)]) + let partial = try apply(absentDisk, to: duplicate) + let shown = CatalogGamePresentation(game: partial.games[1], sourceEnabled: true) + XCTAssertTrue(shown.requiresReview) + XCTAssertNil(shown.launchTarget) + XCTAssertEqual(partial.games[1].steamGame?.installations.first { $0.libraryURL == external }?.availability, .sourceUnavailable) + let reloaded = try JSONDecoder().decode(CatalogDocument.self, from: JSONEncoder().encode(partial)) + XCTAssertTrue(CatalogGamePresentation(game: reloaded.games[1], sourceEnabled: true).requiresReview) + let reconnected = try apply(both, to: reloaded) + XCTAssertTrue(CatalogGamePresentation(game: reconnected.games[1], sourceEnabled: true).requiresReview) + XCTAssertNil(CatalogGamePresentation(game: reconnected.games[1], sourceEnabled: true).launchTarget) + // Only complete evidence of one remaining installation resolves it. + let resolved = try apply(snapshot([a], libraries: [.init(root: root, isReadable: true, isComplete: true), + .init(root: external, isReadable: true, isComplete: true)]), to: reconnected) + XCTAssertFalse(CatalogGamePresentation(game: resolved.games[1], sourceEnabled: true).requiresReview) + XCTAssertEqual(CatalogGamePresentation(game: resolved.games[1], sourceEnabled: true).launchTarget, .steam(appID: 10)) + XCTAssertEqual(resolved.games[1].id, duplicate.games[1].id) + } + +} diff --git a/Tests/NXLibraryCoreTests/SteamDiscoveryConsistencyTests.swift b/Tests/NXLibraryCoreTests/SteamDiscoveryConsistencyTests.swift new file mode 100644 index 0000000..2fb0758 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamDiscoveryConsistencyTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamDiscoveryConsistencyTests: XCTestCase { + func testManifestAddedAfterInventoryInvalidatesTheSource() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/Game.app") + let source = SteamLibrarySource(root: root, directoryReader: { url, limit, cancelled in + let read = try InstallationDirectorySnapshot.read(at: url, maximumEntries: limit, cancelled: cancelled) + if url.path == root.appendingPathComponent("steamapps").path { + try f.manifest(in: root, id: "20", directory: "Second") + try f.app(at: "Steam/steamapps/common/Second/Game.app") + } + return read + }) + XCTAssertThrowsError(try source.scan()) { XCTAssertEqual($0 as? SteamSourceError, .sourceChanged) } + } + + func testPreviouslyAbsentIndexAppearingBeforeInventoryIsDetected() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), external = try f.library("External") + let source = SteamLibrarySource(root: root, directoryReader: { url, limit, cancelled in + if url.path == root.appendingPathComponent("steamapps").path { + try Data("libraryfolders { 1 \(SteamTestFixture.quote(external.path)) }".utf8) + .write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + } + return try InstallationDirectorySnapshot.read(at: url, maximumEntries: limit, cancelled: cancelled) + }) + XCTAssertThrowsError(try source.scan()) { XCTAssertEqual($0 as? SteamSourceError, .sourceChanged) } + } + + func testNativeMetadataAndExecutableAreRecheckedAfterOtherGamesAreRead() throws { + for removeExecutable in [false, true] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.manifest(in: root, id: "20", directory: "Second") + let first = try f.app(at: "Steam/steamapps/common/First/Game.app") + try f.app(at: "Steam/steamapps/common/Second/Game.app") + let second = root.appendingPathComponent("steamapps/common/Second") + let source = SteamLibrarySource(root: root, directoryReader: { url, limit, cancelled in + let read = try InstallationDirectorySnapshot.read(at: url, maximumEntries: limit, cancelled: cancelled) + if url.path == second.path { + if removeExecutable { + try FileManager.default.removeItem(at: first.appendingPathComponent("Contents/MacOS/fixture")) + } else { + let file = first.appendingPathComponent("Contents/Info.plist") + var info = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(contentsOf: file), format: nil) as? [String: Any]) + info["LSApplicationCategoryType"] = "public.app-category.developer-tools" + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: file) + } + } + return read + }) + XCTAssertThrowsError(try source.scan()) { XCTAssertEqual($0 as? SteamSourceError, .sourceChanged) } + } + } + + func testDirectoryReadEnforcesTheEntryBudget() throws { + let f = try SteamTestFixture.temporary(in: self) + for i in 0..<12 { try Data().write(to: f.root.appendingPathComponent("item\(i)")) } + let result = try InstallationDirectorySnapshot.read(at: f.root, maximumEntries: 3, cancelled: { false }) + XCTAssertEqual(result.entries.count, 3) + XCTAssertFalse(result.isComplete) + } + + func testDirectoryReadHonorsCancellationWhileCollectingEntries() throws { + let f = try SteamTestFixture.temporary(in: self) + for i in 0..<12 { try Data().write(to: f.root.appendingPathComponent("item\(i)")) } + var checkpoints = 0 + XCTAssertThrowsError(try InstallationDirectorySnapshot.read(at: f.root, maximumEntries: 100, cancelled: { + checkpoints += 1 + return checkpoints >= 2 + })) { XCTAssertTrue($0 is CancellationError) } + } + + func testHiddenEntriesAlsoConsumeTheDirectoryReadBudget() throws { + let f = try SteamTestFixture.temporary(in: self) + for i in 0..<12 { try Data().write(to: f.root.appendingPathComponent(".hidden\(i)")) } + let result = try InstallationDirectorySnapshot.read(at: f.root, maximumEntries: 3, cancelled: { false }) + XCTAssertTrue(result.entries.isEmpty) + XCTAssertFalse(result.isComplete) + } + + func testWideInstallationRemainsPartialWhenItsEntryBudgetIsReached() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/Game.app") + for i in 0..<12 { + try Data().write(to: root.appendingPathComponent("steamapps/common/First/item\(i)")) + } + var limits = SteamScanLimits.standard + limits.maximumDirectoryEntries = 4 + let result = try SteamLibrarySource(root: root, limits: limits).scan() + XCTAssertFalse(result.isComplete) + XCTAssertEqual(result.games.first?.state, .requiresReview) + XCTAssertTrue(result.issues.contains { $0.reason == .directoryLimit }) + } + + func testCancellationFromInventoryReaderIsNotSwallowedAsUnavailable() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + let source = SteamLibrarySource(root: root, directoryReader: { url, limit, _ in + try InstallationDirectorySnapshot.read(at: url, maximumEntries: limit, cancelled: { true }) + }) + XCTAssertThrowsError(try source.scan()) { XCTAssertTrue($0 is CancellationError) } + } +} diff --git a/Tests/NXLibraryCoreTests/SteamKeyValuesTests.swift b/Tests/NXLibraryCoreTests/SteamKeyValuesTests.swift new file mode 100644 index 0000000..2671fa2 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamKeyValuesTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamKeyValuesTests: XCTestCase { + func testReadsNestedLibraryWithSpacesAndUnicode() throws { + let document = try KeyValuesDocument(text: "\"libraryfolders\" { \"0\" { \"path\" \"/Volumes/Giochi è/SteamLibrary\" } }") + guard case .object(let libraries)? = try document.uniqueValue(for: "libraryfolders"), + case .object(let first)? = try libraries.uniqueValue(for: "0") else { + return XCTFail("Missing library") + } + XCTAssertEqual(try first.uniqueValue(for: "PATH"), .string("/Volumes/Giochi è/SteamLibrary")) + } + + func testCommentsDoNotConsumeSlashesInsideStrings() throws { + let document = try KeyValuesDocument(text: "// heading\nname \"https://example.invalid/game\" // comment\nempty {}") + XCTAssertEqual(try document.uniqueValue(for: "name"), .string("https://example.invalid/game")) + XCTAssertEqual(try document.uniqueValue(for: "empty"), .object([])) + } + + func testQuotedEscapeSequencesAndUnquotedScalars() throws { + let document = try KeyValuesDocument(text: #"name "A \"quote\" \\ slash\nline\ttab\rend" appid 10"#) + XCTAssertEqual(try document.uniqueValue(for: "name"), .string("A \"quote\" \\ slash\nline\ttab\rend")) + XCTAssertEqual(try document.uniqueValue(for: "appid"), .string("10")) + } + + func testConflictingCaseVariantsAreNotSilentlyOverwritten() throws { + let document = try KeyValuesDocument(text: "appid 10 APPID 20") + XCTAssertEqual(document.entries.count, 2) + XCTAssertThrowsError(try document.uniqueValue(for: "appid")) + } + + func testUnknownDuplicateFieldsRemainInTheDocument() throws { + let document = try KeyValuesDocument(text: "unknown first unknown second appid 10") + XCTAssertEqual(document.entries.map(\.key), ["unknown", "unknown", "appid"]) + XCTAssertEqual(try document.uniqueValue(for: "appid"), .string("10")) + XCTAssertNil(try document.uniqueValue(for: "absent")) + } + + func testRejectsMalformedAndTruncatedDocuments() { + for text in ["key", "{", "key {", "key }", "key { child value", "key value }", "key \"unterminated", #"key "bad\q""#] { + XCTAssertThrowsError(try KeyValuesDocument(text: text), text) + } + } + + func testDoesNotExecuteIncludeOrBaseDirectives() { + for directive in ["#include", "#base", "#INCLUDE"] { + XCTAssertThrowsError(try KeyValuesDocument(text: "\(directive) \"/private/should-not-be-read\"")) + } + } + + func testDepthLimitAllowsBoundaryAndRejectsOneMoreObject() throws { + XCTAssertNoThrow(try KeyValuesDocument(text: "a { b c }", maximumDepth: 1)) + XCTAssertThrowsError(try KeyValuesDocument(text: "a { b { c d } }", maximumDepth: 1)) + } + + func testEntryLimitCountsNestedEntries() throws { + XCTAssertNoThrow(try KeyValuesDocument(text: "a { b c }", maximumEntries: 2)) + XCTAssertThrowsError(try KeyValuesDocument(text: "a { b c d e }", maximumEntries: 2)) + } + + func testEmptyDocumentAndUtf8Bom() throws { + XCTAssertEqual(try KeyValuesDocument(text: " \n// empty").entries, []) + XCTAssertEqual(try KeyValuesDocument(text: "\u{FEFF}appid 10").uniqueValue(for: "appid"), .string("10")) + } +} diff --git a/Tests/NXLibraryCoreTests/SteamLibrarySourceTests.swift b/Tests/NXLibraryCoreTests/SteamLibrarySourceTests.swift new file mode 100644 index 0000000..e9a713b --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamLibrarySourceTests.swift @@ -0,0 +1,252 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamLibrarySourceTests: XCTestCase { + func testFindsTwoDistinctGamesAndDoesNotWriteSourceFiles() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.manifest(in: root, id: "20", name: "Second game", directory: "Second") + try f.app(at: "Steam/steamapps/common/First/Game.app") + try f.app(at: "Steam/steamapps/common/Second/Game.app") + let before = try f.inventory() + let result = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(result.games.map(\.appID), [10, 20]) + XCTAssertEqual(result.games.map(\.name), ["First game", "Second game"]) + XCTAssertEqual(result.games.map(\.state), [.nativeReady, .nativeReady]) + XCTAssertEqual(result.games.first?.reportedSizeBytes, 100) + XCTAssertEqual(result.games.first?.buildID, "42") + XCTAssertTrue(result.libraries.first?.isComplete == true) + XCTAssertEqual(try f.inventory(), before) + } + + func testModernAndLegacyIndexesIncludeExternalLibrariesAndDeduplicatePaths() throws { + for modern in [false, true] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), external = try f.library("External library") + try f.manifest(in: external) + try f.app(at: "External library/steamapps/common/First/Game.app") + let value = modern ? "{ path \(SteamTestFixture.quote(external.path)) label \"\" }" : SteamTestFixture.quote(external.path) + let index = "libraryfolders { 0 { path \(SteamTestFixture.quote(root.path)) } 1 \(value) 2 \(value) }" + try Data(index.utf8).write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(result.libraries.count, 2) + XCTAssertEqual(result.games.map(\.appID), [10]) + } + } + + func testMissingOrMalformedIndexDoesNotHideDefaultLibraryOrItsError() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/Game.app") + XCTAssertEqual(try SteamLibrarySource(root: root).scan().games.count, 1) + try Data("libraryfolders {".utf8).write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + let partial = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(partial.games.count, 1) + XCTAssertTrue(partial.issues.contains { $0.reason == .invalidIndex }) + XCTAssertFalse(partial.libraries[0].isComplete) + } + + func testUnavailableExternalLibraryIsNotASuccessfulEmptyScan() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), absent = f.root.appendingPathComponent("disconnected") + let index = "libraryfolders { 1 { path \(SteamTestFixture.quote(absent.path)) } }" + try Data(index.utf8).write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + let result = try SteamLibrarySource(root: root).scan() + let observation = try XCTUnwrap(result.libraries.first { $0.root.path == absent.path }) + XCTAssertFalse(observation.isReadable) + XCTAssertFalse(observation.isComplete) + XCTAssertTrue(result.issues.contains { $0.reason == .unavailableLibrary }) + } + + func testInvalidManifestRetainsFilenameIdentityInIssueContext() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + let manifest = try f.manifest(in: root) + try Data("AppState {".utf8).write(to: manifest) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertTrue(result.games.isEmpty) + XCTAssertTrue(result.issues.contains { $0.reason == .invalidManifest && $0.appID == 10 }) + XCTAssertFalse(result.libraries[0].isComplete) + } + + func testRejectsMismatchedOrInvalidAppIdentifiers() throws { + for id in ["20", "+10", "0", "4294967296", "a10"] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root, id: id, filenameID: "10") + let result = try SteamLibrarySource(root: root).scan() + XCTAssertTrue(result.games.isEmpty, id) + XCTAssertFalse(result.issues.isEmpty, id) + } + } + + func testEscapedUnicodeNamesAndInvalidOptionalSizes() throws { + for size in ["-1", "9223372036854775808"] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root, name: "L'avventura \"è\" bella", directory: "Gioco è", size: size) + try f.app(at: "Steam/steamapps/common/Gioco è/Game.app") + let result = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(result.games.first?.name, "L'avventura \"è\" bella") + XCTAssertNil(result.games.first?.reportedSizeBytes) + XCTAssertEqual(result.games.first?.state, .nativeReady) + } + } + + func testUnsafeInstallDirectoryDoesNotEscapeTheLibrary() throws { + for directory in ["../escape", "/tmp/escape", "First/../../escape"] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root, directory: directory) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertTrue(result.games.isEmpty) + XCTAssertTrue(result.issues.contains { $0.reason == .unsafePath }) + } + } + + func testSymlinkInstallEscapeIsRejected() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + let externalApp = try f.app(at: "Elsewhere/Game.app") + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent("steamapps/common/First"), + withDestinationURL: externalApp.deletingLastPathComponent()) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertTrue(result.games.isEmpty) + XCTAssertTrue(result.issues.contains { $0.reason == .unsafePath }) + } + + func testSymlinkedSteamappsCannotSupplyAnIndexOutsideTheChosenRoot() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), foreign = try f.library("Foreign"), third = try f.library("Third") + try f.manifest(in: third) + try f.app(at: "Third/steamapps/common/First/Game.app") + try Data("libraryfolders { 1 \(SteamTestFixture.quote(third.path)) }".utf8) + .write(to: foreign.appendingPathComponent("steamapps/libraryfolders.vdf")) + try FileManager.default.removeItem(at: root.appendingPathComponent("steamapps")) + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent("steamapps"), + withDestinationURL: foreign.appendingPathComponent("steamapps")) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertTrue(result.games.isEmpty) + XCTAssertEqual(result.libraries.count, 1) + XCTAssertTrue(result.issues.contains { $0.reason == .unsafePath }) + } + + func testLibraryAndManifestLimitsAreReported() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), external = try f.library("External") + try f.manifest(in: root) + try f.manifest(in: root, id: "20", directory: "Second") + try Data("libraryfolders { 1 \(SteamTestFixture.quote(external.path)) }".utf8) + .write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + var limits = SteamScanLimits.standard + limits.maximumLibraries = 1 + limits.maximumManifests = 1 + let result = try SteamLibrarySource(root: root, limits: limits).scan() + XCTAssertEqual(result.libraries.count, 1) + XCTAssertEqual(result.games.count, 1) + XCTAssertFalse(result.isComplete) + XCTAssertTrue(result.issues.contains { $0.reason == .libraryLimit }) + XCTAssertTrue(result.issues.contains { $0.reason == .manifestLimit }) + } + + func testDuplicateAppIdsAcrossLibrariesRemainAmbiguousObservations() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library(), second = try f.library("Other") + for library in [root, second] { try f.manifest(in: library) } + try f.app(at: "Steam/steamapps/common/First/Game.app") + try f.app(at: "Other/steamapps/common/First/Game.app") + try Data("libraryfolders { 1 \(SteamTestFixture.quote(second.path)) }".utf8) + .write(to: root.appendingPathComponent("steamapps/libraryfolders.vdf")) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(result.games.count, 2) + XCTAssertTrue(result.games.allSatisfy { $0.state == .requiresReview }) + XCTAssertTrue(result.issues.contains { $0.reason == .conflictingIdentity }) + } + + func testMainGameAndHelperAreNotTwoGames() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/Game.app") + try f.app(at: "Steam/steamapps/common/First/Helper.app", category: nil) + let result = try SteamLibrarySource(root: root).scan() + XCTAssertEqual(result.games.first?.state, .nativeReady) + XCTAssertEqual(result.games.first?.nativeApps.filter(\.isDeclaredGame).count, 1) + } + + func testRecognizedTopLevelGameDoesNotRequireWalkingItsLooseDataTree() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/Game.app") + try FileManager.default.createDirectory(at: root.appendingPathComponent("steamapps/common/First/LooseData/deep/assets"), + withIntermediateDirectories: true) + var limits = SteamScanLimits.standard + limits.maximumDirectoriesPerInstall = 1 + let result = try SteamLibrarySource(root: root, limits: limits).scan() + XCTAssertEqual(result.games.first?.state, .nativeReady) + XCTAssertTrue(result.isComplete) + } + + func testToolsUnclassifiedAndMultipleMainAppsAreNotConfirmedGames() throws { + for categories: [String?] in [["public.app-category.developer-tools"], [nil], + ["public.app-category.games", "public.app-category.games"]] { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + for (i, category) in categories.enumerated() { + try f.app(at: "Steam/steamapps/common/First/App\(i).app", category: category) + } + XCTAssertEqual(try SteamLibrarySource(root: root).scan().games.first?.state, .requiresReview) + } + } + + func testMissingContentAndWindowsOnlyDirectoryAreNotNativeReady() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + XCTAssertEqual(try SteamLibrarySource(root: root).scan().games.first?.state, .unavailable) + let install = root.appendingPathComponent("steamapps/common/First") + try FileManager.default.createDirectory(at: install, withIntermediateDirectories: true) + try Data("synthetic".utf8).write(to: install.appendingPathComponent("game.exe")) + XCTAssertEqual(try SteamLibrarySource(root: root).scan().games.first?.state, .unavailable) + } + + func testLimitsDoNotReturnADeceptivelyCompleteLibrary() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + try f.manifest(in: root) + try f.app(at: "Steam/steamapps/common/First/deep/Game.app") + var limits = SteamScanLimits.standard + limits.maximumDirectoriesPerInstall = 1 + let limited = try SteamLibrarySource(root: root, limits: limits).scan() + XCTAssertFalse(limited.libraries[0].isComplete) + XCTAssertTrue(limited.issues.contains { $0.reason == .directoryLimit }) + limits = .standard + limits.maximumTextBytes = 10 + let oversized = try SteamLibrarySource(root: root, limits: limits).scan() + XCTAssertTrue(oversized.games.isEmpty) + XCTAssertTrue(oversized.issues.contains { $0.reason == .oversizedManifest }) + } + + func testCancellationThrowsRatherThanPublishingPartialResults() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + XCTAssertThrowsError(try SteamLibrarySource(root: root).scan(cancelled: { true })) { + XCTAssertTrue($0 is CancellationError) + } + } + + func testSourceSnapshotDetectsAFileChangingBeforePublication() throws { + let f = try SteamTestFixture.temporary(in: self) + let root = try f.library() + let file = try f.manifest(in: root) + let snapshot = try InstallationFileSnapshot.read(at: file, maximumBytes: 1024) + try Data("changed".utf8).write(to: file, options: .atomic) + XCTAssertThrowsError(try snapshot.verifyUnchanged()) + XCTAssertEqual(try String(contentsOf: file), "changed") + } +} diff --git a/Tests/NXLibraryCoreTests/SteamMetadataTests.swift b/Tests/NXLibraryCoreTests/SteamMetadataTests.swift new file mode 100644 index 0000000..4233d8e --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamMetadataTests.swift @@ -0,0 +1,192 @@ +import XCTest +import AppKit +@testable import NXLibraryCore + +final class SteamMetadataTests: XCTestCase { + private func payload(id: Int = 10) -> Data { + Data(""" + {"10":{"success":true,"data":{"steam_appid":\(id),"name":"Fixture","short_description":"A & B","genres":[{"id":"2","description":"Strategia"}],"developers":["Studio"],"publishers":["Publisher"],"release_date":{"date":"23 mag 2023"},"screenshots":[{"path_full":"https://shared.akamai.steamstatic.com/test.jpg"},{"path_full":"http://localhost/private"}]}}} + """.utf8) + } + func testIdentityLocalizedFieldsAndSafeScreenshots() throws { + let value = try SteamMetadata.decode(payload(), appID: 10, language: "italian") + XCTAssertEqual(value.description, "A & B") + XCTAssertEqual(value.genres, ["Strategia"]) + XCTAssertEqual(value.screenshots.count, 1) + XCTAssertEqual(value.releaseDate, "23 mag 2023") + XCTAssertThrowsError(try SteamMetadata.decode(payload(id: 20), appID: 10, language: "italian")) + XCTAssertThrowsError(try SteamMetadata.decode(Data("{}".utf8), appID: 10, language: "italian")) + XCTAssertNil(SteamMetadata.safeImage(URL(string: "https://steamstatic.com.attacker.test/x"))) + } + func testLanguageMappingIsIndependentOfRegionalFormats() { + XCTAssertEqual(SteamMetadata.language(.portugueseBrazil), "brazilian") + XCTAssertEqual(SteamMetadata.language(.italian), "italian") + } + func testOfflineCacheAndPersonalDataAreIndependentOfRefresh() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let body = payload() + let online = SteamMetadataService(directory: root, transport: { _ in body }) + let first = await online.metadata(appID: 10, language: "italian") + XCTAssertEqual(first?.genres, ["Strategia"]) + let offline = SteamMetadataService(directory: root, transport: { _ in throw URLError(.notConnectedToInternet) }) + let retained = await offline.metadata(appID: 10, language: "italian", refresh: true) + XCTAssertEqual(retained, first) + let other = await offline.metadata(appID: 10, language: "english") + XCTAssertNil(other) + let picks = MetadataPicks(directory: root.appendingPathComponent("personal")) + picks.set("My description", titleID: "10", field: .description) + _ = await online.metadata(appID: 10, language: "italian", refresh: true) + XCTAssertEqual(picks.value(titleID: "10", field: .description), "My description") + } + func testConcurrentMetadataRequestsShareOneTransport() async throws { + actor Counter { + var count = 0 + func bump() { count += 1 } + } + let counter = Counter(), body = payload() + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in + await counter.bump(); try await Task.sleep(nanoseconds: 30_000_000); return body + }) + async let a = service.metadata(appID: 10, language: "english") + async let b = service.metadata(appID: 10, language: "english") + let values = await (a, b) + XCTAssertNotNil(values.0); XCTAssertEqual(values.0?.description, values.1?.description) + let count = await counter.count + XCTAssertEqual(count, 1) + } + func testFailedPersonalSaveDoesNotPublishUnsavedValues() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try Data("not a folder".utf8).write(to: root) + let picks = SteamPersonalizationStore(directory: root) + XCTAssertThrowsError(try picks.save(fields: [.description: "Lost"], images: [:], automatic: [], titleID: "10")) + XCTAssertNil(picks.value(titleID: "10", field: .description)) + XCTAssertEqual(picks.revision, 0) + } + func testStoreFailuresAndNonHTTPSImagesAreRejected() throws { + XCTAssertThrowsError(try SteamMetadata.decode(Data("{\"10\":{\"success\":false}}".utf8), appID: 10, language: "english")) + for text in ["http://cdn.steamstatic.com/a", "https://user@cdn.steamstatic.com/a", "https://cdn.steamstatic.com:444/a", "file:///tmp/a"] { + XCTAssertNil(SteamMetadata.safeImage(URL(string: text))) + } + } + + func testSemanticFailuresBackOffWithoutLosingTheLibrary() async { + actor Calls { var count = 0; func bump() { count += 1 } } + let calls = Calls() + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in await calls.bump(); return Data("{}".utf8) }) + let first = await service.metadata(appID: 10, language: "english") + let second = await service.metadata(appID: 10, language: "english") + XCTAssertNil(first); XCTAssertNil(second) + let count = await calls.count; XCTAssertEqual(count, 1) + } + func testCancellingSoleImageWaiterStopsTransportAndFallbacks() async { + actor Calls { var count = 0; func bump() { count += 1 } } + let calls = Calls(), started = expectation(description: "started"), stopped = expectation(description: "cancelled") + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in + await calls.bump(); started.fulfill() + do { try await Task.sleep(nanoseconds: 10_000_000_000); return Data() } + catch { stopped.fulfill(); throw error } + }) + let request = Task { await service.image(appID: 10, slot: .cover) } + await fulfillment(of: [started], timeout: 1) + request.cancel() + await fulfillment(of: [stopped], timeout: 1) + let image = await request.value; XCTAssertNil(image) + let count = await calls.count; XCTAssertEqual(count, 1) + } + func testCancelledSharedWaiterPreservesInterestedReader() async { + let started = expectation(description: "started"), body = payload() + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in + started.fulfill(); try await Task.sleep(nanoseconds: 150_000_000); return body + }) + let a = Task { await service.metadata(appID: 10, language: "english") } + await fulfillment(of: [started], timeout: 1) + let b = Task { await service.metadata(appID: 10, language: "english") } + try? await Task.sleep(nanoseconds: 30_000_000) + a.cancel() + let result = await b.value; XCTAssertNotNil(result) + let cancelled = await a.value; XCTAssertNil(cancelled) + } + func testFailedCombinedDraftKeepsAllPreviousPicksAndAutomaticReferences() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let image = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 2, pixelsHigh: 2, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + let png = image.representation(using: .png, properties: [:])! + let initial = SteamPersonalizationStore(directory: root) + try initial.save(fields: [.description: "Previous"], images: [.cover: png], automatic: [], titleID: "10") + let original = initial.url(titleID: "10", slot: .cover)! + let before = try Data(contentsOf: original) + let failing = SteamPersonalizationStore(directory: root, publish: { _,_ in throw CocoaError(.fileWriteOutOfSpace) }) + XCTAssertThrowsError(try failing.save(fields: [.description: "New"], images: [.logo: png], automatic: [.cover], titleID: "10")) + let restored = SteamPersonalizationStore(directory: root) + XCTAssertEqual(restored.value(titleID: "10", field: .description), "Previous") + XCTAssertEqual(restored.url(titleID: "10", slot: .cover), original) + XCTAssertEqual(try Data(contentsOf: original), before) + XCTAssertNil(restored.url(titleID: "10", slot: .logo)) + XCTAssertEqual(failing.revision, 0) + try initial.save(fields: [:], images: [:], automatic: [.cover], titleID: "10") + XCTAssertNil(SteamPersonalizationStore(directory: root).url(titleID: "10", slot: .cover)) + } + + func testRedirectIsRefusedBeforeSendingAnotherRequest() { + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let initial = URL(string: "https://store.steampowered.com/api/appdetails")! + let task = session.dataTask(with: initial) + let response = HTTPURLResponse(url: initial, statusCode: 302, httpVersion: nil, headerFields: nil)! + let delegate = SteamMetadataService.NoRedirects() + var invoked = false + delegate.urlSession(session, task: task, willPerformHTTPRedirection: response, + newRequest: URLRequest(url: URL(string: "https://example.invalid/private")!)) { request in + invoked = true; XCTAssertNil(request) + } + XCTAssertTrue(invoked) + XCTAssertEqual(task.state, .suspended) + XCTAssertFalse(SteamMetadataService.allowed(URL(string: "http://store.steampowered.com/a")!)) + } + func testInvalidImageBytesAreBackedOffForBothCandidateURLs() async { + actor Calls { var count = 0; func bump() { count += 1 } } + let calls = Calls(), root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in await calls.bump(); return Data("invalid image".utf8) }) + _ = await service.image(appID: 10, slot: .cover) + _ = await service.image(appID: 10, slot: .cover) + let count = await calls.count; XCTAssertEqual(count, 2) + } + func testSelectionBurstBoundsOutstandingTransfers() async { + actor Calls { + var active = 0, peak = 0 + func enter() { active += 1; peak = max(peak, active) } + func leave() { active -= 1 } + } + let calls = Calls(), root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let service = SteamMetadataService(directory: root, transport: { _ in + await calls.enter(); try await Task.sleep(nanoseconds: 100_000_000); await calls.leave(); return Data("{}".utf8) + }) + await withTaskGroup(of: Void.self) { group in + for id in 1...20 { group.addTask { _ = await service.metadata(appID: UInt32(id), language: "english") } } + } + let peak = await calls.peak; XCTAssertGreaterThan(peak, 0); XCTAssertLessThanOrEqual(peak, 6) + } + func testUnreadablePersonalManifestIsPreserved() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("personal.json"), original = Data("damaged".utf8) + try original.write(to: file) + let store = SteamPersonalizationStore(directory: root) + XCTAssertThrowsError(try store.save(fields: [.description: "New"], images: [:], automatic: [], titleID: "10")) + XCTAssertEqual(try Data(contentsOf: file), original) + } + +} diff --git a/Tests/NXLibraryCoreTests/SteamOptionsTests.swift b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift new file mode 100644 index 0000000..3c74e33 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamOptionsTests: XCTestCase { + func testSteamMainRowsAreNxnativeCommonRowsInTheSameOrder() { + let nx = GameOptionsPresentation(supportsRenderScale: false, isPlayable: true, sourcesDetached: false) + for origin in [GameOptionsOrigin.spotlight, .collection] { + let steam = SteamOptionsPresentation(canPlay: true, hasApp: true).focusableRows(level: .main, origin: origin) + let shared = nx.focusableRows(level: .main, origin: origin).filter(steam.contains) + XCTAssertEqual(steam, shared) + } + XCTAssertTrue(SteamOptionsPresentation(canPlay: true, hasApp: true).focusableRows(level: .main, origin: .spotlight).contains(.move)) + XCTAssertFalse(SteamOptionsPresentation(canPlay: true, hasApp: true).focusableRows(level: .main, origin: .collection).contains(.move)) + } + + func testSteamOptionsOfferNothingFromNxnative() { + let p = SteamOptionsPresentation(canPlay: true, hasApp: true) + let all = Set(p.focusableRows(level: .main, origin: .spotlight) + p.focusableRows(level: .editDetails, origin: .spotlight)) + XCTAssertTrue(all.isDisjoint(with: [.fullscreen, .frameRate, .consoleMode, .cheats, .mods, .openSaves, .rebuild, + .attachSources, .openLog, .removeSources, .removeGame, .restoreDefaults])) + XCTAssertEqual(p.focusableRows(level: .editDetails, origin: .collection), [.changeArtwork, .changeMetadata, .storePage]) + XCTAssertTrue(all.allSatisfy { p.about($0) != nil }) + } + + func testPlayAndFinderDimWithoutAnInstallation() { + let missing = SteamOptionsPresentation(canPlay: false, hasApp: false) + XCTAssertTrue(missing.isDimmed(.play)) + XCTAssertTrue(missing.isDimmed(.showInFinder)) + XCTAssertFalse(missing.isDimmed(.favorite)) + XCTAssertFalse(SteamOptionsPresentation(canPlay: true, hasApp: true).isDimmed(.play)) + } + + func testSteamArtworkPutsTheExactAppFirstWithoutRepeats() throws { + let shot = try XCTUnwrap(URL(string: "https://shared.akamai.steamstatic.com/shot1.jpg")) + let steamCover = SteamStoreArt.candidates(appID: 10, slot: .cover) + let other = ArtworkCandidate(slot: .cover, source: "SteamGridDB", url: try XCTUnwrap(URL(string: "https://example.com/c.png"))) + let covers = ArtworkCandidates.steamList(for: .cover, appID: 10, screenshots: [shot], + online: [steamCover[0], other, ArtworkCandidate(slot: .logo, source: "x", url: shot)]) + XCTAssertEqual(covers.map(\.url), [steamCover[0].url, other.url]) + let backgrounds = ArtworkCandidates.steamList(for: .background, appID: 10, screenshots: [shot]) + XCTAssertEqual(backgrounds.map(\.source), ["Steam library", "Steam screenshot 1"]) + XCTAssertTrue(ArtworkCandidates.steamList(for: .icon, appID: 10, screenshots: [shot]).isEmpty) + XCTAssertTrue(ArtworkCandidates.steamList(for: .cover, appID: 0, screenshots: []).isEmpty) + } +} + +final class SteamLaunchSettingTests: XCTestCase { + func testSteamStaysInTheBackgroundByDefaultAndOldFilesKeepIt() throws { + XCTAssertTrue(AppSettings().steamInBackground) + let old = try JSONDecoder().decode(AppSettings.self, from: Data("{}".utf8)) + XCTAssertTrue(old.steamInBackground) + var off = AppSettings(); off.steamInBackground = false + let back = try JSONDecoder().decode(AppSettings.self, from: JSONEncoder().encode(off)) + XCTAssertFalse(back.steamInBackground) + } + + func testTheSettingIsListedWithSteamAndMarkedChangedWhenOff() throws { + let row = try XCTUnwrap(SettingsCatalog.all.first { $0.id == "steamInBackground" }) + XCTAssertEqual(row.group, .sources) + var off = AppSettings(); off.steamInBackground = false + XCTAssertTrue(row.isChanged(off)) + XCTAssertFalse(row.isChanged(AppSettings())) + } +} diff --git a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift new file mode 100644 index 0000000..32ba21f --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import NXLibraryCore + +final class SteamPlayStateTests: XCTestCase { + private let app = CatalogNativeApplication(NativeMacApplication( + url: URL(fileURLWithPath: "/Steam/steamapps/common/Game/Game.app"), + executableURL: URL(fileURLWithPath: "/Steam/steamapps/common/Game/Game.app/Contents/MacOS/Game"), + bundleIdentifier: "com.example.game", name: "Game", architectures: [.arm64], isDeclaredGame: true, fileObservations: [])) + private let t0 = Date(timeIntervalSince1970: 1_000_000) + private var game: NativeProcessObservation { + .init(pid: 42, bundleURL: app.url, executableURL: app.executableURL) + } + private let steamClient = NativeProcessObservation(pid: 7, bundleURL: URL(fileURLWithPath: "/Applications/Steam.app"), + executableURL: URL(fileURLWithPath: "/Applications/Steam.app/Contents/MacOS/steam_osx")) + + func testAnInstallationReachedThroughASymlinkMatchesTheRunningApp() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("Drive/common/Game") + try FileManager.default.createDirectory(at: real.appendingPathComponent("Game.app/Contents/MacOS"), withIntermediateDirectories: true) + try Data().write(to: real.appendingPathComponent("Game.app/Contents/MacOS/Game")) + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent("steamapps"), withDestinationURL: root.appendingPathComponent("Drive")) + let linked = root.appendingPathComponent("steamapps/common/Game/Game.app") + let installed = CatalogNativeApplication(NativeMacApplication( + url: linked, executableURL: linked.appendingPathComponent("Contents/MacOS/Game"), + bundleIdentifier: "com.example.game", name: "Game", architectures: [.arm64], isDeclaredGame: true, fileObservations: [])) + // The system names the real path; the caller hands it over in canonical form. + let running = NativeProcessObservation(pid: 42, bundleURL: real.appendingPathComponent("Game.app"), + executableURL: real.appendingPathComponent("Game.app/Contents/MacOS/Game")).canonical() + var state = SteamPlayState() + XCTAssertEqual(state.observe([running], application: installed, now: t0), .started(pid: 42)) + XCTAssertNil(state.observe([running], application: installed, now: t0.addingTimeInterval(30)), "still the same game") + } + + func testHandOffStartsNoSessionUntilTheGameItselfRuns() { + var state = SteamPlayState() + XCTAssertEqual(state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0), + .handOff(URL(string: "steam://run/570")!, generation: 1)) + state.handoffFinished(accepted: true, generation: 1) + XCTAssertNil(state.observe([steamClient], application: app, now: t0.addingTimeInterval(5)), "Steam alone is not the game") + XCTAssertEqual(state.phase, .starting(since: t0, accepted: true)) + XCTAssertEqual(state.observe([steamClient, game], application: app, now: t0.addingTimeInterval(20)), .started(pid: 42)) + XCTAssertEqual(state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0), .resume(pid: 42)) + // The game quits while Steam stays open: the session ends. + XCTAssertEqual(state.observe([steamClient], application: app, now: t0.addingTimeInterval(600)), .ended(pid: 42)) + XCTAssertEqual(state.phase, .idle) + } + + func testDuplicatePlayAndLateAnswersChangeNothing() { + var state = SteamPlayState() + _ = state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0) + XCTAssertEqual(state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0), .wait) + state.cancel() + _ = state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0) + state.handoffFinished(accepted: false, generation: 1) // the cancelled request's answer + XCTAssertEqual(state.phase, .starting(since: t0, accepted: false)) + state.handoffFinished(accepted: false, generation: state.generation) + XCTAssertEqual(state.phase, .failed(.handoffRejected)) + } + + func testRefusalsAndTimeoutNeverStartASession() { + var state = SteamPlayState() + XCTAssertEqual(state.requestPlay(target: nil, clientAvailable: true, now: t0), .refuse(.unavailable)) + XCTAssertEqual(state.requestPlay(target: .nxnative(appURL: app.url), clientAvailable: true, now: t0), .refuse(.unavailable)) + XCTAssertEqual(state.requestPlay(target: .steam(appID: 0), clientAvailable: true, now: t0), .refuse(.unavailable)) + XCTAssertEqual(state.requestPlay(target: .steam(appID: 570), clientAvailable: false, now: t0), .refuse(.clientMissing)) + _ = state.requestPlay(target: .steam(appID: 570), clientAvailable: true, now: t0) + XCTAssertNil(state.observe([], application: app, now: t0.addingTimeInterval(SteamPlayState.startDeadline + 1))) + XCTAssertEqual(state.phase, .failed(.notStarted)) + // A game that appears later (an update finished) is still followed. + XCTAssertEqual(state.observe([game], application: app, now: t0.addingTimeInterval(900)), .started(pid: 42)) + } + + func testOnlyThisInstallationsAppMatches() { + XCTAssertTrue(SteamPlayState.matches(game, app)) + XCTAssertTrue(SteamPlayState.matches(.init(pid: 1, bundleURL: app.url, executableURL: nil), app)) + XCTAssertFalse(SteamPlayState.matches(steamClient, app)) + let otherCopy = NativeProcessObservation(pid: 3, bundleURL: URL(fileURLWithPath: "/Volumes/Ext/Game.app"), + executableURL: URL(fileURLWithPath: "/Volumes/Ext/Game.app/Contents/MacOS/Game")) + XCTAssertFalse(SteamPlayState.matches(otherCopy, app), "same bundle ID elsewhere is another installation") + let helper = NativeProcessObservation(pid: 4, bundleURL: app.url, + executableURL: app.url.appendingPathComponent("Contents/MacOS/CrashReporter")) + XCTAssertFalse(SteamPlayState.matches(helper, app)) + XCTAssertNil(SteamPlayState.launchURL(appID: 0)) + } + + func testAGameAlreadyRunningIsFollowedWithoutAPlay() { + var state = SteamPlayState() + XCTAssertEqual(state.observe([game], application: app, now: t0), .started(pid: 42)) + // The same pid reused by another program is not mistaken for the game. + XCTAssertEqual(state.observe([.init(pid: 42, bundleURL: steamClient.bundleURL, executableURL: steamClient.executableURL)], + application: app, now: t0), .ended(pid: 42)) + XCTAssertNil(state.observe([.init(pid: 42, bundleURL: steamClient.bundleURL, executableURL: steamClient.executableURL)], + application: app, now: t0)) + } + + func testARunningSessionFollowsTheProcessItSawStartNotTheInstallationNow() { + var state = SteamPlayState() + XCTAssertEqual(state.observe([game], application: app, now: t0), .started(pid: 42)) + // The installation disappears from the catalog (a refresh): the game keeps playing. + XCTAssertNil(state.observe([game], application: nil, now: t0.addingTimeInterval(60))) + // The catalog now names the app at a new path: one session, not ended and started again. + let moved = CatalogNativeApplication(NativeMacApplication( + url: URL(fileURLWithPath: "/Volumes/Games/Game.app"), + executableURL: URL(fileURLWithPath: "/Volumes/Games/Game.app/Contents/MacOS/Game"), + bundleIdentifier: "com.example.game", name: "Game", architectures: [.arm64], isDeclaredGame: true, fileObservations: [])) + XCTAssertNil(state.observe([game], application: moved, now: t0.addingTimeInterval(120))) + XCTAssertEqual(state.phase, .running(pid: 42)) + // With no installation to compare, the pid reused by another program still ends it. + XCTAssertEqual(state.observe([steamClient, .init(pid: 42, bundleURL: steamClient.bundleURL, executableURL: nil)], + application: nil, now: t0.addingTimeInterval(180)), .ended(pid: 42)) + } +} + +final class SteamBackgroundLaunchTests: XCTestCase { + func testSilentArgumentsOnlyWhenPlayStartsSteamAndTheSettingIsOn() { + XCTAssertEqual(SteamBackgroundLaunch.arguments(enabled: true, steamRunning: false), ["-silent", "-nofriendsui"]) + XCTAssertEqual(SteamBackgroundLaunch.arguments(enabled: true, steamRunning: true), []) + XCTAssertEqual(SteamBackgroundLaunch.arguments(enabled: false, steamRunning: false), []) + } + + func testSteamIsKeptAwayOnlyWhileWaitingThenComesBackIfTheGameIsLate() { + let start = Date(timeIntervalSince1970: 1_000) + let waiting = SteamPlayState.Phase.starting(since: start, accepted: true) + XCTAssertTrue(SteamBackgroundLaunch.keepsSteamAway(enabled: true, phase: waiting, now: start.addingTimeInterval(5))) + XCTAssertFalse(SteamBackgroundLaunch.keepsSteamAway(enabled: false, phase: waiting, now: start.addingTimeInterval(5))) + let late = start.addingTimeInterval(SteamBackgroundLaunch.attentionDelay + 1) + XCTAssertFalse(SteamBackgroundLaunch.keepsSteamAway(enabled: true, phase: waiting, now: late)) + XCTAssertTrue(SteamBackgroundLaunch.mayNeedPlayer(enabled: true, phase: waiting, now: late)) + XCTAssertFalse(SteamBackgroundLaunch.mayNeedPlayer(enabled: true, phase: waiting, now: start.addingTimeInterval(5))) + XCTAssertFalse(SteamBackgroundLaunch.keepsSteamAway(enabled: true, phase: .running(pid: 7), now: start)) + XCTAssertFalse(SteamBackgroundLaunch.mayNeedPlayer(enabled: true, phase: .idle, now: late)) + } +} diff --git a/Tests/NXLibraryCoreTests/SteamTestSupport.swift b/Tests/NXLibraryCoreTests/SteamTestSupport.swift new file mode 100644 index 0000000..0623051 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamTestSupport.swift @@ -0,0 +1,83 @@ +import Foundation +import XCTest + +/// Generated metadata/header bytes only; these fixtures are not game executables. +struct SteamTestFixture: Sendable { + let root: URL + + static func temporary(in test: XCTestCase) throws -> Self { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("SteamSourceTests-" + UUID().uuidString) + .resolvingSymlinksInPath().standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + test.addTeardownBlock { try FileManager.default.removeItem(at: root) } + return Self(root: root) + } + + @discardableResult + func app(at relative: String = "Game.app", category: String? = "public.app-category.games", + cpuTypes: [UInt32] = [0x0100000c], metadata: [String: Any] = [:]) throws -> URL { + let app = root.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: app.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + var info: [String: Any] = ["CFBundlePackageType": "APPL", "CFBundleIdentifier": "test.game", + "CFBundleDisplayName": "Fixture game", "CFBundleExecutable": "fixture"] + info["LSApplicationCategoryType"] = category + info.merge(metadata) { _, new in new } + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: app.appendingPathComponent("Contents/Info.plist")) + let executable = app.appendingPathComponent("Contents/MacOS/fixture") + let data = cpuTypes.count == 1 ? Self.machHeader(cpu: cpuTypes[0]) : Self.fatHeader(cpus: cpuTypes) + try data.write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + return app + } + + static func words(_ values: [UInt32], bigEndian: Bool = false) -> Data { + Data(values.flatMap { word in + let shifts = bigEndian ? [24, 16, 8, 0] : [0, 8, 16, 24] + return shifts.map { UInt8(truncatingIfNeeded: word >> $0) } + }) + } + + static func machHeader(cpu: UInt32, fileType: UInt32 = 2) -> Data { + words([0xfeedfacf, cpu, 0, fileType, 0, 0, 0, 0]) + } + + func library(_ relative: String = "Steam") throws -> URL { + let library = root.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: library.appendingPathComponent("steamapps/common"), withIntermediateDirectories: true) + return library + } + + @discardableResult + func manifest(in library: URL, id: String = "10", name: String = "First game", directory: String = "First", + filenameID: String? = nil, size: String = "100", extra: String = "") throws -> URL { + let file = library.appendingPathComponent("steamapps/appmanifest_\(filenameID ?? id).acf") + let text = "\"AppState\" { \"appid\" \(Self.quote(id)) \"name\" \(Self.quote(name)) \"installdir\" \(Self.quote(directory)) \"SizeOnDisk\" \(Self.quote(size)) \"buildid\" \"42\" \"StateFlags\" \"4\" \(extra) }" + try Data(text.utf8).write(to: file) + return file + } + + static func quote(_ text: String) -> String { + "\"" + text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + + func inventory() throws -> [String: Data] { + let files = try XCTUnwrap(FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey])) + var result: [String: Data] = [:] + for case let file as URL in files { + if (try file.resourceValues(forKeys: [.isRegularFileKey])).isRegularFile == true { + result[String(file.path.dropFirst(root.path.count))] = try Data(contentsOf: file) + } + } + return result + } + + static func fatHeader(cpus: [UInt32]) -> Data { + var data = words([0xcafebabe, UInt32(cpus.count)], bigEndian: true) + for (i, cpu) in cpus.enumerated() { + data.append(words([cpu, 0, UInt32(8 + cpus.count * 20 + i * 32), 32, 0], bigEndian: true)) + } + for cpu in cpus { data.append(machHeader(cpu: cpu)) } + return data + } +} diff --git a/Tests/PublicationChecks/test_profile_boundaries.py b/Tests/PublicationChecks/test_profile_boundaries.py new file mode 100644 index 0000000..4cb6c1d --- /dev/null +++ b/Tests/PublicationChecks/test_profile_boundaries.py @@ -0,0 +1,19 @@ +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class ProfileBoundaries(unittest.TestCase): + def test_launcher_storage_and_default_preferences_are_centralized(self): + unexpected = [] + for file in (ROOT / 'Sources').rglob('*.swift'): + if file.name == 'AppDataProfile.swift': + continue + source = file.read_text() + if 'UserDefaults.standard' in source or re.search(r'UserDefaults\s*=\s*\.standard', source): + unexpected.append(str(file.relative_to(ROOT)) + ': unscoped preferences') + if re.search(r'\.appendingPathComponent\("Library/Application Support/NXLibrary(?:/|"|\-)', source): + unexpected.append(str(file.relative_to(ROOT)) + ': unscoped launcher directory') + self.assertEqual(unexpected, [], '\n'.join(unexpected)) diff --git a/docs/architecture.md b/docs/architecture.md index 3b873fc..28aac22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ flowchart TD Core --> Network["Cataloghi artwork, metadati e release"] ``` -Il checkout nxnative fornisce estrattore, rewriter, shader, profili dei giochi e runtime dei giochi compilati. NX Library prepara gli argomenti, controlla i requisiti e presenta lo stato delle operazioni. Il pin attuale è `4a0551e4b9abc75ea736006e4dbd30fcac0d03a4`, definito in `AppViewModel.pinnedEngineCommit`. La cache dello stato upstream viene riutilizzata soltanto quando il suo commit di base coincide con il pin. +Il checkout nxnative fornisce estrattore, rewriter, shader, profili dei giochi e runtime dei giochi compilati. NX Library prepara gli argomenti, controlla i requisiti e presenta lo stato delle operazioni. Il pin attuale è `8a5c5e7e1ee606c650fe1aef6959a83009b6a2fc`, definito in `AppViewModel.pinnedEngineCommit`. La cache dello stato upstream viene riutilizzata soltanto quando il suo commit di base coincide con il pin. ## Avvio e stato dell’app @@ -109,7 +109,7 @@ Le viste principali sono Spotlight, Collection e Details. Ricerca, Add Game, Con ### Lingua e regione (non pubblicato, validato dal maintainer) -`AppLanguage` definisce auto/en/it/fr/de/es/pt-BR; `AppLocale` risolve le preferenze ordinate del Mac e una regione indipendente, valida i codici e mappa pt-BR a pt soltanto per Nlib. `AppSettings` conserva i due override in modo additivo: vecchi file e scelte non riconosciute tornano ad Automatic. `NXLibraryApp.init` carica un solo `AppSettingsStore` e configura il locale prima della creazione delle scene; lo stesso store passa a `MainView` e `AppShellView`. Gli initializer delle viste non pubblicano cambi di locale: il controllo sincrono della toolchain può far rientrare il run loop mentre SwiftUI costruisce i propri StateObject. `AppLocalization` pubblica il contesto; la shell lo propaga alle viste tramite `nxAppLocale`, una dipendenza SwiftUI esplicita che non ricrea identità o stato. Settings passa questo valore alla sidebar e `SettingsSection.title(in:)` risolve le categorie nel contesto ricevuto, anche con focus sulle righe. Righe, informazioni, pannelli e suggerimenti condivisi consumano lo stesso valore. `L10n` mantiene al massimo un bundle per ciascuna delle sei lingue; le risorse iniettate restano isolate, senza memorizzare testi interpolati o dati personali. Cattura il contesto per il lavoro asincrono e legge esplicitamente il catalogo compilato, cercando prima nel bundle installato. Nessun servizio di posizione o traduzione esterno viene usato. +`AppLanguage` definisce auto/en/it/fr/de/es/pt-BR; `AppLocale` risolve le preferenze ordinate del Mac e una regione indipendente, valida i codici e mappa pt-BR a pt soltanto per Nlib. `AppSettings` conserva i due override in modo additivo: vecchi file e scelte non riconosciute tornano ad Automatic. Nella linea di generalizzazione `DevelopmentStartupView` prepara il profilo in background prima di costruire gli store e la shell; carica poi un solo `AppSettingsStore`, configura il locale e passa lo store a `MainView` e `AppShellView`. La 0.1.4.3 conservata mantiene il proprio avvio già validato. Gli initializer delle viste non pubblicano cambi di locale: il controllo sincrono della toolchain può far rientrare il run loop mentre SwiftUI costruisce i propri StateObject. `AppLocalization` pubblica il contesto; la shell lo propaga alle viste tramite `nxAppLocale`, una dipendenza SwiftUI esplicita che non ricrea identità o stato. Settings passa questo valore alla sidebar e `SettingsSection.title(in:)` risolve le categorie nel contesto ricevuto, anche con focus sulle righe. Righe, informazioni, pannelli e suggerimenti condivisi consumano lo stesso valore. `L10n` mantiene al massimo un bundle per ciascuna delle sei lingue; le risorse iniettate restano isolate, senza memorizzare testi interpolati o dati personali. Cattura il contesto per il lavoro asincrono e legge esplicitamente il catalogo compilato, cercando prima nel bundle installato. Nessun servizio di posizione o traduzione esterno viene usato. I testi controllati dall'app sono nel catalogo condiviso `NXLibraryCore/Resources/Localizable.xcstrings`, con interpolazioni e plurali. Dati personali, rawValue persistiti, regex, eventi dell'engine, variabili DM_* e identità dei controlli restano indipendenti dalla lingua. I componenti Settings risolvono le etichette alla presentazione e mantengono i rowID originali; le righe coi nomi dei giochi usano titoli verbatim. Search confronta anche i testi tradotti. I testi upstream noti di launcher.json si localizzano solo nella presentazione; valori e note sconosciute conservano il fallback upstream. @@ -318,3 +318,55 @@ codesign --verify --deep --strict "$HOME/Applications/NXLibrary.app" Le prove interattive dell’app e dei giochi sono eseguite dall’utente. Gli agenti svolgono controlli sul codice, test automatici e build/installazione, secondo `AGENTS.md`. Per una modifica, partire dall’area nel catalogo, seguire il collegamento al file e leggere i chiamanti reali. Aggiornare la descrizione quando cambia la responsabilità di un file. I piani datati registrano il lavoro svolto, mentre questa guida e il catalogo descrivono l’organizzazione corrente. + +## Profilo Dev isolato — linea di sviluppo del launcher + +`AppDataProfile` e `AppData` centralizzano percorsi e preferenze del launcher. Il marker di build `NXLibraryDevelopmentProfile` e il bundle ID `local.nxlibrary.development` distinguono la Dev dalla release. `DevelopmentProfileImporter` legge la sorgente senza attivare gli store che la potrebbero riparare, copia una lista dichiarata di dati in staging, verifica gli hash e attiva la destinazione separata. Le preferenze storiche appartengono a una lista esplicita; file di credenziali e nuovi token non vengono copiati per semplice prefisso. Sessioni rimaste aperte nella copia terminano all'ultimo heartbeat registrato. Ripetere l'avvio non riimporta e non sovrascrive la Dev. + +Il recupero dei salvataggi e la riconciliazione automatica delle icone della copia non partono all'avvio. `DevelopmentOperationStore`, escluso dall'import, registra richieste esplicite di build/icone e ne consente la ripresa dopo un riavvio. La pulizia automatica visita solo Temp del profilo Dev, non lo staging sui volumi condivisi. Il factory dei ModStore disabilita la manutenzione automatica: consultazione e aggiornamento delle schede non scrivono l'overlay né eliminano installazioni residue; la lettura usa lo stato reale dell'overlay per non mostrare attivazioni obsolete dei record copiati. Le azioni manuali continuano a operare sui giochi reali. + +La release mantiene i percorsi precedenti; release.sh rifiuta un marker Dev nel pacchetto e AppUpdate rifiuta tale pacchetto come aggiornamento. La copia è asincrona prima della shell e fallisce con Retry/Close, senza ripiego in scrittura sul profilo stabile. Implementazione e prove automatiche non sostituiscono la validazione del maintainer nella Dev. + +`AppShellState` prepara coordinatore, router, lingua e tema sul main actor prima di pubblicare la shell pronta; l'inserimento nella finestra già visibile conserva gli oggetti preparati. Il maintainer ha confermato l'avvio e la libreria Dev il 9 novembre 2026, dopo la correzione del bootstrap. Questo non certifica automaticamente ogni azione su giochi, mod o salvataggi reali. + +## Catalogo comune — attivazione nella Dev da validare + +`CatalogGame` conserva un payload tipizzato: il `PortedGame` completo per nxnative oppure `SteamCatalogGame`. Nome, UUID e scelte personali hanno un solo proprietario; il riferimento alle personalizzazioni conserva chiave e namespace dell'integrazione. Non si costruisce un falso gioco nxnative per Steam. `CatalogGamePresentation` espone installazioni, operazioni supportate e un target di avvio tipizzato, senza avviare servizi o consultare il disco. La disponibilità dell'avvio resta distinta dal supporto dell'operazione; una fonte disattivata non produce un target utilizzabile. + +`CatalogDocument` versione 1 contiene revisioni, fonti abilitate e provenienza della conversione. `CatalogStore` legge input regolari limitati a 16 MiB; converte i byte legacy senza usare i percorsi di riparazione di `LibraryStore`, mantiene UUID/payload/chiavi e lascia `library.json` intatto. Scritture concorrenti usano un lock non bloccante e la revisione attesa. Il candidato viene serializzato, riletto e validato prima della pubblicazione atomica; una copia valida precede la sostituzione del principale. Archivio danneggiato, versione sconosciuta, input legacy cambiato e principale assente con backup richiedono recupero esplicito, mai una libreria vuota automatica. `commitUncertain` segnala un errore della sincronizzazione finale dopo una possibile pubblicazione: il chiamante deve rileggere la revisione prima di ritentare. Il nuovo formato usa i valori Date Codable per conservare le frazioni; solo l'ingresso legacy usa ISO8601. + +`SteamCatalogReconciler` applica snapshot già letti, con ticket legato alla revisione e alla radice configurata. La fusione mantiene UUID, preferiti, nascosti e installazioni storiche; un titolo ambiguo resta da verificare, una scansione parziale conserva le osservazioni non viste, una fonte assente segnala indisponibilità. L'assenza diventa “non rilevato” soltanto con snapshot completo. `needsInstallationReview` conserva l'ambiguità separatamente dalla disponibilità del disco: una fonte assente non risolve un duplicato. Nessun record o file di gioco viene eliminato. + +`CatalogRepository` è ora il confine di scrittura dell'app nella linea di generalizzazione. Carica la versione corrente e applica ogni modifica tipizzata una volta; non ritenta automaticamente operazioni con effetti sui file. Le scansioni confrontano la generazione osservata con build, installazione e sorgenti correnti prima di aggiornare i campi; una richiesta di refresh durante una scansione resta accodata. `AppViewModel` pubblica il catalogo e una proiezione nxnative di sola lettura. Scansioni, icone, sorgenti e risultati delle build usano lo stesso repository; le righe mostrate non vengono mai salvate come libreria di ripiego. `NXNativeLibraryMerge` mantiene un solo algoritmo condiviso con lo store legacy del Core. Il risultato di una build aggiorna soltanto i campi di sua competenza; eventuali risultati non salvati rimangono in `CatalogBuildRecoveries` per il retry della generazione corretta. Le installazioni automatiche attendono la risoluzione di quel risultato; un avviso precedente non sostituisce l’azione Riprova. Il risultato e il menu File mantengono l’azione di salvataggio disponibile dopo la chiusura dell’avviso. + +Il bootstrap prepara catalogo e profilo prima della shell; il modello consuma quel documento senza una seconda lettura fallibile durante l’inizializzazione. L'offerta di ripristino esplicito conserva gli hash di principale e backup: file cambiati invalidano l'offerta, il principale danneggiato viene conservato e il backup rimane intatto. `CatalogPersonalizations` trasporta i riferimenti alle viste e ai loader; i percorsi nxnative restano quelli precedenti, i dati personali Steam hanno una radice separata. L'importatore Dev comprende i nuovi file senza reimportare profili già pronti. + +Questa attivazione è compilata e provata automaticamente, **in attesa di validazione umana nella Dev**. La prima presentazione Steam aggiunge fonti e libreria mista; avvio e sessioni reali restano il passaggio successivo, insieme alla scelta iniziale dell’esperienza. La prova precedente del 9 novembre ha convertito in memoria 10 record completi e 110 riferimenti di sessione, poi aggiunto l'osservazione Steam locale: 11 voci senza scrivere un catalogo reale; 495 hash degli input controllati sono rimasti identici. Il passaggio reale del profilo avviene all'apertura della nuova Dev, non durante le prove dell'agente. + + +### Fonti e presentazione mista nella Dev + +`CatalogSourcesModel` mantiene configurazione/stato della fonte Steam e la richiesta di scansione corrente; il repository rimane l’unico writer del catalogo. La lettura avviene su un worker annullabile. Disattivazione, cambio cartella o nuova richiesta invalidano il risultato precedente. La generazione persistita distingue anche disattivazione/riattivazione sulla stessa radice; nei cataloghi schema 1 precedenti il valore mancante viene letto come una sentinella deterministica. Un risultato valido può essere applicato all’ultima revisione conservando modifiche indipendenti ai preferiti. Scansioni parziali e fonti assenti conservano identità e scelte personali. + +Menu → Manage sources e Settings → Steam · Library permettono di attivare Steam, aggiornare/annullare la ricerca, scegliere la sua cartella e recuperare titoli nascosti o installazioni da controllare. L’avvio scansiona solo Steam già abilitato. L’adozione esplicita di sorgenti/build Switch abilita nxnative nello stesso catalogo; una scansione passiva non attiva un’esperienza non scelta. + +Settings raggruppa le sezioni con `SettingsSidebarGroup`: Search e Changed, General, poi un gruppo per piattaforma (Switch: Library, Games, Engine; Steam: Library) e System (Storage, About). Una sezione di piattaforma contiene solo impostazioni che agiscono sui giochi di quella piattaforma; Search, Changed e il titolo di pagina la chiamano con il nome della piattaforma («Steam · Library»). `SettingsSection.allCases` segue l’ordine della sidebar e le identità restano quelle di prima (la pagina Steam resta `sources`). Le righe solo Switch stanno nel gruppo Switch: Check engine at launch e Setup in Engine (Compatibility vi ripete già il controllo dell’ambiente, quindi Readiness ed Environment non ci sono più), Refresh library at launch e Box covers in Library. Restano dove sono, per scelta: Rumble in Controllers (un comando del controller, con il testo «nuovi giochi Switch»), App icon in Look (è l’icona del Dock) e Storage intero in System, perché è la pagina del disco. Metadata e Artwork cache stanno solo in Storage. + +`CatalogLibraryProjection` seleziona le voci delle fonti attive. Spotlight, Collection e ricerca usano gli stessi record, UUID, filtri e ordine salvato; le viste specifiche ricevono il payload reale. I giochi Steam mostrano l’icona della propria app Mac e la fonte, senza interrogare servizi Nintendo o riscrivere bundle. Hanno la stessa testata, pagina e Opzioni dei giochi nxnative (vedi sotto); non esiste più una scheda Details separata. I titoli nascosti restano recuperabili dalle fonti. I menu e i comandi specifici nxnative ricevono soltanto `PortedGame`: selezionare Steam non può avviare una build o aprire salvataggi/mod Switch. + +Nuovi testi e plurali sono disponibili nelle sei lingue, con dipendenza esplicita dal contesto locale nelle viste introdotte. Questa tappa è **in attesa di validazione umana**; non dichiara completati Play Steam, cronologia Steam o ingresso Mac/Switch/entrambi. Le precedenti verifiche manuali del catalogo, del gate e delle build restano distinte. + + +### Arricchimento Steam nella Dev + +SteamMetadataService mantiene i dati pubblici dello store separati dalle osservazioni delle installazioni e dalle scelte personali. Cache testuale per AppID/lingua e immagini per AppID/slot o hash dell’URL, trasporto HTTPS limitato, richieste condivise e ripiego sui dati validi già presenti. Non viene creato un NintendoStoreMetadata o PortedGame fittizio. SteamArtwork e le viste Steam consumano i dati tipizzati; i percorsi e i servizi nxnative restano indipendenti. SteamPersonalizationStore pubblica testo e riferimenti alle immagini con un solo manifest atomico; le immagini nuove vengono preparate prima e un errore conserva tutte le scelte precedenti. Le richieste condivise contano i lettori interessati, annullano il lavoro rimasto senza lettori, limitano i trasferimenti a sei e rifiutano i redirect; deadline totale di venti secondi e backoff anche sui contenuti invalidi. Le immagini scelte restano interne al launcher. Implementazione verificata automaticamente, in attesa della prova del maintainer. + +### Esperienza condivisa Steam nella Dev (in attesa di validazione umana) + +Un gioco Steam usa i pezzi Selene dei giochi nxnative: `GameLogoArea`, `GamePrimaryActions`, `SeleneStatsRow`, l’arte di `SeleneHeroArt` con la stessa dissolvenza e la pagina sotto la testata (screenshot, About, attività, `SteamInstallationPanel`). `GameDetailPresentation(steamRecord:)` fornisce i fatti senza dichiarare nulla di nxnative. `SteamGameFacts` condivide i dati store per AppID e lingua fra testata, pagina e input; un’assenza di dati viene richiesta di nuovo dopo un minuto. Lo sfondo automatico (`SteamBackdropChoice`) preferisce immagini in formato schermo (Xbox, GOG, TheGamesDB, poi la fan art LaunchBox della voce Mac o Windows), poi il primo screenshot e per ultimo il banner della libreria Steam, ritagliato; senza banner mostra la prima immagine che si carica. La scelta automatica resta in cache: svuotare la cache artwork la ricalcola. La scelta personale vince sempre. Sfondo, logo e tile Steam si decodificano fuori dal thread principale con `GameArtLoader.decode` e le misure dei gemelli nxnative (sfondo 2560 px, logo 1600, copertina 900, icona 512): un `NSImage(contentsOf:)` si decodifica pigramente al primo disegno e costava a quel frame 40–60 ms. + +Play (`SteamPlayState`, `SteamPlayCoordinator`) invia solo `steam://run/` al gestore registrato con bundle `com.valvesoftware.steam`; non apre mai l’app direttamente. La sessione inizia quando il processo dell’app installata (stesso bundle ed eseguibile) è osservato e termina quando esce; Steam aperto non conta e un gioco avviato da Steam viene seguito allo stesso modo. Le risposte tardive sono scartate per generazione, l’attesa scade dopo 300 s, un pid riusato non è il gioco. Una sessione in corso segue il processo che ha visto partire (pid e bundle), anche se nel frattempo il catalogo perde l’installazione o la indica in un altro percorso. Il coordinatore non osserva nulla prima di `start`: `RunningGameMonitor` chiude le sessioni Steam rimaste aperte all’ultimo heartbeat e un gioco ancora in esecuzione ne apre una nuova. Il coordinatore segue i processi con l’osservazione di `runningApplications` (avvii e chiusure arrivano una volta sola), risolve i percorsi di ogni pid una volta finché vive e quelli dell’installazione una volta per controllo; il confronto è tra percorsi già risolti, senza accessi al disco. + +Con «Keep Steam in the background» (`AppSettings.steamInBackground`, regole pure in `SteamBackgroundLaunch`) uno Steam avviato da Play parte con `-silent -nofriendsui` (opzioni documentate da Valve, valide solo se Steam è chiuso) e uno Steam già aperto viene nascosto finché il gioco è in arrivo, al massimo 15 s. Poi Steam torna visibile senza prendere il fuoco e la testata invita a controllarlo; finito Play torna visibile solo se lo era prima. Valve non offre un modo per tenere fuori vista uno Steam aperto: nascondere è un ripiego e potrebbe non riuscire su alcune versioni del client. + +Opzioni (`SteamOptionsView`, righe da `SteamOptionsPresentation`) hanno l’aspetto e l’input di Game options: Play, Edit details (Change artwork, Change metadata, Store page), Move (solo Spotlight), Favorite, Hidden, Show in Finder. `PersonalizationSubject` fa lavorare i pannelli condivisi su un gioco Steam: arte Steam dell’AppID, fonti online e SteamGridDB, scelte in `SteamPersonalizationStore`, mai l’icona dell’app installata. `AppViewModel.isGameOptionsOpen` sostituisce i controlli sul solo pannello nxnative, così lo schermo sotto smette di leggere l’input. diff --git a/docs/development.md b/docs/development.md index d911e2f..74e3f2a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,9 +22,9 @@ bash scripts/build_app.sh codesign --verify --deep --strict "$HOME/Applications/NXLibrary Dev.app" ~~~ -The default destination is `~/Applications/NXLibrary Dev.app`, named **NX Library Dev**: the copy every change is tested in, kept apart from the released **NX Library** (`~/Applications/NX Library.app`, installed from GitHub and updated by itself), which a development build never replaces. Both use the bundle ID `local.nxlibrary`, so they share the library, keys and settings, and macOS runs only one at a time: quit one before opening the other. `build_app.sh` compiles the Release configuration, creates the bundle in staging, includes resources and notices, ad-hoc signs and verifies it, then installs it through the same atomic installer used by the application. It does not launch the app. +The default destination is `~/Applications/NXLibrary Dev.app`, named **NX Library Dev**: the copy every change is tested in, kept apart from the released **NX Library** (`~/Applications/NX Library.app`, installed from GitHub and updated by itself), which a development build never replaces. On the launcher-development branch, Dev uses bundle ID `local.nxlibrary.development`, app data under `~/Library/Application Support/NXLibrary-Development`, a separate cache root and preference domain. At its first launch it prepares an independent, one-time copy of selected launcher data; games and build caches are not copied, and credential files are excluded. Close the previous launcher copy before that initial import. The held 0.1.4.3 branch retains its previously validated behavior. `build_app.sh` compiles the Release configuration, creates the bundle in staging, includes resources and notices, ad-hoc signs and verifies it, then installs it through the same atomic installer used by the application. It does not launch the app. -A development copy carries the version in progress from the `VERSION` file at the repository root and shows it as "(development build)" in Settings; it has no release marker, so it never offers or installs app updates. `scripts/release.sh ` sets the released version itself and refuses to run unless `VERSION` already says that version. After a release, raise `VERSION` to the next version being worked on. +A development copy carries the version in progress from the `VERSION` file at the repository root and shows it as "(Dev · Launcher)" on this development line in Settings; it has no release marker, so it never offers or installs app updates. `scripts/release.sh ` sets the released version itself and refuses to run unless `VERSION` already says that version. After a release, raise `VERSION` to the next version being worked on. An alternate destination can be supplied without replacing the installed development copy: @@ -32,7 +32,7 @@ An alternate destination can be supplied without replacing the installed develop bash scripts/build_app.sh /tmp/NXLibrary-Review.app ~~~ -A plain development app uses bundle ID `local.nxlibrary`, defaults to version `0.1.0` and has no release marker. It never installs its own updates. +A plain development app uses bundle ID `local.nxlibrary.development`, takes its version from `VERSION` and has no release marker. It never installs its own updates. ### Bundled runtime @@ -59,6 +59,7 @@ python3 scripts/check_publication.py python3 scripts/check_localizations.py python3 -m unittest discover -s Tests/PublicationChecks DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer python3 Tests/CatalogAppChecks/run_notices.py bash scripts/build_app.sh codesign --verify --deep --strict "$HOME/Applications/NXLibrary Dev.app" ~~~ @@ -146,3 +147,17 @@ For component interactions, read the [architecture guide](architecture.md). For ### Localization resources SwiftPM processes the shared string catalog into `NXLibrary_NXLibraryCore.bundle`. `build_app.sh` requires and copies that bundle into `Contents/Resources`, declares the six supported app languages and validates the compiled strings/plurals before signing. `scripts/check_localizations.py --app ` verifies the same resources independently. Missing resources or placeholder mismatches fail packaging. Locale selection is independent from each game's DM_LANG setting. + +### Isolated launcher-development data + +`AppDataProfile` selects immutable roots from the explicit build marker, and `AppData` routes app-owned stores/default preferences through that policy. `DevelopmentStartupView` completes the background import before constructing app stores or the shell. A failure shows Retry/Close; it never opens a writable fallback on the release profile. The source is read directly, not through store load/recovery methods. A staged, hashed allowlist copy and preference import can resume after failure; a completed profile is never automatically reimported. Copied unfinished sessions end at their last recorded heartbeat. + +After the import, `AppShellState` creates the view model and initial router and applies the initial locale/theme on the main actor before publishing the ready shell. Views retain these prepared objects; they do not run model startup or mutate shared theme state as SwiftUI inserts the shell into an already-visible window. Startup logs record only phase boundaries, without library contents or preference values. + +The separate bundle IDs select separate standard preference domains. Do not open the app's own bundle ID with `UserDefaults(suiteName:)`: Foundation rejects that suite. Packaging runs `scripts/check_app_profile.swift` in a temporary headless app with the built Info.plist, exercising the real preferences accessor and profile roots without starting UI, running the importer or writing data. Ordinary unit-test hosts do not reproduce the packaged app's identity. + +The Dev does not automatically import credentials or restore saves, reconcile app icons, install copied pending builds, sync real mod overlays, or sweep shared external staging. Explicit Dev icon/build requests are recorded in `DevelopmentOperationStore` and can resume on restart. When automatic mod maintenance is disabled, browsing reads the actual overlay without rewriting it or cleaning another profile's staging. + +The isolation covers launcher data, not the physical game installations and their saves. Explicit Play, game settings, builds, save/mod actions and cleanup still refer to real resources. Do not describe this as a sandbox of all game effects. Release packaging excludes the development marker, and the updater rejects a release that also selects the development profile. Initial Dev import and native interaction remain awaiting maintainer validation. + +Launch the packaged Dev for app checks: an unmarked executable cannot silently choose the stable data profile. Build markers and bundle identity must agree before the startup transaction opens any app store. diff --git a/docs/file-map.md b/docs/file-map.md index 0508861..f066ad6 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -123,7 +123,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT | [Sources/NXLibraryCore/Models/TileMotion.swift](../Sources/NXLibraryCore/Models/TileMotion.swift) | Curve e campioni dei loop di ring, bagliore e riflesso della tile selezionata, sull'orologio condiviso. | | [Sources/NXLibraryCore/Models/TextScroll.swift](../Sources/NXLibraryCore/Models/TextScroll.swift) | Scorrimento della colonna del pannello della mod: altezza della descrizione dopo Read more, limiti, riga a fuoco mostrata intera e passo Su/Giù che legge prima della riga seguente. | | [Sources/NXLibraryCore/Models/AppSettings.swift](../Sources/NXLibraryCore/Models/AppSettings.swift) | Definisce impostazioni globali, default e decodifica compatibile dei campi aggiunti nel tempo. | -| [Sources/NXLibraryCore/Models/LibraryFilter.swift](../Sources/NXLibraryCore/Models/LibraryFilter.swift) | Definisce filtri, PlayHistory e LibraryQuery per ricerca e ordinamento dei giochi. | +| [Sources/NXLibraryCore/Models/LibraryFilter.swift](../Sources/NXLibraryCore/Models/LibraryFilter.swift) | Definisce filtri, PlayHistory e query condivisa per ricerca/ordine del catalogo misto, conservando l’API nxnative. | | [Sources/NXLibraryCore/Models/LibrarySelectionState.swift](../Sources/NXLibraryCore/Models/LibrarySelectionState.swift) | Mantiene selezione e spostamento nel risultato filtrato della Collection. | | [Sources/NXLibraryCore/Models/LibraryPresentation.swift](../Sources/NXLibraryCore/Models/LibraryPresentation.swift) | Formatta metadati e statistiche temporali per le schermate della libreria. | | [Sources/NXLibraryCore/Models/GameDetailPresentation.swift](../Sources/NXLibraryCore/Models/GameDetailPresentation.swift) | Prepara badge, disponibilità delle azioni, date e dati della pagina Details. | @@ -292,9 +292,9 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT | [Sources/NXLibraryCore/Models/SeleneCanvas.swift](../Sources/NXLibraryCore/Models/SeleneCanvas.swift) | Calcola scala uniforme, stage centrato e conversione delle unità del canvas 1920×1080. | | [Sources/NXLibraryCore/Models/SelenePalette.swift](../Sources/NXLibraryCore/Models/SelenePalette.swift) | Decodifica palette, ordine del selettore e fallback della palette predefinita. | | [Sources/NXLibraryCore/Models/SettingsCatalog.swift](../Sources/NXLibraryCore/Models/SettingsCatalog.swift) | Descrive righe, ricerca e valori modificati, distinguendo i default dei nuovi giochi dalla vibrazione in Controllers. | -| [Sources/NXLibraryCore/Models/SettingsSection.swift](../Sources/NXLibraryCore/Models/SettingsSection.swift) | Enumera gruppi, titoli e icone della schermata Settings. | +| [Sources/NXLibraryCore/Models/SettingsSection.swift](../Sources/NXLibraryCore/Models/SettingsSection.swift) | Sezioni di Settings con titoli e icone, e `SettingsSidebarGroup`: gruppi generali e di piattaforma in ordine di sidebar. | | [Sources/NXLibraryCore/Models/SpotlightPage.swift](../Sources/NXLibraryCore/Models/SpotlightPage.swift) | Calcola estratti delle descrizioni e stati dello scorrimento della pagina Spotlight. | -| [Sources/NXLibraryCore/Models/SpotlightRow.swift](../Sources/NXLibraryCore/Models/SpotlightRow.swift) | Costruisce la sequenza di card e giochi, riordino e selezione Surprise. | +| [Sources/NXLibraryCore/Models/SpotlightRow.swift](../Sources/NXLibraryCore/Models/SpotlightRow.swift) | Costruisce la sequenza per UUID e salva riordini conservando giochi/fonti/card temporaneamente nascosti; Surprise mantiene il proprio contratto nxnative. | | [Sources/NXLibraryCore/Models/SpotlightRowGeometry.swift](../Sources/NXLibraryCore/Models/SpotlightRowGeometry.swift) | Posti delle tessere nella riga, camera unica che la fa scorrere e finestra delle tessere create; posto, scala e camera lungo lo scorrimento dalla posizione continua della selezione. | | [Sources/NXLibraryCore/Models/SpotlightState.swift](../Sources/NXLibraryCore/Models/SpotlightState.swift) | Gestisce selezione, focus, azioni e spostamento delle tile su Spotlight. | | [Sources/NXLibraryCore/Models/ThemePicker.swift](../Sources/NXLibraryCore/Models/ThemePicker.swift) | Gestisce griglia, tab Wallpapers/Live e scelte di sfondo nel selettore temi. | @@ -366,7 +366,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT | File | Responsabilità | |---|---| | [Sources/NXLibrary/Views/Settings/SettingsView.swift](../Sources/NXLibrary/Views/Settings/SettingsView.swift) | Compone sidebar e gruppo attivo e coordina focus, input e reset delle impostazioni. | -| [Sources/NXLibrary/Views/Settings/SettingsSidebar.swift](../Sources/NXLibrary/Views/Settings/SettingsSidebar.swift) | Disegna intestazione e lista dei gruppi della schermata Settings. | +| [Sources/NXLibrary/Views/Settings/SettingsSidebar.swift](../Sources/NXLibrary/Views/Settings/SettingsSidebar.swift) | Disegna intestazione e sezioni sotto le intestazioni di gruppo (General, Switch, Steam, System). | | [Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift](../Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift) | Instrada il gruppo selezionato e fornisce sottotitoli e formattazioni condivise. | | [Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift](../Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift) | Presenta ricerca e Changed, con apertura del gruppo della riga selezionata. | | [Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift](../Sources/NXLibrary/Views/Settings/SettingsStartUpSection.swift) | Espone schermata iniziale, intro, Setup e controlli degli aggiornamenti all’avvio. | @@ -659,3 +659,99 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | [Tests/NXLibraryCoreTests/AppLocaleTests.swift](../Tests/NXLibraryCoreTests/AppLocaleTests.swift) | Preferenze, migrazione, formati, ricerca, identità e traduzioni native. | | [scripts/check_localizations.py](../scripts/check_localizations.py) | Verifica copertura, placeholder, plurali e risorse nel bundle installato. | | [Tests/PublicationChecks/test_localizations.py](../Tests/PublicationChecks/test_localizations.py) | Rifiuto di cataloghi incompleti e bundle senza risorse. | + +### Profilo di sviluppo del launcher + +| File | Responsabilità | +|---|---| +| [AppDataProfile.swift](../Sources/NXLibraryCore/Services/AppDataProfile.swift) | Policy di percorsi, cache, preferenze e staging del profilo. | +| [DevelopmentProfileImporter.swift](../Sources/NXLibraryCore/Services/DevelopmentProfileImporter.swift) | Copia iniziale selettiva, hash, validazione e ripresa senza modificare la sorgente. | +| [DevelopmentOperationStore.swift](../Sources/NXLibraryCore/Services/DevelopmentOperationStore.swift) | Autorizzazioni persistenti per le operazioni richieste nella Dev, distinte da quelle importate. | +| [DevelopmentStartupView.swift](../Sources/NXLibrary/Views/Launch/DevelopmentStartupView.swift) | Avvio asincrono, errore recuperabile e costruzione della shell dopo l'importazione. | +| [AppShellState.swift](../Sources/NXLibrary/Views/Shell/AppShellState.swift) | Prepara coordinatore, router, fonti, lingua e tema prima dell’inserimento della libreria nel grafo SwiftUI. | +| [DevelopmentProfileTests.swift](../Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift) | Copia, conservazione della sorgente, errori, sessioni, symlink e ripetizione. | +| [DevelopmentProfileEffectTests.swift](../Tests/NXLibraryCoreTests/DevelopmentProfileEffectTests.swift) | Mod, staging e persistenza delle richieste esplicite. | +| [test_profile_boundaries.py](../Tests/PublicationChecks/test_profile_boundaries.py) | Guardia contro percorsi/defaults del launcher non centralizzati. | +| [check_app_profile.swift](../scripts/check_app_profile.swift) | Prova senza UI di identità, accesso reale alle preferenze e percorsi nel pacchetto prodotto dalla build; non importa né scrive dati. | + +### Rilevamento Steam — modulo in sviluppo + +| File | Responsabilità | +|---|---| +| [SteamBackgroundLaunch.swift](../Sources/NXLibraryCore/Services/Steam/SteamBackgroundLaunch.swift) | Regole di «Keep Steam in the background»: argomenti `-silent`, attesa nascosta di 15 s, ritorno di Steam. | +| [SteamKeyValues.swift](../Sources/NXLibraryCore/Services/Steam/SteamKeyValues.swift) | Lettura limitata del formato testuale con campi duplicati conservati e accesso non ambiguo; nessuna direttiva eseguita. | +| [InstallationFileSnapshot.swift](../Sources/NXLibraryCore/Services/Steam/InstallationFileSnapshot.swift) | Letture limitate e non bloccanti di file regolari; verifica dell'identità prima della pubblicazione. | +| [InstallationDirectorySnapshot.swift](../Sources/NXLibraryCore/Services/Steam/InstallationDirectorySnapshot.swift) | Lettura incrementale delle cartelle, budget anche sulle voci nascoste e annullamento durante l'enumerazione. | +| [NativeMacApplication.swift](../Sources/NXLibraryCore/Services/Steam/NativeMacApplication.swift) | Evidenze del bundle Mac e degli header eseguibili, architetture e categoria dichiarata, senza avvio. | +| [SteamLibrarySource.swift](../Sources/NXLibraryCore/Services/Steam/SteamLibrarySource.swift) | Snapshot delle librerie/manifest dichiarati, disponibilità, ambiguità, limiti e annullamento; non modifica il catalogo. | +| [SteamKeyValuesTests.swift](../Tests/NXLibraryCoreTests/SteamKeyValuesTests.swift) | Formato, escape, Unicode, duplicati, input troncati e limiti del parser. | +| [NativeMacApplicationTests.swift](../Tests/NXLibraryCoreTests/NativeMacApplicationTests.swift) | Fixture native, classificazione, traversal/link, metadata non regolari e header invalidi. | +| [SteamLibrarySourceTests.swift](../Tests/NXLibraryCoreTests/SteamLibrarySourceTests.swift) | Più fonti, dischi assenti, identità, file cambiati, limiti e assenza di scritture. | +| [SteamDiscoveryConsistencyTests.swift](../Tests/NXLibraryCoreTests/SteamDiscoveryConsistencyTests.swift) | Mutazioni reali di inventario e app durante scan, indice inizialmente assente, budget e propagazione dell'annullamento. | +| [SteamTestSupport.swift](../Tests/NXLibraryCoreTests/SteamTestSupport.swift) | Genera solo metadati e header sintetici nelle directory temporanee dei test. | + +## Catalogo comune (attivazione nella Dev in attesa di validazione) + +| File | Responsabilità | +|---|---| +| [CatalogGame.swift](../Sources/NXLibraryCore/Models/CatalogGame.swift) | Record tipizzato nxnative/Steam, identità e personalizzazioni con namespace, osservazioni delle installazioni. | +| [CatalogDocument.swift](../Sources/NXLibraryCore/Models/CatalogDocument.swift) | Schema, revisione, provenienza legacy, configurazione delle fonti e validazione delle identità. | +| [CatalogGamePresentation.swift](../Sources/NXLibraryCore/Models/CatalogGamePresentation.swift) | Contratto comune per presentazione, operazioni supportate e target di avvio; nessuna operazione I/O. | +| [CatalogStore.swift](../Sources/NXLibraryCore/Services/Catalog/CatalogStore.swift) | Conversione conservativa, letture limitate, revisioni attese e serializzazione degli scrittori. | +| [CatalogPublication.swift](../Sources/NXLibraryCore/Services/Catalog/CatalogPublication.swift) | Staging verificato, creazione esclusiva e sostituzione atomica, con confine di errore controllabile. | +| [SteamCatalogReconciler.swift](../Sources/NXLibraryCore/Services/Catalog/SteamCatalogReconciler.swift) | Ticket e fusione pura degli snapshot Steam, identità conservate e assenza trattata secondo completezza. | +| [CatalogModelTests.swift](../Tests/NXLibraryCoreTests/CatalogModelTests.swift) | Conservazione del record precedente, proprietà comuni e separazione delle azioni per integrazione. | +| [CatalogStoreTests.swift](../Tests/NXLibraryCoreTests/CatalogStoreTests.swift) | Conversione, backup, corruzione, versioni, input cambiati, scrittori concorrenti e guasti di pubblicazione. | +| [SteamCatalogReconcilerTests.swift](../Tests/NXLibraryCoreTests/SteamCatalogReconcilerTests.swift) | Identità, aggiornamenti, installazioni ambigue, fonti assenti e scansioni obsolete/parziali. | +| [CatalogTestSupport.swift](../Tests/NXLibraryCoreTests/CatalogTestSupport.swift) | Record legacy sintetico completo e scrittura di fixture; nessun contenuto di gioco. | + +| File | Responsabilità aggiunta per l'attivazione | +|---|---| +| [CatalogRepository.swift](../Sources/NXLibraryCore/Services/Catalog/CatalogRepository.swift) | Modifiche tipizzate sull'ultimo catalogo, senza replay di effetti esterni; risultati build limitati ai propri campi. | +| [NXNativeLibraryMerge.swift](../Sources/NXLibraryCore/Services/NXNativeLibraryMerge.swift) | Unico merge nxnative condiviso tra archivio precedente e catalogo comune. | +| [CatalogRecovery.swift](../Sources/NXLibraryCore/Models/CatalogRecovery.swift) | Offerta di recupero con impronte del principale/backup, rivalidata all'accettazione. | +| [CatalogPersonalizations.swift](../Sources/NXLibraryCore/Models/CatalogPersonalizations.swift) | Lookup personali immutabili per identità e radici distinte per integrazione. | +| [CatalogErrorCopy.swift](../Sources/NXLibraryCore/Services/Catalog/CatalogErrorCopy.swift) | Errori recuperabili del catalogo nelle sei lingue. | +| [AppViewModel+Catalog.swift](../Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift) | Rilettura, errori e retry del risultato di build senza libreria di ripiego. | +| [CatalogPersonalizationsEnvironment.swift](../Sources/NXLibrary/Views/Theme/CatalogPersonalizationsEnvironment.swift) | Dipendenza SwiftUI esplicita per le chiavi personali stabili. | +| [CatalogRepositoryTests.swift](../Tests/NXLibraryCoreTests/CatalogRepositoryTests.swift) | Modifiche concorrenti, confini per integrazione, campi build e conservazione di Steam/legacy. | +| [CatalogRecoveryTests.swift](../Tests/NXLibraryCoreTests/CatalogRecoveryTests.swift) | Recupero esplicito, offerte obsolete, file conservati e importazione del catalogo Dev. | +| [CatalogPersonalizationTests.swift](../Tests/NXLibraryCoreTests/CatalogPersonalizationTests.swift) | Rinomina senza perdita del lookup e isolamento di chiavi uguali tra fonti. | + +| [CatalogBuildRecoveries.swift](../Sources/NXLibraryCore/Models/CatalogBuildRecoveries.swift) | Generazioni di build non ancora salvate, selezione del retry e conferma della sola generazione persistita. | +| [CatalogBuildRecoveryTests.swift](../Tests/NXLibraryCoreTests/CatalogBuildRecoveryTests.swift) | Vecchia build persistita e nuova conservata, sospensione automatica e conferme obsolete. | +| [run_notices.py](../Tests/CatalogAppChecks/run_notices.py) | Esegue i metodi reali degli avvisi e del ponte legacy senza finestre, dopo swift test. | +| [run_game_header.py](../Tests/CatalogAppChecks/run_game_header.py) | Esegue i corpi reali di logo e azioni condivisi senza finestra né store applicativi, per entrambi i layout. | +| [NoticeHost.swift](../Tests/CatalogAppChecks/NoticeHost.swift) | Host minimo per i controlli applicativi, con storage reale e dati sintetici temporanei. | + + +## Fonti e libreria mista (Dev, in attesa di validazione) + +| File | Responsabilità | +|---|---| +| [CatalogSourcesModel.swift](../Sources/NXLibraryCore/Services/Catalog/CatalogSourcesModel.swift) | Opt-in, worker annullabile e stato Steam; pubblica solo documenti salvati dal repository. | +| [CatalogLibraryProjection.swift](../Sources/NXLibraryCore/Models/CatalogLibraryProjection.swift) | Visibilità comune per fonti attive e giochi riconosciuti; nessuna scansione. | +| [AppViewModel+Sources.swift](../Sources/NXLibrary/ViewModels/AppViewModel+Sources.swift) | Proiezione visibile, selezione comune e modifiche personali tipizzate. | +| [SettingsSourcesSection.swift](../Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift) | Attivazione, refresh/cancel, cartella Steam, revisione e recupero dei nascosti. | +| [CatalogNativeArtwork.swift](../Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift) | Icona nativa in sola lettura e indicatore Steam; nessun artwork Nintendo. | +| [CatalogSteamInfo.swift](../Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift) | Intestazione condivisa nelle due librerie: Play/Riprendi/Annulla/Riprova, Opzioni e preferito, con la nota di stato. | +| [SteamPlayCoordinator.swift](../Sources/NXLibrary/Services/SteamPlayCoordinator.swift) | Play tramite il client Steam verificato e sessioni reali dal processo dell’app installata. | +| [SteamOptionsView.swift](../Sources/NXLibrary/Views/GameOptions/SteamOptionsView.swift) | Opzioni di un gioco Steam con l’aspetto e l’input di Game options: Play, Edit details, Move, preferito, nascosto, Finder. | +| [PersonalizationSubject.swift](../Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift) | Il gioco che Change artwork e Change metadata personalizzano: scelte nxnative o archivio personale Steam, senza icona Mac per Steam. | +| [CatalogSourcesRepositoryTests.swift](../Tests/NXLibraryCoreTests/CatalogSourcesRepositoryTests.swift) | Compatibilità generazioni, cambi fonte, preferenze concorrenti e adozione NX esplicita. | +| [CatalogSourcesModelTests.swift](../Tests/NXLibraryCoreTests/CatalogSourcesModelTests.swift) | Opt-in, annullamento, risultati vecchi, cambio radice e recupero errori. | +| [CatalogLibraryQueryTests.swift](../Tests/NXLibraryCoreTests/CatalogLibraryQueryTests.swift) | Visibilità, ricerca/filtri misti e selezione per UUID senza sessioni simulate. | + + +## Metadati Steam (Dev, in attesa di validazione) + +| File | Responsabilità | +|---|---| +| [SteamMetadata.swift](../Sources/NXLibraryCore/Services/Steam/SteamMetadata.swift) | Dati store Steam tipizzati, lingua e URL immagini verificati. | +| [SteamMetadataService.swift](../Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift) | Richieste limitate/coalescenti, cache per lingua, ripiego offline e immagini ridimensionate. | +| [SteamArtwork.swift](../Sources/NXLibrary/Views/Catalog/SteamArtwork.swift) | Immagini e sfondi Steam, con precedenza alle scelte personali e annullamento dei risultati UI obsoleti. | +| [SteamPersonalizations.swift](../Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift) | Archivio delle scelte personali Steam (metadati e artwork), modificate da Change artwork e Change metadata. | +| [SteamMetadataTests.swift](../Tests/NXLibraryCoreTests/SteamMetadataTests.swift) | Identità, lingue, offline, URL, concorrenza e pubblicazione dopo salvataggio riuscito. | + +| [SteamPersonalizationStore.swift](../Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift) | Pubblicazione unica di metadati e riferimenti artwork; fallimenti e Automatic conservano le scelte coerenti. | + diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 43e60c9..f3977be 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -4,16 +4,18 @@ # marks a release copy, the only kind that updates itself. scripts/release.sh sets both; a plain build sets # neither, so a development copy carries the version being worked on and says it is a development build. # A development copy is "NX Library Dev" (default ~/Applications/NXLibrary Dev.app), the maintainer's test copy, -# beside the released "NX Library" that updates itself. Both keep the bundle ID local.nxlibrary, so they share the -# library, keys and settings, and macOS runs only one of them at a time. +# beside the released "NX Library" that updates itself. The development marker selects separate launcher +# data/preferences and a distinct bundle ID, so framework-owned preferences are separate too. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" if [ "${NX_RELEASE:-0}" = "1" ]; then NAME="NX Library" + BUNDLE_ID="local.nxlibrary" APP="${1:-$HOME/Applications/NX Library.app}" else NAME="NX Library Dev" + BUNDLE_ID="local.nxlibrary.development" APP="${1:-$HOME/Applications/NXLibrary Dev.app}" fi CONFIG="${NX_BUILD_CONFIG:-release}" @@ -22,7 +24,7 @@ if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]]; then echo "error: NX_VERSION must be dotted numbers, got '$VERSION'" >&2 exit 1 fi -RELEASE_KEY="" +RELEASE_KEY=" NXLibraryDevelopmentProfile" [ "${NX_RELEASE:-0}" = "1" ] && RELEASE_KEY=" NXLibraryRelease" if [ -z "${DEVELOPER_DIR:-}" ]; then if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then @@ -78,7 +80,7 @@ cat > "$APP/Contents/Info.plist" <CFBundleDisplayName$NAME CFBundleExecutableNXLibrary CFBundleIconFileAppIcon - CFBundleIdentifierlocal.nxlibrary + CFBundleIdentifier$BUNDLE_ID CFBundleInfoDictionaryVersion6.0 CFBundleName$NAME CFBundlePackageTypeAPPL @@ -96,6 +98,15 @@ $RELEASE_KEY PLIST +# Exercise the real profile/defaults accessor under this bundle identity, without +# opening the app or importing data. Unit-test hosts have a different identity. +PROFILE_PROBE="$STAGE/ProfileCheck.app" +mkdir -p "$PROFILE_PROBE/Contents/MacOS" +cp "$APP/Contents/Info.plist" "$PROFILE_PROBE/Contents/Info.plist" +swiftc -I "$ROOT/.build/$CONFIG" "$ROOT/scripts/check_app_profile.swift" \ + "$ROOT/.build/$CONFIG/NXLibraryCore.o" -o "$PROFILE_PROBE/Contents/MacOS/NXLibrary" +"$PROFILE_PROBE/Contents/MacOS/NXLibrary" + python3 "$ROOT/scripts/check_localizations.py" --app "$APP" codesign --force --deep --sign - "$APP" codesign --verify --deep --strict "$APP" diff --git a/scripts/check_app_profile.swift b/scripts/check_app_profile.swift new file mode 100644 index 0000000..643ce46 --- /dev/null +++ b/scripts/check_app_profile.swift @@ -0,0 +1,13 @@ +// Run as a headless executable inside a temporary app with the built app's Info.plist. +// Reading the actual defaults accessor catches startup failures hidden by unmarked unit-test hosts. +// This probe never runs the importer or writes preferences or launcher data. +import Foundation +import NXLibraryCore + +try AppData.validateBuildIdentity(Bundle.main.infoDictionary ?? [:]) +let defaults = AppData.preferences +precondition(defaults === UserDefaults.standard, "The distinct bundle ID must select the app's own preferences") +precondition(Bundle.main.bundleIdentifier == AppData.profile.preferencesDomain, "Preferences must belong to this profile") +precondition(AppData.profile.isDevelopment == (AppData.supportRoot.lastPathComponent == "NXLibrary-Development")) +precondition(AppData.profile.isDevelopment == (AppData.cacheRoot.lastPathComponent == "NXLibrary-Development")) +print("Packaged profile identity, preferences and roots verified without UI or data writes.") diff --git a/scripts/release.sh b/scripts/release.sh index 678bb68..1049170 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -73,6 +73,9 @@ mkdir -p "$OUT" NX_VERSION="$VERSION" NX_RELEASE=1 bash "$ROOT/scripts/build_app.sh" "$APP" codesign --verify --deep --strict "$APP" +if /usr/libexec/PlistBuddy -c 'Print :NXLibraryDevelopmentProfile' "$APP/Contents/Info.plist" >/dev/null 2>&1; then + fail "a release must not select the development data profile" +fi [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" ] \ || fail "the built app does not carry version $VERSION" [ "$(/usr/libexec/PlistBuddy -c 'Print :NXLibraryRelease' "$APP/Contents/Info.plist")" = "true" ] \