diff --git a/Cores/DeltaCore b/Cores/DeltaCore index e2e166d..55ea133 160000 --- a/Cores/DeltaCore +++ b/Cores/DeltaCore @@ -1 +1 @@ -Subproject commit e2e166d01c4e866c9707944fd6df53556335056b +Subproject commit 55ea13366edc40bb7cd9978c6127bd4f49aa45d1 diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index f37608b..738ebe4 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE4269D1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift */; }; BFEC732D1AAECC4A00650035 /* Roxas.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFEC732C1AAECC4A00650035 /* Roxas.framework */; }; BFEC732E1AAECC4A00650035 /* Roxas.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BFEC732C1AAECC4A00650035 /* Roxas.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BFF93AA01E0FB036005EC865 /* InputStreamOutputWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */; }; BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFA71DC1AAC406100EE9DD1 /* AppDelegate.swift */; }; BFFA71E21AAC406100EE9DD1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFFA71E01AAC406100EE9DD1 /* Main.storyboard */; }; BFFC461E1D59823500AF2CC6 /* GamesPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFC461B1D59823500AF2CC6 /* GamesPresentationController.swift */; }; @@ -193,6 +194,7 @@ BFDD04F01D5E2C27002D450E /* GameCollectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameCollectionViewController.swift; sourceTree = ""; }; BFE4269D1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SaveStatesStoryboardSegue.swift; path = Segues/SaveStatesStoryboardSegue.swift; sourceTree = ""; }; BFEC732C1AAECC4A00650035 /* Roxas.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Roxas.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = InputStreamOutputWriter.swift; path = Components/Importing/InputStreamOutputWriter.swift; sourceTree = ""; }; BFFA71D71AAC406100EE9DD1 /* Delta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Delta.app; sourceTree = BUILT_PRODUCTS_DIR; }; BFFA71DB1AAC406100EE9DD1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BFFA71DC1AAC406100EE9DD1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -374,6 +376,7 @@ isa = PBXGroup; children = ( BF59428D1E09BCFB0051894B /* ImportController.swift */, + BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */, ); name = Importing; sourceTree = ""; @@ -758,6 +761,7 @@ BF59425C1E09BB810051894B /* Action.swift in Sources */, BF696B801D9B2B02009639E0 /* Theme.swift in Sources */, BF7AE8081C2E858400B1B5BC /* PauseMenuViewController.swift in Sources */, + BFF93AA01E0FB036005EC865 /* InputStreamOutputWriter.swift in Sources */, BF59427F1E09BC830051894B /* GameCollection.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Delta/AppDelegate.swift b/Delta/AppDelegate.swift index 0fe752c..7234277 100644 --- a/Delta/AppDelegate.swift +++ b/Delta/AppDelegate.swift @@ -83,7 +83,7 @@ extension AppDelegate guard url.isFileURL else { return false } let gameType = GameType.gameType(forFileExtension: url.pathExtension) - if gameType != .unknown + if gameType != .unknown || url.pathExtension.lowercased() == "zip" { return self.importGame(at: url) } diff --git a/Delta/Components/Importing/ImportController.swift b/Delta/Components/Importing/ImportController.swift index 52ced02..c6d0e27 100644 --- a/Delta/Components/Importing/ImportController.swift +++ b/Delta/Components/Importing/ImportController.swift @@ -11,6 +11,8 @@ import ObjectiveC import DeltaCore +import MobileCoreServices + protocol ImportControllerDelegate { func importController(_ importController: ImportController, didImport games: Set) @@ -40,6 +42,7 @@ class ImportController: NSObject var documentTypes = GameType.supportedTypes.map { $0.rawValue } documentTypes.append(kUTTypeDeltaControllerSkin as String) + documentTypes.append(kUTTypeZipArchive as String) // Add GBA4iOS's exported UTIs in case user has GBA4iOS installed (which may override Delta's UTI declarations) documentTypes.append("com.rileytestut.gba") @@ -72,7 +75,7 @@ class ImportController: NSObject let controllerSkinURLs = contents.filter { $0.pathExtension.lowercased() == "deltaskin" } self.importControllerSkins(at: controllerSkinURLs) - let gameURLs = contents.filter { GameType.gameType(forFileExtension: $0.pathExtension) != .unknown } + let gameURLs = contents.filter { GameType.gameType(forFileExtension: $0.pathExtension) != .unknown || $0.pathExtension.lowercased() == "zip" } self.importGames(at: gameURLs) } diff --git a/Delta/Components/Importing/InputStreamOutputWriter.swift b/Delta/Components/Importing/InputStreamOutputWriter.swift new file mode 100644 index 0000000..76e4cb3 --- /dev/null +++ b/Delta/Components/Importing/InputStreamOutputWriter.swift @@ -0,0 +1,148 @@ +// +// InputStreamOutputWriter.swift +// Delta +// +// Created by Riley Testut on 12/25/16. +// Copyright © 2016 Riley Testut. All rights reserved. +// + +import Foundation + +private let MaximumBufferLength = 4 * 1024 // 4 KB + +class InputStreamOutputWriter: NSObject +{ + let inputStream: InputStream + let outputStream: OutputStream + + fileprivate var completion: ((Error?) -> Void)? + + fileprivate var dataBuffer = Data(capacity: MaximumBufferLength * 2) + + init(inputStream: InputStream, outputStream: OutputStream) + { + self.inputStream = inputStream + self.outputStream = outputStream + + super.init() + + self.inputStream.delegate = self + self.outputStream.delegate = self + } + + func start(with completion: @escaping ((Error?) -> Void)) + { + guard self.completion == nil else { return } + + self.completion = completion + + let writingQueue = DispatchQueue(label: "com.rileytestut.InputStreamOutputWriter.writingQueue", qos: .userInitiated) + writingQueue.async { + self.inputStream.schedule(in: .current, forMode: .defaultRunLoopMode) + self.outputStream.schedule(in: .current, forMode: .defaultRunLoopMode) + + self.outputStream.open() + self.inputStream.open() + + RunLoop.current.run() + } + } +} + +private extension InputStreamOutputWriter +{ + func writeDataBuffer() + { + while self.outputStream.hasSpaceAvailable && self.dataBuffer.count > 0 + { + self.dataBuffer.withUnsafeMutableBytes { (buffer: UnsafeMutablePointer) -> Void in + let writtenBytesCount = self.outputStream.write(buffer, maxLength: self.dataBuffer.count) + if writtenBytesCount >= 0 + { + self.dataBuffer.removeSubrange(0 ..< writtenBytesCount) + } + } + } + } + + func finishWriting() + { + self.inputStream.close() + self.outputStream.close() + + self.inputStream.remove(from: .current, forMode: .commonModes) + self.outputStream.remove(from: .current, forMode: .commonModes) + + self.completion?(self.inputStream.streamError ?? self.outputStream.streamError) + + CFRunLoopStop(CFRunLoopGetCurrent()) + } +} + +extension InputStreamOutputWriter: StreamDelegate +{ + func stream(_ aStream: Stream, handle eventCode: Stream.Event) + { + if let inputStream = aStream as? InputStream + { + self.inputStream(inputStream, handle: eventCode) + } + else if let outputStream = aStream as? OutputStream + { + self.outputStream(outputStream, handle: eventCode) + } + } + + private func inputStream(_ inputStream: InputStream, handle eventCode: Stream.Event) + { + switch eventCode + { + case Stream.Event.hasBytesAvailable: + + guard inputStream.streamError == nil else { return } + + while inputStream.hasBytesAvailable + { + let buffer = UnsafeMutablePointer.allocate(capacity: MaximumBufferLength) + + let readBytesCount = inputStream.read(buffer, maxLength: MaximumBufferLength) + + guard readBytesCount >= 0 else { break } + + self.dataBuffer.append(buffer, count: readBytesCount) + + buffer.deallocate(capacity: MaximumBufferLength) + + self.writeDataBuffer() + } + + case Stream.Event.endEncountered: + if self.dataBuffer.count == 0 + { + self.finishWriting() + } + + case Stream.Event.errorOccurred: self.finishWriting() + + default: break + } + } + + private func outputStream(_ outputStream: OutputStream, handle eventCode: Stream.Event) + { + switch eventCode + { + case Stream.Event.hasSpaceAvailable: + self.writeDataBuffer() + + if self.inputStream.streamStatus == .atEnd + { + self.finishWriting() + } + + case Stream.Event.errorOccurred: self.finishWriting() + + default: break + } + } +} diff --git a/Delta/Database/DatabaseManager.swift b/Delta/Database/DatabaseManager.swift index a9ab312..8c492d5 100644 --- a/Delta/Database/DatabaseManager.swift +++ b/Delta/Database/DatabaseManager.swift @@ -11,6 +11,7 @@ import CoreData // Workspace import DeltaCore +import ZipZap // Pods import FileMD5Hash @@ -107,6 +108,80 @@ private extension DatabaseManager /// Importing extension DatabaseManager { + func importGames(at urls: [URL], completion: ((Set) -> Void)?) + { + let zipFileURLs = urls.filter { $0.pathExtension.lowercased() == "zip" } + if zipFileURLs.count > 0 + { + self.extractCompressedGames(at: zipFileURLs) { (extractedURLs) in + let gameURLs = urls.filter { $0.pathExtension.lowercased() != "zip" } + extractedURLs + self.importGames(at: gameURLs, completion: completion) + } + + return + } + + self.performBackgroundTask { (context) in + + var identifiers = Set() + + for url in urls + { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + + let identifier = FileHash.sha1HashOfFile(atPath: url.path) as String + + let filename = identifier + "." + url.pathExtension + + let game = Game.insertIntoManagedObjectContext(context) + game.name = url.deletingPathExtension().lastPathComponent + game.identifier = identifier + game.filename = filename + game.artworkURL = self.gamesDatabase?.artworkURL(for: game) + + let gameCollection = GameCollection.gameSystemCollectionForPathExtension(url.pathExtension, inManagedObjectContext: context) + game.type = GameType(rawValue: gameCollection.identifier) + game.gameCollections.insert(gameCollection) + + do + { + let destinationURL = DatabaseManager.gamesDirectoryURL.appendingPathComponent(filename) + + if FileManager.default.fileExists(atPath: destinationURL.path) + { + // Game already exists, so we choose not to override it and just delete the new game instead + try FileManager.default.removeItem(at: url) + } + else + { + try FileManager.default.moveItem(at: url, to: destinationURL) + } + + identifiers.insert(game.identifier) + } + catch + { + print("Import Games error:", error) + game.managedObjectContext?.delete(game) + } + + } + + do + { + try context.save() + } + catch + { + print("Failed to save import context:", error) + + identifiers.removeAll() + } + + completion?(identifiers) + } + } + func importControllerSkins(at urls: [URL], completion: ((Set) -> Void)?) { self.performBackgroundTask { (context) in @@ -159,66 +234,97 @@ extension DatabaseManager } } - func importGames(at urls: [URL], completion: ((Set) -> Void)?) + private func extractCompressedGames(at urls: [URL], completion: @escaping ((Set) -> Void)) { - self.performBackgroundTask { (context) in + DispatchQueue.global().async { - var identifiers = Set() + var semaphores = Set() + var outputURLs = Set() for url in urls { - guard FileManager.default.fileExists(atPath: url.path) else { continue } - - let identifier = FileHash.sha1HashOfFile(atPath: url.path) as String - - let filename = identifier + "." + url.pathExtension - - let game = Game.insertIntoManagedObjectContext(context) - game.name = url.deletingPathExtension().lastPathComponent - game.identifier = identifier - game.filename = filename - game.artworkURL = self.gamesDatabase?.artworkURL(for: game) - - let gameCollection = GameCollection.gameSystemCollectionForPathExtension(url.pathExtension, inManagedObjectContext: context) - game.type = GameType(rawValue: gameCollection.identifier) - game.gameCollections.insert(gameCollection) - do { - let destinationURL = DatabaseManager.gamesDirectoryURL.appendingPathComponent(filename) + let archive = try ZZArchive(url: url) - if FileManager.default.fileExists(atPath: destinationURL.path) + for entry in archive.entries { - // Game already exists, so we choose not to override it and just delete the new game instead - try FileManager.default.removeItem(at: url) + // Ensure entry is not in a subdirectory + guard !entry.fileName.contains("/") else { continue } + + let fileExtension = (entry.fileName as NSString).pathExtension + let gameType = GameType.gameType(forFileExtension: fileExtension) + + guard gameType != .unknown else { continue } + + // ROMs may potentially be very large, so we extract using file streams and not raw Data + let inputStream = try entry.newStream() + + let outputURL = url.deletingLastPathComponent().appendingPathComponent(entry.fileName) + + if FileManager.default.fileExists(atPath: outputURL.path) + { + try FileManager.default.removeItem(at: outputURL) + } + + guard let outputStream = OutputStream(url: outputURL, append: false) else { continue } + + let semaphore = DispatchSemaphore(value: 0) + semaphores.insert(semaphore) + + let outputWriter = InputStreamOutputWriter(inputStream: inputStream, outputStream: outputStream) + outputWriter.start { (error) in + if let error = error + { + if FileManager.default.fileExists(atPath: outputURL.path) + { + do + { + try FileManager.default.removeItem(at: outputURL) + } + catch + { + print(error) + } + } + + print(error) + } + else + { + outputURLs.insert(outputURL) + semaphore.signal() + } + } } - else - { - try FileManager.default.moveItem(at: url, to: destinationURL) - } - - identifiers.insert(game.identifier) } catch { - print("Import Games error:", error) - game.managedObjectContext?.delete(game) + print(error) } - } - do + for semaphore in semaphores { - try context.save() - } - catch - { - print("Failed to save import context:", error) - - identifiers.removeAll() + semaphore.wait() } - completion?(identifiers) + for url in urls + { + if FileManager.default.fileExists(atPath: url.path) + { + do + { + try FileManager.default.removeItem(at: url) + } + catch + { + print(error) + } + } + } + + completion(outputURLs) } } } diff --git a/Delta/Supporting Files/Info.plist b/Delta/Supporting Files/Info.plist index 3575f5e..5ba993f 100644 --- a/Delta/Supporting Files/Info.plist +++ b/Delta/Supporting Files/Info.plist @@ -46,6 +46,20 @@ com.rileytestut.delta.skin + + CFBundleTypeIconFiles + + CFBundleTypeName + ZIP File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + com.pkware.zip-archive + + CFBundleExecutable $(EXECUTABLE_NAME)