Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user