Skip to content

Commit 7e131f5

Browse files
feat(file-provider): add save state & screenshot enumeration
Adds Save States and Screenshots as browsable top-level categories in the ROM File Provider extension. Each game with saves or screenshots gets a named sub-folder; files can be copied out or deleted from Files.app via the file provider. Changes: - RomFileProviderRootCategory: add .saveStates and .screenshots cases - RomFileProviderVirtualPath: add save-state / screenshot identifier helpers (ss-game:, ss:, sc-game:, sc:) with encode/parse round-trip - FileProviderItem: new Kind cases + inits for saveStateGameFolder, saveStateFile, screenshotGameFolder, screenshotFile - RomFileProviderCPDI: SaveStateEntry / ScreenshotEntry structs and loaders (loadAllSaveStateEntries, loadAllScreenshotEntries, saveStateGameFolders, screenshotGameFolders) - FileProviderEnumerator: route ss-game: and sc-game: prefixes; enumerate save state / screenshot items with paging - FileProviderExtension: fetchContents, deleteItem, and resolveItem support for save state and screenshot items; modifyItem guard - Tests: round-trip tests for new identifier helpers Part of #3595 Closes #3617 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e37b4a8 commit 7e131f5

File tree

7 files changed

+612
-6
lines changed

7 files changed

+612
-6
lines changed

.changelog/3617.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Added
2+
- **File Provider Save States & Screenshots** — Provenance now exposes Save States and Screenshots as browsable top-level folders in Files.app; each game with saves/screenshots gets a named sub-folder, and files can be copied out or deleted from Files.app.

Extensions/ROM File Provider/FileProviderEnumerator.swift

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
2727
private var cachedLocalSystemIDs: Set<String>?
2828
private var cachedCanonicalGames: [(game: Game, url: URL)]?
2929
private var cachedLocalRows: [RomFileProviderLibrary.LocalEntry]?
30+
private var cachedSaveStateEntries: [RomFileProviderLibrary.SaveStateEntry]?
31+
private var cachedScreenshotEntries: [RomFileProviderLibrary.ScreenshotEntry]?
3032

3133
init(enumeratedItemIdentifier: NSFileProviderItemIdentifier) {
3234
self.enumeratedItemIdentifier = enumeratedItemIdentifier
@@ -88,6 +90,20 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
8890
return rows
8991
}
9092

93+
private func loadSaveStateEntriesIfNeeded() -> [RomFileProviderLibrary.SaveStateEntry] {
94+
if let cached = cachedSaveStateEntries { return cached }
95+
let entries = RomFileProviderLibrary.loadAllSaveStateEntries()
96+
cachedSaveStateEntries = entries
97+
return entries
98+
}
99+
100+
private func loadScreenshotEntriesIfNeeded() -> [RomFileProviderLibrary.ScreenshotEntry] {
101+
if let cached = cachedScreenshotEntries { return cached }
102+
let entries = RomFileProviderLibrary.loadAllScreenshotEntries()
103+
cachedScreenshotEntries = entries
104+
return entries
105+
}
106+
91107
// MARK: - buildItems
92108

93109
private func buildItems(offset: Int, limit: Int) throws -> ([FileProviderItem], Int) {
@@ -156,6 +172,17 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
156172
return try buildSymlinkGameItems(rows: filtered, parentRaw: raw, parentIdentifier: enumeratedItemIdentifier, offset: offset, limit: limit)
157173
}
158174

175+
if let md5 = RomFileProviderVirtualPath.parseSaveStateGameMD5(from: raw) {
176+
let entries = loadSaveStateEntriesIfNeeded().filter { $0.game.md5.uppercased() == md5 }
177+
.sorted { $0.date > $1.date }
178+
return buildSaveStateItems(entries: entries, parentIdentifier: enumeratedItemIdentifier, offset: offset, limit: limit)
179+
}
180+
181+
if let md5 = RomFileProviderVirtualPath.parseScreenshotGameMD5(from: raw) {
182+
let entries = loadScreenshotEntriesIfNeeded().filter { $0.gameMD5.uppercased() == md5 }
183+
return buildScreenshotItems(entries: entries, parentIdentifier: enumeratedItemIdentifier, offset: offset, limit: limit)
184+
}
185+
159186
throw NSFileProviderError(.noSuchItem)
160187
}
161188

