Adds support for importing zip archives containing games
This commit is contained in:
parent
062abf6dbe
commit
7b43b48b51
@ -1 +1 @@
|
||||
Subproject commit e2e166d01c4e866c9707944fd6df53556335056b
|
||||
Subproject commit 55ea13366edc40bb7cd9978c6127bd4f49aa45d1
|
||||
@ -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 = "<group>"; };
|
||||
BFE4269D1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SaveStatesStoryboardSegue.swift; path = Segues/SaveStatesStoryboardSegue.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
BFFA71DC1AAC406100EE9DD1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@ -374,6 +376,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF59428D1E09BCFB0051894B /* ImportController.swift */,
|
||||
BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */,
|
||||
);
|
||||
name = Importing;
|
||||
sourceTree = "<group>";
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import ObjectiveC
|
||||
|
||||
import DeltaCore
|
||||
|
||||
import MobileCoreServices
|
||||
|
||||
protocol ImportControllerDelegate
|
||||
{
|
||||
func importController(_ importController: ImportController, didImport games: Set<Game>)
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
148
Delta/Components/Importing/InputStreamOutputWriter.swift
Normal file
148
Delta/Components/Importing/InputStreamOutputWriter.swift
Normal file
@ -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<UInt8>) -> 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<UInt8>.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String>) -> 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<String>()
|
||||
|
||||
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<String>) -> Void)?)
|
||||
{
|
||||
self.performBackgroundTask { (context) in
|
||||
@ -159,66 +234,97 @@ extension DatabaseManager
|
||||
}
|
||||
}
|
||||
|
||||
func importGames(at urls: [URL], completion: ((Set<String>) -> Void)?)
|
||||
private func extractCompressedGames(at urls: [URL], completion: @escaping ((Set<URL>) -> Void))
|
||||
{
|
||||
self.performBackgroundTask { (context) in
|
||||
DispatchQueue.global().async {
|
||||
|
||||
var identifiers = Set<String>()
|
||||
var semaphores = Set<DispatchSemaphore>()
|
||||
var outputURLs = Set<URL>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,20 @@
|
||||
<string>com.rileytestut.delta.skin</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ZIP File</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.pkware.zip-archive</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user