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) } } /// Packs a whole selection into one ZIP, the way Finder's *Compress* does. /// Inputs that live in the same folder keep their relative names; a /// selection spanning folders is staged through symlinks so the archive /// still has flat, readable entries instead of absolute paths. static func createArchive( from inputs: [URL], at destination: URL, cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws { guard !inputs.isEmpty else { throw ConversionError.unsupported("There is nothing to archive.") } try cancellation?.check() progress?(0.1) let folders = Set(inputs.map { $0.deletingLastPathComponent().path }) if folders.count == 1, let folder = inputs.first?.deletingLastPathComponent() { try Shell.run( Toolchain.zip, ["-r", "-X", destination.path] + inputs.map { $0.lastPathComponent }, currentDirectory: folder, cancellation: cancellation ) progress?(1) return } let staging = FileManager.default.temporaryDirectory .appendingPathComponent("konverter-zip-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: staging) } var names: [String] = [] for input in inputs { var name = input.lastPathComponent var index = 2 while names.contains(name) { let ext = input.pathExtension let base = input.deletingPathExtension().lastPathComponent name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)" index += 1 } names.append(name) try FileManager.default.createSymbolicLink( at: staging.appendingPathComponent(name), withDestinationURL: input ) } // `zip` follows symlinks unless asked for `-y`, so the archive holds // the real files rather than links to them. try Shell.run( Toolchain.zip, ["-r", "-X", destination.path] + names, currentDirectory: staging, cancellation: cancellation ) progress?(1) } /// 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), 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 } }