@@ -166,7 +193,7 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
166193
return (Array(items[offset..<end]), total)
167194
}
168195

169-
private func buildCategoryContents(cat: RomFileProviderRootCategory, offset: Int, limit: Int) throws -> ([FileProviderItem], Int) {
196+
private func buildCategoryContents(cat: RomFileProviderRootCategory, offset: Int, limit: Int) throws -> ([FileProviderItem], Int) { // swiftlint:disable:this cyclomatic_complexity
170197
switch cat {
171198
case .systems:
172199
return buildSystemFolderItems(offset: offset, limit: limit)
@@ -227,6 +254,16 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
227254
)
228255
}
229256
return pageSlice(items: items, offset: offset, limit: limit)
257+
case .saveStates:
258+
let parent = NSFileProviderItemIdentifier(cat.rawIdentifier)
259+
let games = RomFileProviderLibrary.saveStateGameFolders()
260+
let folders = games.map { FileProviderItem(saveStateGameFolder: $0, parentItemIdentifier: parent) }
261+
return pageSlice(items: folders, offset: offset, limit: limit)
262+
case .screenshots:
263+
let parent = NSFileProviderItemIdentifier(cat.rawIdentifier)
264+
let games = RomFileProviderLibrary.screenshotGameFolders()
265+
let folders = games.map { FileProviderItem(screenshotGameFolder: $0, parentItemIdentifier: parent) }
266+
return pageSlice(items: folders, offset: offset, limit: limit)
230267
}
231268
}
232269

@@ -296,6 +333,49 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
296333
return pageSlice(items: children, offset: offset, limit: limit)
297334
}
298335

