@@ -432,139 +432,13 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
432432 ) throws -> NSFileProviderEnumerator {
433433 return FileProviderEnumerator ( enumeratedItemIdentifier: containerItemIdentifier)
434434 }
435+ }
435436
436- private func performImport( from sourceURL: URL , filename: String , systemID: String ) throws -> FileProviderItem {
437- let realm = RomFileProviderLibrary . realm
438-
439- guard let pvSystem = realm. object ( ofType: PVSystem . self, forPrimaryKey: systemID) ,
440- !pvSystem. isInvalidated else {
441- throw NSFileProviderError ( . noSuchItem)
442- }
443-
444- try Task . checkCancellation ( )
445- guard let md5 = streamingMD5 ( for: sourceURL) else {
446- ELOG ( " FileProvider: failed to compute MD5 for \( filename) " )
447- throw NSFileProviderError ( . cannotSynchronize)
448- }
449-
450- if let existing = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
451- !existing. isInvalidated {
452- if let existingFileURL = existing. file? . url,
453- FileManager . default. fileExists ( atPath: existingFileURL. path) {
454- ILOG ( " FileProvider: ROM already exists (md5= \( md5) ), skipping copy " )
455- return FileProviderItem ( game: existing. asDomain ( ) , romURL: existingFileURL)
456- }
457- }
458-
459- let destDir = PVEmulatorConfiguration . romDirectory ( forSystemIdentifier: systemID)
460- try FileManager . default. createDirectory ( at: destDir, withIntermediateDirectories: true , attributes: nil )
461-
462- try Task . checkCancellation ( )
463-
464- let destURL = uniqueDestinationURL ( in: destDir, for: filename)
465- try FileManager . default. copyItem ( at: sourceURL, to: destURL)
466-
467- try Task . checkCancellation ( )
468-
469- if let existing = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
470- !existing. isInvalidated {
471- ILOG ( " FileProvider: existing ROM record for md5= \( md5) has no valid local file; updating record with imported copy at \( destURL. lastPathComponent) " )
472- let newRomFile = PVFile ( withURL: destURL)
473- let newPartialPath = newRomFile. partialPath
474- try realm. write {
475- if let oldFile = existing. file {
476- realm. delete ( oldFile)
477- }
478- realm. add ( newRomFile)
479- existing. file = newRomFile
480- existing. romPath = newPartialPath
481- existing. isDownloaded = true
482- }
483- return FileProviderItem ( game: existing. asDomain ( ) , romURL: destURL)
484- }
485-
486- let romFile = PVFile ( withURL: destURL)
487- let actualFilename = destURL. lastPathComponent
488- let game = PVGame ( )
489- game. md5Hash = md5
490- game. systemIdentifier = systemID
491- game. system = pvSystem
492- game. title = ( actualFilename as NSString ) . deletingPathExtension
493- game. requiresSync = true
494- game. isDownloaded = true
495- game. file = romFile
496- game. romPath = romFile. partialPath
497-
498- try realm. write {
499- realm. add ( romFile)
500- realm. add ( game)
501- }
502-
503- ILOG ( " FileProvider: imported \( filename) md5= \( md5) system= \( systemID) " )
504- return FileProviderItem ( game: game. asDomain ( ) , romURL: destURL)
505- }
506-
507- private func sanitizedFilename( from rawFilename: String ) -> String {
508- var name = ( rawFilename as NSString ) . lastPathComponent
509- name = name
510- . replacingOccurrences ( of: " / " , with: " - " )
511- . replacingOccurrences ( of: " : " , with: " - " )
512- let filteredScalars = name. unicodeScalars. filter { !CharacterSet. controlCharacters. contains ( $0) }
513- name = String ( String . UnicodeScalarView ( filteredScalars) )
514- let components = name. components ( separatedBy: . whitespacesAndNewlines) . filter { !$0. isEmpty }
515- name = components. joined ( separator: " " )
516- return name. isEmpty ? " Untitled " : name
517- }
518-
519- private func uniqueDestinationURL( in directory: URL , for filename: String ) -> URL {
520- let safeFilename = sanitizedFilename ( from: filename)
521- let candidate = directory. appendingPathComponent ( safeFilename)
522- guard FileManager . default. fileExists ( atPath: candidate. path) else {
523- return candidate
524- }
525- let base = ( safeFilename as NSString ) . deletingPathExtension
526- let ext = ( safeFilename as NSString ) . pathExtension
527- var counter = 2
528- while true {
529- let name = ext. isEmpty ? " \( base) - \( counter) " : " \( base) - \( counter) . \( ext) "
530- let url = directory. appendingPathComponent ( name)
531- if !FileManager. default. fileExists ( atPath: url. path) { return url }
532- counter += 1
533- }
534- }
535-
536- private func streamingMD5( for url: URL ) -> String ? {
537- let chunkSize = 1024 * 1024
538- guard let stream = InputStream ( url: url) else { return nil }
539- stream. open ( )
540- defer { stream. close ( ) }
541-
542- var hasher = Insecure . MD5 ( )
543- let buffer = UnsafeMutablePointer< UInt8> . allocate( capacity: chunkSize)
544- defer { buffer. deallocate ( ) }
545-
546- while stream. hasBytesAvailable {
547- let n = stream. read ( buffer, maxLength: chunkSize)
548- if n < 0 { return nil }
549- if n == 0 { break }
550- hasher. update ( bufferPointer: UnsafeRawBufferPointer ( start: buffer, count: n) )
551- }
552-
553- return hasher. finalize ( ) . map { String ( format: " %02X " , $0) } . joined ( )
554- }
437+ // MARK: - Item Resolution
555438
556- /// Resolves symlink or canonical `game:` raw value to uppercase MD5 primary key.
557- private func canonicalGameMD5( from raw: String ) -> String ? {
558- if raw. hasPrefix ( FileProviderItem . gameIdentifierPrefix) {
559- return String ( raw. dropFirst ( FileProviderItem . gameIdentifierPrefix. count) ) . uppercased ( )
560- }
561- if raw. hasPrefix ( RomFileProviderVirtualPath . symlinkPrefix) {
562- return RomFileProviderVirtualPath . parseSymlinkMD5 ( from: raw)
563- }
564- return nil
565- }
439+ private extension FileProviderExtension {
566440
567- private func resolveItem( for identifier: NSFileProviderItemIdentifier ) -> FileProviderItem ? {
441+ func resolveItem( for identifier: NSFileProviderItemIdentifier ) -> FileProviderItem ? {
568442 if identifier == . rootContainer {
569443 return FileProviderItem ( root: ( ) )
570444 }
@@ -642,7 +516,7 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
642516 return nil
643517 }
644518
645- private func resolveSystemFolder( raw: String ) -> FileProviderItem ? {
519+ func resolveSystemFolder( raw: String ) -> FileProviderItem ? {
646520 let sysID = String ( raw. dropFirst ( FileProviderItem . systemIdentifierPrefix. count) )
647521 let realm = RomFileProviderLibrary . realm
648522 guard let pvSystem = realm. object ( ofType: PVSystem . self, forPrimaryKey: sysID) ,
@@ -651,15 +525,15 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
651525 return FileProviderItem ( system: pvSystem. asDomain ( ) , parentItemIdentifier: parent)
652526 }
653527
654- private func resolveCanonicalGame( raw: String ) -> FileProviderItem ? {
528+ func resolveCanonicalGame( raw: String ) -> FileProviderItem ? {
655529 let md5 = String ( raw. dropFirst ( FileProviderItem . gameIdentifierPrefix. count) ) . uppercased ( )
656530 let realm = RomFileProviderLibrary . realm
657531 guard let pvGame = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
658532 !pvGame. isInvalidated else { return nil }
659533 return FileProviderItem ( game: pvGame. asDomain ( ) , romURL: pvGame. file? . url)
660534 }
661535
662- private func resolveSymlink( raw: String ) -> FileProviderItem ? {
536+ func resolveSymlink( raw: String ) -> FileProviderItem ? {
663537 guard let parsed = RomFileProviderVirtualPath . parseSymlink ( from: raw) else { return nil }
664538 let realm = RomFileProviderLibrary . realm
665539 guard let pvGame = realm. object ( ofType: PVGame . self, forPrimaryKey: parsed. md5) ,
@@ -676,23 +550,36 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
676550 )
677551 }
678552
679- private func resolvePublisherFolder( raw: String ) -> FileProviderItem ? {
553+ func resolvePublisherFolder( raw: String ) -> FileProviderItem ? {
680554 let enc = String ( raw. dropFirst ( RomFileProviderVirtualPath . publisherFolderPrefix. count) )
681555 guard let groupingKey = RomFileProviderVirtualPath . decodeSegment ( enc) else { return nil }
682556 let parent = NSFileProviderItemIdentifier ( RomFileProviderRootCategory . publishers. rawIdentifier)
683557 let title = RomFileProviderLibrary . displayName ( axis: . publisher, groupingKey: groupingKey)
684558 return FileProviderItem ( publisherFolderGroupingKey: groupingKey, title: title, parentItemIdentifier: parent)
685559 }
686560
687- private func resolveSaveStateGameFolder( md5: String ) -> FileProviderItem ? {
561+ func resolvePublisherSystemFolder( raw: String ) -> FileProviderItem ? {
562+ let rest = String ( raw. dropFirst ( RomFileProviderVirtualPath . publisherSystemPrefix. count) )
563+ guard let colon = rest. firstIndex ( of: " : " ) else { return nil }
564+ let enc = String ( rest [ ..< colon] )
565+ let systemId = String ( rest [ rest. index ( after: colon) ... ] )
566+ guard let groupingKey = RomFileProviderVirtualPath . decodeSegment ( enc) else { return nil }
567+ let realm = RomFileProviderLibrary . realm
568+ guard let pvSystem = realm. object ( ofType: PVSystem . self, forPrimaryKey: systemId) ,
569+ !pvSystem. isInvalidated else { return nil }
570+ let parent = NSFileProviderItemIdentifier ( RomFileProviderVirtualPath . publisherFolderPrefix + enc)
571+ return FileProviderItem ( publisherSystemFolderGroupingKey: groupingKey, system: pvSystem. asDomain ( ) , parentItemIdentifier: parent)
572+ }
573+
574+ func resolveSaveStateGameFolder( md5: String ) -> FileProviderItem ? {
688575 let realm = RomFileProviderLibrary . realm
689576 guard let pvGame = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
690577 !pvGame. isInvalidated else { return nil }
691578 let parent = NSFileProviderItemIdentifier ( RomFileProviderRootCategory . saveStates. rawIdentifier)
692579 return FileProviderItem ( saveStateGameFolder: pvGame. asDomain ( ) , parentItemIdentifier: parent)
693580 }
694581
695- private func resolveSaveStateItem( id: String ) -> FileProviderItem ? {
582+ func resolveSaveStateItem( id: String ) -> FileProviderItem ? {
696583 let realm = RomFileProviderLibrary . realm
697584 guard let pvSS = realm. object ( ofType: PVSaveState . self, forPrimaryKey: id) ,
698585 !pvSS. isInvalidated,
@@ -717,15 +604,15 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
717604 )
718605 }
719606
720- private func resolveScreenshotGameFolder( md5: String ) -> FileProviderItem ? {
607+ func resolveScreenshotGameFolder( md5: String ) -> FileProviderItem ? {
721608 let realm = RomFileProviderLibrary . realm
722609 guard let pvGame = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
723610 !pvGame. isInvalidated else { return nil }
724611 let parent = NSFileProviderItemIdentifier ( RomFileProviderRootCategory . screenshots. rawIdentifier)
725612 return FileProviderItem ( screenshotGameFolder: pvGame. asDomain ( ) , parentItemIdentifier: parent)
726613 }
727614
728- private func resolveScreenshotItem( gameMD5: String , index: Int ) -> FileProviderItem ? {
615+ func resolveScreenshotItem( gameMD5: String , index: Int ) -> FileProviderItem ? {
729616 let realm = RomFileProviderLibrary . realm
730617 guard let pvGame = realm. object ( ofType: PVGame . self, forPrimaryKey: gameMD5) ,
731618 !pvGame. isInvalidated else { return nil }
@@ -738,17 +625,140 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
738625 return FileProviderItem ( screenshotGameMD5: gameMD5, index: index, imageURL: imageURL, parentItemIdentifier: parent)
739626 }
740627
741- private func resolvePublisherSystemFolder( raw: String ) -> FileProviderItem ? {
742- let rest = String ( raw. dropFirst ( RomFileProviderVirtualPath . publisherSystemPrefix. count) )
743- guard let colon = rest. firstIndex ( of: " : " ) else { return nil }
744- let enc = String ( rest [ ..< colon] )
745- let systemId = String ( rest [ rest. index ( after: colon) ... ] )
746- guard let groupingKey = RomFileProviderVirtualPath . decodeSegment ( enc) else { return nil }
628+ /// Resolves symlink or canonical `game:` raw value to uppercase MD5 primary key.
629+ func canonicalGameMD5( from raw: String ) -> String ? {
630+ if raw. hasPrefix ( FileProviderItem . gameIdentifierPrefix) {
631+ return String ( raw. dropFirst ( FileProviderItem . gameIdentifierPrefix. count) ) . uppercased ( )
632+ }
633+ if raw. hasPrefix ( RomFileProviderVirtualPath . symlinkPrefix) {
634+ return RomFileProviderVirtualPath . parseSymlinkMD5 ( from: raw)
635+ }
636+ return nil
637+ }
638+ }
639+
640+ // MARK: - Import & File Utilities
641+
642+ private extension FileProviderExtension {
643+
644+ func performImport( from sourceURL: URL , filename: String , systemID: String ) throws -> FileProviderItem {
747645 let realm = RomFileProviderLibrary . realm
748- guard let pvSystem = realm. object ( ofType: PVSystem . self, forPrimaryKey: systemId) ,
749- !pvSystem. isInvalidated else { return nil }
750- let parent = NSFileProviderItemIdentifier ( RomFileProviderVirtualPath . publisherFolderPrefix + enc)
751- return FileProviderItem ( publisherSystemFolderGroupingKey: groupingKey, system: pvSystem. asDomain ( ) , parentItemIdentifier: parent)
646+
647+ guard let pvSystem = realm. object ( ofType: PVSystem . self, forPrimaryKey: systemID) ,
648+ !pvSystem. isInvalidated else {
649+ throw NSFileProviderError ( . noSuchItem)
650+ }
651+
652+ try Task . checkCancellation ( )
653+ guard let md5 = streamingMD5 ( for: sourceURL) else {
654+ ELOG ( " FileProvider: failed to compute MD5 for \( filename) " )
655+ throw NSFileProviderError ( . cannotSynchronize)
656+ }
657+
658+ if let existing = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
659+ !existing. isInvalidated {
660+ if let existingFileURL = existing. file? . url,
661+ FileManager . default. fileExists ( atPath: existingFileURL. path) {
662+ ILOG ( " FileProvider: ROM already exists (md5= \( md5) ), skipping copy " )
663+ return FileProviderItem ( game: existing. asDomain ( ) , romURL: existingFileURL)
664+ }
665+ }
666+
667+ let destDir = PVEmulatorConfiguration . romDirectory ( forSystemIdentifier: systemID)
668+ try FileManager . default. createDirectory ( at: destDir, withIntermediateDirectories: true , attributes: nil )
669+
670+ try Task . checkCancellation ( )
671+
672+ let destURL = uniqueDestinationURL ( in: destDir, for: filename)
673+ try FileManager . default. copyItem ( at: sourceURL, to: destURL)
674+
675+ try Task . checkCancellation ( )
676+
677+ if let existing = realm. object ( ofType: PVGame . self, forPrimaryKey: md5) ,
678+ !existing. isInvalidated {
679+ ILOG ( " FileProvider: existing ROM record for md5= \( md5) has no valid local file; updating record with imported copy at \( destURL. lastPathComponent) " )
680+ let newRomFile = PVFile ( withURL: destURL)
681+ let newPartialPath = newRomFile. partialPath
682+ try realm. write {
683+ if let oldFile = existing. file {
684+ realm. delete ( oldFile)
685+ }
686+ realm. add ( newRomFile)
687+ existing. file = newRomFile
688+ existing. romPath = newPartialPath
689+ existing. isDownloaded = true
690+ }
691+ return FileProviderItem ( game: existing. asDomain ( ) , romURL: destURL)
692+ }
693+
694+ let romFile = PVFile ( withURL: destURL)
695+ let actualFilename = destURL. lastPathComponent
696+ let game = PVGame ( )
697+ game. md5Hash = md5
698+ game. systemIdentifier = systemID
699+ game. system = pvSystem
700+ game. title = ( actualFilename as NSString ) . deletingPathExtension
701+ game. requiresSync = true
702+ game. isDownloaded = true
703+ game. file = romFile
704+ game. romPath = romFile. partialPath
705+
706+ try realm. write {
707+ realm. add ( romFile)
708+ realm. add ( game)
709+ }
710+
711+ ILOG ( " FileProvider: imported \( filename) md5= \( md5) system= \( systemID) " )
712+ return FileProviderItem ( game: game. asDomain ( ) , romURL: destURL)
713+ }
714+
715+ func sanitizedFilename( from rawFilename: String ) -> String {
716+ var name = ( rawFilename as NSString ) . lastPathComponent
717+ name = name
718+ . replacingOccurrences ( of: " / " , with: " - " )
719+ . replacingOccurrences ( of: " : " , with: " - " )
720+ let filteredScalars = name. unicodeScalars. filter { !CharacterSet. controlCharacters. contains ( $0) }
721+ name = String ( String . UnicodeScalarView ( filteredScalars) )
722+ let components = name. components ( separatedBy: . whitespacesAndNewlines) . filter { !$0. isEmpty }
723+ name = components. joined ( separator: " " )
724+ return name. isEmpty ? " Untitled " : name
725+ }
726+
727+ func uniqueDestinationURL( in directory: URL , for filename: String ) -> URL {
728+ let safeFilename = sanitizedFilename ( from: filename)
729+ let candidate = directory. appendingPathComponent ( safeFilename)
730+ guard FileManager . default. fileExists ( atPath: candidate. path) else {
731+ return candidate
732+ }
733+ let base = ( safeFilename as NSString ) . deletingPathExtension
734+ let ext = ( safeFilename as NSString ) . pathExtension
735+ var counter = 2
736+ while true {
737+ let name = ext. isEmpty ? " \( base) - \( counter) " : " \( base) - \( counter) . \( ext) "
738+ let url = directory. appendingPathComponent ( name)
739+ if !FileManager. default. fileExists ( atPath: url. path) { return url }
740+ counter += 1
741+ }
742+ }
743+
744+ func streamingMD5( for url: URL ) -> String ? {
745+ let chunkSize = 1024 * 1024
746+ guard let stream = InputStream ( url: url) else { return nil }
747+ stream. open ( )
748+ defer { stream. close ( ) }
749+
750+ var hasher = Insecure . MD5 ( )
751+ let buffer = UnsafeMutablePointer< UInt8> . allocate( capacity: chunkSize)
752+ defer { buffer. deallocate ( ) }
753+
754+ while stream. hasBytesAvailable {
755+ let n = stream. read ( buffer, maxLength: chunkSize)
756+ if n < 0 { return nil }
757+ if n == 0 { break }
758+ hasher. update ( bufferPointer: UnsafeRawBufferPointer ( start: buffer, count: n) )
759+ }
760+
761+ return hasher. finalize ( ) . map { String ( format: " %02X " , $0) } . joined ( )
752762 }
753763}
754764
0 commit comments