Files
konverter/Sources/Konverter/Convert/ArchiveConverter.swift
T
TZGyn a01d5def89 Give mixed selections something to do
A Drag of different kinds showed an empty wheel, because the catalog only
answers for a single category. Filee's answer is a mixed profile, so add
Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF
first); a multi-file drag of one kind gets Zip too.
2026-10-20 01:30:42 +08:00

204 lines
8.5 KiB
Swift

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