336+
private func buildSaveStateItems(
337+
entries: [RomFileProviderLibrary.SaveStateEntry],
338+
parentIdentifier: NSFileProviderItemIdentifier,
339+
offset: Int,
340+
limit: Int
341+
) -> ([FileProviderItem], Int) {
342+
let total = entries.count
343+
guard offset < total else { return ([], total) }
344+
let slice = entries[offset..<min(offset + limit, total)]
345+
let items = slice.map { entry -> FileProviderItem in
346+
FileProviderItem(
347+
saveStateID: entry.id,
348+
game: entry.game,
349+
date: entry.date,
350+
isAutosave: entry.isAutosave,
351+
userDescription: entry.userDescription,
352+
fileURL: entry.fileURL,
353+
parentItemIdentifier: parentIdentifier
354+
)
355+
}
356+
return (Array(items), total)
357+
}
358+
359+
private func buildScreenshotItems(
360+
entries: [RomFileProviderLibrary.ScreenshotEntry],
361+
parentIdentifier: NSFileProviderItemIdentifier,
362+
offset: Int,
363+
limit: Int
364+
) -> ([FileProviderItem], Int) {
365+
let total = entries.count
366+
guard offset < total else { return ([], total) }
367+
let slice = entries[offset..<min(offset + limit, total)]
368+
let items = slice.map { entry -> FileProviderItem in
369+
FileProviderItem(
370+
screenshotGameMD5: entry.gameMD5,
371+
index: entry.index,
372+
imageURL: entry.imageURL,
373+
parentItemIdentifier: parentIdentifier
374+
)
375+
}
376+
return (Array(items), total)
377+
}
378+
299379
private func buildSymlinkGameItems(
300380
rows: [RomFileProviderLibrary.LocalEntry],
301381
parentRaw: String,

Extensions/ROM File Provider/FileProviderExtension.swift

Lines changed: 210 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,69 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
9292
request: NSFileProviderRequest,
9393
completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void
9494
) -> Progress {
95-
guard let md5 = canonicalGameMD5(from: itemIdentifier.rawValue) else {
95+
let raw = itemIdentifier.rawValue
96+
97+
// Save state file
98+
if let ssID = RomFileProviderVirtualPath.parseSaveStateID(from: raw) {
99+
let realm = RomFileProviderLibrary.realm
100+
guard let pvSS = realm.object(ofType: PVSaveState.self, forPrimaryKey: ssID),
101+
!pvSS.isInvalidated,
102+
let pvGame = pvSS.game, !pvGame.isInvalidated,
103+
let pvFile = pvSS.file, let fileURL = pvFile.url,
104+
FileManager.default.fileExists(atPath: fileURL.path) else {
105+
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
106+
return Progress()
107+
}
108+
let md5 = pvGame.md5Hash
109+
let parent = NSFileProviderItemIdentifier(RomFileProviderVirtualPath.saveStateGameFolderIdentifier(gameMD5: md5))
110+
let item = FileProviderItem(
111+
saveStateID: ssID,
112+
game: pvGame.asDomain(),
113+
date: pvSS.date,
114+
isAutosave: pvSS.isAutosave,
115+
userDescription: pvSS.userDescription,
116+
fileURL: fileURL,
117+
parentItemIdentifier: parent
118+
)
119+
ILOG("FileProvider: serving save state \(fileURL.lastPathComponent) for \(pvGame.title)")
120+
completionHandler(fileURL, item, nil)
121+
return Progress()
122+
}
123+
124+
// Screenshot file
125+
if let parsed = RomFileProviderVirtualPath.parseScreenshotID(from: raw) {
126+
let realm = RomFileProviderLibrary.realm
127+
guard let pvGame = realm.object(ofType: PVGame.self, forPrimaryKey: parsed.gameMD5),
128+
!pvGame.isInvalidated else {
129+
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
130+
return Progress()
131+
}
132+
let shots = Array(pvGame.screenShots)
133+
guard parsed.index < shots.count else {
134+
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
135+
return Progress()
136+
}
137+
let pvImageFile = shots[parsed.index]
138+
guard !pvImageFile.isInvalidated,
139+
let imageURL = pvImageFile.url,
140+
FileManager.default.fileExists(atPath: imageURL.path) else {
141+
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
142+
return Progress()
143+
}
144+
let parent = NSFileProviderItemIdentifier(RomFileProviderVirtualPath.screenshotGameFolderIdentifier(gameMD5: parsed.gameMD5))
145+
let item = FileProviderItem(
146+
screenshotGameMD5: parsed.gameMD5,
147+
index: parsed.index,
148+
imageURL: imageURL,
149+
parentItemIdentifier: parent
150+
)
151+
ILOG("FileProvider: serving screenshot \(imageURL.lastPathComponent) for \(pvGame.title)")
152+
completionHandler(imageURL, item, nil)
153+
return Progress()
154+
}
155+
156+
// ROM file
157+
guard let md5 = canonicalGameMD5(from: raw) else {
96158
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
97159
return Progress()
98160
}
@@ -173,7 +235,16 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
173235
request: NSFileProviderRequest,
174236
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void
175237
) -> Progress {
176-
guard let md5 = canonicalGameMD5(from: item.itemIdentifier.rawValue) else {
238+
let raw = item.itemIdentifier.rawValue
239+
240+
// Save states and screenshots are read-only (no renames or content edits)
241+
if RomFileProviderVirtualPath.parseSaveStateID(from: raw) != nil
242+
|| RomFileProviderVirtualPath.parseScreenshotID(from: raw) != nil {
243+
completionHandler(nil, [], false, CocoaError(.featureUnsupported))
244+
return Progress()
245+
}
246+
247+
guard let md5 = canonicalGameMD5(from: raw) else {
177248
completionHandler(nil, [], false, CocoaError(.featureUnsupported))
178249
return Progress()
179250
}
@@ -245,7 +316,73 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
245316
request: NSFileProviderRequest,
246317
completionHandler: @escaping (Error?) -> Void
247318
) -> Progress {
248-
guard let md5 = canonicalGameMD5(from: identifier.rawValue) else {
319+
let raw = identifier.rawValue
320+
321+
// Delete save state
322+
if let ssID = RomFileProviderVirtualPath.parseSaveStateID(from: raw) {
323+
do {
324+
let realm = RomFileProviderLibrary.realm
325+
guard let pvSS = realm.object(ofType: PVSaveState.self, forPrimaryKey: ssID),
326+
!pvSS.isInvalidated else {
327+
completionHandler(nil)
328+
return Progress()
329+
}
330+
if let pvFile = pvSS.file, let fileURL = pvFile.url,
331+
FileManager.default.fileExists(atPath: fileURL.path) {
332+
try FileManager.default.removeItem(at: fileURL)
333+
ILOG("FileProvider: deleted save state file \(fileURL.lastPathComponent)")
334+
}
335+
let imageToDelete = pvSS.image
336+
try realm.write {
337+
if let img = imageToDelete { realm.delete(img) }
338+
if let f = pvSS.file { realm.delete(f) }
339+
realm.delete(pvSS)
340+
}
341+
completionHandler(nil)
342+
} catch {
343+
ELOG("FileProvider: deleteItem (save state) error — \(error)")
344+
completionHandler(error)
345+
}
346+
return Progress()
347+
}
348+
349+
// Delete screenshot
350+
if let parsed = RomFileProviderVirtualPath.parseScreenshotID(from: raw) {
351+
do {
352+
let realm = RomFileProviderLibrary.realm
353+
guard let pvGame = realm.object(ofType: PVGame.self, forPrimaryKey: parsed.gameMD5),
354+
!pvGame.isInvalidated else {
355+
completionHandler(nil)
356+
return Progress()
357+
}
358+
let shots = Array(pvGame.screenShots)
359+
guard parsed.index < shots.count else {
360+
completionHandler(nil)
361+
return Progress()
362+
}
363+
let pvImageFile = shots[parsed.index]
364+
guard !pvImageFile.isInvalidated else {
365+
completionHandler(nil)
366+
return Progress()
367+
}
368+
if let imageURL = pvImageFile.url,
369+
FileManager.default.fileExists(atPath: imageURL.path) {
370+
try FileManager.default.removeItem(at: imageURL)
371+
ILOG("FileProvider: deleted screenshot \(imageURL.lastPathComponent)")
372+
}
373+
try realm.write {
374+
realm.delete(pvImageFile)
375+
}
376+
completionHandler(nil)
377+
} catch {
378+
ELOG("FileProvider: deleteItem (screenshot) error — \(error)")
379+
completionHandler(error)
380+
}
381+
return Progress()
382+
}
383+
384+
// Delete ROM game
385+
guard let md5 = canonicalGameMD5(from: raw) else {
249386
completionHandler(CocoaError(.featureUnsupported))
250387
return Progress()
251388
}
@@ -486,6 +623,22 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
486623
return FileProviderItem(ratingFolderKey: key, title: title, parentItemIdentifier: parent)
487624
}
488625

626+
if let md5 = RomFileProviderVirtualPath.parseSaveStateGameMD5(from: raw) {
627+
return resolveSaveStateGameFolder(md5: md5)
628+
}
629+
630+
if let ssID = RomFileProviderVirtualPath.parseSaveStateID(from: raw) {
631+
return resolveSaveStateItem(id: ssID)
632+
}
633+
634+
if let md5 = RomFileProviderVirtualPath.parseScreenshotGameMD5(from: raw) {
635+
return resolveScreenshotGameFolder(md5: md5)
636+
}
637+
638+
if let parsed = RomFileProviderVirtualPath.parseScreenshotID(from: raw) {
639+
return resolveScreenshotItem(gameMD5: parsed.gameMD5, index: parsed.index)
640+
}
641+
489642
return nil
490643
}
491644

@@ -531,6 +684,60 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
531684
return FileProviderItem(publisherFolderGroupingKey: groupingKey, title: title, parentItemIdentifier: parent)
532685
}
533686

687+
private func resolveSaveStateGameFolder(md5: String) -> FileProviderItem? {
688+
let realm = RomFileProviderLibrary.realm
689+
guard let pvGame = realm.object(ofType: PVGame.self, forPrimaryKey: md5),
690+
!pvGame.isInvalidated else { return nil }
691+
let parent = NSFileProviderItemIdentifier(RomFileProviderRootCategory.saveStates.rawIdentifier)
692+
return FileProviderItem(saveStateGameFolder: pvGame.asDomain(), parentItemIdentifier: parent)
693+
}
694+
695+
private func resolveSaveStateItem(id: String) -> FileProviderItem? {
696+
let realm = RomFileProviderLibrary.realm
697+
guard let pvSS = realm.object(ofType: PVSaveState.self, forPrimaryKey: id),
698+
!pvSS.isInvalidated,
699+
let pvGame = pvSS.game, !pvGame.isInvalidated else { return nil }
700+
let fileURL: URL?
701+
if let pvFile = pvSS.file, let url = pvFile.url,
702+
FileManager.default.fileExists(atPath: url.path) {
703+
fileURL = url
704+
} else {
705+
fileURL = nil
706+
}
707+
let md5 = pvGame.md5Hash
708+
let parent = NSFileProviderItemIdentifier(RomFileProviderVirtualPath.saveStateGameFolderIdentifier(gameMD5: md5))
709+
return FileProviderItem(
710+
saveStateID: id,
711+
game: pvGame.asDomain(),
712+
date: pvSS.date,
713+
isAutosave: pvSS.isAutosave,
714+
userDescription: pvSS.userDescription,
715+
fileURL: fileURL,
716+
parentItemIdentifier: parent
717+
)
718+
}
719+
720+
private func resolveScreenshotGameFolder(md5: String) -> FileProviderItem? {
721+
let realm = RomFileProviderLibrary.realm
722+
guard let pvGame = realm.object(ofType: PVGame.self, forPrimaryKey: md5),
723+
!pvGame.isInvalidated else { return nil }
724+
let parent = NSFileProviderItemIdentifier(RomFileProviderRootCategory.screenshots.rawIdentifier)
725+
return FileProviderItem(screenshotGameFolder: pvGame.asDomain(), parentItemIdentifier: parent)
726+
}
727+
728+
private func resolveScreenshotItem(gameMD5: String, index: Int) -> FileProviderItem? {
729+
let realm = RomFileProviderLibrary.realm
730+
guard let pvGame = realm.object(ofType: PVGame.self, forPrimaryKey: gameMD5),
731+
!pvGame.isInvalidated else { return nil }
732+
let shots = Array(pvGame.screenShots)
733+
guard index < shots.count else { return nil }
734+
let pvImageFile = shots[index]
735+
guard !pvImageFile.isInvalidated else { return nil }
736+
let imageURL = pvImageFile.url.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
737+
let parent = NSFileProviderItemIdentifier(RomFileProviderVirtualPath.screenshotGameFolderIdentifier(gameMD5: gameMD5))
738+
return FileProviderItem(screenshotGameMD5: gameMD5, index: index, imageURL: imageURL, parentItemIdentifier: parent)
739+
}
740+
534741
private func resolvePublisherSystemFolder(raw: String) -> FileProviderItem? {
535742
let rest = String(raw.dropFirst(RomFileProviderVirtualPath.publisherSystemPrefix.count))
536743
guard let colon = rest.firstIndex(of: ":") else { return nil }

0 commit comments

Comments
 (0)