Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
public typealias SystemType = PVSystem
public typealias Savetype = PVSaveState
public typealias RecentGame = PVRecentGame

///
private let database: RomDatabase

///
public lazy var saveStates: SavesObservable = {
Observable
Expand All @@ -47,7 +47,7 @@
.collection(from: recentsResults)
.mapMany { $0 }
}()

///
public lazy var mostPlayed: GamesObservable = {
Observable
Expand All @@ -65,34 +65,40 @@
.sorted(byKeyPath: #keyPath(Savetype.date),
ascending: false)
}()

///
public lazy var favoritesResults: Results<GameType> = {
database.all(GameType.self, where: #keyPath(GameType.isFavorite), value: true).sorted(byKeyPath: #keyPath(GameType.title), ascending: false)
database.all(GameType.self)
.filter("isFavorite == true AND isHiddenPatchVariant == false")
.sorted(byKeyPath: #keyPath(GameType.title), ascending: false)
}()

///
public lazy var recentsResults: Results<RecentGame> = {
database.all(RecentGame.self).sorted(byKeyPath: #keyPath(RecentGame.lastPlayedDate), ascending: false)
database.all(RecentGame.self)
.filter("game == nil OR game.isHiddenPatchVariant == false")
.sorted(byKeyPath: #keyPath(RecentGame.lastPlayedDate), ascending: false)
}()

///
public lazy var mostPlayedResults: Results<GameType> = {
database.all(GameType.self).sorted(byKeyPath: #keyPath(GameType.playCount), ascending: false)
database.all(GameType.self)
.filter("isHiddenPatchVariant == false")
.sorted(byKeyPath: #keyPath(GameType.playCount), ascending: false)
}()

///
public lazy var activeSystems: Results<SystemType> = {
database.all(SystemType.self, filter: NSPredicate(format: "games.@count > 0")).sorted(byKeyPath: #keyPath(SystemType.name), ascending: true)
}()


Check warning on line 95 in PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Limit vertical whitespace to a single empty line; currently 2 (vertical_whitespace)
/// Init a new RealmDatabaseDriver
/// - Parameter database: The RomDatabase to query on.
required public init(database: RomDatabase) {
self.database = database
}

// MARK: - Query by Identifier

/// Game for identifier
Expand All @@ -101,14 +107,14 @@
public func game(identifier: String) -> GameType? {
database.object(ofType: GameType.self, wherePrimaryKeyEquals: identifier)
}

/// GameType .isFavorite toggle
/// - Returns: Completable for when finished
public func toggleFavorite(for game: GameType) -> Completable {
Completable.create { observer in
do {
try self.database.writeTransaction {
game.isFavorite = !game.isFavorite

Check warning on line 117 in PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer `someBool.toggle()` over `someBool = !someBool` (toggle_bool)
observer(.completed)
}
} catch {
Expand All @@ -122,34 +128,39 @@
/// System Query methods
///
extension RealmDatabaseDriver {

/// System for identifier
/// - Parameter identifier: ID String of the system
/// - Returns: Optional matching SystemType
public func system(identifier: String) -> SystemType? {
database.object(ofType: SystemType.self, wherePrimaryKeyEquals: identifier)
}

/// Get all games for a System
/// - Parameter systemIdentifier: ID String of the system to query
/// - Returns: A query result with live updating as long as the reference is active
public func gamesForSystem(systemIdentifier: String) -> Results<GameType> {
database.all(GameType.self).filter(NSPredicate(format: "systemIdentifier == %@", argumentArray: [systemIdentifier]))
database.all(GameType.self).filter(
Comment thread
cursor[bot] marked this conversation as resolved.
NSPredicate(
format: "systemIdentifier == %@ AND isHiddenPatchVariant == false",
argumentArray: [systemIdentifier]
)
)
}

/// Systems sorted by Options -> RxRealm.Observable

public typealias SystemStruct = PVGameLibrary<RealmDatabaseDriver>.System
public typealias SystemObservable = Observable<SystemStruct>
public typealias SystemArrayObservable = Observable<[SystemStruct]>

/// Get all systems sorted by...
/// - Parameter sortOptions: Sory options, .title, .importDate, .lastPlayed, .mostPlayed
/// - Returns: An Observable of systems by the sort options that updates as ROMs are played or imported
public func systems(sortedBy sortOptions: PVSettings.SortOptions) -> Observable<[PVGameLibrary<RealmDatabaseDriver>.System]> {
let betaIDs: [SystemIdentifier] = SystemIdentifier.betas
let unsuppotedIDs: [SystemIdentifier] = SystemIdentifier.unsupported

return Observable
.collection(from: database.all(SystemType.self))
.flatMapLatest({ systems -> SystemArrayObservable in
Expand Down Expand Up @@ -181,7 +192,7 @@
})
.map { systems in systems.sorted(by: sortOptions) }
}

}

/// Searching methods
Expand All @@ -201,21 +212,24 @@
/// - Parameter searchText: Text to search for
/// - Returns: Live updating results of GameTypes
public func searchResults(for searchText: String) -> Results<GameType> {
let visible = NSPredicate(format: "isHiddenPatchVariant == false")
// Search first by title, and a broader search if that one's empty
if searchText.count == 0 {

Check warning on line 217 in PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer checking `isEmpty` over comparing `count` to zero (empty_count)
return self.database.all(GameType.self).sorted(byKeyPath: #keyPath(GameType.title), ascending: true)
return self.database.all(GameType.self)
.filter(visible)
.sorted(byKeyPath: #keyPath(GameType.title), ascending: true)
} else {

/// Search by title contains
let titlePredicate = NSPredicate(
format: "title CONTAINS[c] %@",
format: "isHiddenPatchVariant == false AND title CONTAINS[c] %@",
argumentArray: [searchText])

/// Search LIKE in genre, region, description, developer or publisher
let searchPredicate = NSPredicate(
format: "genres LIKE[c] %@ OR gameDescription CONTAINS[c] %@ OR regionName LIKE[c] %@ OR developer LIKE[c] %@ or publisher LIKE[c] %@",
format: "isHiddenPatchVariant == false AND (genres LIKE[c] %@ OR gameDescription CONTAINS[c] %@ OR regionName LIKE[c] %@ OR developer LIKE[c] %@ or publisher LIKE[c] %@)",
argumentArray: [searchText, searchText, searchText, searchText, searchText])

let titleResults = self.database.all(GameType.self, filter: titlePredicate)
let generalresults = { return self.database.all(GameType.self, filter: searchPredicate) }
let results = !titleResults.isEmpty ? titleResults : generalresults()
Expand All @@ -227,7 +241,7 @@
/// Clearing methods
///
extension RealmDatabaseDriver {

/// Clear the library
/// - Returns: Completable for when finished
public func clearLibrary() -> Completable {
Expand Down Expand Up @@ -257,7 +271,7 @@
return Disposables.create()
}
}

/// Refrehes the library
/// - Returns: Completable for when finished
public func refreshLibrary() -> Completable {
Expand Down
3 changes: 2 additions & 1 deletion PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
@_exported public import PVSettings

public class PVGameLibrary<T> where T: DatabaseDriver {

Check warning on line 20 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
public struct System {
public let identifier: String
public let manufacturer: String
Expand All @@ -26,16 +26,16 @@
public let unsupported: Bool
public let sortedGames: [T.GameType]
}

Check warning on line 29 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
public let database: RomDatabase
public let databaseDriver: T
public let romMigrator: ROMLocationMigrator

Check warning on line 33 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
public init(database: RomDatabase, migrator: ROMLocationMigrator = .init()) {
self.database = database
self.databaseDriver = .init(database: database)
self.romMigrator = migrator

Check warning on line 38 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
// Kick off ROM migration
Task {
if PVFeatureFlags.shared.isEnabled(.romPathMigrator) {
Expand Down Expand Up @@ -70,9 +70,10 @@
case .mostPlayed:
sortDescriptors.append(SortDescriptor(keyPath: #keyPath(PVGame.playCount), ascending: false))
}

Check warning on line 73 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
sortDescriptors.append(SortDescriptor(keyPath: #keyPath(PVGame.title), ascending: true))
return sorted(by: sortDescriptors)
// Hidden patch variants stay out of normal library lists until "Add as separate tile".

Check warning on line 75 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Tiles are deprecated in favor of Frame (tiles_deprecated)
return filter("isHiddenPatchVariant == false").sorted(by: sortDescriptors)
}
}

Expand All @@ -86,7 +87,7 @@
return mc == .orderedAscending
}
}

Check warning on line 90 in PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
switch sortOptions {
case .title:
return sorted(by: titleSort)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import AsyncAlgorithms
import PVSystems
import PVMediaCache

public let schemaVersion: UInt64 = 28
public let schemaVersion: UInt64 = 29

public enum RomDeletionError: Error {
case relatedFiledDeletionError
Expand Down Expand Up @@ -298,6 +298,35 @@ public final class RealmConfiguration {
// PVControllerProfile.lightBarColorHex — optional String; Realm defaults new optional fields to nil.
ILOG("Migration to version 28 complete. (Added PVControllerProfile.lightBarColorHex)")
}
if oldSchemaVersion < 29 {
// Patch variant support: PVGame patch-variant fields + extended PVPatch metadata.
migration.enumerateObjects(ofType: PVGame.className()) { _, newObject in
newObject?["isPatchVariant"] = false
newObject?["baseGameMD5"] = nil
newObject?["patchID"] = nil
newObject?["isHiddenPatchVariant"] = false
}
migration.enumerateObjects(ofType: PVPatch.className()) { oldObject, newObject in
// Preserve existing game links — never orphan linked patches as pending.
if let linkedGame = oldObject?["game"] as? MigrationObject,
let md5 = linkedGame["md5Hash"] as? String, !md5.isEmpty {
newObject?["baseGameMD5"] = md5
newObject?["matchState"] = "manual"
newObject?["matchConfidenceRaw"] = "exact"
newObject?["matchScore"] = 1.0
} else {
newObject?["baseGameMD5"] = nil
newObject?["matchState"] = "pending"
newObject?["matchConfidenceRaw"] = "none"
newObject?["matchScore"] = 0.0
}
newObject?["hasSourceCRC32"] = false
newObject?["sourceCRC32"] = 0
newObject?["showAsLibraryTile"] = false
newObject?["isDefaultLaunch"] = (oldObject?["isEnabled"] as? Bool) ?? false
}
Comment thread
cursor[bot] marked this conversation as resolved.
ILOG("Migration to version 29 complete. (Patch variants + PVPatch matching metadata)")
}
}

#if DEBUG
Expand Down Expand Up @@ -1082,6 +1111,10 @@ public extension RomDatabase {
// --- Cloud Sync Pre-Deletion Hook ---
// Capture MD5 *before* attempting any local deletion.
let md5 = game.md5Hash
let isPatchVariant = game.isPatchVariant
let patchID = game.patchID
// Prefer the real on-disk file for variants (PatchedROMs/…); never the ROMs/ path.
let variantFileURL = isPatchVariant ? game.file?.url : nil

// Only post notification if the deletion was initiated by the user AND md5 is valid
if source == .userInitiated {
Expand All @@ -1097,7 +1130,6 @@ public extension RomDatabase {
}
// --- End Cloud Sync Hook ---

let romURL = PVEmulatorConfiguration.path(forGame: game)
if deleteArtwork, !game.customArtworkURL.isEmpty {
do {
try PVMediaCache.deleteImage(forKey: game.customArtworkURL)
Expand Down Expand Up @@ -1125,6 +1157,40 @@ public extension RomDatabase {
}
}
}

// Patch variants: never call path(forGame:) — that points at ROMs/<system>/<filename>
// and can erase the original ROM when stems collide. Delete the patched artifact only.
if isPatchVariant {
#if os(iOS)
deleteFromSpotlight(game: game)
#endif
defer { RomDatabase.reloadGamesCache() }
deletePatchRecords(linkedToVariantMD5: md5, patchID: patchID)
// Orphan cleanup: if no PVPatch owned the row, still remove the PVGame.
if let orphan = realm.object(ofType: PVGame.self, forPrimaryKey: md5), !orphan.isInvalidated {
do {
try writeTransaction {
orphan.saveStates.forEach { realm.delete($0) }
orphan.cheats.forEach { realm.delete($0) }
orphan.recentPlays.forEach { realm.delete($0) }
orphan.screenShots.forEach { realm.delete($0) }
realm.delete(orphan)
}
} catch {
WLOG("Failed to delete orphan patch variant \(md5): \(error.localizedDescription)")
}
}
if let variantFileURL, FileManager.default.fileExists(atPath: variantFileURL.path) {
// Only remove files under PatchedROMs / cache — never ROMs/.
let path = variantFileURL.path
if path.contains("/PatchedROMs/") || path.contains("/PVPatchedROMs/") {
try? FileManager.default.removeItem(at: variantFileURL)
}
}
return
}

let romURL = PVEmulatorConfiguration.path(forGame: game)
if let romURL = romURL, FileManager.default.fileExists(atPath: romURL.path) {
do {
try FileManager.default.removeItem(at: romURL)
Expand All @@ -1143,6 +1209,8 @@ public extension RomDatabase {
RomDatabase.reloadGamesCache()
}
do {
// Deleting a base game removes its managed variants/patches (never other ROMs).
deletePatchRecords(ownedByBaseGameMD5: md5)
deleteRelatedFilesGame(game)
game.saveStates.forEach { try? $0.delete() }
game.cheats.forEach { try? $0.delete() }
Expand All @@ -1158,6 +1226,56 @@ public extension RomDatabase {
}
}

/// Delete patch Realm records + variant games for a base ROM. Never deletes the base ROM file.
private func deletePatchRecords(ownedByBaseGameMD5 md5: String) {
let patches = Array(realm.objects(PVPatch.self).filter("baseGameMD5 == %@", md5))
for patch in patches {
deleteSinglePatchRecord(patch)
}
}

private func deletePatchRecords(linkedToVariantMD5 md5: String, patchID: String?) {
if let patchID, let patch = realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) {
deleteSinglePatchRecord(patch)
return
}
if let patch = realm.objects(PVPatch.self).filter("variantGameMD5 == %@", md5).first {
deleteSinglePatchRecord(patch)
}
}

private func deleteSinglePatchRecord(_ patch: PVPatch) {
let patchFileURL = patch.file?.url
let variant = patch.variantGame
// Capture before Realm delete — only remove managed variant artifacts, never base ROMs.
let shouldDeleteVariantGame = PatchRepository.shouldDeleteVariantGame(isPatchVariant: variant?.isPatchVariant)
let variantFileURL = shouldDeleteVariantGame ? variant?.file?.url : nil
do {
try writeTransaction {
if let variant, !variant.isInvalidated, shouldDeleteVariantGame {
variant.saveStates.forEach { realm.delete($0) }
variant.cheats.forEach { realm.delete($0) }
variant.recentPlays.forEach { realm.delete($0) }
realm.delete(variant)
}
if !patch.isInvalidated {
realm.delete(patch)
}
}
} catch {
WLOG("Failed to delete patch record \(patch.id): \(error.localizedDescription)")
}
if let patchFileURL, FileManager.default.fileExists(atPath: patchFileURL.path) {
try? FileManager.default.removeItem(at: patchFileURL)
}
if let variantFileURL, FileManager.default.fileExists(atPath: variantFileURL.path) {
let path = variantFileURL.path
if path.contains("/PatchedROMs/") || path.contains("/PVPatchedROMs/") {
try? FileManager.default.removeItem(at: variantFileURL)
}
}
}

// Deletes a save state and its associated files
/// Deletes a save state and its associated files
func delete(saveState: PVSaveState) throws {
Expand Down
Loading
Loading