chore: move files around
This commit is contained in:
215
src/Common/AppIcon.swift
Normal file
215
src/Common/AppIcon.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import AppKit // NSImage
|
||||
import AssetCarReader // CarReader
|
||||
import os // OSLog
|
||||
|
||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AppIcon")
|
||||
|
||||
|
||||
struct AppIcon {
|
||||
let meta: MetaInfo
|
||||
|
||||
init(_ meta: MetaInfo) {
|
||||
self.meta = meta
|
||||
}
|
||||
|
||||
/// Convenience getter to extract app icon regardless of bundle-type.
|
||||
func extractImageForThumbnail() -> NSImage {
|
||||
switch meta.type {
|
||||
case .IPA, .Archive, .Extension:
|
||||
extractImage(from: meta.readPlist_Icon()?.filenames)
|
||||
case .APK:
|
||||
extractImage(from: meta.readApk_Icon())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract image from Android app bundle.
|
||||
func extractImage(from apkIcon: Apk_Icon?) -> NSImage {
|
||||
if let data = apkIcon?.data, let img = NSImage(data: data) {
|
||||
return img
|
||||
}
|
||||
return defaultIcon()
|
||||
}
|
||||
|
||||
/// Try multiple methods to extract image.
|
||||
/// This method will always return an image even if none is found, in which case it returns the default image.
|
||||
func extractImage(from plistIcons: [String]?) -> NSImage {
|
||||
// no need to unwrap the plist, and most .ipa should include the Artwork anyway
|
||||
if meta.type == .IPA {
|
||||
if let data = meta.zipFile!.unzipFile("iTunesArtwork") {
|
||||
os_log(.debug, log: log, "[icon] using iTunesArtwork.")
|
||||
return NSImage(data: data)!
|
||||
}
|
||||
// else, fallthrough
|
||||
}
|
||||
|
||||
// Extract image name from app plist
|
||||
var plistImgNames = plistIcons ?? []
|
||||
os_log(.debug, log: log, "[icon] icon names in plist: %{public}@", plistImgNames)
|
||||
|
||||
// If no previous filename works (or empty), try default icon names
|
||||
plistImgNames.append("Icon")
|
||||
plistImgNames.append("icon")
|
||||
plistImgNames.append("AppIcon")
|
||||
|
||||
// First, try if an image file with that name exists.
|
||||
if let actualName = expandImageName(plistImgNames) {
|
||||
os_log(.debug, log: log, "[icon] using plist image file %{public}@", actualName)
|
||||
if meta.type == .IPA {
|
||||
let data = meta.zipFile!.unzipFile(actualName)!
|
||||
return NSImage(data: data)!
|
||||
}
|
||||
return NSImage(contentsOfFile: actualName)!
|
||||
}
|
||||
|
||||
// Else: try Assets.car
|
||||
if let img = imageFromAssetsCar(plistImgNames.first!) {
|
||||
return img
|
||||
}
|
||||
|
||||
// Fallback to default icon
|
||||
return defaultIcon()
|
||||
}
|
||||
|
||||
/// Return the bundled default icon `"defaultIcon.png"`
|
||||
private func defaultIcon() -> NSImage {
|
||||
let iconURL = Bundle.main.url(forResource: "defaultIcon", withExtension: "png")!
|
||||
return NSImage(contentsOf: iconURL)!
|
||||
}
|
||||
|
||||
/// Extract an image from `Assets.car`
|
||||
func imageFromAssetsCar(_ imageName: String) -> NSImage? {
|
||||
guard let data = meta.readPayloadFile("Assets.car", osxSubdir: "Resources") else {
|
||||
return nil
|
||||
}
|
||||
return CarReader(data)?.imageFromAssetsCar(imageName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Plist
|
||||
|
||||
extension AppIcon {
|
||||
/// Given a filename, search Bundle or Filesystem for files that match. Select the filename with the highest resolution.
|
||||
private func expandImageName(_ iconList: [String]) -> String? {
|
||||
var matches: [String] = []
|
||||
switch meta.type {
|
||||
case .IPA:
|
||||
for iconPath in iconList {
|
||||
let zipPath = "Payload/*.app/\(iconPath)*"
|
||||
for zip in meta.zipFile!.filesMatching(zipPath) {
|
||||
if zip.sizeUncompressed > 0 {
|
||||
matches.append(zip.filepath)
|
||||
}
|
||||
}
|
||||
if matches.count > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case .APK:
|
||||
return nil // handled in `extractImage()`
|
||||
|
||||
case .Archive, .Extension:
|
||||
for iconPath in iconList {
|
||||
let fileName = iconPath.components(separatedBy: "/").last!
|
||||
let parentDir = meta.effectiveUrl("Resources", iconPath).parentDir().path
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(atPath: parentDir) else {
|
||||
continue
|
||||
}
|
||||
for file in files {
|
||||
if file.hasPrefix(fileName) {
|
||||
let fullPath = parentDir + "/" + file
|
||||
if let fSize = try? FileManager.default.attributesOfItem(atPath: fullPath)[FileAttributeKey.size] as? Int {
|
||||
if fSize > 0 {
|
||||
matches.append(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches.count > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches.isEmpty ? nil : sortedByResolution(matches).first
|
||||
}
|
||||
|
||||
/// @return lower index means higher resolution.
|
||||
private func resolutionIndex(_ iconName: String) -> Int {
|
||||
let lower = iconName.lowercased()
|
||||
// "defaultX" = launch image
|
||||
let penalty = lower.contains("small") || lower.hasPrefix("default") ? 20 : 0
|
||||
|
||||
let resolutionOrder: [String] = [
|
||||
"@3x", "180", "167", "152", "@2x", "120",
|
||||
"144", "114", "87", "80", "76", "72", "58", "57"
|
||||
]
|
||||
for (i, res) in resolutionOrder.enumerated() {
|
||||
if iconName.contains(res) {
|
||||
return i + penalty
|
||||
}
|
||||
}
|
||||
return 50 + penalty
|
||||
}
|
||||
|
||||
/// Given a list of filenames, order them highest resolution first.
|
||||
private func sortedByResolution(_ icons: [String]) -> [String] {
|
||||
return icons.sorted { (icon1, icon2) -> Bool in
|
||||
let index1 = self.resolutionIndex(icon1)
|
||||
let index2 = self.resolutionIndex(icon2)
|
||||
return index1 < index2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Extension: NSImage
|
||||
|
||||
// AppIcon extension
|
||||
extension NSImage {
|
||||
/// Because some (PNG) image data will return weird float values
|
||||
private func bestImageSize() -> NSSize {
|
||||
var w: Int = 0
|
||||
var h: Int = 0
|
||||
for imageRep in self.representations {
|
||||
w = max(w, imageRep.pixelsWide)
|
||||
h = max(h, imageRep.pixelsHigh)
|
||||
}
|
||||
return NSSize(width: w, height: h)
|
||||
}
|
||||
|
||||
/// Apply rounded corners to image (iOS7 style)
|
||||
func withRoundCorners() -> NSImage {
|
||||
let existingSize = bestImageSize()
|
||||
let composedImage = NSImage(size: existingSize)
|
||||
|
||||
composedImage.lockFocus()
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
let imageFrame = NSRect(origin: .zero, size: existingSize)
|
||||
let clipPath = NSBezierPath.IOS7RoundedRect(imageFrame, cornerRadius: existingSize.width * 0.225)
|
||||
clipPath.windingRule = .evenOdd
|
||||
clipPath.addClip()
|
||||
|
||||
self.draw(in: imageFrame)
|
||||
composedImage.unlockFocus()
|
||||
return composedImage
|
||||
}
|
||||
|
||||
/// Convert image to PNG and encode with base64 to be embeded in html output.
|
||||
func asBase64() -> String {
|
||||
let imageData = tiffRepresentation!
|
||||
let imageRep = NSBitmapImageRep(data: imageData)!
|
||||
let imageDataPNG = imageRep.representation(using: .png, properties: [:])!
|
||||
return imageDataPNG.base64EncodedString()
|
||||
}
|
||||
|
||||
/// If the image is larger than the provided maximum size, scale it down. Otherwise leave it untouched.
|
||||
// func downscale(ifLargerThan maxSize: CGSize) {
|
||||
// // TODO: if downscale, then this should respect retina resolution
|
||||
// if size.width > maxSize.width && size.height > maxSize.height {
|
||||
// self.size = maxSize
|
||||
// }
|
||||
// }
|
||||
}
|
||||
130
src/Common/MetaInfo.swift
Normal file
130
src/Common/MetaInfo.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import os // OSLog
|
||||
|
||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "MetaInfo")
|
||||
|
||||
typealias PlistDict = [String: Any] // basically an untyped Dict
|
||||
|
||||
|
||||
// Init QuickLook Type
|
||||
enum FileType {
|
||||
case IPA
|
||||
case Archive
|
||||
case Extension
|
||||
case APK
|
||||
}
|
||||
|
||||
struct MetaInfo {
|
||||
let UTI: String
|
||||
let url: URL
|
||||
private let effectiveUrl: URL // if set, will point to the app inside of an archive
|
||||
|
||||
let type: FileType
|
||||
let zipFile: ZipFile? // only set for zipped file types
|
||||
let isOSX: Bool
|
||||
|
||||
/// Use file url and UTI type to generate an info object to pass around.
|
||||
init(_ url: URL) {
|
||||
self.url = url
|
||||
self.UTI = try! url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier ?? "Unknown"
|
||||
|
||||
var isOSX = false
|
||||
var effective: URL? = nil
|
||||
var zipFile: ZipFile? = nil
|
||||
|
||||
switch self.UTI {
|
||||
case "com.apple.itunes.ipa", "com.opa334.trollstore.tipa", "dyn.ah62d4rv4ge81k4puqe" /* tipa */:
|
||||
self.type = FileType.IPA
|
||||
zipFile = ZipFile(self.url.path)
|
||||
case "com.apple.xcode.archive":
|
||||
self.type = FileType.Archive
|
||||
let productsDir = url.appendingPathComponent("Products", isDirectory: true)
|
||||
if productsDir.exists(), let bundleDir = recursiveSearchInfoPlist(productsDir) {
|
||||
isOSX = bundleDir.appendingPathComponent("MacOS").exists() && bundleDir.lastPathComponent == "Contents"
|
||||
effective = bundleDir
|
||||
} else {
|
||||
effective = productsDir // this is wrong but dont use `url` either because that will find the `Info.plist` of the archive itself
|
||||
}
|
||||
case "com.apple.application-and-system-extension":
|
||||
self.type = FileType.Extension
|
||||
case "com.google.android.apk", "dyn.ah62d4rv4ge80c6dp" /* apk */, "public.archive.apk", "dyn.ah62d4rv4ge80c6dpry" /* apkm */:
|
||||
self.type = FileType.APK
|
||||
zipFile = ZipFile(self.url.path)
|
||||
default:
|
||||
os_log(.error, log: log, "Unsupported file type: %{public}@", self.UTI)
|
||||
fatalError()
|
||||
}
|
||||
self.isOSX = isOSX
|
||||
self.zipFile = zipFile
|
||||
self.effectiveUrl = effective ?? url
|
||||
}
|
||||
|
||||
/// Evaluate path with `osxSubdir` and `filename`
|
||||
func effectiveUrl(_ osxSubdir: String?, _ filename: String) -> URL {
|
||||
switch self.type {
|
||||
case .IPA, .APK:
|
||||
return effectiveUrl
|
||||
case .Archive, .Extension:
|
||||
if isOSX, let osxSubdir {
|
||||
return effectiveUrl
|
||||
.appendingPathComponent(osxSubdir, isDirectory: true)
|
||||
.appendingPathComponent(filename, isDirectory: false)
|
||||
}
|
||||
return effectiveUrl.appendingPathComponent(filename, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a file from bundle into memory. Either by file path or via unzip.
|
||||
func readPayloadFile(_ filename: String, osxSubdir: String?) -> Data? {
|
||||
switch self.type {
|
||||
case .IPA:
|
||||
return zipFile!.unzipFile("Payload/*.app/".appending(filename))
|
||||
case .APK:
|
||||
return nil // not applicable for .apk
|
||||
case .Archive, .Extension:
|
||||
return try? Data(contentsOf: self.effectiveUrl(osxSubdir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Plist
|
||||
|
||||
extension Data {
|
||||
/// Helper for optional chaining.
|
||||
func asPlistOrNil() -> PlistDict? {
|
||||
if self.isEmpty {
|
||||
return nil
|
||||
}
|
||||
// var format: PropertyListSerialization.PropertyListFormat = .xml
|
||||
do {
|
||||
return try PropertyListSerialization.propertyList(from: self, format: nil) as? PlistDict
|
||||
} catch {
|
||||
os_log(.error, log: log, "ERROR reading plist %{public}@", error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - helper methods
|
||||
|
||||
/// breadth-first search for `Info.plist`
|
||||
private func recursiveSearchInfoPlist(_ url: URL) -> URL? {
|
||||
var queue: [URL] = [url]
|
||||
while !queue.isEmpty {
|
||||
let current = queue.removeLast()
|
||||
if current.pathExtension == "framework" {
|
||||
continue // do not evaluate bundled frameworks
|
||||
}
|
||||
if let subfiles = try? FileManager.default.contentsOfDirectory(at: current, includingPropertiesForKeys: []) {
|
||||
for fname in subfiles {
|
||||
if fname.lastPathComponent == "Info.plist" {
|
||||
return fname.parentDir()
|
||||
}
|
||||
}
|
||||
queue.append(contentsOf: subfiles)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
src/Common/NSBezierPath+RoundedRect.swift
Normal file
58
src/Common/NSBezierPath+RoundedRect.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import AppKit // NSBezierPath
|
||||
|
||||
//
|
||||
// NSBezierPath+IOS7RoundedRect
|
||||
//
|
||||
// Created by Matej Dunik on 11/12/13.
|
||||
// Copyright (c) 2013 PixelCut. All rights reserved except as below:
|
||||
// This code is provided as-is, without warranty of any kind. You may use it in your projects as you wish.
|
||||
//
|
||||
|
||||
extension NSBezierPath {
|
||||
public class func IOS7RoundedRect(_ rect: NSRect, cornerRadius: CGFloat) -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
let limit = min(rect.size.width, rect.size.height) / 2 / 1.52866483
|
||||
let limitedRadius = min(cornerRadius, limit)
|
||||
|
||||
@inline(__always) func topLeft(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
|
||||
return NSPoint(x: rect.origin.x + x * limitedRadius, y: rect.origin.y + y * limitedRadius)
|
||||
}
|
||||
|
||||
@inline(__always) func topRight(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
|
||||
return NSPoint(x: rect.origin.x + rect.size.width - x * limitedRadius, y: rect.origin.y + y * limitedRadius)
|
||||
}
|
||||
|
||||
@inline(__always) func bottomRight(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
|
||||
return NSPoint(x: rect.origin.x + rect.size.width - x * limitedRadius, y: rect.origin.y + rect.size.height - y * limitedRadius)
|
||||
}
|
||||
|
||||
@inline(__always) func bottomLeft(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
|
||||
return NSPoint(x: rect.origin.x + x * limitedRadius, y: rect.origin.y + rect.size.height - y * limitedRadius)
|
||||
}
|
||||
|
||||
path.move(to: topLeft(1.52866483, 0.00000000))
|
||||
path.line(to: topRight(1.52866471, 0.00000000))
|
||||
path.curve(to: topRight(0.66993427, 0.06549600), controlPoint1: topRight(1.08849323, 0.00000000), controlPoint2: topRight(0.86840689, 0.00000000))
|
||||
path.line(to: topRight(0.63149399, 0.07491100))
|
||||
path.curve(to: topRight(0.07491176, 0.63149399), controlPoint1: topRight(0.37282392, 0.16905899), controlPoint2: topRight(0.16906013, 0.37282401))
|
||||
path.curve(to: topRight(0.00000000, 1.52866483), controlPoint1: topRight(0.00000000, 0.86840701), controlPoint2: topRight(0.00000000, 1.08849299))
|
||||
path.line(to: bottomRight(0.00000000, 1.52866471))
|
||||
path.curve(to: bottomRight(0.06549569, 0.66993493), controlPoint1: bottomRight(0.00000000, 1.08849323), controlPoint2: bottomRight(0.00000000, 0.86840689))
|
||||
path.line(to: bottomRight(0.07491111, 0.63149399))
|
||||
path.curve(to: bottomRight(0.63149399, 0.07491111), controlPoint1: bottomRight(0.16905883, 0.37282392), controlPoint2: bottomRight(0.37282392, 0.16905883))
|
||||
path.curve(to: bottomRight(1.52866471, 0.00000000), controlPoint1: bottomRight(0.86840689, 0.00000000), controlPoint2: bottomRight(1.08849323, 0.00000000))
|
||||
path.line(to: bottomLeft(1.52866483, 0.00000000))
|
||||
path.curve(to: bottomLeft(0.66993397, 0.06549569), controlPoint1: bottomLeft(1.08849299, 0.00000000), controlPoint2: bottomLeft(0.86840701, 0.00000000))
|
||||
path.line(to: bottomLeft(0.63149399, 0.07491111))
|
||||
path.curve(to: bottomLeft(0.07491100, 0.63149399), controlPoint1: bottomLeft(0.37282401, 0.16905883), controlPoint2: bottomLeft(0.16906001, 0.37282392))
|
||||
path.curve(to: bottomLeft(0.00000000, 1.52866471), controlPoint1: bottomLeft(0.00000000, 0.86840689), controlPoint2: bottomLeft(0.00000000, 1.08849323))
|
||||
path.line(to: topLeft(0.00000000, 1.52866483))
|
||||
path.curve(to: topLeft(0.06549600, 0.66993397), controlPoint1: topLeft(0.00000000, 1.08849299), controlPoint2: topLeft(0.00000000, 0.86840701))
|
||||
path.line(to: topLeft(0.07491100, 0.63149399))
|
||||
path.curve(to: topLeft(0.63149399, 0.07491100), controlPoint1: topLeft(0.16906001, 0.37282401), controlPoint2: topLeft(0.37282401, 0.16906001))
|
||||
path.curve(to: topLeft(1.52866483, 0.00000000), controlPoint1: topLeft(0.86840701, 0.00000000), controlPoint2: topLeft(1.08849299, 0.00000000))
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
}
|
||||
17
src/Common/URL+File.swift
Normal file
17
src/Common/URL+File.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
/// Folder where user can mofifications to html template
|
||||
static let UserModDir: URL? =
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
|
||||
/// Returns `true` if file or folder exists.
|
||||
@inlinable func exists() -> Bool {
|
||||
FileManager.default.fileExists(atPath: self.path)
|
||||
}
|
||||
|
||||
/// Returns URL by deleting last path component
|
||||
@inlinable func parentDir() -> URL {
|
||||
self.deletingLastPathComponent()
|
||||
}
|
||||
}
|
||||
376
src/Common/Zip.swift
Normal file
376
src/Common/Zip.swift
Normal file
@@ -0,0 +1,376 @@
|
||||
import Foundation
|
||||
import Compression // compression_decode_buffer
|
||||
import zlib // Z_DEFLATED, crc32
|
||||
import os // OSLog
|
||||
|
||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Zip")
|
||||
|
||||
|
||||
// MARK: - Helper to parse byte headers
|
||||
|
||||
private struct ByteScanner {
|
||||
private let data: Data
|
||||
private var index: Int
|
||||
private let endIndex: Int
|
||||
|
||||
init (_ data: Data, start: Int) {
|
||||
self.data = data
|
||||
self.index = start
|
||||
self.endIndex = data.endIndex
|
||||
}
|
||||
|
||||
mutating func scan<T>() -> T {
|
||||
let newIndex = index + MemoryLayout<T>.size
|
||||
if newIndex > endIndex {
|
||||
os_log(.fault, log: log, "ByteScanner out of bounds")
|
||||
fatalError()
|
||||
}
|
||||
let result = data.subdata(in: index ..< newIndex).withUnsafeBytes { $0.load(as: T.self) }
|
||||
index = newIndex
|
||||
return result
|
||||
}
|
||||
|
||||
mutating func scanString(length: Int) -> String {
|
||||
let bytes = data.subdata(in: index ..< index + length)
|
||||
index += length
|
||||
return String(data: bytes, encoding: .utf8) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - ZIP Headers
|
||||
|
||||
// See http://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers
|
||||
|
||||
/// Local file header
|
||||
private struct ZIP_LocalFile {
|
||||
static let LENGTH: Int = 30
|
||||
|
||||
let magicNumber: UInt32 // 50 4B 03 04
|
||||
let versionNeededToExtract: UInt16
|
||||
let generalPurposeBitFlag: UInt16
|
||||
let compressionMethod: UInt16
|
||||
let fileLastModificationTime: UInt16
|
||||
let fileLastModificationDate: UInt16
|
||||
let CRC32: UInt32
|
||||
let compressedSize: UInt32
|
||||
let uncompressedSize: UInt32
|
||||
let fileNameLength: UInt16
|
||||
let extraFieldLength: UInt16
|
||||
|
||||
// let fileName: String
|
||||
// Extra field
|
||||
|
||||
init(_ data: Data, start: Data.Index = 0) {
|
||||
var scanner = ByteScanner(data, start: start)
|
||||
magicNumber = scanner.scan()
|
||||
versionNeededToExtract = scanner.scan()
|
||||
generalPurposeBitFlag = scanner.scan()
|
||||
compressionMethod = scanner.scan()
|
||||
fileLastModificationTime = scanner.scan()
|
||||
fileLastModificationDate = scanner.scan()
|
||||
CRC32 = scanner.scan()
|
||||
compressedSize = scanner.scan()
|
||||
uncompressedSize = scanner.scan()
|
||||
fileNameLength = scanner.scan()
|
||||
extraFieldLength = scanner.scan()
|
||||
// fileName = scanner.scanString(length: Int(fileNameLength))
|
||||
}
|
||||
}
|
||||
|
||||
/// Central directory file header
|
||||
private struct ZIP_CDFH {
|
||||
static let LENGTH: Int = 46
|
||||
|
||||
let magicNumber: UInt32 // 50 4B 01 02
|
||||
let versionMadeBy: UInt16
|
||||
let versionNeededToExtract: UInt16
|
||||
let generalPurposeBitFlag: UInt16
|
||||
let compressionMethod: UInt16
|
||||
let fileLastModificationTime: UInt16
|
||||
let fileLastModificationDate: UInt16
|
||||
let CRC32: UInt32
|
||||
let compressedSize: UInt32
|
||||
let uncompressedSize: UInt32
|
||||
let fileNameLength: UInt16
|
||||
let extraFieldLength: UInt16
|
||||
let fileCommentLength: UInt16
|
||||
let diskNumberWhereFileStarts: UInt16
|
||||
let internalFileAttributes: UInt16
|
||||
let externalFileAttributes: UInt32
|
||||
let relativeOffsetOfLocalFileHeader: UInt32
|
||||
|
||||
let fileName: String
|
||||
// Extra field
|
||||
// File comment
|
||||
|
||||
init(_ data: Data, start: Data.Index = 0) {
|
||||
var scanner = ByteScanner(data, start: start)
|
||||
magicNumber = scanner.scan()
|
||||
versionMadeBy = scanner.scan()
|
||||
versionNeededToExtract = scanner.scan()
|
||||
generalPurposeBitFlag = scanner.scan()
|
||||
compressionMethod = scanner.scan()
|
||||
fileLastModificationTime = scanner.scan()
|
||||
fileLastModificationDate = scanner.scan()
|
||||
CRC32 = scanner.scan()
|
||||
compressedSize = scanner.scan()
|
||||
uncompressedSize = scanner.scan()
|
||||
fileNameLength = scanner.scan()
|
||||
extraFieldLength = scanner.scan()
|
||||
fileCommentLength = scanner.scan()
|
||||
diskNumberWhereFileStarts = scanner.scan()
|
||||
internalFileAttributes = scanner.scan()
|
||||
externalFileAttributes = scanner.scan()
|
||||
relativeOffsetOfLocalFileHeader = scanner.scan()
|
||||
fileName = scanner.scanString(length: Int(fileNameLength))
|
||||
}
|
||||
}
|
||||
|
||||
/// End of central directory record
|
||||
private struct ZIP_EOCD {
|
||||
static let LENGTH: Int = 22
|
||||
|
||||
let magicNumber: UInt32 // 50 4B 05 06
|
||||
let numberOfThisDisk: UInt16
|
||||
let diskWhereCentralDirectoryStarts: UInt16
|
||||
let numberOfCentralDirectoryRecordsOnThisDisk: UInt16
|
||||
let totalNumberOfCentralDirectoryRecords: UInt16
|
||||
let sizeOfCentralDirectory: UInt32
|
||||
let offsetOfStartOfCentralDirectory: UInt32
|
||||
let commentLength: UInt16
|
||||
// Comment
|
||||
|
||||
init(_ data: Data, start: Data.Index = 0) {
|
||||
var scanner = ByteScanner(data, start: start)
|
||||
magicNumber = scanner.scan()
|
||||
numberOfThisDisk = scanner.scan()
|
||||
diskWhereCentralDirectoryStarts = scanner.scan()
|
||||
numberOfCentralDirectoryRecordsOnThisDisk = scanner.scan()
|
||||
totalNumberOfCentralDirectoryRecords = scanner.scan()
|
||||
sizeOfCentralDirectory = scanner.scan()
|
||||
offsetOfStartOfCentralDirectory = scanner.scan()
|
||||
commentLength = scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - CRC32 check
|
||||
|
||||
extension Data {
|
||||
func crc() -> UInt32 {
|
||||
return UInt32(self.withUnsafeBytes { crc32(0, $0.baseAddress!, UInt32($0.count)) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Unzip data
|
||||
|
||||
func unzipFileEntry(_ path: String, _ entry: ZipEntry) -> Data? {
|
||||
guard let fp = FileHandle(forReadingAtPath: path) else {
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
try? fp.close()
|
||||
}
|
||||
fp.seek(toFileOffset: UInt64(entry.offset))
|
||||
let file_record = ZIP_LocalFile(fp.readData(ofLength: ZIP_LocalFile.LENGTH))
|
||||
|
||||
// central directory size and local file size may differ! use local file for ground truth
|
||||
let dataOffset = Int(entry.offset) + ZIP_LocalFile.LENGTH + Int(file_record.fileNameLength) + Int(file_record.extraFieldLength)
|
||||
fp.seek(toFileOffset: UInt64(dataOffset))
|
||||
let rawData = fp.readData(ofLength: Int(entry.sizeCompressed))
|
||||
|
||||
if entry.method == Z_DEFLATED {
|
||||
let size = Int(entry.sizeUncompressed)
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
|
||||
defer {
|
||||
buffer.deallocate()
|
||||
}
|
||||
|
||||
let uncompressedData = rawData.withUnsafeBytes ({
|
||||
let ptr = $0.baseAddress!.bindMemory(to: UInt8.self, capacity: 1)
|
||||
let read = compression_decode_buffer(buffer, size, ptr, Int(entry.sizeCompressed), nil, COMPRESSION_ZLIB)
|
||||
return Data(bytes: buffer, count:read)
|
||||
})
|
||||
if file_record.CRC32 != 0, uncompressedData.crc() != file_record.CRC32 {
|
||||
os_log(.error, log: log, "CRC check failed (after uncompress)")
|
||||
return nil
|
||||
}
|
||||
return uncompressedData
|
||||
|
||||
} else if entry.method == 0 {
|
||||
if file_record.CRC32 != 0, rawData.crc() != file_record.CRC32 {
|
||||
os_log(.error, log: log, "CRC check failed (uncompressed data)")
|
||||
return nil
|
||||
}
|
||||
return rawData
|
||||
|
||||
} else {
|
||||
os_log(.error, log: log, "unimplemented compression method: %{public}d", entry.method)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - List files
|
||||
|
||||
private func listZip(_ path: String) -> [ZipEntry] {
|
||||
guard let fp = FileHandle(forReadingAtPath: path) else {
|
||||
return []
|
||||
}
|
||||
defer {
|
||||
try? fp.close()
|
||||
}
|
||||
|
||||
guard let endRecord = findCentralDirectory(fp), endRecord.sizeOfCentralDirectory > 0 else {
|
||||
return []
|
||||
}
|
||||
return listDirectoryEntries(fp, endRecord)
|
||||
}
|
||||
|
||||
/// Find signature for central directory.
|
||||
private func findCentralDirectory(_ fp: FileHandle) -> ZIP_EOCD? {
|
||||
let eof = fp.seekToEndOfFile()
|
||||
fp.seek(toFileOffset: max(0, eof - 4096))
|
||||
let data = fp.readDataToEndOfFile()
|
||||
|
||||
let centralDirSignature: [UInt8] = [0x50, 0x4b, 0x05, 0x06]
|
||||
|
||||
guard let range = data.lastRange(of: centralDirSignature) else {
|
||||
os_log(.error, log: log, "no zip end-header found!")
|
||||
return nil
|
||||
}
|
||||
return ZIP_EOCD(data, start: range.lowerBound)
|
||||
}
|
||||
|
||||
/// List all files and folders of of the central directory.
|
||||
private func listDirectoryEntries(_ fp: FileHandle, _ centralDir: ZIP_EOCD) -> [ZipEntry] {
|
||||
fp.seek(toFileOffset: UInt64(centralDir.offsetOfStartOfCentralDirectory))
|
||||
let data = fp.readData(ofLength: Int(centralDir.sizeOfCentralDirectory))
|
||||
let total = data.count
|
||||
|
||||
var idx = 0
|
||||
var entries: [ZipEntry] = []
|
||||
|
||||
while idx + ZIP_CDFH.LENGTH < total {
|
||||
let record = ZIP_CDFH(data, start: idx)
|
||||
// read filename
|
||||
idx += ZIP_CDFH.LENGTH
|
||||
let filename = String(data: data.subdata(in: idx ..< idx + Int(record.fileNameLength)), encoding: .utf8)!
|
||||
entries.append(ZipEntry(filename, record))
|
||||
// update index
|
||||
idx += Int(record.fileNameLength + record.extraFieldLength + record.fileCommentLength)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
// MARK: - ZipEntry
|
||||
|
||||
struct ZipEntry {
|
||||
let filepath: String
|
||||
let offset: UInt32
|
||||
let method: UInt16
|
||||
let sizeCompressed: UInt32
|
||||
let sizeUncompressed: UInt32
|
||||
let filenameLength: UInt16
|
||||
let extraFieldLength: UInt16
|
||||
let CRC32: UInt32
|
||||
|
||||
fileprivate init(_ filename: String, _ record: ZIP_CDFH) {
|
||||
self.filepath = filename
|
||||
self.offset = record.relativeOffsetOfLocalFileHeader
|
||||
self.method = record.compressionMethod
|
||||
self.sizeCompressed = record.compressedSize
|
||||
self.sizeUncompressed = record.uncompressedSize
|
||||
self.filenameLength = record.fileNameLength
|
||||
self.extraFieldLength = record.extraFieldLength
|
||||
self.CRC32 = record.CRC32
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == ZipEntry {
|
||||
/// Return entry with shortest possible path (thus ignoring deeper nested files).
|
||||
func zipEntryWithShortestPath() -> ZipEntry? {
|
||||
var shortest = 99999
|
||||
var bestMatch: ZipEntry? = nil
|
||||
|
||||
for entry in self {
|
||||
if shortest > entry.filepath.count {
|
||||
shortest = entry.filepath.count
|
||||
bestMatch = entry
|
||||
}
|
||||
}
|
||||
return bestMatch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - ZipFile
|
||||
|
||||
struct ZipFile {
|
||||
private let pathToZipFile: String
|
||||
private let centralDirectory: [ZipEntry]
|
||||
|
||||
init(_ path: String) {
|
||||
self.pathToZipFile = path
|
||||
self.centralDirectory = listZip(path)
|
||||
}
|
||||
|
||||
// MARK: - public methods
|
||||
|
||||
func filesMatching(_ path: String) -> [ZipEntry] {
|
||||
let parts = path.split(separator: "*", omittingEmptySubsequences: false)
|
||||
return centralDirectory.filter {
|
||||
var idx = $0.filepath.startIndex
|
||||
if !$0.filepath.hasPrefix(parts.first!) || !$0.filepath.hasSuffix(parts.last!) {
|
||||
return false
|
||||
}
|
||||
for part in parts {
|
||||
guard let found = $0.filepath.range(of: part, range: idx..<$0.filepath.endIndex) else {
|
||||
return false
|
||||
}
|
||||
idx = found.upperBound
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Unzip file directly into memory.
|
||||
/// @param filePath File path inside zip file.
|
||||
func unzipFile(_ filePath: String) -> Data? {
|
||||
if let matchingFile = self.filesMatching(filePath).zipEntryWithShortestPath() {
|
||||
os_log(.debug, log: log, "[unzip] %{public}@", matchingFile.filepath)
|
||||
return unzipFileEntry(pathToZipFile, matchingFile)
|
||||
}
|
||||
|
||||
// There is a dir listing but no matching file.
|
||||
// This means there wont be anything to extract.
|
||||
os_log(.error, log: log, "cannot find '%{public}@' for unzip", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Unzip file to filesystem.
|
||||
/// @param filePath File path inside zip file.
|
||||
/// @param targetDir Directory in which to unzip the file.
|
||||
@discardableResult
|
||||
func unzipFile(_ filePath: String, toDir targetDir: String) throws -> String? {
|
||||
guard let data = self.unzipFile(filePath) else {
|
||||
return nil
|
||||
}
|
||||
let filename = filePath.components(separatedBy: "/").last!
|
||||
let outputPath = targetDir.appending("/" + filename)
|
||||
os_log(.debug, log: log, "[unzip] write to %{public}@", outputPath)
|
||||
try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic)
|
||||
return outputPath
|
||||
}
|
||||
|
||||
/// Extract selected `filePath` inside zip to a new temporary directory and return path to that file.
|
||||
/// @return Path to extracted data. Returns `nil` or throws exception if data could not be extracted.
|
||||
func unzipFileToTempDir(_ filePath: String) throws -> String? {
|
||||
let tmpPath = NSTemporaryDirectory() + "/" + UUID().uuidString
|
||||
try! FileManager.default.createDirectory(atPath: tmpPath, withIntermediateDirectories: true)
|
||||
return try unzipFile(filePath, toDir: tmpPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user