chore: move files around

This commit is contained in:
relikd
2025-12-01 01:03:02 +01:00
parent 38c861442c
commit abdee3b780
29 changed files with 48 additions and 16 deletions

215
src/Common/AppIcon.swift Normal file
View 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
View 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
}

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