From 107ad0926d549f010afd2b140d3dc177bde057cd Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 17:54:57 +0100 Subject: [PATCH 01/27] wip: generalization checkpoint (catalog, Steam sources, shared presentation) Catalog repository and migration, Dev profile, Steam discovery, visibility and metadata, and the first half of the shared game experience plan (common models, GameLogoArea, GamePrimaryActions). Rebased on the published 0.1.4.3 (nxnative 8a5c5e7e). Known gap: SteamArtworkRequest is referenced by its test but not implemented yet, so the test target does not compile. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 + Sources/NXLibrary/NXLibraryApp.swift | 18 +- Sources/NXLibrary/Services/AppUpdater.swift | 5 +- .../Services/GameControllerService.swift | 6 +- .../Services/NativeMenuCommands.swift | 1 + .../ViewModels/AppViewModel+AppIcon.swift | 17 +- .../ViewModels/AppViewModel+Catalog.swift | 51 + .../ViewModels/AppViewModel+Controller.swift | 5 +- .../ViewModels/AppViewModel+Mods.swift | 5 +- .../AppViewModel+PendingInstall.swift | 100 +- .../ViewModels/AppViewModel+Saves.swift | 4 +- .../ViewModels/AppViewModel+Sources.swift | 28 + .../NXLibrary/ViewModels/AppViewModel.swift | 332 +- .../ViewModels/GameBananaServices.swift | 4 +- .../NXLibrary/ViewModels/ModStoreHost.swift | 3 +- .../ViewModels/SetupCheckModel.swift | 8 +- .../Views/AddGame/AddGameStepThreeView.swift | 12 +- .../Views/Catalog/CatalogNativeArtwork.swift | 29 + .../Catalog/CatalogSteamDetailsView.swift | 197 ++ .../Views/Catalog/CatalogSteamInfo.swift | 39 + .../Views/Catalog/SteamArtwork.swift | 54 + .../Views/Catalog/SteamPersonalizations.swift | 79 + .../Views/Collection/CollectionCoverRow.swift | 20 +- .../Collection/CollectionView+Input.swift | 18 +- .../Views/Collection/CollectionView.swift | 64 +- .../Views/Components/GameLogoArea.swift | 47 + .../Views/Components/GamePrimaryActions.swift | 28 + .../Views/Components/SelenePillButton.swift | 3 +- .../Views/Components/SessionCard.swift | 5 +- .../Components/SpotlightTileArtwork.swift | 3 +- .../Views/Components/SwitchBoxArtCover.swift | 3 +- .../Views/Detail/GameDetailView+Input.swift | 2 +- .../Views/Detail/GameDetailView.swift | 6 +- .../Views/GameOptions/ChangeArtworkView.swift | 2 +- .../GameOptions/ChangeMetadataView.swift | 2 +- .../Views/Launch/DevelopmentStartupView.swift | 116 + .../Views/Launch/LaunchScreenView.swift | 4 +- Sources/NXLibrary/Views/MainView.swift | 8 +- .../NXLibrary/Views/Search/FindGameView.swift | 14 +- .../Settings/SettingsLibrarySection.swift | 7 +- .../Settings/SettingsSectionContent.swift | 4 +- .../Settings/SettingsSourcesSection.swift | 105 + .../Views/Settings/SettingsView.swift | 4 +- .../NXLibrary/Views/Shell/AppShellState.swift | 27 + .../NXLibrary/Views/Shell/AppShellView.swift | 48 +- .../Views/Spotlight/GameArtLoader.swift | 8 +- .../Views/Spotlight/SeleneHeroArt.swift | 8 +- .../Views/Spotlight/SpotlightInfoView.swift | 39 +- .../Views/Spotlight/SpotlightRowView.swift | 5 +- .../Views/Spotlight/SpotlightView+Input.swift | 28 +- .../Views/Spotlight/SpotlightView.swift | 46 +- .../CatalogPersonalizationsEnvironment.swift | 13 + .../Models/CatalogBuildRecoveries.swift | 41 + .../Models/CatalogDocument.swift | 85 + .../NXLibraryCore/Models/CatalogGame.swift | 200 ++ .../Models/CatalogGameOptions.swift | 18 + .../Models/CatalogGamePresentation.swift | 78 + .../Models/CatalogLibraryProjection.swift | 8 + .../Models/CatalogPersonalizations.swift | 25 + .../Models/CatalogRecovery.swift | 9 + .../Models/GameRuntimeConfig.swift | 4 +- .../Models/LibraryArtworkRole.swift | 15 + .../NXLibraryCore/Models/LibraryFilter.swift | 41 +- .../Models/LibraryPresentation.swift | 34 + .../Models/LibrarySelectionState.swift | 67 +- .../Models/MenuSelectionModel.swift | 4 +- .../Models/SettingsCatalog.swift | 4 + .../Models/SettingsSection.swift | 5 +- .../NXLibraryCore/Models/SpotlightRow.swift | 13 +- .../Resources/Localizable.xcstrings | 3042 +++++++++++++++++ .../Services/AppDataProfile.swift | 54 + .../Services/AppIconWriter.swift | 3 +- .../Services/AppSettingsStore.swift | 3 +- .../NXLibraryCore/Services/AppUpdate.swift | 10 +- .../Services/ArtworkPicker.swift | 3 +- .../NXLibraryCore/Services/ArtworkStore.swift | 3 +- .../Services/Catalog/CatalogErrorCopy.swift | 43 + .../Services/Catalog/CatalogPublication.swift | 46 + .../Services/Catalog/CatalogRepository.swift | 155 + .../Catalog/CatalogSourcesModel.swift | 101 + .../Services/Catalog/CatalogStore.swift | 223 ++ .../Catalog/SteamCatalogReconciler.swift | 107 + .../Services/DevelopmentOperationStore.swift | 32 + .../Services/DevelopmentProfileImporter.swift | 169 + .../NXLibraryCore/Services/LibraryStore.swift | 60 +- .../Services/MetadataPicker.swift | 3 +- Sources/NXLibraryCore/Services/ModStore.swift | 36 +- .../Services/NXNativeGameSettings.swift | 2 +- .../Services/NXNativeLibraryMerge.swift | 70 + .../Services/NintendoStoreMetadata.swift | 3 +- .../Services/PlaySessionStore.swift | 3 +- .../Services/ScreenshotFallback.swift | 3 +- .../Services/SeleneImporter.swift | 3 +- .../Steam/InstallationDirectorySnapshot.swift | 50 + .../Steam/InstallationFileSnapshot.swift | 101 + .../Services/Steam/NativeMacApplication.swift | 144 + .../Services/Steam/SteamKeyValues.swift | 145 + .../Services/Steam/SteamLibrarySource.swift | 310 ++ .../Services/Steam/SteamMetadata.swift | 58 + .../Services/Steam/SteamMetadataService.swift | 127 + .../Steam/SteamPersonalizationStore.swift | 63 + .../Services/SteamGridDBArt.swift | 3 +- .../Services/StorageManager.swift | 3 +- Tests/CatalogAppChecks/NoticeHost.swift | 54 + Tests/CatalogAppChecks/run_backdrop.py | 56 + Tests/CatalogAppChecks/run_game_header.py | 38 + Tests/CatalogAppChecks/run_notices.py | 43 + Tests/NXLibraryCoreTests/AppRouterTests.swift | 6 +- .../AppSettingsStoreTests.swift | 2 +- Tests/NXLibraryCoreTests/AppUpdateTests.swift | 10 + .../CatalogBuildRecoveryTests.swift | 51 + .../CatalogLibraryQueryTests.swift | 49 + .../CatalogModelTests.swift | 95 + .../CatalogPersonalizationTests.swift | 30 + .../CatalogRecoveryTests.swift | 106 + .../CatalogRepositoryTests.swift | 159 + .../CatalogSourcesModelTests.swift | 136 + .../CatalogSourcesRepositoryTests.swift | 100 + .../CatalogStoreTests.swift | 237 ++ .../CatalogTestSupport.swift | 41 + .../DevelopmentProfileEffectTests.swift | 74 + .../DevelopmentProfileTests.swift | 141 + .../NativeMacApplicationTests.swift | 106 + .../SharedGamePresentationTests.swift | 54 + .../SpotlightRowTests.swift | 15 + .../SteamArtworkRequestTests.swift | 27 + .../SteamCatalogReconcilerTests.swift | 200 ++ .../SteamDiscoveryConsistencyTests.swift | 111 + .../SteamKeyValuesTests.swift | 65 + .../SteamLibrarySourceTests.swift | 252 ++ .../SteamMetadataTests.swift | 192 ++ .../NXLibraryCoreTests/SteamTestSupport.swift | 83 + .../test_profile_boundaries.py | 19 + docs/architecture.md | 44 +- docs/development.md | 21 +- docs/file-map.md | 97 +- scripts/build_app.sh | 19 +- scripts/check_app_profile.swift | 13 + scripts/release.sh | 3 + 139 files changed, 9701 insertions(+), 568 deletions(-) create mode 100644 Sources/NXLibrary/ViewModels/AppViewModel+Catalog.swift create mode 100644 Sources/NXLibrary/ViewModels/AppViewModel+Sources.swift create mode 100644 Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift create mode 100644 Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift create mode 100644 Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift create mode 100644 Sources/NXLibrary/Views/Catalog/SteamArtwork.swift create mode 100644 Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift create mode 100644 Sources/NXLibrary/Views/Components/GameLogoArea.swift create mode 100644 Sources/NXLibrary/Views/Components/GamePrimaryActions.swift create mode 100644 Sources/NXLibrary/Views/Launch/DevelopmentStartupView.swift create mode 100644 Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift create mode 100644 Sources/NXLibrary/Views/Shell/AppShellState.swift create mode 100644 Sources/NXLibrary/Views/Theme/CatalogPersonalizationsEnvironment.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogBuildRecoveries.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogDocument.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogGame.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogGameOptions.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogGamePresentation.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogLibraryProjection.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogPersonalizations.swift create mode 100644 Sources/NXLibraryCore/Models/CatalogRecovery.swift create mode 100644 Sources/NXLibraryCore/Models/LibraryArtworkRole.swift create mode 100644 Sources/NXLibraryCore/Services/AppDataProfile.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/CatalogErrorCopy.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/CatalogPublication.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/CatalogRepository.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/CatalogSourcesModel.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/CatalogStore.swift create mode 100644 Sources/NXLibraryCore/Services/Catalog/SteamCatalogReconciler.swift create mode 100644 Sources/NXLibraryCore/Services/DevelopmentOperationStore.swift create mode 100644 Sources/NXLibraryCore/Services/DevelopmentProfileImporter.swift create mode 100644 Sources/NXLibraryCore/Services/NXNativeLibraryMerge.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/InstallationDirectorySnapshot.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/InstallationFileSnapshot.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/NativeMacApplication.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamKeyValues.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamLibrarySource.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamMetadata.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift create mode 100644 Tests/CatalogAppChecks/NoticeHost.swift create mode 100644 Tests/CatalogAppChecks/run_backdrop.py create mode 100644 Tests/CatalogAppChecks/run_game_header.py create mode 100644 Tests/CatalogAppChecks/run_notices.py create mode 100644 Tests/NXLibraryCoreTests/CatalogBuildRecoveryTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogLibraryQueryTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogModelTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogPersonalizationTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogRecoveryTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogRepositoryTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogSourcesModelTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogSourcesRepositoryTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogStoreTests.swift create mode 100644 Tests/NXLibraryCoreTests/CatalogTestSupport.swift create mode 100644 Tests/NXLibraryCoreTests/DevelopmentProfileEffectTests.swift create mode 100644 Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift create mode 100644 Tests/NXLibraryCoreTests/NativeMacApplicationTests.swift create mode 100644 Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamCatalogReconcilerTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamDiscoveryConsistencyTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamKeyValuesTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamLibrarySourceTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamMetadataTests.swift create mode 100644 Tests/NXLibraryCoreTests/SteamTestSupport.swift create mode 100644 Tests/PublicationChecks/test_profile_boundaries.py create mode 100644 scripts/check_app_profile.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a324f5..1e981d0 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: Steam backdrop regression without UI + run: python3 Tests/CatalogAppChecks/run_backdrop.py - name: Release compilation run: swift build -c release --product NXLibrary - name: Package and verify development app 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/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..b83a899 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,17 +284,22 @@ 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. @@ -333,11 +338,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 +356,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 +409,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 +463,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 +479,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 +492,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 +503,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 +514,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 +546,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 +558,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 +610,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 +643,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 +756,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 +842,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 +876,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 +977,7 @@ public final class AppViewModel: ObservableObject { public func advanceSetupToGames() { guard showSetupCheck else { return } if !setupCompleted { prepareInitialGameAppsFolder() } - loadLibrary() + importExistingBuilds() firstRunSetup.advance() } @@ -977,14 +986,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 +1133,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 +1190,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 - } - 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 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) + } } - 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 @@ -1506,18 +1513,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 +1551,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 +1587,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 +1692,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 +1750,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 +1765,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 +1836,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 +2045,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 +2384,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 +2469,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 +2514,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 +2663,7 @@ public final class AppViewModel: ObservableObject { return } if canRetryReadyInstall { - installPendingBuilds(only: id) + installPendingBuilds(only: id, automatic: false) return } startBuild(for: game) @@ -2829,9 +2811,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 +2830,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 +2898,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..2a238ad --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift @@ -0,0 +1,29 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +/// Native-app artwork for non-nxnative entries. It never asks Nintendo artwork +/// services or writes the installed app. Existing nxnative art keeps its path. +struct CatalogNativeArtwork: View { + let game: CatalogGame + @Environment(\.seleneCanvas) private var canvas + @State private var image: NSImage? + private var application: CatalogNativeApplication? { + game.steamGame?.installations.first { $0.availability == .available }?.primaryApplication + } + private var key: String { (application?.url.path ?? "none") + "|" + (game.steamGame?.installations.first?.buildID ?? "") } + var body: some View { + ZStack(alignment: .bottomLeading) { + SeleneTheme.glassSolid + SteamArtwork(game: game, slot: .cover) + 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)) + } + .task(id: key) { + guard !Task.isCancelled else { return } + image = application.map { NSWorkspace.shared.icon(forFile: $0.url.path) } + } + .accessibilityLabel(game.name) + } +} diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift new file mode 100644 index 0000000..2f78982 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift @@ -0,0 +1,197 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +struct CatalogSteamDetailsView: View { + @ObservedObject var viewModel: AppViewModel + @ObservedObject var router: AppRouter + @ObservedObject var settings: AppSettingsStore + let game: CatalogGame + @Environment(\.seleneCanvas) private var canvas + @Environment(\.nxAppLocale) private var localization + @State private var metadata: SteamMetadata? + @State private var editing = false + @State private var gallery: Int? + @ObservedObject private var picks = SteamPersonalizations.shared.metadata + @State private var focus = 0 + @State private var keyMonitor: Any? + private var installation: SteamCatalogInstallation? { + let current = viewModel.catalogGames.first { $0.id == game.id }?.steamGame + return current?.installations.first { $0.availability == .available } ?? current?.installations.first + } + private var status: String { + if game.steamGame?.needsInstallationReview == true { return L10n.text("Needs review", context: localization) } + switch installation?.availability { + case .available: return L10n.text("Available on this Mac", context: localization) + case .sourceUnavailable: return L10n.text("Source unavailable", context: localization) + case .notDetected, .unavailable, nil: return L10n.text("Installation not detected", context: localization) + case .requiresReview: return L10n.text("Needs review", context: localization) + } + } + var body: some View { + SeleneHomeChrome(leftHints: [HintItem(.b, L10n.text("Back", context: localization)) { router.back() }], + rightHints: [HintItem(.y, L10n.text("Game sources", context: localization)) { router.openSettings(.sources) }, HintItem(.a, L10n.text("Select", context: localization))], + hintsHidden: router.overlayHasHints(viewModel), backdrop: { + SteamBackdrop(game: game, shade: settings.settings.backgroundShade, + showsArt: !settings.settings.keepWallpaperOnGames) + }, stage: { + ScrollViewReader { proxy in + ScrollView { LazyVStack(alignment: .leading, spacing: canvas.u(30)) { + HStack(alignment: .top, spacing: canvas.u(44)) { + CatalogNativeArtwork(game: game).frame(width: canvas.u(240), height: canvas.u(240)) + .clipShape(RoundedRectangle(cornerRadius: canvas.u(18))) + VStack(alignment: .leading, spacing: canvas.u(18)) { + SteamArtwork(game: game, slot: .logo).frame(width: canvas.u(340), height: canvas.u(80)) + Text(game.name).seleneFont(40, .semibold).foregroundColor(SeleneTheme.ink).lineLimit(2) + HStack { SteamArtwork(game: game, slot: .icon).frame(width: canvas.u(32), height: canvas.u(32)); Text("Steam · Mac").seleneFont(18, .semibold).foregroundColor(SeleneTheme.ink60) } + Text(status).seleneFont(18).foregroundColor(SeleneTheme.ink60) + if let bytes = installation?.reportedSizeBytes { + Text(L10n.format("Size reported by Steam: %@", [seleneBytes(bytes)], context: localization)) + .seleneFont(16).foregroundColor(SeleneTheme.ink60) + } + } + } + .id("top") + HStack(spacing: canvas.u(14)) { + SelenePillButton(title: "Reveal in Finder", focused: focus == 0) { perform(0) } + SelenePillButton(title: game.isFavorite ? "Unfavorite" : "Favorite", focused: focus == 1) { perform(1) } + SelenePillButton(title: "Hide", focused: focus == 2) { perform(2) } + SelenePillButton(title: "Move in row", focused: focus == 3) { perform(3) } + } + HStack(spacing: canvas.u(14)) { + SelenePillButton(title: "Open in Steam", focused: focus == 4) { perform(4) } + SelenePillButton(title: "Personalize", focused: focus == 5) { perform(5) } + } + Group { + ForEach(MetadataField.allCases, id: \.self) { field in + if let value = fieldValue(field, metadata), !value.isEmpty { + VStack(alignment: .leading, spacing: canvas.u(8)) { + Text(field.title).seleneFont(16, .semibold).foregroundColor(SeleneTheme.ink60) + Text(value).seleneFont(19).foregroundColor(SeleneTheme.ink).textSelection(.enabled) + } + } + } + ForEach(Array((metadata?.screenshots ?? []).enumerated()), id: \.offset) { index, url in + Button { gallery = index } label: { + SteamArtwork(game: game, slot: .background, remote: url) + .frame(width: canvas.u(600), height: canvas.u(338)) + .overlay(SeleneTileRing(cornerRadius: canvas.u(12), active: focus == index + 6)) + }.buttonStyle(.plain).id(index + 6) + } + } + if let installation { + Text(L10n.text("Installation", context: localization)).seleneFont(17, .semibold).foregroundColor(SeleneTheme.ink) + Text(installation.contentURL.path).seleneFont(16).foregroundColor(SeleneTheme.ink60) + .lineLimit(3).textSelection(.enabled) + } + Color.clear.frame(height: canvas.u(80)) + } + .padding(.horizontal, canvas.u(80)).padding(.top, canvas.u(130)) + .frame(maxWidth: .infinity, alignment: .leading) + }.onChange(of: focus) { value in if value >= 6 { proxy.scrollTo(value, anchor: .center) } else { proxy.scrollTo("top", anchor: .top) } } + }.frame(width: canvas.stageSize.width, height: canvas.size.height, alignment: .topLeading) + }) + .task(id: "\(game.id)|\(localization.language.rawValue)") { + metadata = nil; gallery = nil; focus = 0 + guard let id = game.steamGame?.appID else { return } + let result = await SteamMetadataService.shared.metadata(appID: id, language: SteamMetadata.language(localization.language)) + guard !Task.isCancelled else { return }; metadata = result + } + .sheet(isPresented: $editing) { SteamPersonalizationEditor(game: game) } + .overlay { if let index = gallery, let shots = metadata?.screenshots, shots.indices.contains(index) { + ZStack { + Color.black.opacity(0.96) + VStack { + SteamArtwork(game: game, slot: .background, remote: shots[index]).aspectRatio(16/9, contentMode: .fit) + HStack { + Button("‹") { gallery = max(0, index - 1) } + Text("\(index + 1) / \(shots.count)") + Button("›") { gallery = min(shots.count - 1, index + 1) } + Button(L10n.text("Close", context: localization)) { gallery = nil } + }.padding() + }.padding(50) + } + } } + .onAppear(perform: installKeyboard) + .onDisappear { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }; keyMonitor = nil } + .onReceive(NotificationCenter.default.publisher(for: .nxDetailGamepadAction)) { note in + guard router.route == .detail(game.id), !router.isMenuPresented, !router.isSearchPresented, + !router.isIntroShowing, !editing, viewModel.settingsNotice == nil, viewModel.alertMessage == nil, + !viewModel.showSetupCheck, !viewModel.isUpdateOfferOpen, NSApp.modalWindow == nil, + let action = note.object as? GamepadAction else { return } + handle(action) + } + } + private func perform(_ index: Int) { + switch index { + case 0: + guard let location = installation?.contentURL, FileManager.default.fileExists(atPath: location.path) else { + viewModel.showSettingsNotice(title: L10n.text("Installation not detected", context: localization), message: L10n.text("Refresh the Steam source or check the installation in Steam.", context: localization)) + return + } + NSWorkspace.shared.activateFileViewerSelecting([location]) + case 1: viewModel.setCatalogFavorite(game) + case 2: if viewModel.setCatalogHidden(true, game: game) { router.showLibrary() } + case 3: + router.requestSpotlightMove(SpotlightItem.game(game.id).key) + router.showLibrary() + case 4: if let id = game.steamGame?.appID { NSWorkspace.shared.open(URL(string: "https://store.steampowered.com/app/\(id)/")!) } + case 5: editing = true + default: if let count = metadata?.screenshots.count, (0.. String? { + if let value = picks.value(titleID: game.personalizationReference.key, field: field) { return value } + switch field { + case .description: return data?.description + case .genres: return data?.genres.joined(separator: " · ") + case .developer: return data?.developers.joined(separator: ", ") + case .publisher: return data?.publishers.joined(separator: ", ") + case .releaseDate: return data?.releaseDate + } + } + private func handle(_ action: GamepadAction) { + if let index = gallery { + switch action { + case .navigateLeft: gallery = max(0, index - 1) + case .navigateRight: gallery = min((metadata?.screenshots.count ?? 1) - 1, index + 1) + case .cancel, .confirm: gallery = nil + default: break + } + return + } + switch action { + case .navigateDown: focus = min(5 + (metadata?.screenshots.count ?? 0), focus < 4 ? focus + 4 : max(6, focus + 1)) + case .navigateUp: focus = focus >= 6 ? max(4, focus - 1) : max(0, focus - 4) + case .navigateLeft: focus = max(0, focus - 1) + case .navigateRight: focus = min(5 + (metadata?.screenshots.count ?? 0), focus + 1) + case .confirm: perform(focus) + case .cancel: router.back() + case .contextAction: router.openSettings(.sources) + default: break + } + } + private func installKeyboard() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard router.route == .detail(game.id), !router.isMenuPresented, !router.isSearchPresented, + !router.isIntroShowing, !editing, !NativeMenuCommands.shared.isTracking, + viewModel.settingsNotice == nil, viewModel.alertMessage == nil, + !viewModel.isUpdateOfferOpen, !viewModel.showSetupCheck, + let window = event.window, !window.isSheet, window.attachedSheet == nil, + NSApp.modalWindow == nil, !(window.firstResponder is NSText), + event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return event } + switch event.keyCode { + case 53 where gallery != nil: gallery = nil + case 125: handle(.navigateDown) + case 126: handle(.navigateUp) + case 123: handle(.navigateLeft) + case 124: handle(.navigateRight) + case 36, 49: handle(.confirm) + case 16: handle(.contextAction) + default: return event + } + return nil + } + } +} diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift new file mode 100644 index 0000000..d483b60 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -0,0 +1,39 @@ +import SwiftUI +import NXLibraryCore + +/// The common library's Steam header. This visibility milestone opens Details; +/// the later launch/session milestone owns Play, not an engine fallback. +struct CatalogSteamInfo: View { + let game: CatalogGame + var focused: Int? = nil + let onAction: (Int) -> Void + @Environment(\.seleneCanvas) private var canvas + @Environment(\.nxAppLocale) private var localization + @ObservedObject private var picks = SteamPersonalizations.shared.metadata + @State private var metadata: SteamMetadata? + var body: some View { + VStack(alignment: .leading, spacing: canvas.u(20)) { + Text(game.name).seleneFont(38, .semibold).foregroundColor(SeleneTheme.ink).lineLimit(2) + HStack(spacing: canvas.u(12)) { + Text("Steam").seleneFont(17, .semibold) + Text(L10n.text("Mac game", context: localization)).seleneFont(17) + }.foregroundColor(SeleneTheme.ink60) + if metadata != nil || picks.value(titleID: game.personalizationReference.key, field: .description) != nil || picks.value(titleID: game.personalizationReference.key, field: .genres) != nil { + Text(picks.value(titleID: game.personalizationReference.key, field: .genres) ?? (metadata?.genres ?? []).joined(separator: " · ")).seleneFont(17).foregroundColor(SeleneTheme.ink60) + Text(picks.value(titleID: game.personalizationReference.key, field: .description) ?? metadata?.description ?? "").seleneFont(18).foregroundColor(SeleneTheme.ink).lineLimit(3) + } + HStack(spacing: canvas.u(14)) { + SelenePillButton(title: "Details", focused: focused == 0) { onAction(0) } + SelenePillButton(title: game.isFavorite ? "Unfavorite" : "Favorite", focused: focused == 1) { onAction(1) } + SelenePillButton(title: "Hide", focused: focused == 2) { onAction(2) } + } + } + .task(id: "\(game.id)|\(localization.language.rawValue)") { + metadata = nil + guard let id = game.steamGame?.appID else { return } + let result = await SteamMetadataService.shared.metadata(appID: id, language: SteamMetadata.language(localization.language)) + guard !Task.isCancelled else { return }; metadata = result + } + .frame(maxWidth: canvas.u(1300), alignment: .leading) + } +} diff --git a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift new file mode 100644 index 0000000..5c30fd9 --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -0,0 +1,54 @@ +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 = "" + @Environment(\.seleneCanvas) private var canvas + @ObservedObject private var picks = SteamPersonalizations.shared.artwork + private var key: String { "\(game.id)|\(slot.rawValue)|\(remote?.absoluteString ?? "")|\(picks.revision)" } + 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 app = game.steamGame?.installations.first(where: { $0.availability == .available })?.primaryApplication { + Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)).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 + guard let id = game.steamGame?.appID else { return } + let personal = picks + let picked = remote == nil ? personal.url(titleID: game.personalizationReference.key, slot: slot) : nil + let url: URL? + if let picked { url = picked } + else { url = await SteamMetadataService.shared.image(appID: id, slot: slot, remote: remote) } + guard !Task.isCancelled else { return } + image = url.flatMap { NSImage(contentsOf: $0) }; loadedKey = expected + } + + .accessibilityLabel(game.name) + } +} + +extension Notification.Name { static let steamPersonalizationChanged = Notification.Name("NXSteamPersonalizationChanged") } + +struct SteamBackdrop: View { + let game: CatalogGame + let shade: BackgroundShade + let showsArt: Bool + var body: some View { + Group { if showsArt { SteamArtwork(game: game, slot: .background) + .overlay(SeleneShades(shade: shade)) } } + .allowsHitTesting(false).accessibilityHidden(true) + } +} diff --git a/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift new file mode 100644 index 0000000..11e062b --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift @@ -0,0 +1,79 @@ +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 } + +} + +struct SteamPersonalizationEditor: View { + let game: CatalogGame + @Environment(\.dismiss) private var dismiss + @Environment(\.nxAppLocale) private var localization + @State private var fields: [MetadataField: String] = [:] + @State private var saveFocused = true + @State private var slot: ArtworkSlot = .cover + @State private var pendingArtwork: [ArtworkSlot: Data] = [:] + @State private var automatic: Set = [] + @State private var error: String? + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text(game.name).font(.title2) + Text(L10n.text("Change metadata", context: localization)).font(.headline) + ForEach(MetadataField.allCases, id: \.self) { field in + TextField(field.title, text: Binding(get: { fields[field] ?? "" }, set: { fields[field] = $0 })) + } + Text(L10n.text("Leave blank to use Steam data.", context: localization)).font(.caption) + Divider() + Picker(L10n.text("Change artwork", context: localization), selection: $slot) { + ForEach(ArtworkSlot.allCases, id: \.self) { Text($0.title).tag($0) } + } + HStack { + Button(L10n.text("Choose", context: localization), action: choose) + Button(L10n.text("Automatic", context: localization)) { pendingArtwork[slot] = nil; automatic.insert(slot) } + } + if let error { Text(error).foregroundColor(.red).font(.caption) } + HStack { + Button(L10n.text("Cancel", context: localization)) { dismiss() }.keyboardShortcut(.cancelAction).overlay(RoundedRectangle(cornerRadius: 5).stroke(saveFocused ? Color.clear : Color.accentColor, lineWidth: 2)) + Spacer() + Button(L10n.text("Save", context: localization)) { + save() + }.keyboardShortcut(.defaultAction).overlay(RoundedRectangle(cornerRadius: 5).stroke(saveFocused ? Color.accentColor : Color.clear, lineWidth: 2)) + } + }.onReceive(NotificationCenter.default.publisher(for: .nxDetailGamepadAction)) { note in + guard let action = note.object as? GamepadAction, NSApp.modalWindow == nil else { return } + switch action { + case .cancel: dismiss() + case .navigateLeft: saveFocused = false + case .navigateRight: saveFocused = true + case .confirm: if saveFocused { save() } else { dismiss() } + default: break + } + }.padding(28).frame(width: 560) + .onAppear { + for field in MetadataField.allCases { fields[field] = SteamPersonalizations.shared.metadata.value(titleID: game.personalizationReference.key, field: field) ?? "" } + } + } + private func save() { + do { + try SteamPersonalizations.shared.metadata.save(fields: fields, images: pendingArtwork, automatic: automatic, titleID: game.personalizationReference.key); dismiss() + } catch { self.error = error.localizedDescription } + } + private func choose() { + let panel = NSOpenPanel(); panel.allowedContentTypes = [.png, .jpeg, .tiff, .heic] + panel.canChooseDirectories = false; panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, (values.fileSize ?? Int.max) <= 12_582_912 else { throw URLError(.dataLengthExceedsMaximum) } + let data = try Data(contentsOf: url) + guard NSImage(data: data) != nil else { throw URLError(.cannotDecodeContentData) } + pendingArtwork[slot] = data; automatic.remove(slot) + } catch { self.error = error.localizedDescription } + } +} 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..1523129 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,16 @@ 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) } + if let game = selectedCatalogGame { router.showDetail(game.id) } case .contextAction: + if selectedCatalogGame?.integration == .steam { router.openSettings(.sources); return } if let game = selectedGame { viewModel.openGameOptions(game, from: .collection) } case .cancel: router.showLibrary() // B goes home, like Selene's library. @@ -50,6 +51,11 @@ extension CollectionView { if moved { SoundEffectService.shared.play(.focus) } } + func activateCatalog(_ record: CatalogGame) { + if let native = record.nxnativeGame { play(native) } + 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) diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 303ab99..974a904 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -36,18 +36,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 @@ -63,15 +64,17 @@ public struct CollectionView: View { backdrop: { SeleneArtBackground(image: art.image, imageKey: art.key, shade: settings.settings.backgroundShade, - showsArt: !settings.settings.keepWallpaperOnGames) + showsArt: !settings.settings.keepWallpaperOnGames && selectedCatalogGame?.integration != .steam) + if let steam = selectedCatalogGame, steam.integration == .steam, !settings.settings.keepWallpaperOnGames { SteamBackdrop(game: steam, shade: settings.settings.backgroundShade, showsArt: !settings.settings.keepWallpaperOnGames) } }, 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 @@ -90,7 +93,7 @@ public struct CollectionView: View { .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 +108,15 @@ 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) { index in + switch index { + case 0: router.showDetail(record.id) + case 1: viewModel.setCatalogFavorite(record) + case 2: viewModel.setCatalogHidden(true, game: record) + default: break + } + }.offset(x: canvas.u(80), y: canvas.u(110)) } if games.isEmpty { @@ -121,7 +133,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 +172,9 @@ public struct CollectionView: View { // MARK: - Hints private func contextHints(_ game: PortedGame?) -> [HintItem] { + if selectedCatalogGame?.integration == .steam { + return [HintItem(.a, L10n.text("Details")), HintItem(.y, L10n.text("Game sources")), 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) @@ -215,12 +231,12 @@ public struct CollectionView: 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) } 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) } } @@ -272,29 +288,7 @@ struct CollectionInfoView: View { } 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 fade always runs; the slide is movement and drops under reduce motion. 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/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/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/GameDetailView+Input.swift b/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift index 042b5d9..84d0de2 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) } diff --git a/Sources/NXLibrary/Views/Detail/GameDetailView.swift b/Sources/NXLibrary/Views/Detail/GameDetailView.swift index d5d1a2b..b324688 100644 --- a/Sources/NXLibrary/Views/Detail/GameDetailView.swift +++ b/Sources/NXLibrary/Views/Detail/GameDetailView.swift @@ -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..5da5ef2 100644 --- a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift +++ b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift @@ -74,7 +74,7 @@ struct ChangeArtworkView: View { case automatic, yourPick, iconLocation, art(Found), browse(ArtworkBrowseSource), file } - private var titleKey: String { game.titleId ?? game.uniqueKey } + private var titleKey: String { viewModel.personalizationKey(for: game) } private var hasPick: Bool { picks.url(titleID: titleKey, slot: slot) != nil } private var switch2: Bool { game.titleId?.hasPrefix("04") == true } private var gameNames: [String] { diff --git a/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift b/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift index 1f2f5fb..0d7fae6 100644 --- a/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift +++ b/Sources/NXLibrary/Views/GameOptions/ChangeMetadataView.swift @@ -36,7 +36,7 @@ struct ChangeMetadataView: View { case write } - private var titleKey: String { game.titleId ?? game.uniqueKey } + private var titleKey: String { viewModel.personalizationKey(for: game) } private var pick: String? { picks.value(titleID: titleKey, field: field) } private var store: NintendoStoreMetadata? { viewModel.storeMetadataAsFetched(for: game) } 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..e74fd80 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) } + } .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/SettingsLibrarySection.swift b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift index c2a239e..0c45673 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift @@ -5,6 +5,7 @@ struct SettingsLibrarySection: View { @ObservedObject var viewModel: AppViewModel @ObservedObject var settings: AppSettingsStore @ObservedObject var artwork: ArtworkStore + let onManageSources: () -> Void let onActions: ([SettingsRowAction]) -> Void @State private var metadataBytes: Int64 = 0 @@ -12,6 +13,7 @@ struct SettingsLibrarySection: View { var body: some View { VStack(spacing: 9) { + SeleneButtonRow("Manage sources", subtitle: "Choose where NX Library finds your games.", buttonTitle: "Open", action: onManageSources) SeleneRow(GameFoldersCopy.appsTitle, subtitle: GameFoldersCopy.appsContents, info: "Choose where to keep your game apps. Put any existing nxnative games (.app) here. Changing folders does not move apps; rebuilds update them where they are.") { @@ -65,7 +67,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) @@ -115,6 +117,7 @@ struct SettingsLibrarySection: View { private func publishActions() { onActions([ + .init(id: "manageSources", title: "Manage sources", activate: onManageSources), .init(id: "appsChange", title: GameFoldersCopy.appsTitle) { viewModel.selectLibraryFolder() }, @@ -122,7 +125,7 @@ struct SettingsLibrarySection: View { viewModel.selectGameFolder() }, .init(id: "refresh", title: "Scan game apps", isEnabled: !viewModel.isScanningLibrary) { - viewModel.refreshLibrary() + viewModel.importExistingBuilds() }, .init(id: "notify", title: "Notify when a porting ends") { settings.settings.notifyPortingEnds.toggle() diff --git a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift index 84ac9ed..8307aa9 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -39,7 +39,9 @@ struct SettingsSectionContent: View { onViewControls: onViewControls) case .library: SettingsLibrarySection(viewModel: viewModel, settings: settings, - artwork: artwork, onActions: onActions) + artwork: artwork, onManageSources: { onOpenGroup(.sources) }, onActions: onActions) + case .sources: + SettingsSourcesSection(viewModel: viewModel, onActions: onActions) case .storage: SettingsStorageSection(viewModel: viewModel, artwork: artwork, onActions: onActions, onOpenConsole: onOpenConsole) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift new file mode 100644 index 0000000..fe41abc --- /dev/null +++ b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift @@ -0,0 +1,105 @@ +import SwiftUI +import AppKit +import NXLibraryCore + +@MainActor +struct SettingsSourcesSection: View { + @ObservedObject var viewModel: AppViewModel + @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) + 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) + ] + 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/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index 02b481d..b8cbb05 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -217,7 +217,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 } } @@ -286,7 +286,7 @@ public struct SettingsView: View { 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 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..4182ff6 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, @@ -90,7 +82,8 @@ public struct AppShellView: View { && 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) + details: isHomeRoute && viewModel.currentFocusedCatalogGame != nil, options: game != nil, + catalogRecovery: general && !viewModel.isBuilding && !viewModel.catalogBuildRecoveries.isEmpty) } /// The launch screen sits over everything, menu included, until the game's window shows. @@ -256,6 +249,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) @@ -308,6 +303,7 @@ 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 @@ -323,8 +319,8 @@ public struct AppShellView: View { viewModel.gameForSettingsSheet == nil, !isCloseGameOpen, isHomeRoute, - let game = viewModel.currentFocusedGame else { return } - if router.route == .library { viewModel.spotlight.showGameDetails() } + let game = viewModel.currentFocusedCatalogGame else { return } + if router.route == .library, game.integration == .nxnative { viewModel.spotlight.showGameDetails() } else { router.showDetail(game.id) } } .onReceive(NotificationCenter.default.publisher(for: .nxLibraryOpenGameOptions)) { _ in @@ -364,6 +360,10 @@ public struct AppShellView: View { 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 } router.closeMenu() @@ -432,6 +432,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. @@ -466,6 +469,8 @@ public struct AppShellView: View { artwork: artwork, game: game ) + } else if let game = viewModel.catalogGames.first(where: { $0.id == id && $0.integration == .steam }) { + CatalogSteamDetailsView(viewModel: viewModel, router: router, settings: settings, game: game) } else { Color.clear .onAppear { router.showLibrary() } @@ -492,7 +497,7 @@ 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) viewModel.installPendingBuilds() @@ -640,10 +645,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 +989,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) 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..f49459b 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,19 @@ 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 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..6385ab2 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift @@ -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 { diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift index 78a1f23..808c0b1 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).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..2308e9b 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, @@ -57,11 +57,13 @@ extension SpotlightView { if router.isSearchPresented { router.isSearchPresented = false } mutate { if $0.focus != .row { $0.focus = .row; moved = true } } case .secondaryAction: + if let game = selectedCatalogGame, game.integration == .steam { router.showDetail(game.id); return } // X scrolls to the game's page (the pills), like Down from the row. if selectedGame != nil { mutate { if $0.focus == .row { moved = $0.showGameDetails() } } } case .contextAction: + if selectedCatalogGame?.integration == .steam { router.openSettings(.sources); return } if let game = selectedGame { viewModel.openGameOptions(game, from: .spotlight) } else if case .card(let card) = selectedItem { @@ -82,6 +84,15 @@ extension SpotlightView { if moved { SoundEffectService.shared.play(.focus) } } + func pressCatalogPill(_ index: Int, game: CatalogGame) { + switch index { + case 0: router.showDetail(game.id) + case 1: viewModel.setCatalogFavorite(game) + case 2: viewModel.setCatalogHidden(true, game: game) + default: break + } + } + private func confirm() { let state = viewModel.spotlight switch state.focus { @@ -89,6 +100,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: @@ -120,7 +132,7 @@ extension SpotlightView { let urls = Array((viewModel.storeMetadata[game.id]?.screenshotURLs ?? []) .prefix(SpotlightScreenshotStrip.maxCount)) 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) } @@ -162,12 +174,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 +232,9 @@ extension SpotlightView { func activate(_ item: SpotlightItem) { switch item { case .game(let id): + if viewModel.visibleCatalogGames.first(where: { $0.id == id })?.integration == .steam { + SoundEffectService.shared.play(.click); router.showDetail(id); return + } guard let game = viewModel.libraryGames.first(where: { $0.id == id }) else { return } if viewModel.portingPills(game) == .porting { SoundEffectService.shared.play(.click) diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index 819ae18..7225985 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -100,8 +100,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 +127,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 +137,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. @@ -161,7 +172,8 @@ public struct SpotlightView: View { let shown = shownArt(model) SeleneArtBackground(image: shown, imageKey: shown == nil ? "none" : art.key, shade: settings.settings.backgroundShade, crossfade: 0.8, - showsArt: !settings.settings.keepWallpaperOnGames, modsTint: modsBackdrop) + showsArt: !settings.settings.keepWallpaperOnGames && model.selectedCatalogGame?.integration != .steam, modsTint: modsBackdrop) + if let steam = selectedCatalogGame, steam.integration == .steam, !settings.settings.keepWallpaperOnGames { SteamBackdrop(game: steam, shade: settings.settings.backgroundShade, showsArt: !settings.settings.keepWallpaperOnGames) } // Deeper in the page the art dims so the text reads. Color.black.opacity(dimmed ? 0.4 : 0) .allowsHitTesting(false) @@ -183,7 +195,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) @@ -254,6 +267,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 +291,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) @@ -362,7 +376,7 @@ public struct SpotlightView: View { } SpotlightScreenshotStrip( - titleID: game.titleId ?? game.uniqueKey, + titleID: viewModel.personalizationKey(for: game), urls: metadata?.screenshotURLs ?? [], known: screenshotsKnown(game, metadata: metadata), focusedIndex: state.focus == .strip ? state.stripIndex : nil, @@ -399,6 +413,10 @@ public struct SpotlightView: View { // 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 { + CatalogSteamInfo(game: game, focused: state.focus == .pills ? state.pillIndex : nil) { index in + pressCatalogPill(index, game: game) + }.spotlightBlock(.head) } else if model.selectedItem == .card(.mods) { VStack(alignment: .leading, spacing: 0) { SpotlightCardInfoView( @@ -415,12 +433,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 +533,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 model.selectedCatalogGame?.integration == .steam { + return [HintItem(.y, L10n.text("Game sources")), HintItem(.a, L10n.text("Details"))] + } 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 +569,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 +613,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 { @@ -627,6 +648,7 @@ public struct SpotlightView: View { artRule.select(viewModel.spotlight.selectedKey, isGame: selectedGame != nil) 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 +712,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. @@ -966,6 +988,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/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..deccf77 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Shared library operations are ordered once. Integration handlers still enforce +/// CatalogGamePresentation capabilities before performing an operation. +public enum CatalogGameOptions { + 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, .storePage] + return rows + } + public static func specificRows(for integration: CatalogIntegration) -> [GameOptionsRow] { + switch integration { + case .steam: return [] + case .nxnative: return [.cheats, .mods, .openSaves, .rebuild, .attachSources, .openLog, .removeSources] + } + } +} diff --git a/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift new file mode 100644 index 0000000..070b641 --- /dev/null +++ b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum CatalogGameOperation: String, Hashable, CaseIterable, Sendable { + case play, favorite, hide, customizeMetadata, customizeArtwork, revealInstallation, openSourceClient + case build, writeRuntimeSettings, manageSaves, manageMods, manageCheats, writeAppIcon, deleteInstalledFiles, manageBuildInputs +} + +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 let supportedOperations: Set + + public init(game: CatalogGame, sourceEnabled: Bool) { + id = game.id + name = game.name + integration = game.integration + personalizationReference = game.personalizationReference + isFavorite = game.isFavorite + isHidden = game.isHidden + let common: Set = [.play, .favorite, .hide, .customizeMetadata, .customizeArtwork, .revealInstallation] + switch game.payload { + case .nxnative(let legacy): + supportedOperations = common.union([.build, .writeRuntimeSettings, .manageSaves, .manageMods, .manageCheats, + .writeAppIcon, .deleteInstalledFiles, .manageBuildInputs]) + 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): + supportedOperations = common.union([.openSourceClient]) + 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) + } + } + } + + public func requireSupported(_ operation: CatalogGameOperation) throws { + guard supportedOperations.contains(operation) else { throw CatalogModelError.wrongIntegration } + } +} 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/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..d7d7447 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -35,6 +35,10 @@ public enum SettingsCatalog { } public static let all: [SettingDescriptor] = [ + action("manageSources", .library, "Manage sources", "Choose where NX Library finds your games."), + 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."), // 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 }, diff --git a/Sources/NXLibraryCore/Models/SettingsSection.swift b/Sources/NXLibraryCore/Models/SettingsSection.swift index 097c3c4..746de4e 100644 --- a/Sources/NXLibraryCore/Models/SettingsSection.swift +++ b/Sources/NXLibraryCore/Models/SettingsSection.swift @@ -4,7 +4,7 @@ import Foundation /// 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 + case look, games, controllers, library, sources, storage, engine, about public var id: String { rawValue } @@ -26,6 +26,7 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { case .games: return "Games" case .controllers: return "Controllers" case .library: return "Library" + case .sources: return "Game sources" case .storage: return "Storage" case .engine: return "Engine" case .about: return "About" @@ -44,6 +45,7 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { 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 .sources: return L10n.text("Where NX Library finds your games.") case .storage: return L10n.text("Caches, logs, saves and the console.") case .engine: return L10n.text("Engine pin, toolchain gate and keys.") case .about: return L10n.text("Version, credits and licenses.") @@ -63,6 +65,7 @@ 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 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..7dffb29 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": { @@ -8498,6 +8765,47 @@ } } }, + "Available on this Mac": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Available on this Mac" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disponibile su questo Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disponible sur ce Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf diesem Mac verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disponible en este Mac" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Disponível neste Mac" + } + } + } + }, "Available when the compatibility gate blocks a build.": { "comment": "SettingsEngineSection.swift:228", "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": { @@ -14361,6 +14833,47 @@ } } }, + "Choose where NX Library finds your games.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose where NX Library finds your games." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli dove NX Library cerca i tuoi giochi." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez où NX Library recherche vos jeux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle, wo NX Library deine Spiele sucht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige dónde busca NX Library tus juegos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha onde o NX Library encontra seus jogos." + } + } + } + }, "Choose where to find existing nxnative game apps and install new builds.": { "comment": "SettingsCatalog.swift", "localizations": { @@ -15345,6 +15858,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": { @@ -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": { @@ -23472,6 +24108,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": { @@ -26506,6 +27183,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 +28659,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 +30955,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 +31037,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": { @@ -32287,6 +33169,46 @@ } } }, + "Leave blank to use Steam data.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave blank to use Steam data." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lascia vuoto per usare i dati di Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Laissez vide pour utiliser les données Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leer lassen, um Steam-Daten zu verwenden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Déjalo vacío para usar los datos de Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Deixe em branco para usar os dados do Steam." + } + } + } + }, "Left stick": { "comment": "ControlsView.swift:29", "localizations": { @@ -32656,6 +33578,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 +33742,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": { @@ -33886,6 +34890,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 +34972,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": { @@ -35280,6 +36366,47 @@ } } }, + "Move in row": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move in row" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta nella fila" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer dans la rangée" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In der Reihe verschieben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover en la fila" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover na fileira" + } + } + } + }, "Move the library to a folder outside iCloud Drive.": { "comment": "PortingFailureReason.swift", "localizations": { @@ -36592,6 +37719,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": { @@ -41676,6 +42844,46 @@ } } }, + "Open in Steam": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Steam" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri su Steam" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir sur Steam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Steam öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir en Steam" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir no Steam" + } + } + } + }, "Open library": { "comment": "YourGamesSetupView.swift", "localizations": { @@ -41717,6 +42925,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": { @@ -43275,6 +44524,46 @@ } } }, + "Personalize": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Personalize" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Personalizza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnaliser" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anpassen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Personalizar" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Personalizar" + } + } + } + }, "Pick": { "comment": "SpotlightView.swift", "localizations": { @@ -44422,6 +45711,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 +45793,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": { @@ -45693,6 +47105,47 @@ } } }, + "Ready to check Steam.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready to check Steam." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Pronto per controllare Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prêt à vérifier Steam." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereit, Steam zu prüfen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Listo para revisar Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pronto para verificar o Steam." + } + } + } + }, "Ready to find game sources.": { "comment": "SetupCheck.swift", "localizations": { @@ -46267,6 +47720,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 +47802,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 +49308,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 +49749,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 +50200,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 +51266,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": { @@ -51750,6 +53521,47 @@ } } }, + "Size reported by Steam: %@": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Size reported by Steam: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensioni indicate da Steam: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille indiquée par Steam : %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von Steam gemeldete Größe: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño indicado por Steam: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho informado pelo Steam: %@" + } + } + } + }, "Skip for now": { "comment": "YourGamesSetupView.swift", "localizations": { @@ -52160,6 +53972,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": { @@ -52406,6 +54300,47 @@ } } }, + "Source unavailable": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source unavailable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fonte non disponibile" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source indisponible" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle nicht verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente no disponible" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fonte indisponível" + } + } + } + }, "Sources": { "comment": "AddGameView+Rows.swift", "localizations": { @@ -53226,6 +55161,170 @@ } } }, + "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 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." + } + } + } + }, "SteamGridDB": { "comment": "SettingsCatalog.swift", "localizations": { @@ -54620,6 +56719,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": { @@ -54743,6 +56883,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 +57498,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 +57785,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": { @@ -57244,6 +59589,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 +60368,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 +61352,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 +62459,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 +63443,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": { @@ -61672,6 +64468,47 @@ } } }, + "Unfavorite": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unfavorite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi dai preferiti" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer des favoris" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus Favoriten entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar de favoritos" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover dos favoritos" + } + } + } + }, "Unknown": { "comment": "Save safety and presentation; user content stays unchanged.", "localizations": { @@ -64255,6 +67092,47 @@ } } }, + "Where NX Library finds your games.": { + "comment": "Steam source management and native Mac library presentation.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Where NX Library finds your games." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dove NX Library cerca i tuoi giochi." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Où NX Library recherche vos jeux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wo NX Library deine Spiele findet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dónde busca NX Library tus juegos." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Onde o NX Library encontra seus jogos." + } + } + } + }, "Where should the icon appear?": { "comment": "ChangeArtworkView.swift", "localizations": { @@ -65403,6 +68281,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 +68732,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 +69019,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": { diff --git a/Sources/NXLibraryCore/Services/AppDataProfile.swift b/Sources/NXLibraryCore/Services/AppDataProfile.swift new file mode 100644 index 0000000..3a66b83 --- /dev/null +++ b/Sources/NXLibraryCore/Services/AppDataProfile.swift @@ -0,0 +1,54 @@ +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 + } + public var stableSupportRoot: URL { home.appendingPathComponent("Library/Application Support/NXLibrary") } + public var supportRoot: URL { + isDevelopment ? home.appendingPathComponent("Library/Application Support/NXLibrary-Development") : stableSupportRoot + } + public var cacheRoot: URL { home.appendingPathComponent("Library/Caches/" + (isDevelopment ? "NXLibrary-Development" : "NXLibrary")) } + 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/ArtworkPicker.swift b/Sources/NXLibraryCore/Services/ArtworkPicker.swift index 89800bb..89d64fb 100644 --- a/Sources/NXLibraryCore/Services/ArtworkPicker.swift +++ b/Sources/NXLibraryCore/Services/ArtworkPicker.swift @@ -244,8 +244,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/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..ecca91c 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 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/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..a6607ca --- /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 = SteamMetadataService.download) { + 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/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_backdrop.py b/Tests/CatalogAppChecks/run_backdrop.py new file mode 100644 index 0000000..82acb5a --- /dev/null +++ b/Tests/CatalogAppChecks/run_backdrop.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Evaluate the actual Steam backdrop body without a window or injected settings. + +Only image/gradient children are inert substitutes. This reaches the missing +EnvironmentObject failure in the real parent view; it is not UI automation. +""" +from pathlib import Path +import os +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +BIN = ROOT / '.build/debug' +source = (ROOT / 'Sources/NXLibrary/Views/Catalog/SteamArtwork.swift').read_text() +start = source.index('struct SteamBackdrop: View {') +opening = source.index('{', start) +end, depth = opening + 1, 1 +while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 +actual = source[start:end] +explicit = 'let shade: BackgroundShade' in actual +arguments = ', shade: shade, showsArt: shown' if explicit else '' +host = '''import SwiftUI +import NXLibraryCore +struct SteamArtwork: View { + let game: CatalogGame + let slot: ArtworkSlot + var body: some View { Color.clear } +} +struct SeleneShades: View { + let shade: BackgroundShade + var body: some View { Color.clear } +} +''' +host += actual +host += ''' +@main struct Check { + @MainActor static func main() { + let game = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Fixture")) + for shade in BackgroundShade.allCases { + for shown in [true, false] { + let view = SteamBackdrop(game: game''' + arguments + ''') + withExtendedLifetime(view.body) {} + } + } + print("Actual Steam backdrop body works without implicit settings, for every shade/wallpaper choice.") + } +} +''' +with tempfile.TemporaryDirectory(prefix='steam-backdrop-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,cwd=ROOT) + subprocess.run([str(binary)],check=True,env=dict(os.environ,PACKAGE_RESOURCE_BUNDLE_PATH=str(BIN))) 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..3e1b429 100644 --- a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift +++ b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift @@ -235,7 +235,7 @@ 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", "Games", "Controllers", "Library", "Game sources", "Storage", "Engine", "About" ]) XCTAssertTrue(SettingsSection.allCases.allSatisfy { !$0.icon.rawValue.isEmpty }) } 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..882a1c0 --- /dev/null +++ b/Tests/NXLibraryCoreTests/CatalogModelTests.swift @@ -0,0 +1,95 @@ +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) + for operation: CatalogGameOperation in [.build, .writeRuntimeSettings, .manageSaves, .manageMods, .manageCheats, + .writeAppIcon, .deleteInstalledFiles, .manageBuildInputs] { + XCTAssertThrowsError(try shown.requireSupported(operation)) + } + XCTAssertNoThrow(try shown.requireSupported(.customizeArtwork)) + XCTAssertNoThrow(try shown.requireSupported(.favorite)) + XCTAssertNoThrow(try shown.requireSupported(.openSourceClient)) + 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..98c2ad3 --- /dev/null +++ b/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift @@ -0,0 +1,141 @@ +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 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/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/SharedGamePresentationTests.swift b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift new file mode 100644 index 0000000..598742e --- /dev/null +++ b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift @@ -0,0 +1,54 @@ +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 steam = Set(CatalogGameOptions.specificRows(for: .steam)) + XCTAssertTrue(steam.isDisjoint(with: [.fullscreen, .frameRate, .consoleMode, .rebuild, .openSaves, .removeSources])) + XCTAssertTrue(CatalogGameOptions.specificRows(for: .nxnative).contains(.rebuild)) + } +} 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..feb2535 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift @@ -0,0 +1,27 @@ +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)) + } +} 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/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..3c67e22 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,43 @@ 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 → Game sources 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. + +`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. In questa prima tappa A/Invio apre Details con preferito, nascondi, sposta e Mostra nel Finder. 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. La scheda aggiunge dettagli, galleria e store; non abilita Play Steam. Implementazione verificata automaticamente, in attesa della prova del maintainer. 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..bde78a4 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. | @@ -294,7 +294,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT | [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/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. | @@ -659,3 +659,96 @@ 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à | +|---|---| +| [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. | +| [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 e azioni Details/preferito/nascondi. | +| [CatalogSteamDetailsView.swift](../Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift) | Informazioni sull’installazione e comandi pertinenti alla fonte; nessun Play in questa tappa. | +| [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) | Preferenze Steam separate e pannello di modifica metadati/artwork. | +| [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. | + +| [run_backdrop.py](../Tests/CatalogAppChecks/run_backdrop.py) | Esegue il corpo reale di SteamBackdrop senza finestra né impostazioni implicite; regressione del crash all’avvio. | 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" ] \ From 0a46af7df8124842c9932a56293ebc1b1d069237 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 17:58:10 +0100 Subject: [PATCH 02/27] feat: Steam squares use the app's own icon, never a cropped cover SteamArtworkRequest picks the layout role's local art: the player's pick first, then for squares the bundle's CFBundleIconFile (kept inside Contents/Resources). Spotlight, search and the Steam details square ask for the square role; Collection keeps the portrait cover. Co-Authored-By: Claude Opus 5.5 --- .../Views/Catalog/CatalogNativeArtwork.swift | 17 ++----- .../Catalog/CatalogSteamDetailsView.swift | 2 +- .../Views/Catalog/SteamArtwork.swift | 8 +-- .../NXLibrary/Views/Search/FindGameView.swift | 2 +- .../Views/Spotlight/SpotlightRowView.swift | 2 +- .../Services/Steam/SteamArtworkRequest.swift | 49 +++++++++++++++++++ .../SteamArtworkRequestTests.swift | 45 +++++++++++++++++ 7 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift diff --git a/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift b/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift index 2a238ad..b0c2038 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogNativeArtwork.swift @@ -2,28 +2,21 @@ import SwiftUI import AppKit import NXLibraryCore -/// Native-app artwork for non-nxnative entries. It never asks Nintendo artwork -/// services or writes the installed app. Existing nxnative art keeps its path. +/// 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 - @State private var image: NSImage? - private var application: CatalogNativeApplication? { - game.steamGame?.installations.first { $0.availability == .available }?.primaryApplication - } - private var key: String { (application?.url.path ?? "none") + "|" + (game.steamGame?.installations.first?.buildID ?? "") } var body: some View { ZStack(alignment: .bottomLeading) { SeleneTheme.glassSolid - SteamArtwork(game: game, slot: .cover) + 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)) } - .task(id: key) { - guard !Task.isCancelled else { return } - image = application.map { NSWorkspace.shared.icon(forFile: $0.url.path) } - } .accessibilityLabel(game.name) } } diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift index 2f78982..8dd7765 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift @@ -38,7 +38,7 @@ struct CatalogSteamDetailsView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: canvas.u(30)) { HStack(alignment: .top, spacing: canvas.u(44)) { - CatalogNativeArtwork(game: game).frame(width: canvas.u(240), height: canvas.u(240)) + CatalogNativeArtwork(game: game, role: .square).frame(width: canvas.u(240), height: canvas.u(240)) .clipShape(RoundedRectangle(cornerRadius: canvas.u(18))) VStack(alignment: .leading, spacing: canvas.u(18)) { SteamArtwork(game: game, slot: .logo).frame(width: canvas.u(340), height: canvas.u(80)) diff --git a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift index 5c30fd9..3365559 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -13,6 +13,7 @@ struct SteamArtwork: View { @Environment(\.seleneCanvas) private var canvas @ObservedObject private var picks = SteamPersonalizations.shared.artwork private var key: String { "\(game.id)|\(slot.rawValue)|\(remote?.absoluteString ?? "")|\(picks.revision)" } + private var role: LibraryArtworkRole { LibraryArtworkRole.allCases.first { $0.slot == slot } ?? .hero } var body: some View { ZStack { if let image, loadedKey == key { @@ -27,10 +28,11 @@ struct SteamArtwork: View { .task(id: key) { let expected = key guard let id = game.steamGame?.appID else { return } - let personal = picks - let picked = remote == nil ? personal.url(titleID: game.personalizationReference.key, slot: slot) : nil + let request = SteamArtworkRequest(game: game, role: role) let url: URL? - if let picked { url = picked } + 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 } image = url.flatMap { NSImage(contentsOf: $0) }; loadedKey = expected diff --git a/Sources/NXLibrary/Views/Search/FindGameView.swift b/Sources/NXLibrary/Views/Search/FindGameView.swift index e74fd80..7d7d23c 100644 --- a/Sources/NXLibrary/Views/Search/FindGameView.swift +++ b/Sources/NXLibrary/Views/Search/FindGameView.swift @@ -110,7 +110,7 @@ struct FindGameView: View { VStack(spacing: canvas.u(10)) { Group { if let native = game.nxnativeGame { SpotlightTileArtwork(game: native, side: canvas.u(160), artwork: artwork) } - else { CatalogNativeArtwork(game: game) } + else { CatalogNativeArtwork(game: game, role: .square) } } .frame(width: canvas.u(160), height: canvas.u(160)) .clipShape(RoundedRectangle(cornerRadius: canvas.u(12), style: .continuous)) diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift index 808c0b1..2c20eb5 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightRowView.swift @@ -145,7 +145,7 @@ struct SpotlightRowView: View { .frame(width: side, height: side) .clipShape(face) } else if let game = catalogGamesByID[id] { - CatalogNativeArtwork(game: game).frame(width: side, height: side).clipShape(face) + CatalogNativeArtwork(game: game, role: .square).frame(width: side, height: side).clipShape(face) } case .card(.mods): ModsCardCover(urls: modsCover, side: side) diff --git a/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift new file mode 100644 index 0000000..4aea98e --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift @@ -0,0 +1,49 @@ +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 } + 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/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift b/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift index feb2535..61a1ef8 100644 --- a/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift +++ b/Tests/NXLibraryCoreTests/SteamArtworkRequestTests.swift @@ -24,4 +24,49 @@ final class SteamArtworkRequestTests: XCTestCase { 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: [:]) } + } } From 6fe31775bd6bae64ef49cf249cad56a09dd63a4c Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 18:03:15 +0100 Subject: [PATCH 03/27] feat: Steam games use the shared Selene head and hero Spotlight and Collection show a Steam game with the same logo area, action positions, stats line, genre chips and arrival motion as an nxnative game. Until Steam Play and Options exist, the first two positions open Details and hide the game. The Steam hero now loads through SeleneHeroArt, so it crossfades like nxnative art. Co-Authored-By: Claude Opus 5.5 --- .../Views/Catalog/CatalogSteamInfo.swift | 83 +++++++++++++++---- .../Views/Collection/CollectionView.swift | 17 ++-- .../Views/Components/SeleneStatsRow.swift | 5 +- .../Views/Spotlight/SeleneHeroArt.swift | 23 +++++ .../Views/Spotlight/SpotlightView+Input.swift | 5 +- .../Views/Spotlight/SpotlightView.swift | 29 +++++-- 6 files changed, 124 insertions(+), 38 deletions(-) diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index d483b60..3fc7a23 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -1,39 +1,86 @@ import SwiftUI +import AppKit import NXLibraryCore -/// The common library's Steam header. This visibility milestone opens Details; -/// the later launch/session milestone owns Play, not an engine fallback. +/// 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 the genre +/// chips. Until Steam Play and common Options exist, the first two positions open +/// Details and hide the game: what Steam games can already do, never an inert Play. 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 - let onAction: (Int) -> Void + 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 picks = SteamPersonalizations.shared.metadata + @ObservedObject private var fields = SteamPersonalizations.shared.metadata + @ObservedObject private var picks = SteamPersonalizations.shared.artwork @State private var metadata: SteamMetadata? + @State private var logo: NSImage? + @State private var logoKey = "" + @State private var shown = 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 { - VStack(alignment: .leading, spacing: canvas.u(20)) { - Text(game.name).seleneFont(38, .semibold).foregroundColor(SeleneTheme.ink).lineLimit(2) - HStack(spacing: canvas.u(12)) { - Text("Steam").seleneFont(17, .semibold) - Text(L10n.text("Mac game", context: localization)).seleneFont(17) - }.foregroundColor(SeleneTheme.ink60) - if metadata != nil || picks.value(titleID: game.personalizationReference.key, field: .description) != nil || picks.value(titleID: game.personalizationReference.key, field: .genres) != nil { - Text(picks.value(titleID: game.personalizationReference.key, field: .genres) ?? (metadata?.genres ?? []).joined(separator: " · ")).seleneFont(17).foregroundColor(SeleneTheme.ink60) - Text(picks.value(titleID: game.personalizationReference.key, field: .description) ?? metadata?.description ?? "").seleneFont(18).foregroundColor(SeleneTheme.ink).lineLimit(3) + let presentation = presentation + VStack(alignment: .leading, spacing: 0) { + GameLogoArea(title: game.name, logo: logoKey == currentLogoKey ? logo : nil, collection: collection) + if !collection { + GamePrimaryActions(primaryTitle: L10n.text("Details"), secondaryTitle: L10n.text("Hide"), + isFavorite: game.isFavorite, focused: focused, + onAction: onAction, onHover: onHover) + .padding(.leading, canvas.u(2)).padding(.top, canvas.u(18)) } - HStack(spacing: canvas.u(14)) { - SelenePillButton(title: "Details", focused: focused == 0) { onAction(0) } - SelenePillButton(title: game.isFavorite ? "Unfavorite" : "Favorite", focused: focused == 1) { onAction(1) } - SelenePillButton(title: "Hide", focused: focused == 2) { onAction(2) } + SeleneStatsRow(presentation: presentation) + .padding(.leading, canvas.u(collection ? 0 : 4)).padding(.top, canvas.u(collection ? 28 : 26)) + if !presentation.genres.isEmpty { + SpotlightGenreChips(genres: presentation.genres).padding(.top, canvas.u(20)) } } + .frame(width: collection ? nil : canvas.u(900), alignment: .leading) + .frame(maxWidth: collection ? .infinity : nil, alignment: .topLeading) + .opacity(shown ? 1 : 0) + .offset(x: slid ? 0 : canvas.u(collection ? -24 : -30)) + .onAppear { reveal() } + .onChange(of: game.id) { _ in reveal() } .task(id: "\(game.id)|\(localization.language.rawValue)") { metadata = nil guard let id = game.steamGame?.appID else { return } let result = await SteamMetadataService.shared.metadata(appID: id, language: SteamMetadata.language(localization.language)) guard !Task.isCancelled else { return }; metadata = result } - .frame(maxWidth: canvas.u(1300), alignment: .leading) + .task(id: currentLogoKey) { + let expected = currentLogoKey + 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 else { return } + logo = url.flatMap { NSImage(contentsOf: $0) }; logoKey = expected + } + } + + /// The same arrival as the nxnative head: a short hold, then fade and slide (no slide under reduce motion). + private func reveal() { + var reset = Transaction() + reset.disablesAnimations = true + withTransaction(reset) { shown = false; slid = reduceMotion } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: collection ? 0.3 : 0.45).delay(collection ? 0.12 : 0.3)) { shown = true } + if !reduceMotion { withAnimation(SeleneTheme.cubicOut(collection ? 0.3 : 0.55).delay(collection ? 0.12 : 0.3)) { slid = true } } + } } } diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 974a904..72d85a6 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -64,8 +64,7 @@ public struct CollectionView: View { backdrop: { SeleneArtBackground(image: art.image, imageKey: art.key, shade: settings.settings.backgroundShade, - showsArt: !settings.settings.keepWallpaperOnGames && selectedCatalogGame?.integration != .steam) - if let steam = selectedCatalogGame, steam.integration == .steam, !settings.settings.keepWallpaperOnGames { SteamBackdrop(game: steam, shade: settings.settings.backgroundShade, showsArt: !settings.settings.keepWallpaperOnGames) } + showsArt: !settings.settings.keepWallpaperOnGames) }, stage: { stage(games: games, game: game) }) .onAppear(perform: appear) @@ -90,6 +89,7 @@ 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 }) } .task(id: art.logoTaskKey(game, metadata: game.flatMap { viewModel.storeMetadata[$0.id] })) { await loadLogo(game) } } @@ -109,14 +109,11 @@ public struct CollectionView: View { .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) { index in - switch index { - case 0: router.showDetail(record.id) - case 1: viewModel.setCatalogFavorite(record) - case 2: viewModel.setCatalogHidden(true, game: record) - default: break - } - }.offset(x: canvas.u(80), y: canvas.u(110)) + 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 { 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/Spotlight/SeleneHeroArt.swift b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift index f49459b..83d19a0 100644 --- a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift +++ b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift @@ -56,6 +56,29 @@ final class SeleneHeroArt: ObservableObject { 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 Steam library hero. No game (or an nxnative one) leaves the image to `loadHero`. + func loadSteamHero(game: CatalogGame?, current: () -> UUID?, 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) + var url = request.localURL(picks: picks) + if url == nil, let id = request.appID { url = await SteamMetadataService.shared.image(appID: id, slot: .background) } + let loaded = url.flatMap { NSImage(contentsOf: $0) } + 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, personalizations: CatalogPersonalizations) async { guard let game, GameArtLoader.shared.cachedLogo(for: game) == nil else { return } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift index 2308e9b..e34891e 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift @@ -86,9 +86,10 @@ extension SpotlightView { func pressCatalogPill(_ index: Int, game: CatalogGame) { switch index { + // The positions of Play, Options and favorite: Details and Hide until Steam Play and Options exist. case 0: router.showDetail(game.id) - case 1: viewModel.setCatalogFavorite(game) - case 2: viewModel.setCatalogHidden(true, game: game) + case 1: viewModel.setCatalogHidden(true, game: game) + case 2: viewModel.setCatalogFavorite(game) default: break } } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index 7225985..8ff9ab3 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -172,8 +172,7 @@ public struct SpotlightView: View { let shown = shownArt(model) SeleneArtBackground(image: shown, imageKey: shown == nil ? "none" : art.key, shade: settings.settings.backgroundShade, crossfade: 0.8, - showsArt: !settings.settings.keepWallpaperOnGames && model.selectedCatalogGame?.integration != .steam, modsTint: modsBackdrop) - if let steam = selectedCatalogGame, steam.integration == .steam, !settings.settings.keepWallpaperOnGames { SteamBackdrop(game: steam, shade: settings.settings.backgroundShade, showsArt: !settings.settings.keepWallpaperOnGames) } + showsArt: !settings.settings.keepWallpaperOnGames, modsTint: modsBackdrop) // Deeper in the page the art dims so the text reads. Color.black.opacity(dimmed ? 0.4 : 0) .allowsHitTesting(false) @@ -236,6 +235,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 }, 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) } } @@ -414,9 +417,20 @@ public struct SpotlightView: View { .opacity(belowShown ? 1 : 0) } } else if let game = model.selectedCatalogGame, game.integration == .steam { - CatalogSteamInfo(game: game, focused: state.focus == .pills ? state.pillIndex : nil) { index in - pressCatalogPill(index, game: game) - }.spotlightBlock(.head) + 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) } else if model.selectedItem == .card(.mods) { VStack(alignment: .leading, spacing: 0) { SpotlightCardInfoView( @@ -723,8 +737,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 } From feb77429ff502609cf6be67185ef8efb75fe3c07 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 18:28:37 +0100 Subject: [PATCH 04/27] fix: keep game art between Steam and nxnative games Spotlight recorded a Steam selection as a non-game, so moving between Steam and nxnative games dropped the art to the wallpaper until the next hero loaded. Collection cleared it the same way. Both now keep the art until the next game's hero crossfades in. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibrary/Views/Collection/CollectionView.swift | 4 +++- Sources/NXLibrary/Views/Spotlight/SpotlightView.swift | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 72d85a6..3e96c0e 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -227,8 +227,10 @@ 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, personalizations: viewModel.personalizations) + metadata: game.flatMap { viewModel.storeMetadata[$0.id] }, artwork: artwork, personalizations: viewModel.personalizations, + keepsWithoutGame: selectedCatalogGame?.integration == .steam) } private func loadLogo(_ game: PortedGame?) async { diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index 8ff9ab3..6bc199b 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -659,7 +659,8 @@ 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 } From b92b5318b47bcd9624caa1ba91acab10c826beb7 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 18:35:10 +0100 Subject: [PATCH 05/27] feat: Steam games get the Spotlight page below the head The Spotlight page now takes a source's presentation and actions: Steam games get the same screenshot strip and viewer, About this game, activity column and action grid as nxnative games, with an Installation panel instead of Build & Sources. GameDetailPresentation gains a Steam initializer that claims nothing nxnative. Steam facts are shared by the head, the page and the input counts. Co-Authored-By: Claude Opus 5.5 --- .../Views/Catalog/CatalogSteamInfo.swift | 13 +- .../Views/Catalog/SteamGamePage.swift | 94 +++++++++ .../Views/Detail/BuildSourcesPanel.swift | 19 +- .../Views/Spotlight/SpotlightView+Input.swift | 15 +- .../Views/Spotlight/SpotlightView.swift | 199 ++++++++++++------ .../Models/GameDetailPresentation+Steam.swift | 55 +++++ .../Models/GameDetailPresentation.swift | 14 +- .../SharedGamePresentationTests.swift | 25 +++ 8 files changed, 355 insertions(+), 79 deletions(-) create mode 100644 Sources/NXLibrary/Views/Catalog/SteamGamePage.swift create mode 100644 Sources/NXLibraryCore/Models/GameDetailPresentation+Steam.swift diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index 3fc7a23..c29b7b3 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -3,7 +3,7 @@ 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 the genre +/// logo area, the Play/Options/favorite positions, the stats line and (in Collection) the genre /// chips. Until Steam Play and common Options exist, the first two positions open /// Details and hide the game: what Steam games can already do, never an inert Play. struct CatalogSteamInfo: View { @@ -20,7 +20,8 @@ struct CatalogSteamInfo: View { @Environment(\.nxAppLocale) private var localization @ObservedObject private var fields = SteamPersonalizations.shared.metadata @ObservedObject private var picks = SteamPersonalizations.shared.artwork - @State private var metadata: SteamMetadata? + @ObservedObject private var facts = SteamGameFacts.shared + private var metadata: SteamMetadata? { facts.metadata(for: game) } @State private var logo: NSImage? @State private var logoKey = "" @State private var shown = true @@ -47,7 +48,8 @@ struct CatalogSteamInfo: View { } SeleneStatsRow(presentation: presentation) .padding(.leading, canvas.u(collection ? 0 : 4)).padding(.top, canvas.u(collection ? 28 : 26)) - if !presentation.genres.isEmpty { + // 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)) } } @@ -58,10 +60,7 @@ struct CatalogSteamInfo: View { .onAppear { reveal() } .onChange(of: game.id) { _ in reveal() } .task(id: "\(game.id)|\(localization.language.rawValue)") { - metadata = nil - guard let id = game.steamGame?.appID else { return } - let result = await SteamMetadataService.shared.metadata(appID: id, language: SteamMetadata.language(localization.language)) - guard !Task.isCancelled else { return }; metadata = result + await facts.load(game, language: localization.language) } .task(id: currentLogoKey) { let expected = currentLogoKey diff --git a/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift new file mode 100644 index 0000000..409513f --- /dev/null +++ b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift @@ -0,0 +1,94 @@ +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 = "" + + 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 = [] } + guard !known.contains(appID) 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 } + 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/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/Spotlight/SpotlightView+Input.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift index e34891e..0a0bb7a 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift @@ -57,9 +57,8 @@ extension SpotlightView { if router.isSearchPresented { router.isSearchPresented = false } mutate { if $0.focus != .row { $0.focus = .row; moved = true } } case .secondaryAction: - if let game = selectedCatalogGame, game.integration == .steam { router.showDetail(game.id); return } // 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: @@ -129,11 +128,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: viewModel.personalizationKey(for: game), urls: urls, + viewer = ViewerContext(titleID: titleID, urls: urls, state: ScreenshotViewerState(count: urls.count, start: index)) SoundEffectService.shared.play(.click) } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index 6bc199b..d90edb9 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -27,6 +27,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. @@ -148,6 +151,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) @@ -378,59 +384,47 @@ public struct SpotlightView: View { .spotlightBlock(.genres) } - SpotlightScreenshotStrip( - titleID: viewModel.personalizationKey(for: game), - 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 { - 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) + 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( @@ -899,7 +893,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 @@ -930,18 +944,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, @@ -958,12 +1040,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( 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/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift index 598742e..ff0618d 100644 --- a/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift +++ b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift @@ -51,4 +51,29 @@ final class SharedGamePresentationTests: XCTestCase { XCTAssertTrue(steam.isDisjoint(with: [.fullscreen, .frameRate, .consoleMode, .rebuild, .openSaves, .removeSources])) XCTAssertTrue(CatalogGameOptions.specificRows(for: .nxnative).contains(.rebuild)) } + + 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) + } } From 95582ee0e53308efa4b036e50303e77fca31aab1 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 18:45:01 +0100 Subject: [PATCH 06/27] feat: Steam backgrounds prefer screen-shaped art Steam's library hero is a ~3:1 banner, so filling the screen enlarged and cropped it. SteamBackdropChoice now picks the first screen-shaped image: the stores' clean heroes found by name (Xbox, GOG Galaxy, TheGamesDB fan art), then the game's first Steam screenshot, the banner last. The player's own pick still comes first, and the choice is kept. Co-Authored-By: Claude Opus 5.5 --- .../Views/Collection/CollectionView.swift | 2 +- .../Views/Spotlight/SeleneHeroArt.swift | 28 +++++++-- .../Views/Spotlight/SpotlightView.swift | 2 +- .../Services/Steam/SteamBackdropChoice.swift | 62 +++++++++++++++++++ .../SteamBackdropChoiceTests.swift | 51 +++++++++++++++ 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift create mode 100644 Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 3e96c0e..65ff4b9 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -89,7 +89,7 @@ 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 }) } + .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) } } diff --git a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift index 83d19a0..f05108d 100644 --- a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift +++ b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift @@ -62,8 +62,11 @@ final class SeleneHeroArt: ObservableObject { } /// A Steam game's hero through the same image, key and crossfade as an nxnative one: the - /// player's pick, else the Steam library hero. No game (or an nxnative one) leaves the image to `loadHero`. - func loadSteamHero(game: CatalogGame?, current: () -> UUID?, settle: UInt64 = 150_000_000) async { + /// player's pick, else the automatic choice kept from last time, else a new one (`SteamBackdropChoice`: + /// screen-shaped store 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)|" @@ -71,14 +74,29 @@ final class SeleneHeroArt: ObservableObject { try? await Task.sleep(nanoseconds: settle) guard !Task.isCancelled else { return } let request = SteamArtworkRequest(game: game, role: .hero) - var url = request.localURL(picks: picks) - if url == nil, let id = request.appID { url = await SteamMetadataService.shared.image(appID: id, slot: .background) } - let loaded = url.flatMap { NSImage(contentsOf: $0) } + 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, data: { await SteamBackdropChoice.download($0) }), !Task.isCancelled { + url = await artwork.fetch(titleID: titleKey, remoteURL: chosen, kind: .hero) + } + } + guard !Task.isCancelled else { return } + let loaded: NSImage? = if let url { await Self.decodeBackdrop(at: url) } else { nil } guard !Task.isCancelled, current() == game.id else { return } image = loaded key = loaded.map { loadedFor + "\(ObjectIdentifier($0).hashValue)" } ?? "none" } + private static func decodeBackdrop(at url: URL) async -> NSImage? { + await Task.detached(priority: .userInitiated) { NSImage(contentsOf: url) }.value + } + func loadLogo(game: PortedGame?, metadata: NintendoStoreMetadata?, artwork: ArtworkStore, personalizations: CatalogPersonalizations) async { guard let game, GameArtLoader.shared.cachedLogo(for: game) == nil else { return } diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index d90edb9..f311559 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -242,7 +242,7 @@ 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 }, settle: 420_000_000) + 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) } diff --git a/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift new file mode 100644 index 0000000..473e9c7 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift @@ -0,0 +1,62 @@ +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] } ?? [] + } + var urls = first("Xbox Store") + first("GOG") + first("TheGamesDB fan art") + 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, or the last one (the library hero) + /// when none does. Nothing is kept here: the caller caches the choice. + public static func choose(_ urls: [URL], data: (URL) async -> Data?) async -> 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 } + } + return urls.last + } + + /// Store art for the name, from the sources that publish clean 16:9 heroes. + 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) + return await xbox + gog + gamesDB + } + + /// 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/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift new file mode 100644 index 0000000..5908354 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift @@ -0,0 +1,51 @@ +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 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]) { 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]) { _ in banner } + XCTAssertEqual(fallback, hero) + } + + 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: [:]) + } +} From 34715499c21de46d196bbc46c3b0116cebb9c24b Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 21:32:43 +0100 Subject: [PATCH 07/27] feat: Steam Play with real sessions Play hands steam://run/ to the verified Steam client only. A session starts when the installation's own app is seen running and ends when it exits; Steam staying open never counts. The Steam head shows Play, Resume, Cancel while waiting and Try again after a failure, with a one-line note; Spotlight and Collection A and hints follow it. Co-Authored-By: Claude Opus 5.5 --- .../Services/SteamPlayCoordinator.swift | 147 ++++++++++++++++ .../Views/Catalog/CatalogSteamInfo.swift | 43 ++++- .../Collection/CollectionView+Input.swift | 1 + .../Views/Collection/CollectionView.swift | 7 +- .../NXLibrary/Views/Shell/AppShellView.swift | 10 ++ .../Views/Spotlight/SpotlightView+Input.swift | 8 +- .../Views/Spotlight/SpotlightView.swift | 6 +- .../Resources/Localizable.xcstrings | 164 ++++++++++++++++++ .../Services/PlaySessionStore.swift | 14 ++ .../Services/Steam/SteamPlayState.swift | 124 +++++++++++++ .../PlaySessionStoreTests.swift | 22 +++ .../SteamPlayStateTests.swift | 77 ++++++++ 12 files changed, 612 insertions(+), 11 deletions(-) create mode 100644 Sources/NXLibrary/Services/SteamPlayCoordinator.swift create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift create mode 100644 Tests/NXLibraryCoreTests/SteamPlayStateTests.swift diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift new file mode 100644 index 0000000..52ed8a3 --- /dev/null +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -0,0 +1,147 @@ +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. +@MainActor +final class SteamPlayCoordinator: ObservableObject { + static let shared = SteamPlayCoordinator() + static let steamBundleID = "com.valvesoftware.steam" + private static let tick: TimeInterval = 30 + + @Published private(set) var states: [UUID: SteamPlayState] = [:] + + private var games: [CatalogGame] = [] + private var steamEnabled = false + private var sessions: PlaySessionStore? + private var tokens: [NSObjectProtocol] = [] + 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) } + } + + func start(sessions: PlaySessionStore) { + guard tokens.isEmpty else { return } + self.sessions = sessions + let center = NSWorkspace.shared.notificationCenter + tokens = [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification].map { + center.addObserver(forName: $0, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.refresh() } + } + } + 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 + } + + /// 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) { + 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 } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + let id = game.id + 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 + } + + 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) { + let processes = NSWorkspace.shared.runningApplications.filter { !$0.isTerminated }.map { + NativeProcessObservation(pid: $0.processIdentifier, bundleURL: $0.bundleURL, executableURL: $0.executableURL) + } + 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 } + } +} diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index c29b7b3..3c41606 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -4,8 +4,9 @@ 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. Until Steam Play and common Options exist, the first two positions open -/// Details and hide the game: what Steam games can already do, never an inert Play. +/// 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. +/// Until common Options exist, the second position hides the game. struct CatalogSteamInfo: View { let game: CatalogGame let totalPlaytime: TimeInterval @@ -21,6 +22,7 @@ struct CatalogSteamInfo: View { @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 private var metadata: SteamMetadata? { facts.metadata(for: game) } @State private var logo: NSImage? @State private var logoKey = "" @@ -40,9 +42,17 @@ struct CatalogSteamInfo: View { let presentation = presentation VStack(alignment: .leading, spacing: 0) { GameLogoArea(title: game.name, logo: logoKey == currentLogoKey ? logo : nil, collection: collection) + 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: L10n.text("Details"), secondaryTitle: L10n.text("Hide"), + GamePrimaryActions(primaryTitle: primaryTitle, secondaryTitle: L10n.text("Hide"), isFavorite: game.isFavorite, focused: focused, + primaryEnabled: primaryEnabled, onAction: onAction, onHover: onHover) .padding(.leading, canvas.u(2)).padding(.top, canvas.u(18)) } @@ -72,6 +82,33 @@ struct CatalogSteamInfo: View { } } + 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 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 same arrival as the nxnative head: a short hold, then fade and slide (no slide under reduce motion). private func reveal() { var reset = Transaction() diff --git a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift index 1523129..ca56b56 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift @@ -53,6 +53,7 @@ extension CollectionView { 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) } } diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 65ff4b9..1756039 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, @@ -169,8 +171,9 @@ public struct CollectionView: View { // MARK: - Hints private func contextHints(_ game: PortedGame?) -> [HintItem] { - if selectedCatalogGame?.integration == .steam { - return [HintItem(.a, L10n.text("Details")), HintItem(.y, L10n.text("Game sources")), HintItem(.rs, L10n.text("Filter"))] + if let steam = selectedCatalogGame, steam.integration == .steam { + return SteamPlayCoordinator.shared.hint(for: steam) + + [HintItem(.x, L10n.text("Details")), HintItem(.y, L10n.text("Game sources")), HintItem(.rs, L10n.text("Filter"))] } guard let game else { if viewModel.isScanningLibrary { return [] } diff --git a/Sources/NXLibrary/Views/Shell/AppShellView.swift b/Sources/NXLibrary/Views/Shell/AppShellView.swift index 4182ff6..1be72ba 100644 --- a/Sources/NXLibrary/Views/Shell/AppShellView.swift +++ b/Sources/NXLibrary/Views/Shell/AppShellView.swift @@ -266,11 +266,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) } @@ -500,6 +507,9 @@ public struct AppShellView: View { 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) viewModel.installPendingBuilds() viewModel.syncPendingAppIcons() closeGameWatch = runningGames.$runningGameIDs.sink { [weak viewModel] ids in diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift index 0a0bb7a..7493eb5 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift @@ -85,8 +85,8 @@ extension SpotlightView { func pressCatalogPill(_ index: Int, game: CatalogGame) { switch index { - // The positions of Play, Options and favorite: Details and Hide until Steam Play and Options exist. - case 0: router.showDetail(game.id) + // The positions of Play, Options and favorite: Hide stands in until common Options exist. + case 0: SoundEffectService.shared.play(.click); SteamPlayCoordinator.shared.press(game) case 1: viewModel.setCatalogHidden(true, game: game) case 2: viewModel.setCatalogFavorite(game) default: break @@ -236,8 +236,8 @@ extension SpotlightView { func activate(_ item: SpotlightItem) { switch item { case .game(let id): - if viewModel.visibleCatalogGames.first(where: { $0.id == id })?.integration == .steam { - SoundEffectService.shared.play(.click); router.showDetail(id); return + 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 { diff --git a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift index f311559..928dcb5 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 @@ -541,8 +543,8 @@ 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 model.selectedCatalogGame?.integration == .steam { - return [HintItem(.y, L10n.text("Game sources")), HintItem(.a, L10n.text("Details"))] + if let steam = model.selectedCatalogGame, steam.integration == .steam { + return [HintItem(.y, L10n.text("Game sources"))] + SteamPlayCoordinator.shared.hint(for: steam) } guard let game = model.selectedGame else { return [] } if viewModel.portingPills(game) == .porting { diff --git a/Sources/NXLibraryCore/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index 7dffb29..eecfd49 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -55243,6 +55243,47 @@ } } }, + "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": { @@ -55325,6 +55366,47 @@ } } }, + "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." + } + } + } + }, "SteamGridDB": { "comment": "SettingsCatalog.swift", "localizations": { @@ -58810,6 +58892,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": { @@ -66518,6 +66641,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": { diff --git a/Sources/NXLibraryCore/Services/PlaySessionStore.swift b/Sources/NXLibraryCore/Services/PlaySessionStore.swift index ecca91c..ae1f022 100644 --- a/Sources/NXLibraryCore/Services/PlaySessionStore.swift +++ b/Sources/NXLibraryCore/Services/PlaySessionStore.swift @@ -51,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/Steam/SteamPlayState.swift b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift new file mode 100644 index 0000000..afe9a12 --- /dev/null +++ b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift @@ -0,0 +1,124 @@ +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 + } +} + +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 + + 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. 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 this app: a pid the system reused for another program is not the game. + if processes.contains(where: { process in process.pid == pid && (application.map { Self.matches(process, $0) } ?? true) }) { return nil } + phase = .idle + return .ended(pid: pid) + } + if let application, let match = processes.first(where: { Self.matches($0, application) }) { + phase = .running(pid: match.pid) + 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. + public static func matches(_ process: NativeProcessObservation, _ application: CatalogNativeApplication) -> Bool { + guard let bundle = process.bundleURL, same(bundle, application.url) else { return false } + guard let executable = process.executableURL else { return true } + return same(executable, application.executableURL) + } + + static func same(_ a: URL, _ b: URL) -> Bool { + a.resolvingSymlinksInPath().standardizedFileURL.path == b.resolvingSymlinksInPath().standardizedFileURL.path + } +} 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/SteamPlayStateTests.swift b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift new file mode 100644 index 0000000..9d82d89 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift @@ -0,0 +1,77 @@ +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 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)) + } +} From ff52c65c9f5c1f82756aaf8724a5d360aefa5ab4 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 21:46:05 +0100 Subject: [PATCH 08/27] feat: Steam Options replace the separate Steam page A Steam game's Options pill, Y in Spotlight and Y in Collection open Options in Game options' look and input: Play, Edit details (Change artwork, Change metadata, Store page), Move on Spotlight, Favorite, Hidden and Show in Finder. The shared artwork and metadata pickers now work on a Steam game: Steam's own art for its AppID, online finds and SteamGridDB browsing, saved in the Steam personal store, never touching the installed app's icon. The separate Steam details page and its sheet editor are gone. Co-Authored-By: Claude Opus 5.5 --- .../NXLibrary/ViewModels/AppViewModel.swift | 15 + .../Catalog/CatalogSteamDetailsView.swift | 197 ---------- .../Views/Catalog/CatalogSteamInfo.swift | 4 +- .../Views/Catalog/SteamPersonalizations.swift | 68 ---- .../Collection/CollectionView+Input.swift | 11 +- .../Views/Collection/CollectionView.swift | 2 +- .../Views/Components/SeleneHomeChrome.swift | 2 +- .../Views/Detail/GameDetailView+Input.swift | 2 +- .../Views/Detail/GameDetailView.swift | 2 +- .../Views/GameOptions/ChangeArtworkView.swift | 113 ++++-- .../GameOptions/ChangeMetadataView.swift | 68 +++- .../Views/GameOptions/GameOptionsView.swift | 4 +- .../GameOptions/PersonalizationSubject.swift | 76 ++++ .../Views/GameOptions/SteamOptionsView.swift | 277 +++++++++++++ .../NXLibrary/Views/Shell/AppShellView.swift | 83 ++-- .../Views/Spotlight/SpotlightView+Input.swift | 12 +- .../Views/Spotlight/SpotlightView.swift | 4 +- .../Models/CatalogGameOptions.swift | 55 ++- .../Resources/Localizable.xcstrings | 369 ++++++++++++++++++ .../Services/ArtworkPicker.swift | 18 + .../Services/Steam/SteamArtworkRequest.swift | 5 + .../SteamOptionsTests.swift | 45 +++ docs/file-map.md | 8 +- 23 files changed, 1067 insertions(+), 373 deletions(-) delete mode 100644 Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift create mode 100644 Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift create mode 100644 Sources/NXLibrary/Views/GameOptions/SteamOptionsView.swift create mode 100644 Tests/NXLibraryCoreTests/SteamOptionsTests.swift diff --git a/Sources/NXLibrary/ViewModels/AppViewModel.swift b/Sources/NXLibrary/ViewModels/AppViewModel.swift index b83a899..7596288 100644 --- a/Sources/NXLibrary/ViewModels/AppViewModel.swift +++ b/Sources/NXLibrary/ViewModels/AppViewModel.swift @@ -305,6 +305,12 @@ public final class AppViewModel: ObservableObject { /// 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). @@ -1460,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() } diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift deleted file mode 100644 index 8dd7765..0000000 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift +++ /dev/null @@ -1,197 +0,0 @@ -import SwiftUI -import AppKit -import NXLibraryCore - -struct CatalogSteamDetailsView: View { - @ObservedObject var viewModel: AppViewModel - @ObservedObject var router: AppRouter - @ObservedObject var settings: AppSettingsStore - let game: CatalogGame - @Environment(\.seleneCanvas) private var canvas - @Environment(\.nxAppLocale) private var localization - @State private var metadata: SteamMetadata? - @State private var editing = false - @State private var gallery: Int? - @ObservedObject private var picks = SteamPersonalizations.shared.metadata - @State private var focus = 0 - @State private var keyMonitor: Any? - private var installation: SteamCatalogInstallation? { - let current = viewModel.catalogGames.first { $0.id == game.id }?.steamGame - return current?.installations.first { $0.availability == .available } ?? current?.installations.first - } - private var status: String { - if game.steamGame?.needsInstallationReview == true { return L10n.text("Needs review", context: localization) } - switch installation?.availability { - case .available: return L10n.text("Available on this Mac", context: localization) - case .sourceUnavailable: return L10n.text("Source unavailable", context: localization) - case .notDetected, .unavailable, nil: return L10n.text("Installation not detected", context: localization) - case .requiresReview: return L10n.text("Needs review", context: localization) - } - } - var body: some View { - SeleneHomeChrome(leftHints: [HintItem(.b, L10n.text("Back", context: localization)) { router.back() }], - rightHints: [HintItem(.y, L10n.text("Game sources", context: localization)) { router.openSettings(.sources) }, HintItem(.a, L10n.text("Select", context: localization))], - hintsHidden: router.overlayHasHints(viewModel), backdrop: { - SteamBackdrop(game: game, shade: settings.settings.backgroundShade, - showsArt: !settings.settings.keepWallpaperOnGames) - }, stage: { - ScrollViewReader { proxy in - ScrollView { LazyVStack(alignment: .leading, spacing: canvas.u(30)) { - HStack(alignment: .top, spacing: canvas.u(44)) { - CatalogNativeArtwork(game: game, role: .square).frame(width: canvas.u(240), height: canvas.u(240)) - .clipShape(RoundedRectangle(cornerRadius: canvas.u(18))) - VStack(alignment: .leading, spacing: canvas.u(18)) { - SteamArtwork(game: game, slot: .logo).frame(width: canvas.u(340), height: canvas.u(80)) - Text(game.name).seleneFont(40, .semibold).foregroundColor(SeleneTheme.ink).lineLimit(2) - HStack { SteamArtwork(game: game, slot: .icon).frame(width: canvas.u(32), height: canvas.u(32)); Text("Steam · Mac").seleneFont(18, .semibold).foregroundColor(SeleneTheme.ink60) } - Text(status).seleneFont(18).foregroundColor(SeleneTheme.ink60) - if let bytes = installation?.reportedSizeBytes { - Text(L10n.format("Size reported by Steam: %@", [seleneBytes(bytes)], context: localization)) - .seleneFont(16).foregroundColor(SeleneTheme.ink60) - } - } - } - .id("top") - HStack(spacing: canvas.u(14)) { - SelenePillButton(title: "Reveal in Finder", focused: focus == 0) { perform(0) } - SelenePillButton(title: game.isFavorite ? "Unfavorite" : "Favorite", focused: focus == 1) { perform(1) } - SelenePillButton(title: "Hide", focused: focus == 2) { perform(2) } - SelenePillButton(title: "Move in row", focused: focus == 3) { perform(3) } - } - HStack(spacing: canvas.u(14)) { - SelenePillButton(title: "Open in Steam", focused: focus == 4) { perform(4) } - SelenePillButton(title: "Personalize", focused: focus == 5) { perform(5) } - } - Group { - ForEach(MetadataField.allCases, id: \.self) { field in - if let value = fieldValue(field, metadata), !value.isEmpty { - VStack(alignment: .leading, spacing: canvas.u(8)) { - Text(field.title).seleneFont(16, .semibold).foregroundColor(SeleneTheme.ink60) - Text(value).seleneFont(19).foregroundColor(SeleneTheme.ink).textSelection(.enabled) - } - } - } - ForEach(Array((metadata?.screenshots ?? []).enumerated()), id: \.offset) { index, url in - Button { gallery = index } label: { - SteamArtwork(game: game, slot: .background, remote: url) - .frame(width: canvas.u(600), height: canvas.u(338)) - .overlay(SeleneTileRing(cornerRadius: canvas.u(12), active: focus == index + 6)) - }.buttonStyle(.plain).id(index + 6) - } - } - if let installation { - Text(L10n.text("Installation", context: localization)).seleneFont(17, .semibold).foregroundColor(SeleneTheme.ink) - Text(installation.contentURL.path).seleneFont(16).foregroundColor(SeleneTheme.ink60) - .lineLimit(3).textSelection(.enabled) - } - Color.clear.frame(height: canvas.u(80)) - } - .padding(.horizontal, canvas.u(80)).padding(.top, canvas.u(130)) - .frame(maxWidth: .infinity, alignment: .leading) - }.onChange(of: focus) { value in if value >= 6 { proxy.scrollTo(value, anchor: .center) } else { proxy.scrollTo("top", anchor: .top) } } - }.frame(width: canvas.stageSize.width, height: canvas.size.height, alignment: .topLeading) - }) - .task(id: "\(game.id)|\(localization.language.rawValue)") { - metadata = nil; gallery = nil; focus = 0 - guard let id = game.steamGame?.appID else { return } - let result = await SteamMetadataService.shared.metadata(appID: id, language: SteamMetadata.language(localization.language)) - guard !Task.isCancelled else { return }; metadata = result - } - .sheet(isPresented: $editing) { SteamPersonalizationEditor(game: game) } - .overlay { if let index = gallery, let shots = metadata?.screenshots, shots.indices.contains(index) { - ZStack { - Color.black.opacity(0.96) - VStack { - SteamArtwork(game: game, slot: .background, remote: shots[index]).aspectRatio(16/9, contentMode: .fit) - HStack { - Button("‹") { gallery = max(0, index - 1) } - Text("\(index + 1) / \(shots.count)") - Button("›") { gallery = min(shots.count - 1, index + 1) } - Button(L10n.text("Close", context: localization)) { gallery = nil } - }.padding() - }.padding(50) - } - } } - .onAppear(perform: installKeyboard) - .onDisappear { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }; keyMonitor = nil } - .onReceive(NotificationCenter.default.publisher(for: .nxDetailGamepadAction)) { note in - guard router.route == .detail(game.id), !router.isMenuPresented, !router.isSearchPresented, - !router.isIntroShowing, !editing, viewModel.settingsNotice == nil, viewModel.alertMessage == nil, - !viewModel.showSetupCheck, !viewModel.isUpdateOfferOpen, NSApp.modalWindow == nil, - let action = note.object as? GamepadAction else { return } - handle(action) - } - } - private func perform(_ index: Int) { - switch index { - case 0: - guard let location = installation?.contentURL, FileManager.default.fileExists(atPath: location.path) else { - viewModel.showSettingsNotice(title: L10n.text("Installation not detected", context: localization), message: L10n.text("Refresh the Steam source or check the installation in Steam.", context: localization)) - return - } - NSWorkspace.shared.activateFileViewerSelecting([location]) - case 1: viewModel.setCatalogFavorite(game) - case 2: if viewModel.setCatalogHidden(true, game: game) { router.showLibrary() } - case 3: - router.requestSpotlightMove(SpotlightItem.game(game.id).key) - router.showLibrary() - case 4: if let id = game.steamGame?.appID { NSWorkspace.shared.open(URL(string: "https://store.steampowered.com/app/\(id)/")!) } - case 5: editing = true - default: if let count = metadata?.screenshots.count, (0.. String? { - if let value = picks.value(titleID: game.personalizationReference.key, field: field) { return value } - switch field { - case .description: return data?.description - case .genres: return data?.genres.joined(separator: " · ") - case .developer: return data?.developers.joined(separator: ", ") - case .publisher: return data?.publishers.joined(separator: ", ") - case .releaseDate: return data?.releaseDate - } - } - private func handle(_ action: GamepadAction) { - if let index = gallery { - switch action { - case .navigateLeft: gallery = max(0, index - 1) - case .navigateRight: gallery = min((metadata?.screenshots.count ?? 1) - 1, index + 1) - case .cancel, .confirm: gallery = nil - default: break - } - return - } - switch action { - case .navigateDown: focus = min(5 + (metadata?.screenshots.count ?? 0), focus < 4 ? focus + 4 : max(6, focus + 1)) - case .navigateUp: focus = focus >= 6 ? max(4, focus - 1) : max(0, focus - 4) - case .navigateLeft: focus = max(0, focus - 1) - case .navigateRight: focus = min(5 + (metadata?.screenshots.count ?? 0), focus + 1) - case .confirm: perform(focus) - case .cancel: router.back() - case .contextAction: router.openSettings(.sources) - default: break - } - } - private func installKeyboard() { - guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard router.route == .detail(game.id), !router.isMenuPresented, !router.isSearchPresented, - !router.isIntroShowing, !editing, !NativeMenuCommands.shared.isTracking, - viewModel.settingsNotice == nil, viewModel.alertMessage == nil, - !viewModel.isUpdateOfferOpen, !viewModel.showSetupCheck, - let window = event.window, !window.isSheet, window.attachedSheet == nil, - NSApp.modalWindow == nil, !(window.firstResponder is NSText), - event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return event } - switch event.keyCode { - case 53 where gallery != nil: gallery = nil - case 125: handle(.navigateDown) - case 126: handle(.navigateUp) - case 123: handle(.navigateLeft) - case 124: handle(.navigateRight) - case 36, 49: handle(.confirm) - case 16: handle(.contextAction) - default: return event - } - return nil - } - } -} diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index 3c41606..5398649 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -6,7 +6,7 @@ import NXLibraryCore /// 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. -/// Until common Options exist, the second position hides the game. +/// Options opens `SteamOptionsView`. struct CatalogSteamInfo: View { let game: CatalogGame let totalPlaytime: TimeInterval @@ -50,7 +50,7 @@ struct CatalogSteamInfo: View { .padding(.top, canvas.u(20)) } if !collection { - GamePrimaryActions(primaryTitle: primaryTitle, secondaryTitle: L10n.text("Hide"), + GamePrimaryActions(primaryTitle: primaryTitle, isFavorite: game.isFavorite, focused: focused, primaryEnabled: primaryEnabled, onAction: onAction, onHover: onHover) diff --git a/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift index 11e062b..543a06e 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamPersonalizations.swift @@ -8,72 +8,4 @@ final class SteamPersonalizations { static let shared = SteamPersonalizations() let metadata = SteamPersonalizationStore(directory: AppData.supportRoot.appendingPathComponent("integrations/steam/personal")) var artwork: SteamPersonalizationStore { metadata } - -} - -struct SteamPersonalizationEditor: View { - let game: CatalogGame - @Environment(\.dismiss) private var dismiss - @Environment(\.nxAppLocale) private var localization - @State private var fields: [MetadataField: String] = [:] - @State private var saveFocused = true - @State private var slot: ArtworkSlot = .cover - @State private var pendingArtwork: [ArtworkSlot: Data] = [:] - @State private var automatic: Set = [] - @State private var error: String? - var body: some View { - VStack(alignment: .leading, spacing: 18) { - Text(game.name).font(.title2) - Text(L10n.text("Change metadata", context: localization)).font(.headline) - ForEach(MetadataField.allCases, id: \.self) { field in - TextField(field.title, text: Binding(get: { fields[field] ?? "" }, set: { fields[field] = $0 })) - } - Text(L10n.text("Leave blank to use Steam data.", context: localization)).font(.caption) - Divider() - Picker(L10n.text("Change artwork", context: localization), selection: $slot) { - ForEach(ArtworkSlot.allCases, id: \.self) { Text($0.title).tag($0) } - } - HStack { - Button(L10n.text("Choose", context: localization), action: choose) - Button(L10n.text("Automatic", context: localization)) { pendingArtwork[slot] = nil; automatic.insert(slot) } - } - if let error { Text(error).foregroundColor(.red).font(.caption) } - HStack { - Button(L10n.text("Cancel", context: localization)) { dismiss() }.keyboardShortcut(.cancelAction).overlay(RoundedRectangle(cornerRadius: 5).stroke(saveFocused ? Color.clear : Color.accentColor, lineWidth: 2)) - Spacer() - Button(L10n.text("Save", context: localization)) { - save() - }.keyboardShortcut(.defaultAction).overlay(RoundedRectangle(cornerRadius: 5).stroke(saveFocused ? Color.accentColor : Color.clear, lineWidth: 2)) - } - }.onReceive(NotificationCenter.default.publisher(for: .nxDetailGamepadAction)) { note in - guard let action = note.object as? GamepadAction, NSApp.modalWindow == nil else { return } - switch action { - case .cancel: dismiss() - case .navigateLeft: saveFocused = false - case .navigateRight: saveFocused = true - case .confirm: if saveFocused { save() } else { dismiss() } - default: break - } - }.padding(28).frame(width: 560) - .onAppear { - for field in MetadataField.allCases { fields[field] = SteamPersonalizations.shared.metadata.value(titleID: game.personalizationReference.key, field: field) ?? "" } - } - } - private func save() { - do { - try SteamPersonalizations.shared.metadata.save(fields: fields, images: pendingArtwork, automatic: automatic, titleID: game.personalizationReference.key); dismiss() - } catch { self.error = error.localizedDescription } - } - private func choose() { - let panel = NSOpenPanel(); panel.allowedContentTypes = [.png, .jpeg, .tiff, .heic] - panel.canChooseDirectories = false; panel.allowsMultipleSelection = false - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true, (values.fileSize ?? Int.max) <= 12_582_912 else { throw URLError(.dataLengthExceedsMaximum) } - let data = try Data(contentsOf: url) - guard NSImage(data: data) != nil else { throw URLError(.cannotDecodeContentData) } - pendingArtwork[slot] = data; automatic.remove(slot) - } catch { self.error = error.localizedDescription } - } } diff --git a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift index ca56b56..011b785 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView+Input.swift @@ -33,9 +33,14 @@ extension CollectionView { router.openAddGame() } case .secondaryAction: - if let game = selectedCatalogGame { 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 selectedCatalogGame?.integration == .steam { router.openSettings(.sources); return } + 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. @@ -80,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 1756039..623ffd7 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -173,7 +173,7 @@ public struct CollectionView: View { private func contextHints(_ game: PortedGame?) -> [HintItem] { if let steam = selectedCatalogGame, steam.integration == .steam { return SteamPlayCoordinator.shared.hint(for: steam) - + [HintItem(.x, L10n.text("Details")), HintItem(.y, L10n.text("Game sources")), HintItem(.rs, L10n.text("Filter"))] + + [HintItem(.y, L10n.text("Game options")), HintItem(.rs, L10n.text("Filter"))] } guard let game else { if viewModel.isScanningLibrary { return [] } 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/Detail/GameDetailView+Input.swift b/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift index 84d0de2..4c56812 100644 --- a/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift +++ b/Sources/NXLibrary/Views/Detail/GameDetailView+Input.swift @@ -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 b324688..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 diff --git a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift index 5da5ef2..717d7d6 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,20 +77,26 @@ struct ChangeArtworkView: View { case automatic, yourPick, iconLocation, art(Found), browse(ArtworkBrowseSource), file } - private var titleKey: String { viewModel.personalizationKey(for: game) } - 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) } } } + /// LaunchBox is searched among Switch games only. + private var browseSources: [ArtworkBrowseSource] { + subject.steam == nil ? ArtworkBrowseSource.allCases : ArtworkBrowseSource.allCases.filter { $0 != .launchBox } + } private var tiles: [Tile] { 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] : []) + browseSources.map(Tile.browse) + (found[slot] ?? []).map(Tile.art) + [.file] } @@ -123,7 +132,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 +176,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 +197,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 +205,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 +284,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 @@ -432,6 +441,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 +450,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 +461,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,17 +486,24 @@ 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, + let candidates: [ArtworkCandidate] + if let steam = subject.steam, let appID = subject.steamAppID { + if online == nil { online = await OnlineArtwork.find(names: names) } + let screenshots = SteamGameFacts.shared.metadata(for: steam)?.screenshots ?? [] + candidates = ArtworkCandidates.steamList(for: slot, appID: appID, screenshots: screenshots, online: online ?? []) + } 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] ?? [] @@ -485,8 +512,8 @@ struct ChangeArtworkView: View { found[slot] = list } 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 { @@ -551,7 +578,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 +588,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 +618,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 +673,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 +686,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 +695,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 +709,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 +827,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 +852,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 0d7fae6..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 { viewModel.personalizationKey(for: game) } - 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..b9873f9 --- /dev/null +++ b/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift @@ -0,0 +1,76 @@ +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 steamStore: SteamPersonalizationStore { SteamPersonalizations.shared.metadata } + + // MARK: Artwork picks + + func artworkURL(_ slot: ArtworkSlot) -> URL? { + steam != nil ? steamStore.url(titleID: key, slot: slot) : ArtworkPicks.shared.url(titleID: key, slot: slot) + } + + func saveArtwork(_ data: Data, slot: ArtworkSlot) throws { + if steam != nil { try steamStore.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 steamStore.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 ? steamStore.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 steamStore.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/Shell/AppShellView.swift b/Sources/NXLibrary/Views/Shell/AppShellView.swift index 1be72ba..6ecf570 100644 --- a/Sources/NXLibrary/Views/Shell/AppShellView.swift +++ b/Sources/NXLibrary/Views/Shell/AppShellView.swift @@ -78,11 +78,17 @@ 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 && viewModel.currentFocusedCatalogGame != 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) } @@ -108,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, @@ -208,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) + } } } @@ -227,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) @@ -316,30 +337,32 @@ public struct AppShellView: View { .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.currentFocusedCatalogGame else { return } - if router.route == .library, game.integration == .nxnative { viewModel.spotlight.showGameDetails() } - else { router.showDetail(game.id) } + if router.route == .library { viewModel.spotlight.showGameDetails() } + 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 }) { @@ -359,11 +382,11 @@ 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) } @@ -372,12 +395,12 @@ public struct AppShellView: View { 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" { @@ -394,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() } @@ -417,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 { @@ -476,8 +499,6 @@ public struct AppShellView: View { artwork: artwork, game: game ) - } else if let game = viewModel.catalogGames.first(where: { $0.id == id && $0.integration == .steam }) { - CatalogSteamDetailsView(viewModel: viewModel, router: router, settings: settings, game: game) } else { Color.clear .onAppear { router.showLibrary() } @@ -588,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 } @@ -1099,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/SpotlightView+Input.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift index 7493eb5..91169b0 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView+Input.swift @@ -62,7 +62,11 @@ extension SpotlightView { mutate { if $0.focus == .row { moved = $0.showGameDetails() } } } case .contextAction: - if selectedCatalogGame?.integration == .steam { router.openSettings(.sources); return } + 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 { @@ -85,9 +89,9 @@ extension SpotlightView { func pressCatalogPill(_ index: Int, game: CatalogGame) { switch index { - // The positions of Play, Options and favorite: Hide stands in until common Options exist. + // The positions of Play, Options and favorite, as for an nxnative game. case 0: SoundEffectService.shared.play(.click); SteamPlayCoordinator.shared.press(game) - case 1: viewModel.setCatalogHidden(true, game: game) + case 1: SoundEffectService.shared.play(.click); viewModel.openSteamOptions(game, from: .spotlight) case 2: viewModel.setCatalogFavorite(game) default: break } @@ -362,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 928dcb5..ef82cfa 100644 --- a/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift +++ b/Sources/NXLibrary/Views/Spotlight/SpotlightView.swift @@ -219,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. @@ -544,7 +544,7 @@ public struct SpotlightView: View { 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("Game sources"))] + SteamPlayCoordinator.shared.hint(for: steam) + return [HintItem(.y, L10n.text("Options"))] + SteamPlayCoordinator.shared.hint(for: steam) } guard let game = model.selectedGame else { return [] } if viewModel.portingPills(game) == .porting { diff --git a/Sources/NXLibraryCore/Models/CatalogGameOptions.swift b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift index deccf77..293c5f2 100644 --- a/Sources/NXLibraryCore/Models/CatalogGameOptions.swift +++ b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift @@ -3,10 +3,12 @@ import Foundation /// Shared library operations are ordered once. Integration handlers still enforce /// CatalogGamePresentation capabilities before performing an operation. 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, .storePage] + rows += [.favorite, .hidden, .showInFinder] return rows } public static func specificRows(for integration: CatalogIntegration) -> [GameOptionsRow] { @@ -16,3 +18,54 @@ public enum CatalogGameOptions { } } } + +/// 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/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index eecfd49..ce9c5d6 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -8232,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": { @@ -18400,6 +18441,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": { @@ -43417,6 +43499,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": { @@ -51430,6 +51553,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": { @@ -54915,6 +55079,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": { @@ -55407,6 +55612,47 @@ } } }, + "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: %@" + } + } + } + }, "SteamGridDB": { "comment": "SettingsCatalog.swift", "localizations": { @@ -55612,6 +55858,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": { @@ -56924,6 +57252,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": { diff --git a/Sources/NXLibraryCore/Services/ArtworkPicker.swift b/Sources/NXLibraryCore/Services/ArtworkPicker.swift index 89d64fb..0598b8c 100644 --- a/Sources/NXLibraryCore/Services/ArtworkPicker.swift +++ b/Sources/NXLibraryCore/Services/ArtworkPicker.swift @@ -195,6 +195,24 @@ 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] { + var list = appID > 0 ? SteamStoreArt.candidates(appID: Int(appID), slot: slot) : [] + if slot == .background { + for shot in OnlineArtwork.screenshots(screenshots.map(\.absoluteString), source: "Steam screenshot") + 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? { diff --git a/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift index 4aea98e..ca8c1ec 100644 --- a/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift +++ b/Sources/NXLibraryCore/Services/Steam/SteamArtworkRequest.swift @@ -25,6 +25,11 @@ public struct SteamArtworkRequest: Equatable { 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) } diff --git a/Tests/NXLibraryCoreTests/SteamOptionsTests.swift b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift new file mode 100644 index 0000000..5a8dec6 --- /dev/null +++ b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift @@ -0,0 +1,45 @@ +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", "Steam screenshot 1"]) + XCTAssertTrue(ArtworkCandidates.steamList(for: .icon, appID: 10, screenshots: [shot]).isEmpty) + XCTAssertTrue(ArtworkCandidates.steamList(for: .cover, appID: 0, screenshots: []).isEmpty) + } +} diff --git a/docs/file-map.md b/docs/file-map.md index bde78a4..311ae82 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -732,8 +732,10 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | [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 e azioni Details/preferito/nascondi. | -| [CatalogSteamDetailsView.swift](../Sources/NXLibrary/Views/Catalog/CatalogSteamDetailsView.swift) | Informazioni sull’installazione e comandi pertinenti alla fonte; nessun Play in questa tappa. | +| [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. | @@ -746,7 +748,7 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | [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) | Preferenze Steam separate e pannello di modifica metadati/artwork. | +| [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. | From 554b57ab6c9662db05a4f8d96315d346430b8bf3 Mon Sep 17 00:00:00 2001 From: Bum Date: Fri, 13 Nov 2026 21:51:39 +0100 Subject: [PATCH 09/27] fix: Steam sessions survive an NX Library restart A Steam game already running when NX Library starts now gets its session: the coordinator reads no processes before it has the session store. Stopping it forgets old states, Play reads the processes before resuming, and an empty store answer is asked again after a minute. Architecture notes describe the shared Steam experience. Co-Authored-By: Claude Opus 5.5 --- .../NXLibrary/Services/SteamPlayCoordinator.swift | 8 ++++++++ Sources/NXLibrary/Views/Catalog/SteamGamePage.swift | 11 ++++++++--- .../Views/GameOptions/PersonalizationSubject.swift | 13 +++++++------ docs/architecture.md | 12 ++++++++++-- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift index 52ed8a3..a20c627 100644 --- a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -68,6 +68,10 @@ final class SteamPlayCoordinator: ObservableObject { 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 = [:] + sessions = nil } /// The catalog changed. A game no longer in it keeps its session until its process ends. @@ -86,6 +90,8 @@ final class SteamPlayCoordinator: ObservableObject { } 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() @@ -122,6 +128,8 @@ final class SteamPlayCoordinator: ObservableObject { } 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 } let processes = NSWorkspace.shared.runningApplications.filter { !$0.isTerminated }.map { NativeProcessObservation(pid: $0.processIdentifier, bundleURL: $0.bundleURL, executableURL: $0.executableURL) } diff --git a/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift index 409513f..b3ced31 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamGamePage.swift @@ -12,6 +12,9 @@ final class SteamGameFacts: ObservableObject { /// 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] } @@ -24,11 +27,13 @@ final class SteamGameFacts: ObservableObject { 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 = [] } - guard !known.contains(appID) else { return } + 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 } + if let result { facts[appID] = result; missedAt[appID] = nil } else { missedAt[appID] = Date() } known.insert(appID) } } diff --git a/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift b/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift index b9873f9..fd7355c 100644 --- a/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift +++ b/Sources/NXLibrary/Views/GameOptions/PersonalizationSubject.swift @@ -28,33 +28,34 @@ struct PersonalizationSubject { var titleId: String? { ported?.titleId } var steamAppID: UInt32? { steam?.steamGame?.appID } - private var steamStore: SteamPersonalizationStore { SteamPersonalizations.shared.metadata } + private var steamArt: SteamPersonalizationStore { SteamPersonalizations.shared.artwork } + private var steamFields: SteamPersonalizationStore { SteamPersonalizations.shared.metadata } // MARK: Artwork picks func artworkURL(_ slot: ArtworkSlot) -> URL? { - steam != nil ? steamStore.url(titleID: key, slot: slot) : ArtworkPicks.shared.url(titleID: key, slot: slot) + 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 steamStore.save(fields: [:], images: [slot: data], automatic: [], titleID: key) } + 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 steamStore.save(fields: [:], images: [:], automatic: [slot], titleID: key) } + 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 ? steamStore.value(titleID: key, field: field) : MetadataPicks.shared.value(titleID: key, field: field) + 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 steamStore.save(fields: [field: value ?? ""], images: [:], automatic: [], titleID: key) } + if steam != nil { try steamFields.save(fields: [field: value ?? ""], images: [:], automatic: [], titleID: key) } else if let ported { viewModel.pickMetadata(value, field: field, for: ported) } } } diff --git a/docs/architecture.md b/docs/architecture.md index 3c67e22..5ea7d54 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -350,11 +350,19 @@ Questa attivazione è compilata e provata automaticamente, **in attesa di valida Menu → Manage sources e Settings → Game sources 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. -`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. In questa prima tappa A/Invio apre Details con preferito, nascondi, sposta e Mostra nel Finder. 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. +`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. La scheda aggiunge dettagli, galleria e store; non abilita Play Steam. Implementazione verificata automaticamente, in attesa della prova del maintainer. +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 il primo screenshot e per ultimo il banner della libreria Steam; la scelta personale vince sempre. + +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. 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. + +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. From b814538e1ed307a5e8b34df5a1e7f703a148e4b5 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 09:46:33 +0100 Subject: [PATCH 10/27] feat: Steam art shows at once and LaunchBox finds PC art Change artwork lists a Steam game's own art (labelled Steam library) before the slower online search finishes, and shows wide banners whole. Browse online on LaunchBox looks for a Steam game's Mac or Windows entry, never a console one. Play hands the request to Steam without bringing Steam to the front. Co-Authored-By: Claude Opus 5.5 --- .../Services/SteamPlayCoordinator.swift | 4 +- .../Views/GameOptions/ChangeArtworkView.swift | 47 ++++++++++++------- .../Services/ArtworkBrowse.swift | 5 +- .../Services/ArtworkPicker.swift | 7 ++- .../NXLibraryCore/Services/LaunchBoxArt.swift | 24 +++++++--- .../LaunchBoxArtTests.swift | 13 +++++ .../SteamOptionsTests.swift | 2 +- 7 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift index a20c627..665d462 100644 --- a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -102,8 +102,10 @@ final class SteamPlayCoordinator: ObservableObject { 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 = true + configuration.activates = false let id = game.id NSWorkspace.shared.open([url], withApplicationAt: client, configuration: configuration) { [weak self] _, error in let accepted = error == nil diff --git a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift index 717d7d6..2e066a2 100644 --- a/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift +++ b/Sources/NXLibrary/Views/GameOptions/ChangeArtworkView.swift @@ -87,16 +87,12 @@ struct ChangeArtworkView: View { return [subject.name, metadata?.name, titleDB?.name].compactMap { $0 } .reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } } } - /// LaunchBox is searched among Switch games only. - private var browseSources: [ArtworkBrowseSource] { - subject.steam == nil ? ArtworkBrowseSource.allCases : ArtworkBrowseSource.allCases.filter { $0 != .launchBox } - } private var tiles: [Tile] { 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 && macApp != nil ? [.iconLocation] : []) + browseSources.map(Tile.browse) + return [.automatic] + (hasPick ? [.yourPick] : []) + (slot == .icon && macApp != nil ? [.iconLocation] : []) + ArtworkBrowseSource.allCases.map(Tile.browse) + (found[slot] ?? []).map(Tile.art) + [.file] } @@ -303,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 { @@ -427,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) @@ -492,24 +493,24 @@ struct ChangeArtworkView: View { } let metadata = viewModel.storeMetadata[subject.id] let names = gameNames - let candidates: [ArtworkCandidate] + var candidates: [ArtworkCandidate] + found[slot] = [] if let steam = subject.steam, let appID = subject.steamAppID { - if online == nil { online = await OnlineArtwork.find(names: names) } + // 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 = subject.titleId, @@ -525,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 @@ -558,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 } 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 0598b8c..a53eb0d 100644 --- a/Sources/NXLibraryCore/Services/ArtworkPicker.swift +++ b/Sources/NXLibraryCore/Services/ArtworkPicker.swift @@ -200,9 +200,12 @@ public enum ArtworkCandidates { /// 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] { - var list = appID > 0 ? SteamStoreArt.candidates(appID: Int(appID), slot: slot) : [] + // 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") + for shot in OnlineArtwork.screenshots(screenshots.map(\.absoluteString), source: "Steam screenshot", stripQuery: true) where !list.contains(where: { $0.url == shot.url }) { list.append(shot) } diff --git a/Sources/NXLibraryCore/Services/LaunchBoxArt.swift b/Sources/NXLibraryCore/Services/LaunchBoxArt.swift index 994bc7e..f3a9d03 100644 --- a/Sources/NXLibraryCore/Services/LaunchBoxArt.swift +++ b/Sources/NXLibraryCore/Services/LaunchBoxArt.swift @@ -55,12 +55,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 +139,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/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/SteamOptionsTests.swift b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift index 5a8dec6..27fd3f3 100644 --- a/Tests/NXLibraryCoreTests/SteamOptionsTests.swift +++ b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift @@ -38,7 +38,7 @@ final class SteamOptionsTests: XCTestCase { 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", "Steam screenshot 1"]) + 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) } From c4f134a606ad34d4260aa72525159b27fdcc9613 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 10:09:06 +0100 Subject: [PATCH 11/27] feat: Keep Steam in the background while a game starts Valve has no way to keep an open Steam out of sight when it gets steam://run, so Play now starts a closed Steam with the documented -silent -nofriendsui options and hides an open one while the game is on its way. After 15 s without the game 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. The setting lives in Game sources and is on by default. Co-Authored-By: Claude Opus 5.5 --- .../Services/SteamPlayCoordinator.swift | 82 ++++++++- .../Views/Catalog/CatalogSteamInfo.swift | 4 +- .../Settings/SettingsSectionContent.swift | 2 +- .../Settings/SettingsSourcesSection.swift | 8 +- .../NXLibrary/Views/Shell/AppShellView.swift | 2 +- .../NXLibraryCore/Models/AppSettings.swift | 4 + .../Models/SettingsCatalog.swift | 2 + .../Resources/Localizable.xcstrings | 164 ++++++++++++++++++ .../Steam/SteamBackgroundLaunch.swift | 27 +++ .../SteamOptionsTests.swift | 19 ++ .../SteamPlayStateTests.swift | 21 +++ docs/architecture.md | 2 + docs/file-map.md | 1 + 13 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 Sources/NXLibraryCore/Services/Steam/SteamBackgroundLaunch.swift diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift index 665d462..e8b6201 100644 --- a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -7,6 +7,11 @@ import NXLibraryCore /// 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() @@ -14,10 +19,15 @@ final class SteamPlayCoordinator: ObservableObject { 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] = [] private var listObservation: NSKeyValueObservation? private var timer: Timer? @@ -44,15 +54,27 @@ final class SteamPlayCoordinator: ObservableObject { if case .starting = phase(of: game) { cancel(game) } else { play(game) } } - func start(sessions: PlaySessionStore) { + /// 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 tokens = [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification].map { center.addObserver(forName: $0, object: nil, queue: .main) { [weak self] _ in Task { @MainActor in self?.refresh() } } } + // 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() } } @@ -72,6 +94,10 @@ final class SteamPlayCoordinator: ObservableObject { // at their last heartbeat, and a game still running must start a new one, not stay "running". states = [:] 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. @@ -107,6 +133,18 @@ final class SteamPlayCoordinator: ObservableObject { 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) } @@ -120,6 +158,7 @@ final class SteamPlayCoordinator: ObservableObject { 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) { @@ -153,5 +192,46 @@ final class SteamPlayCoordinator: ObservableObject { 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/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index 5398649..9d434ab 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -99,7 +99,9 @@ struct CatalogSteamInfo: View { private var playNote: String? { switch phase { - case .starting: return L10n.text("Waiting for Steam to start the game…") + 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.") diff --git a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift index 8307aa9..5126625 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -41,7 +41,7 @@ struct SettingsSectionContent: View { SettingsLibrarySection(viewModel: viewModel, settings: settings, artwork: artwork, onManageSources: { onOpenGroup(.sources) }, onActions: onActions) case .sources: - SettingsSourcesSection(viewModel: viewModel, onActions: onActions) + SettingsSourcesSection(viewModel: viewModel, settings: settings, onActions: onActions) case .storage: SettingsStorageSection(viewModel: viewModel, artwork: artwork, onActions: onActions, onOpenConsole: onOpenConsole) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift index fe41abc..5fd5362 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift @@ -5,6 +5,7 @@ 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 @@ -38,6 +39,10 @@ struct SettingsSourcesSection: View { }.disabled(!sources.configuration.isEnabled) SeleneValueRow("Steam folder", subtitle: "Choose the Steam folder containing steamapps.", value: (sources.configuration.rootURL ?? defaultRoot).path, actionTitle: "Change", action: chooseFolder) + 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) @@ -96,7 +101,8 @@ struct SettingsSourcesSection: View { .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: "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) } } diff --git a/Sources/NXLibrary/Views/Shell/AppShellView.swift b/Sources/NXLibrary/Views/Shell/AppShellView.swift index 6ecf570..95b5f6d 100644 --- a/Sources/NXLibrary/Views/Shell/AppShellView.swift +++ b/Sources/NXLibrary/Views/Shell/AppShellView.swift @@ -530,7 +530,7 @@ public struct AppShellView: View { 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) + SteamPlayCoordinator.shared.start(sessions: playSessions, settings: settings) viewModel.installPendingBuilds() viewModel.syncPendingAppIcons() closeGameWatch = runningGames.$runningGameIDs.sink { [weak viewModel] ids in 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/SettingsCatalog.swift b/Sources/NXLibraryCore/Models/SettingsCatalog.swift index d7d7447..623776b 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -39,6 +39,8 @@ public enum SettingsCatalog { 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 }, // 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 }, diff --git a/Sources/NXLibraryCore/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index ce9c5d6..2404dc5 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -32062,6 +32062,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": { @@ -55612,6 +55653,88 @@ } } }, + "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": { @@ -67502,6 +67625,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": { 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/Tests/NXLibraryCoreTests/SteamOptionsTests.swift b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift index 27fd3f3..3c74e33 100644 --- a/Tests/NXLibraryCoreTests/SteamOptionsTests.swift +++ b/Tests/NXLibraryCoreTests/SteamOptionsTests.swift @@ -43,3 +43,22 @@ final class SteamOptionsTests: XCTestCase { 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 index 9d82d89..ba34e4e 100644 --- a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift +++ b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift @@ -75,3 +75,24 @@ final class SteamPlayStateTests: XCTestCase { application: app, now: t0)) } } + +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/docs/architecture.md b/docs/architecture.md index 5ea7d54..53fc7f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -365,4 +365,6 @@ Un gioco Steam usa i pezzi Selene dei giochi nxnative: `GameLogoArea`, `GamePrim 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. 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. +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/file-map.md b/docs/file-map.md index 311ae82..3f9ed9a 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -678,6 +678,7 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | 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. | From 20494a28c3352d3a7a6758474d2a2305b88199b0 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 10:21:27 +0100 Subject: [PATCH 12/27] feat: Settings groups keep Switch and Steam apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar now lists sections under General, Switch, Steam and System. Game sources becomes Steam's Library page, with Keep Steam in the background under Play, and Switch's Library no longer links to it. Search, Changed and the page title name a platform section with its platform ("Steam · Library"), and section order, sidebar navigation and the settings catalog follow the groups. Section identities are unchanged. Settings subheadings are now translated. Co-Authored-By: Claude Opus 5.5 --- .../Settings/SettingsLibrarySection.swift | 3 - .../Settings/SettingsSearchSection.swift | 2 +- .../Settings/SettingsSectionContent.swift | 4 +- .../Views/Settings/SettingsSidebar.swift | 21 +- .../Settings/SettingsSourcesSection.swift | 1 + .../Views/Settings/SettingsView.swift | 2 +- .../Models/SettingsCatalog.swift | 23 +- .../Models/SettingsSection.swift | 50 ++++- .../Resources/Localizable.xcstrings | 205 ++++++++++++++++++ .../AppSettingsStoreTests.swift | 24 +- docs/architecture.md | 4 +- docs/file-map.md | 4 +- 12 files changed, 311 insertions(+), 32 deletions(-) diff --git a/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift index 0c45673..96d16f2 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsLibrarySection.swift @@ -5,7 +5,6 @@ struct SettingsLibrarySection: View { @ObservedObject var viewModel: AppViewModel @ObservedObject var settings: AppSettingsStore @ObservedObject var artwork: ArtworkStore - let onManageSources: () -> Void let onActions: ([SettingsRowAction]) -> Void @State private var metadataBytes: Int64 = 0 @@ -13,7 +12,6 @@ struct SettingsLibrarySection: View { var body: some View { VStack(spacing: 9) { - SeleneButtonRow("Manage sources", subtitle: "Choose where NX Library finds your games.", buttonTitle: "Open", action: onManageSources) SeleneRow(GameFoldersCopy.appsTitle, subtitle: GameFoldersCopy.appsContents, info: "Choose where to keep your game apps. Put any existing nxnative games (.app) here. Changing folders does not move apps; rebuilds update them where they are.") { @@ -117,7 +115,6 @@ struct SettingsLibrarySection: View { private func publishActions() { onActions([ - .init(id: "manageSources", title: "Manage sources", activate: onManageSources), .init(id: "appsChange", title: GameFoldersCopy.appsTitle) { viewModel.selectLibraryFolder() }, diff --git a/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift index c65d73f..4e42ded 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift @@ -103,7 +103,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 5126625..38fabf0 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -39,7 +39,7 @@ struct SettingsSectionContent: View { onViewControls: onViewControls) case .library: SettingsLibrarySection(viewModel: viewModel, settings: settings, - artwork: artwork, onManageSources: { onOpenGroup(.sources) }, onActions: onActions) + artwork: artwork, onActions: onActions) case .sources: SettingsSourcesSection(viewModel: viewModel, settings: settings, onActions: onActions) case .storage: @@ -61,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..9787814 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 @@ -24,19 +24,20 @@ struct SettingsSidebar: View { } } + /// 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) } } } } diff --git a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift index 5fd5362..1675e4a 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSourcesSection.swift @@ -39,6 +39,7 @@ struct SettingsSourcesSection: View { }.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.", diff --git a/Sources/NXLibrary/Views/Settings/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index b8cbb05..e12fd29 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -124,7 +124,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)) diff --git a/Sources/NXLibraryCore/Models/SettingsCatalog.swift b/Sources/NXLibraryCore/Models/SettingsCatalog.swift index 623776b..a376840 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -34,13 +34,16 @@ public enum SettingsCatalog { SettingDescriptor(id: id, group: group, title: title, about: about) } - public static let all: [SettingDescriptor] = [ - action("manageSources", .library, "Manage sources", "Choose where NX Library finds your games."), - 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 }, + /// 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 }, @@ -161,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."), diff --git a/Sources/NXLibraryCore/Models/SettingsSection.swift b/Sources/NXLibraryCore/Models/SettingsSection.swift index 746de4e..db709d7 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, sources, 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,7 +29,7 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { case .games: return "Games" case .controllers: return "Controllers" case .library: return "Library" - case .sources: return "Game sources" + case .sources: return "Library" case .storage: return "Storage" case .engine: return "Engine" case .about: return "About" @@ -45,7 +48,7 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { 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 .sources: return L10n.text("Where NX Library finds your games.") + 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 .about: return L10n.text("Version, credits and licenses.") @@ -71,4 +74,43 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Equatable { 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/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index 2404dc5..3e3adce 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -52701,6 +52701,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": { @@ -55653,6 +55817,47 @@ } } }, + "Steam libraries, refresh and how Play opens Steam.": { + "comment": "SettingsSection.swift", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Steam libraries, refresh and how Play opens Steam." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Librerie Steam, aggiornamento e come Gioca apre Steam." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bibliothèques Steam, actualisation et ouverture de Steam par Jouer." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steam-Bibliotheken, Aktualisieren und wie „Spielen“ Steam öffnet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bibliotecas de Steam, actualización y cómo Jugar abre Steam." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "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": { diff --git a/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift b/Tests/NXLibraryCoreTests/AppSettingsStoreTests.swift index 3e1b429..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", "Game sources", "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/docs/architecture.md b/docs/architecture.md index 53fc7f2..8d877ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -348,7 +348,9 @@ Questa attivazione è compilata e provata automaticamente, **in attesa di valida `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 → Game sources 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. +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`). Alcune righe solo Switch stanno ancora in sezioni generali (Start-up, Look, Controllers, Storage); si spostano in passi successivi. `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. diff --git a/docs/file-map.md b/docs/file-map.md index 3f9ed9a..25678d5 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -292,7 +292,7 @@ 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 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. | @@ -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. | From db29089a1f42b1d3a541ffec8d2d7e8afafef029 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 11:07:44 +0100 Subject: [PATCH 13/27] fix: Settings sidebar follows the selected section With the group headings the sections are taller than the window, and the sidebar never scrolled to the selection, so About stayed out of view. The sidebar now scrolls the selected section into view when it appears and whenever the selection changes. Co-Authored-By: Claude Opus 5.5 --- .../Views/Settings/SettingsSidebar.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift index 9787814..8e9df7f 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift @@ -8,6 +8,7 @@ struct SettingsSidebar: View { var isFocused: Bool = false let context: AppLocale @Environment(\.seleneCanvas) private var canvas + @Environment(\.seleneReduceMotion) private var reduceMotion private var version: String { L10n.format("Version %@", [AppUpdate.versionLabel(Bundle.main.infoDictionary)], context: context) @@ -17,10 +18,17 @@ 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. + ScrollViewReader { proxy in + ScrollView { + groups + } + .scrollIndicators(.hidden) + .onAppear { proxy.scrollTo(selection) } + .onChange(of: selection) { section in + withAnimation(reduceMotion ? nil : SeleneTheme.quick) { proxy.scrollTo(section) } + } } - .scrollIndicators(.hidden) } } @@ -37,7 +45,7 @@ struct SettingsSidebar: View { .padding(.bottom, canvas.u(2)) .accessibilityAddTraits(.isHeader) } - ForEach(group.sections) { section in groupRow(section) } + ForEach(group.sections) { section in groupRow(section).id(section) } } } } From 2f732347ba148ccc379e127b913f8c92a2cfb943 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 11:12:02 +0100 Subject: [PATCH 14/27] fix: Settings sidebar scrolls to the selection at once The animated scroll started in the same frame as the new section's content and stuttered, most visibly when the selection wraps from About back to Search. The sidebar now brings the selection into view without animation, as macOS sidebars do. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibrary/Views/Settings/SettingsSidebar.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift index 8e9df7f..301d643 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSidebar.swift @@ -8,7 +8,6 @@ struct SettingsSidebar: View { var isFocused: Bool = false let context: AppLocale @Environment(\.seleneCanvas) private var canvas - @Environment(\.seleneReduceMotion) private var reduceMotion private var version: String { L10n.format("Version %@", [AppUpdate.versionLabel(Bundle.main.infoDictionary)], context: context) @@ -18,16 +17,16 @@ struct SettingsSidebar: View { VStack(alignment: .leading, spacing: 0) { header .padding(.bottom, canvas.u(26)) - // The groups can be taller than the window: the selected section is always scrolled into view. + // 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 - withAnimation(reduceMotion ? nil : SeleneTheme.quick) { proxy.scrollTo(section) } - } + .onChange(of: selection) { section in proxy.scrollTo(section) } } } } From a63bd787409546ea3370456f65c1ba76061bc4f7 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 11:21:55 +0100 Subject: [PATCH 15/27] fix: Settings Search waits for the player to open its field Selecting Search in the sidebar focused its field at once, so the keys went to the field instead of the sidebar. The field now opens only on A, Return or Right from the sidebar (or a click); Down from the field still goes to its results. Co-Authored-By: Claude Opus 5.5 --- .../Settings/SettingsSearchSection.swift | 9 +++++---- .../Settings/SettingsSectionContent.swift | 3 ++- .../Views/Settings/SettingsView.swift | 20 ++++++++++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift b/Sources/NXLibrary/Views/Settings/SettingsSearchSection.swift index 4e42ded..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() } } diff --git a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift index 38fabf0..ba5824c 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -12,13 +12,14 @@ 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) diff --git a/Sources/NXLibrary/Views/Settings/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index e12fd29..b581fc4 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? @@ -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 @@ -307,7 +321,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) @@ -406,7 +420,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) From 2ba7b209a671f26749af87687969ef678520bcf2 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 11:29:48 +0100 Subject: [PATCH 16/27] fix: Esc in Settings Search closes the field, not Settings Esc (or B) while typing in Search resigned the field and then also went back, leaving Settings. Back now only closes the field when typing, and the player stays on Search in the sidebar. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibrary/Views/Settings/SettingsView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/NXLibrary/Views/Settings/SettingsView.swift b/Sources/NXLibrary/Views/Settings/SettingsView.swift index b581fc4..785c8f0 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -384,7 +384,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 } @@ -436,8 +435,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() From d5440ffa4f838f72a9c56c6f39413e53f5b066de Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:04:03 +0100 Subject: [PATCH 17/27] fix: moonlight keeps 60 fps on ProMotion while nothing else moves The moonlight was paced by a CVDisplayLink, which tells the system nothing about its frame rate. After about ten seconds without input a variable-refresh display slowed down, nextDrawable waited up to 80 ms and the motes fell from 60 to ~30 fps; typing or any other on-screen change hid it. Measured with a temporary frame log: Settings Search stuttered after 12 s idle, Start-up too. On macOS 14 and later the moonlight now runs from a CAMetalDisplayLink asking for 60 fps, on a render thread of its own: the display keeps its rate and each drawable comes when the screen can take it, and the motes advance by presentation time. The same log then read 57-61 fps idle with no stalls. macOS 13 keeps the CVDisplayLink. Co-Authored-By: Claude Opus 5.5 --- .../Views/Theme/MoonlightRenderer.swift | 104 +++++++++++++++++- .../Views/Theme/SeleneBackdrop.swift | 2 +- 2 files changed, 99 insertions(+), 7 deletions(-) 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 From 7a8f28439310487cc85edc696300a113c04023e8 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:39:15 +0100 Subject: [PATCH 18/27] fix: Steam heads arrive exactly like Switch ones In Spotlight a Steam head slid as one block, while a Switch head slides only its logo and fades the pills and stats in after it; in Collection its fade eased instead of running linear. The Steam head now uses the Switch heads' own motions, shared as constants. Steam logos are kept decoded like Switch ones, so a game seen before shows its logo from the first frame instead of its title, and a logo that lands later fades in. Co-Authored-By: Claude Opus 5.5 --- .../Views/Catalog/CatalogSteamInfo.swift | 91 ++++++++++++++----- .../Views/Collection/CollectionView.swift | 11 ++- .../Views/Spotlight/SpotlightInfoView.swift | 10 +- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index 9d434ab..294f9e9 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -23,10 +23,10 @@ struct CatalogSteamInfo: View { @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 logo: NSImage? - @State private var logoKey = "" @State private var shown = true + @State private var restShown = true @State private var slid = true private var presentation: LibraryPresentation { @@ -41,7 +41,31 @@ struct CatalogSteamInfo: View { var body: some View { let presentation = presentation VStack(alignment: .leading, spacing: 0) { - GameLogoArea(title: game.name, logo: logoKey == currentLogoKey ? logo : nil, collection: collection) + // 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) @@ -63,23 +87,6 @@ struct CatalogSteamInfo: View { SpotlightGenreChips(genres: presentation.genres).padding(.top, canvas.u(20)) } } - .frame(width: collection ? nil : canvas.u(900), alignment: .leading) - .frame(maxWidth: collection ? .infinity : nil, alignment: .topLeading) - .opacity(shown ? 1 : 0) - .offset(x: slid ? 0 : canvas.u(collection ? -24 : -30)) - .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) { - let expected = currentLogoKey - 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 else { return } - logo = url.flatMap { NSImage(contentsOf: $0) }; logoKey = expected - } } private var phase: SteamPlayState.Phase { player.phase(of: game) } @@ -111,14 +118,50 @@ struct CatalogSteamInfo: View { } } - /// The same arrival as the nxnative head: a short hold, then fade and slide (no slide under reduce motion). + /// 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; slid = reduceMotion } + withTransaction(reset) { shown = false; restShown = false; slid = reduceMotion } DispatchQueue.main.async { - withAnimation(.easeOut(duration: collection ? 0.3 : 0.45).delay(collection ? 0.12 : 0.3)) { shown = true } - if !reduceMotion { withAnimation(SeleneTheme.cubicOut(collection ? 0.3 : 0.55).delay(collection ? 0.12 : 0.3)) { slid = true } } + 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 } + let image = await Task.detached(priority: .userInitiated) { NSImage(contentsOf: url) }.value + 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/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 623ffd7..3b800ce 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -285,7 +285,7 @@ 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() } } @@ -293,6 +293,11 @@ struct CollectionInfoView: View { 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. + static let fade = Animation.linear(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() @@ -302,9 +307,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/Spotlight/SpotlightInfoView.swift b/Sources/NXLibrary/Views/Spotlight/SpotlightInfoView.swift index 6385ab2..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 { @@ -172,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() { @@ -183,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 } } } } From c4e18ff7d6e5d6e1fd0699678f13c7c0dbf98ad1 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:40:37 +0100 Subject: [PATCH 19/27] feat: Steam backgrounds also look for LaunchBox fan art The automatic Steam background now tries LaunchBox's fan art for the game's Mac or Windows entry after Xbox, GOG and TheGamesDB and before screenshots: the full file, and only one whose stated size suits a screen. Steam's 3:1 library banner stays the last resort, shown cropped. With no banner, the first image that loads is shown instead of the last URL tried. Co-Authored-By: Claude Opus 5.5 --- .../Views/Spotlight/SeleneHeroArt.swift | 4 +-- .../NXLibraryCore/Services/LaunchBoxArt.swift | 2 ++ .../Services/Steam/SteamBackdropChoice.swift | 26 +++++++++++---- .../SteamBackdropChoiceTests.swift | 33 +++++++++++++++++-- docs/architecture.md | 2 +- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift index f05108d..6cc95be 100644 --- a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift +++ b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift @@ -63,7 +63,7 @@ final class SeleneHeroArt: ObservableObject { /// 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, then the first screenshot, the library banner last). No game (or an + /// 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 { @@ -82,7 +82,7 @@ final class SeleneHeroArt: ObservableObject { 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, data: { await SteamBackdropChoice.download($0) }), !Task.isCancelled { + 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) } } diff --git a/Sources/NXLibraryCore/Services/LaunchBoxArt.swift b/Sources/NXLibraryCore/Services/LaunchBoxArt.swift index f3a9d03..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 diff --git a/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift index 473e9c7..fd5ea6c 100644 --- a/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift +++ b/Sources/NXLibraryCore/Services/Steam/SteamBackdropChoice.swift @@ -21,30 +21,42 @@ public enum SteamBackdropChoice { func first(_ source: String) -> [URL] { storeArt.first { $0.slot == .background && $0.source == source }.map { [$0.url] } ?? [] } - var urls = first("Xbox Store") + first("GOG") + first("TheGamesDB fan art") + // 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, or the last one (the library hero) - /// when none does. Nothing is kept here: the caller caches the choice. - public static func choose(_ urls: [URL], data: (URL) async -> Data?) async -> URL? { + /// 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 urls.last + return fallback ?? loaded } - /// Store art for the name, from the sources that publish clean 16:9 heroes. + /// 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) - return await xbox + gog + gamesDB + 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`. diff --git a/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift index 5908354..9ad5d13 100644 --- a/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift +++ b/Tests/NXLibraryCoreTests/SteamBackdropChoiceTests.swift @@ -28,20 +28,49 @@ final class SteamBackdropChoiceTests: XCTestCase { 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]) { url in + 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]) { _ in banner } + 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, diff --git a/docs/architecture.md b/docs/architecture.md index 8d877ee..d3500a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -363,7 +363,7 @@ SteamMetadataService mantiene i dati pubblici dello store separati dalle osserva ### 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 il primo screenshot e per ultimo il banner della libreria Steam; la scelta personale vince sempre. +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. 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. 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. From e07686eb01b4f53b6e791b714b7229a102fd749e Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:41:36 +0100 Subject: [PATCH 20/27] fix: a Steam session follows the process it saw start A running Steam session was checked against the installation as the catalog has it now. With the installation gone (a refresh) any process with the same pid kept it open, and with the app named at a new path the session ended while the game was still playing. The session now lasts while the process it saw start, same pid and same bundle, is alive. Co-Authored-By: Claude Opus 5.5 --- .../Services/Steam/SteamPlayState.swift | 11 +++++++++-- .../SteamPlayStateTests.swift | 17 +++++++++++++++++ docs/architecture.md | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift index afe9a12..737d9bb 100644 --- a/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift +++ b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift @@ -53,6 +53,9 @@ public struct SteamPlayState: Equatable, Sendable { 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() {} @@ -95,13 +98,17 @@ public struct SteamPlayState: Equatable, Sendable { public mutating func observe(_ processes: [NativeProcessObservation], application: CatalogNativeApplication?, now: Date) -> Change? { if case .running(let pid) = phase { - // Same pid and still this app: a pid the system reused for another program is not the game. - if processes.contains(where: { process in process.pid == pid && (application.map { Self.matches(process, $0) } ?? true) }) { return nil } + // 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) } if let application, let match = processes.first(where: { Self.matches($0, application) }) { phase = .running(pid: match.pid) + runningBundle = match.bundleURL return .started(pid: match.pid) } if case .starting(let since, _) = phase, now.timeIntervalSince(since) > Self.startDeadline { diff --git a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift index ba34e4e..0433657 100644 --- a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift +++ b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift @@ -74,6 +74,23 @@ final class SteamPlayStateTests: XCTestCase { 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 { diff --git a/docs/architecture.md b/docs/architecture.md index d3500a0..8d8a49e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -365,7 +365,7 @@ SteamMetadataService mantiene i dati pubblici dello store separati dalle osserva 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. -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. 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. +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. 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. From 324451baeeec4c6db8faa2bb49cd38ce6b2e02da Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:47:52 +0100 Subject: [PATCH 21/27] feat: Switch-only settings move into the Switch group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check engine at launch and Setup move to Switch · Engine; Refresh library at launch and Box covers to Switch · Library. Readiness and Environment go: Engine's Compatibility already shows and re-checks the same environment. The caches stay only in Storage. Rumble, App icon and Storage keep their places, with the reasons in architecture.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../Settings/SettingsControllersSection.swift | 2 +- .../Settings/SettingsEngineSection.swift | 19 + .../Settings/SettingsLibrarySection.swift | 45 +- .../Views/Settings/SettingsLookSection.swift | 9 - .../Settings/SettingsSectionContent.swift | 7 +- .../Settings/SettingsStartUpSection.swift | 76 -- .../Views/Settings/SettingsView.swift | 3 +- .../Models/SettingsCatalog.swift | 15 +- .../Models/SettingsSection.swift | 8 +- .../Resources/Localizable.xcstrings | 822 +++--------------- .../SettingsCatalogTests.swift | 19 + docs/architecture.md | 2 +- 13 files changed, 194 insertions(+), 835 deletions(-) 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/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 96d16f2..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, @@ -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() } } @@ -124,16 +109,14 @@ struct SettingsLibrarySection: View { .init(id: "refresh", title: "Scan game apps", isEnabled: !viewModel.isScanningLibrary) { 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/SettingsSectionContent.swift b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift index ba5824c..856501d 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsSectionContent.swift @@ -24,7 +24,7 @@ struct SettingsSectionContent: View { 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: @@ -39,15 +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) } 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 785c8f0..f93114f 100644 --- a/Sources/NXLibrary/Views/Settings/SettingsView.swift +++ b/Sources/NXLibrary/Views/Settings/SettingsView.swift @@ -247,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: @@ -296,6 +294,7 @@ 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") diff --git a/Sources/NXLibraryCore/Models/SettingsCatalog.swift b/Sources/NXLibraryCore/Models/SettingsCatalog.swift index a376840..de61110 100644 --- a/Sources/NXLibraryCore/Models/SettingsCatalog.swift +++ b/Sources/NXLibraryCore/Models/SettingsCatalog.swift @@ -53,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 }, @@ -112,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 @@ -154,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."), @@ -182,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 db709d7..1b06f53 100644 --- a/Sources/NXLibraryCore/Models/SettingsSection.swift +++ b/Sources/NXLibraryCore/Models/SettingsSection.swift @@ -40,17 +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.") } } diff --git a/Sources/NXLibraryCore/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index 3e3adce..4109b31 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -18728,43 +18728,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." } } } @@ -18851,43 +18851,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." } } } @@ -22141,47 +22141,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": { @@ -22264,84 +22223,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." } } } @@ -22592,47 +22551,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": { @@ -24436,43 +24354,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." } } } @@ -32759,6 +32677,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": { @@ -33619,43 +33578,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." } } } @@ -34152,47 +34111,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": { @@ -35546,47 +35464,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": { @@ -38826,47 +38703,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": { @@ -40835,47 +40671,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": { @@ -46490,88 +46285,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": { @@ -46613,47 +46326,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": { @@ -47023,47 +46695,6 @@ } } }, - "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": { @@ -47105,47 +46736,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": { @@ -47228,47 +46818,6 @@ } } }, - "Ready to build and play": { - "comment": "SettingsStartUpSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready to build and play" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Pronto per compilare e giocare" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Prêt à compiler et à jouer" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Bereit zum Erstellen und Spielen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Listo para compilar y jugar" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Pronto para compilar e jogar" - } - } - } - }, "Ready to check Steam.": { "comment": "Steam source management and native Mac library presentation.", "localizations": { @@ -53234,47 +52783,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": { @@ -55530,47 +55038,6 @@ } } }, - "Startup, setup and readiness.": { - "comment": "SettingsSection.swift", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Startup, setup and readiness." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Avvio, configurazione e stato di preparazione." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Démarrage, configuration et état de préparation." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Start, Einrichtung und Bereitschaft." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Inicio, configuración y estado de preparación." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Inicialização, configuração e prontidão." - } - } - } - }, "Steam": { "comment": "Steam source management and native Mac library presentation.", "localizations": { @@ -56760,47 +56227,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": { @@ -66846,6 +66272,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": { @@ -68814,47 +68281,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": { 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/docs/architecture.md b/docs/architecture.md index 8d8a49e..3a860a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -350,7 +350,7 @@ Questa attivazione è compilata e provata automaticamente, **in attesa di valida 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`). Alcune righe solo Switch stanno ancora in sezioni generali (Start-up, Look, Controllers, Storage); si spostano in passi successivi. +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. From 4ab1e831c7588e5227b1222add96849c37748fed Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 13:49:12 +0100 Subject: [PATCH 22/27] chore: drop the unused Steam backdrop view and orphan strings SteamBackdrop had no callers; its UI-less check goes with it and CI runs the shared game header check instead. Twelve strings with no use left in the sources are removed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +- .../Views/Catalog/SteamArtwork.swift | 11 - .../Resources/Localizable.xcstrings | 489 ------------------ Tests/CatalogAppChecks/run_backdrop.py | 56 -- docs/file-map.md | 2 +- 5 files changed, 3 insertions(+), 559 deletions(-) delete mode 100644 Tests/CatalogAppChecks/run_backdrop.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e981d0..3cf876d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: run: swift test - name: Catalog notice regressions without UI run: python3 Tests/CatalogAppChecks/run_notices.py - - name: Steam backdrop regression without UI - run: python3 Tests/CatalogAppChecks/run_backdrop.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/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift index 3365559..cf4a595 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -43,14 +43,3 @@ struct SteamArtwork: View { } extension Notification.Name { static let steamPersonalizationChanged = Notification.Name("NXSteamPersonalizationChanged") } - -struct SteamBackdrop: View { - let game: CatalogGame - let shade: BackgroundShade - let showsArt: Bool - var body: some View { - Group { if showsArt { SteamArtwork(game: game, slot: .background) - .overlay(SeleneShades(shade: shade)) } } - .allowsHitTesting(false).accessibilityHidden(true) - } -} diff --git a/Sources/NXLibraryCore/Resources/Localizable.xcstrings b/Sources/NXLibraryCore/Resources/Localizable.xcstrings index 4109b31..9698d79 100644 --- a/Sources/NXLibraryCore/Resources/Localizable.xcstrings +++ b/Sources/NXLibraryCore/Resources/Localizable.xcstrings @@ -8806,47 +8806,6 @@ } } }, - "Available on this Mac": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Available on this Mac" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Disponibile su questo Mac" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Disponible sur ce Mac" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Auf diesem Mac verfügbar" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Disponible en este Mac" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Disponível neste Mac" - } - } - } - }, "Available when the compatibility gate blocks a build.": { "comment": "SettingsEngineSection.swift:228", "localizations": { @@ -14874,47 +14833,6 @@ } } }, - "Choose where NX Library finds your games.": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose where NX Library finds your games." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scegli dove NX Library cerca i tuoi giochi." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Choisissez où NX Library recherche vos jeux." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wähle, wo NX Library deine Spiele sucht." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elige dónde busca NX Library tus juegos." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Escolha onde o NX Library encontra seus jogos." - } - } - } - }, "Choose where to find existing nxnative game apps and install new builds.": { "comment": "SettingsCatalog.swift", "localizations": { @@ -33251,46 +33169,6 @@ } } }, - "Leave blank to use Steam data.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Leave blank to use Steam data." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Lascia vuoto per usare i dati di Steam." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Laissez vide pour utiliser les données Steam." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Leer lassen, um Steam-Daten zu verwenden." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Déjalo vacío para usar los datos de Steam." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Deixe em branco para usar os dados do Steam." - } - } - } - }, "Left stick": { "comment": "ControlsView.swift:29", "localizations": { @@ -36366,47 +36244,6 @@ } } }, - "Move in row": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move in row" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sposta nella fila" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Déplacer dans la rangée" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "In der Reihe verschieben" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mover en la fila" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Mover na fileira" - } - } - } - }, "Move the library to a folder outside iCloud Drive.": { "comment": "PortingFailureReason.swift", "localizations": { @@ -42762,46 +42599,6 @@ } } }, - "Open in Steam": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open in Steam" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Apri su Steam" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ouvrir sur Steam" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Auf Steam öffnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir en Steam" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Abrir no Steam" - } - } - } - }, "Open library": { "comment": "YourGamesSetupView.swift", "localizations": { @@ -44483,46 +44280,6 @@ } } }, - "Personalize": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Personalize" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Personalizza" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Personnaliser" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Anpassen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Personalizar" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Personalizar" - } - } - } - }, "Pick": { "comment": "SpotlightView.swift", "localizations": { @@ -46654,47 +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." - } - } - } - }, "Read more": { "comment": "SpotlightPageSections.swift", "localizations": { @@ -53398,47 +53114,6 @@ } } }, - "Size reported by Steam: %@": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Size reported by Steam: %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Dimensioni indicate da Steam: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Taille indiquée par Steam : %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Von Steam gemeldete Größe: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Tamaño indicado por Steam: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Tamanho informado pelo Steam: %@" - } - } - } - }, "Skip for now": { "comment": "YourGamesSetupView.swift", "localizations": { @@ -54177,47 +53852,6 @@ } } }, - "Source unavailable": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Source unavailable" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Fonte non disponibile" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Source indisponible" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Quelle nicht verfügbar" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Fuente no disponible" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Fonte indisponível" - } - } - } - }, "Sources": { "comment": "AddGameView+Rows.swift", "localizations": { @@ -64714,47 +64348,6 @@ } } }, - "Unfavorite": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unfavorite" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Rimuovi dai preferiti" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Retirer des favoris" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus Favoriten entfernen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Quitar de favoritos" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Remover dos favoritos" - } - } - } - }, "Unknown": { "comment": "Save safety and presentation; user content stays unchanged.", "localizations": { @@ -67461,47 +67054,6 @@ } } }, - "Where NX Library finds your games.": { - "comment": "Steam source management and native Mac library presentation.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Where NX Library finds your games." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Dove NX Library cerca i tuoi giochi." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Où NX Library recherche vos jeux." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wo NX Library deine Spiele findet." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Dónde busca NX Library tus juegos." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Onde o NX Library encontra seus jogos." - } - } - } - }, "Where should the icon appear?": { "comment": "ChangeArtworkView.swift", "localizations": { @@ -69593,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/Tests/CatalogAppChecks/run_backdrop.py b/Tests/CatalogAppChecks/run_backdrop.py deleted file mode 100644 index 82acb5a..0000000 --- a/Tests/CatalogAppChecks/run_backdrop.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Evaluate the actual Steam backdrop body without a window or injected settings. - -Only image/gradient children are inert substitutes. This reaches the missing -EnvironmentObject failure in the real parent view; it is not UI automation. -""" -from pathlib import Path -import os -import subprocess -import tempfile - -ROOT = Path(__file__).resolve().parents[2] -BIN = ROOT / '.build/debug' -source = (ROOT / 'Sources/NXLibrary/Views/Catalog/SteamArtwork.swift').read_text() -start = source.index('struct SteamBackdrop: View {') -opening = source.index('{', start) -end, depth = opening + 1, 1 -while depth: - depth += (source[end] == '{') - (source[end] == '}') - end += 1 -actual = source[start:end] -explicit = 'let shade: BackgroundShade' in actual -arguments = ', shade: shade, showsArt: shown' if explicit else '' -host = '''import SwiftUI -import NXLibraryCore -struct SteamArtwork: View { - let game: CatalogGame - let slot: ArtworkSlot - var body: some View { Color.clear } -} -struct SeleneShades: View { - let shade: BackgroundShade - var body: some View { Color.clear } -} -''' -host += actual -host += ''' -@main struct Check { - @MainActor static func main() { - let game = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Fixture")) - for shade in BackgroundShade.allCases { - for shown in [true, false] { - let view = SteamBackdrop(game: game''' + arguments + ''') - withExtendedLifetime(view.body) {} - } - } - print("Actual Steam backdrop body works without implicit settings, for every shade/wallpaper choice.") - } -} -''' -with tempfile.TemporaryDirectory(prefix='steam-backdrop-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,cwd=ROOT) - subprocess.run([str(binary)],check=True,env=dict(os.environ,PACKAGE_RESOURCE_BUNDLE_PATH=str(BIN))) diff --git a/docs/file-map.md b/docs/file-map.md index 25678d5..f066ad6 100644 --- a/docs/file-map.md +++ b/docs/file-map.md @@ -721,6 +721,7 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | [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. | @@ -754,4 +755,3 @@ Quando si aggiunge, rinomina o rimuove un file versionato, aggiornare la sua rig | [SteamPersonalizationStore.swift](../Sources/NXLibraryCore/Services/Steam/SteamPersonalizationStore.swift) | Pubblicazione unica di metadati e riferimenti artwork; fallimenti e Automatic conservano le scelte coerenti. | -| [run_backdrop.py](../Tests/CatalogAppChecks/run_backdrop.py) | Esegue il corpo reale di SteamBackdrop senza finestra né impostazioni implicite; regressione del crash all’avvio. | From 0300f090468875882be386d6e6b85dec870ec497 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 14:09:25 +0100 Subject: [PATCH 23/27] fix: app data roots are folders before they exist appendingPathComponent asked the disk, so a missing folder had no trailing slash and no longer equalled file.deletingLastPathComponent(). CI, on a fresh Mac, failed testSharedAndScopedDefaultPathsAreConsistent. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibraryCore/Services/AppDataProfile.swift | 8 +++++--- Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/NXLibraryCore/Services/AppDataProfile.swift b/Sources/NXLibraryCore/Services/AppDataProfile.swift index 3a66b83..79fed69 100644 --- a/Sources/NXLibraryCore/Services/AppDataProfile.swift +++ b/Sources/NXLibraryCore/Services/AppDataProfile.swift @@ -8,11 +8,13 @@ public struct AppDataProfile: Sendable { self.home = home self.isDevelopment = isDevelopment } - public var stableSupportRoot: URL { home.appendingPathComponent("Library/Application Support/NXLibrary") } + /// 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") : stableSupportRoot + isDevelopment ? home.appendingPathComponent("Library/Application Support/NXLibrary-Development", isDirectory: true) : stableSupportRoot } - public var cacheRoot: URL { home.appendingPathComponent("Library/Caches/" + (isDevelopment ? "NXLibrary-Development" : "NXLibrary")) } + 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) } diff --git a/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift b/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift index 98c2ad3..d2ed138 100644 --- a/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift +++ b/Tests/NXLibraryCoreTests/DevelopmentProfileTests.swift @@ -127,6 +127,16 @@ final class DevelopmentProfileTests: XCTestCase { 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) From 81fda68fd05acdd63a198a53b7fe1765ac0b4011 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 14:13:20 +0100 Subject: [PATCH 24/27] fix: Steam art decodes off the main thread like nxnative art Steam's hero, logo and tiles used NSImage(contentsOf:), which decodes on first draw on the main thread. Measured on a cached 1920x1080 Steam hero: 56-75 ms for that first draw against 14-16 ms predecoded, the hitch seen going from a Switch game to a Steam one. They now use GameArtLoader.decode at the nxnative sizes. Co-Authored-By: Claude Opus 5.5 --- .../Views/Catalog/CatalogSteamInfo.swift | 3 ++- .../Views/Catalog/SteamArtwork.swift | 19 ++++++++++++++++++- .../Views/Spotlight/SeleneHeroArt.swift | 7 ++----- docs/architecture.md | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift index 294f9e9..ef46dbd 100644 --- a/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift +++ b/Sources/NXLibrary/Views/Catalog/CatalogSteamInfo.swift @@ -159,7 +159,8 @@ final class SteamLogoCache: ObservableObject { 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 } - let image = await Task.detached(priority: .userInitiated) { NSImage(contentsOf: url) }.value + // 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 index cf4a595..9026b9a 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -35,11 +35,28 @@ struct SteamArtwork: View { else if role == .square { url = nil } else { url = await SteamMetadataService.shared.image(appID: id, slot: slot, remote: remote) } guard !Task.isCancelled else { return } - image = url.flatMap { NSImage(contentsOf: $0) }; loadedKey = expected + // 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) } } +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 + } + } +} + extension Notification.Name { static let steamPersonalizationChanged = Notification.Name("NXSteamPersonalizationChanged") } diff --git a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift index 6cc95be..df5238c 100644 --- a/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift +++ b/Sources/NXLibrary/Views/Spotlight/SeleneHeroArt.swift @@ -87,16 +87,13 @@ final class SeleneHeroArt: ObservableObject { } } guard !Task.isCancelled else { return } - let loaded: NSImage? = if let url { await Self.decodeBackdrop(at: url) } else { nil } + // 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" } - private static func decodeBackdrop(at url: URL) async -> NSImage? { - await Task.detached(priority: .userInitiated) { NSImage(contentsOf: url) }.value - } - func loadLogo(game: PortedGame?, metadata: NintendoStoreMetadata?, artwork: ArtworkStore, personalizations: CatalogPersonalizations) async { guard let game, GameArtLoader.shared.cachedLogo(for: game) == nil else { return } diff --git a/docs/architecture.md b/docs/architecture.md index 3a860a7..c94c3b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -363,7 +363,7 @@ SteamMetadataService mantiene i dati pubblici dello store separati dalle osserva ### 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. +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. From 64fbf7f0fbf6db8ac55fcde997b101b70e24a090 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 14:37:54 +0100 Subject: [PATCH 25/27] fix: Collection's head fades with Spotlight's curve The column keeps its quicker 0.12 s delay and 0.3 s length; its fade now eases out like Spotlight's logo instead of running linear. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibrary/Views/Collection/CollectionView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/NXLibrary/Views/Collection/CollectionView.swift b/Sources/NXLibrary/Views/Collection/CollectionView.swift index 3b800ce..9aa4296 100644 --- a/Sources/NXLibrary/Views/Collection/CollectionView.swift +++ b/Sources/NXLibrary/Views/Collection/CollectionView.swift @@ -294,7 +294,8 @@ struct CollectionInfoView: View { } /// The whole column's fade and slide after a change. A Steam game's column uses the same. - static let fade = Animation.linear(duration: 0.3).delay(0.12) + /// 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 From 3150129c3472364561c46dfa2d72e8ac7b9607e4 Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 14:56:47 +0100 Subject: [PATCH 26/27] fix: drop two concurrency warnings and an unused notification steamBundleID is a constant read from NSWorkspace observers, so it is nonisolated; SteamMetadataService's default transport is a @Sendable closure. steamPersonalizationChanged was never posted or observed. Co-Authored-By: Claude Opus 5.5 --- Sources/NXLibrary/Services/SteamPlayCoordinator.swift | 2 +- Sources/NXLibrary/Views/Catalog/SteamArtwork.swift | 2 -- Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift index e8b6201..83a3e9f 100644 --- a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -15,7 +15,7 @@ import NXLibraryCore @MainActor final class SteamPlayCoordinator: ObservableObject { static let shared = SteamPlayCoordinator() - static let steamBundleID = "com.valvesoftware.steam" + nonisolated static let steamBundleID = "com.valvesoftware.steam" private static let tick: TimeInterval = 30 @Published private(set) var states: [UUID: SteamPlayState] = [:] diff --git a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift index 9026b9a..888989f 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -58,5 +58,3 @@ private extension ArtworkSlot { } } } - -extension Notification.Name { static let steamPersonalizationChanged = Notification.Name("NXSteamPersonalizationChanged") } diff --git a/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift b/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift index a6607ca..fb85795 100644 --- a/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift +++ b/Sources/NXLibraryCore/Services/Steam/SteamMetadataService.swift @@ -33,7 +33,7 @@ public actor SteamMetadataService { } public init(directory: URL = AppData.supportRoot.appendingPathComponent("integrations/steam/enrichment"), - transport: @escaping Transport = SteamMetadataService.download) { + transport: @escaping Transport = { try await SteamMetadataService.download($0) }) { self.directory = directory; self.transport = transport } public static func download(_ url: URL) async throws -> Data { From e70772cc91baebf88b3e5f5e2b742221d8e39a2c Mon Sep 17 00:00:00 2001 From: Bum Date: Sat, 14 Nov 2026 15:04:27 +0100 Subject: [PATCH 27/27] fix: Steam Play checks processes cheaply; drop unused capability rules - refresh no longer asks LaunchServices isTerminated for every running app, and launches/quits arrive once (runningApplications KVO), not also through the workspace notifications. - A pid's paths are resolved once while it lives and an installation's once per check, instead of every process x game x refresh. - Steam tiles read the app icon once per bundle instead of in body. - supportedOperations/requireSupported/CatalogGameOperation and CatalogGameOptions.specificRows were never used by the app; the real guard is the record payload (wrongIntegration), now what tests check. Co-Authored-By: Claude Opus 5.5 --- .../Services/SteamPlayCoordinator.swift | 24 +++++++---- .../Views/Catalog/SteamArtwork.swift | 29 ++++++++++++-- .../Models/CatalogGameOptions.swift | 10 +---- .../Models/CatalogGamePresentation.swift | 14 ------- .../Services/Steam/SteamPlayState.swift | 40 +++++++++++++++---- .../CatalogModelTests.swift | 9 ++--- .../SharedGamePresentationTests.swift | 11 +++-- .../SteamPlayStateTests.swift | 19 +++++++++ docs/architecture.md | 2 +- 9 files changed, 107 insertions(+), 51 deletions(-) diff --git a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift index 83a3e9f..6f20b3a 100644 --- a/Sources/NXLibrary/Services/SteamPlayCoordinator.swift +++ b/Sources/NXLibrary/Services/SteamPlayCoordinator.swift @@ -29,6 +29,8 @@ final class SteamPlayCoordinator: ObservableObject { /// 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? @@ -62,13 +64,9 @@ final class SteamPlayCoordinator: ObservableObject { self.sessions = sessions self.settings = settings let center = NSWorkspace.shared.notificationCenter - tokens = [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification].map { - center.addObserver(forName: $0, object: nil, queue: .main) { [weak self] _ in - Task { @MainActor in self?.refresh() } - } - } + // 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 { + 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 } @@ -93,6 +91,7 @@ final class SteamPlayCoordinator: ObservableObject { // 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() } @@ -171,9 +170,18 @@ final class SteamPlayCoordinator: ObservableObject { 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 } - let processes = NSWorkspace.shared.runningApplications.filter { !$0.isTerminated }.map { - NativeProcessObservation(pid: $0.processIdentifier, bundleURL: $0.bundleURL, executableURL: $0.executableURL) + // 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) diff --git a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift index 888989f..a17e642 100644 --- a/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift +++ b/Sources/NXLibrary/Views/Catalog/SteamArtwork.swift @@ -10,23 +10,34 @@ struct SteamArtwork: View { 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 key: String { "\(game.id)|\(slot.rawValue)|\(remote?.absoluteString ?? "")|\(picks.revision)" } + 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 app = game.steamGame?.installations.first(where: { $0.availability == .available })?.primaryApplication { - Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)).resizable().scaledToFit().padding(canvas.u(16)) + 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? @@ -46,6 +57,18 @@ struct SteamArtwork: View { } } +/// 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`. diff --git a/Sources/NXLibraryCore/Models/CatalogGameOptions.swift b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift index 293c5f2..29b9f5e 100644 --- a/Sources/NXLibraryCore/Models/CatalogGameOptions.swift +++ b/Sources/NXLibraryCore/Models/CatalogGameOptions.swift @@ -1,7 +1,7 @@ import Foundation -/// Shared library operations are ordered once. Integration handlers still enforce -/// CatalogGamePresentation capabilities before performing an operation. +/// 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. @@ -11,12 +11,6 @@ public enum CatalogGameOptions { rows += [.favorite, .hidden, .showInFinder] return rows } - public static func specificRows(for integration: CatalogIntegration) -> [GameOptionsRow] { - switch integration { - case .steam: return [] - case .nxnative: return [.cheats, .mods, .openSaves, .rebuild, .attachSources, .openLog, .removeSources] - } - } } /// A Steam game's Options: only what NX Library can do for it. The shared rows keep diff --git a/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift index 070b641..18bfd6f 100644 --- a/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift +++ b/Sources/NXLibraryCore/Models/CatalogGamePresentation.swift @@ -1,10 +1,5 @@ import Foundation -public enum CatalogGameOperation: String, Hashable, CaseIterable, Sendable { - case play, favorite, hide, customizeMetadata, customizeArtwork, revealInstallation, openSourceClient - case build, writeRuntimeSettings, manageSaves, manageMods, manageCheats, writeAppIcon, deleteInstalledFiles, manageBuildInputs -} - public enum CatalogLaunchTarget: Equatable, Sendable { case nxnative(appURL: URL), steam(appID: UInt32) } public enum CatalogInstallationSize: Equatable, Sendable { case cached(Int64), steamReported(Int64), unknown } @@ -32,7 +27,6 @@ public struct CatalogGamePresentation { public let requiresReview: Bool public let installations: [CatalogInstallationPresentation] public let launchTarget: CatalogLaunchTarget? - public let supportedOperations: Set public init(game: CatalogGame, sourceEnabled: Bool) { id = game.id @@ -41,11 +35,8 @@ public struct CatalogGamePresentation { personalizationReference = game.personalizationReference isFavorite = game.isFavorite isHidden = game.isHidden - let common: Set = [.play, .favorite, .hide, .customizeMetadata, .customizeArtwork, .revealInstallation] switch game.payload { case .nxnative(let legacy): - supportedOperations = common.union([.build, .writeRuntimeSettings, .manageSaves, .manageMods, .manageCheats, - .writeAppIcon, .deleteInstalledFiles, .manageBuildInputs]) requiresReview = false isListedNormally = sourceEnabled && !legacy.isHidden launchTarget = sourceEnabled && legacy.isPlayableLocally ? .nxnative(appURL: legacy.url) : nil @@ -56,7 +47,6 @@ public struct CatalogGamePresentation { size: .cached(legacy.sizeBytes)) ] : [] case .steam(let steam): - supportedOperations = common.union([.openSourceClient]) 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 @@ -71,8 +61,4 @@ public struct CatalogGamePresentation { } } } - - public func requireSupported(_ operation: CatalogGameOperation) throws { - guard supportedOperations.contains(operation) else { throw CatalogModelError.wrongIntegration } - } } diff --git a/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift index 737d9bb..e008bc2 100644 --- a/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift +++ b/Sources/NXLibraryCore/Services/Steam/SteamPlayState.swift @@ -8,6 +8,13 @@ public struct NativeProcessObservation: Equatable, Sendable { 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 { @@ -93,8 +100,8 @@ public struct SteamPlayState: Equatable, Sendable { } } - /// Reads the process list. 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. + /// 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 { @@ -106,7 +113,8 @@ public struct SteamPlayState: Equatable, Sendable { runningBundle = nil return .ended(pid: pid) } - if let application, let match = processes.first(where: { Self.matches($0, application) }) { + // 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) @@ -119,13 +127,29 @@ public struct SteamPlayState: Equatable, Sendable { /// 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 { - guard let bundle = process.bundleURL, same(bundle, application.url) else { return false } - guard let executable = process.executableURL else { return true } - return same(executable, application.executableURL) + Target(application).matches(process) } - static func same(_ a: URL, _ b: URL) -> Bool { - a.resolvingSymlinksInPath().standardizedFileURL.path == b.resolvingSymlinksInPath().standardizedFileURL.path + /// 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/Tests/NXLibraryCoreTests/CatalogModelTests.swift b/Tests/NXLibraryCoreTests/CatalogModelTests.swift index 882a1c0..c024ec3 100644 --- a/Tests/NXLibraryCoreTests/CatalogModelTests.swift +++ b/Tests/NXLibraryCoreTests/CatalogModelTests.swift @@ -60,13 +60,10 @@ final class CatalogModelTests: XCTestCase { func testSteamDoesNotSupportEngineOrDestructiveOperations() throws { let game = CatalogGame(steam: SteamCatalogGame(appID: 10, name: "Steam game")) let shown = CatalogGamePresentation(game: game, sourceEnabled: true) - for operation: CatalogGameOperation in [.build, .writeRuntimeSettings, .manageSaves, .manageMods, .manageCheats, - .writeAppIcon, .deleteInstalledFiles, .manageBuildInputs] { - XCTAssertThrowsError(try shown.requireSupported(operation)) + var record = game + XCTAssertThrowsError(try record.updateNXNative(CatalogTestSupport.completeLegacyGame())) { error in + XCTAssertEqual(error as? CatalogModelError, .wrongIntegration) } - XCTAssertNoThrow(try shown.requireSupported(.customizeArtwork)) - XCTAssertNoThrow(try shown.requireSupported(.favorite)) - XCTAssertNoThrow(try shown.requireSupported(.openSourceClient)) XCTAssertNil(shown.launchTarget) XCTAssertFalse(shown.isListedNormally) } diff --git a/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift index ff0618d..ba7f185 100644 --- a/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift +++ b/Tests/NXLibraryCoreTests/SharedGamePresentationTests.swift @@ -47,9 +47,14 @@ final class SharedGamePresentationTests: XCTestCase { 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 steam = Set(CatalogGameOptions.specificRows(for: .steam)) - XCTAssertTrue(steam.isDisjoint(with: [.fullscreen, .frameRate, .consoleMode, .rebuild, .openSaves, .removeSources])) - XCTAssertTrue(CatalogGameOptions.specificRows(for: .nxnative).contains(.rebuild)) + 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 { diff --git a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift index 0433657..32ba21f 100644 --- a/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift +++ b/Tests/NXLibraryCoreTests/SteamPlayStateTests.swift @@ -13,6 +13,25 @@ final class SteamPlayStateTests: XCTestCase { 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), diff --git a/docs/architecture.md b/docs/architecture.md index c94c3b7..28aac22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -365,7 +365,7 @@ SteamMetadataService mantiene i dati pubblici dello store separati dalle osserva 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. +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.