import Foundation /// ZIP/TAR/GZIP via the system's own tools (`ditto`, `tar`, `gzip`), which is /// both faster and more correct than rolling our own containers. enum ArchiveConverter { static func convert( source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws { try cancellation?.check() progress?(0.1) switch format { case .zip: try makeZip(source: source, at: destination, cancellation: cancellation) case .tar: try Shell.run( Toolchain.tar, ["-cf", destination.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent], cancellation: cancellation ) case .gzip: try makeGzip(source: source, at: destination, cancellation: cancellation) default: throw ConversionError.unsupported("\(format.displayName) is not an archive format Konverter writes.") } progress?(1) } private static func makeZip(source: URL, at destination: URL, cancellation: Cancellation?) throws { var arguments = ["-c", "-k", "--sequesterRsrc"] if source.hasDirectoryPath || isDirectory(source) { arguments.append("--keepParent") } arguments += [source.path, destination.path] try Shell.run(Toolchain.ditto, arguments, cancellation: cancellation) } private static func makeGzip(source: URL, at destination: URL, cancellation: Cancellation?) throws { // gzip only compresses a single stream, so a folder is tarred first and // the pair is squeezed into one .gz — the classic .tar.gz shape. if isDirectory(source) { let staging = FileManager.default.temporaryDirectory .appendingPathComponent("konverter-\(UUID().uuidString).tar") defer { try? FileManager.default.removeItem(at: staging) } try Shell.run( Toolchain.tar, ["-cf", staging.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent], cancellation: cancellation ) let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", staging.path], cancellation: cancellation) try data.write(to: destination) } else { let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", source.path], cancellation: cancellation) try data.write(to: destination) } } /// Expands an archive into `directory`, returning the folder that callers /// should reveal. @discardableResult static func extract( source: URL, into directory: URL, cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws -> URL { try cancellation?.check() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) progress?(0.2) let ext = source.pathExtension.lowercased() switch ext { case "zip": try Shell.run(Toolchain.ditto, ["-x", "-k", source.path, directory.path], cancellation: cancellation) case "tar", "tgz": try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation) case "gz": let inner = source.deletingPathExtension().lastPathComponent if inner.lowercased().hasSuffix(".tar") { try Shell.run(Toolchain.tar, ["-xzf", source.path, "-C", directory.path], cancellation: cancellation) } else { let data = try Shell.runData(Toolchain.gunzip, ["-c", source.path], cancellation: cancellation) try data.write(to: directory.appendingPathComponent(inner)) } default: try extractWithLibarchive(source: source, into: directory, cancellation: cancellation) } progress?(1) return directory } /// RAR/7z/BZip2/XZ: libarchive (bsdtar) reads most of them; `unar` is used /// when present because it handles RAR5 and encrypted archives better. private static func extractWithLibarchive(source: URL, into directory: URL, cancellation: Cancellation?) throws { if let unar = Shell.find("unar", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"]) { try Shell.run(unar, ["-quiet", "-force-overwrite", "-output-directory", directory.path, source.path], cancellation: cancellation) return } do { try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation) return } catch { throw ConversionError.missingTool( "Konverter cannot open .\(source.pathExtension) archives. Install unar (brew install unar) and try again." ) } } static func list(_ source: URL) throws -> String { switch source.pathExtension.lowercased() { case "zip": let result = try Shell.run(Toolchain.unzip, ["-l", source.path]) return result.stdout case "tar", "tgz": return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout case "gz": let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0 return """ GZIP stream Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file)) Inner name: \(source.deletingPathExtension().lastPathComponent) """ default: let result = try? Shell.run(Toolchain.tar, ["-tf", source.path]) guard let stdout = result?.stdout else { throw ConversionError.missingTool("Cannot list .\(source.pathExtension) archives without unar.") } return stdout } } static func isArchive(_ url: URL) -> Bool { FileKind.category(of: url) == .archive } private static func isDirectory(_ url: URL) -> Bool { (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } }