Adds support for importing zip archives containing games

This commit is contained in:
Riley Testut 2016-12-25 02:19:37 -06:00
parent 062abf6dbe
commit 7b43b48b51
7 changed files with 319 additions and 44 deletions

@ -1 +1 @@
Subproject commit e2e166d01c4e866c9707944fd6df53556335056b
Subproject commit 55ea13366edc40bb7cd9978c6127bd4f49aa45d1

View File

@ -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;

View File

@ -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)
}

View File

@ -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)
}

View 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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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>