Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Config/products/macpacker.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@
{
"version": "1.0.1",
"items": [
{
"type": "feat",
"title": {
"en": "Calculate and verify file checksums",
"zh-Hans": "计算并验证文件校验和",
"fr": "Calculer et vérifier les sommes de contrôle des fichiers",
"de": "Dateiprüfsummen berechnen und überprüfen",
"it": "Calcola e verifica i checksum dei file",
"ja": "ファイルのチェックサムを計算して検証",
"fa": "محاسبه و بررسی جمع‌های کنترلی فایل‌ها",
"pl": "Obliczanie i weryfikacja sum kontrolnych plików",
"pt-BR": "Calcular e verificar somas de verificação de arquivos",
"ru": "Вычисление и проверка контрольных сумм файлов",
"uk": "Обчислення та перевірка контрольних сум файлів",
"es-MX": "Calcular y verificar sumas de comprobación de archivos",
"ko": "파일 체크섬 계산 및 검증",
"nl": "Bestandscontrolesommen berekenen en verifiëren",
"vi": "Tính và xác minh tổng kiểm tra tệp",
"tr": "Dosya sağlama toplamlarını hesapla ve doğrula"
},
"issues": [
"233"
]
},
{
"type": "fix",
"highlight": true,
Expand Down
18 changes: 17 additions & 1 deletion FinderExtension/FinderSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ class FinderSync: FIFinderSync {
case .compressTo7z: #selector(compressTo7z(_:))
case .compressEachSeparately: #selector(compressEachSeparately(_:))
case .compressFolderContents: #selector(compressFolderContents(_:))
case .checksums: #selector(checksums(_:))
case .verifyChecksum: #selector(verifyChecksum(_:))
}
}

Expand Down Expand Up @@ -236,6 +238,12 @@ class FinderSync: FIFinderSync {
case .compressFolderContents:
let folder = allItems.first?.lastPathComponent ?? ""
return String(localized: .commonCompressContentsTo(folder))

case .checksums:
return String(localized: "Checksums…", comment: "Finder action to calculate file checksums")

case .verifyChecksum:
return String(localized: "Verify Checksum from Clipboard", comment: "Finder action to compare selected files with a copied checksum")
}
}

Expand Down Expand Up @@ -357,5 +365,13 @@ class FinderSync: FIFinderSync {
communicateWithMainApp(item: .compressFolderContents)
}

}
@objc func checksums(_ sender: Any?) {
log.notice("Finder menu: Checksums")
communicateWithMainApp(item: .checksums)
}

@objc func verifyChecksum(_ sender: Any?) {
log.notice("Finder menu: Verify Checksum from Clipboard")
communicateWithMainApp(item: .verifyChecksum)
}
}
17 changes: 16 additions & 1 deletion MacPacker/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
handler = AppUrlCompressEachHandler(catalog: appState.catalog, engineSelector: appState.engineSelector)
case .compressContents:
handler = AppUrlCompressContentsHandler(catalog: appState.catalog, engineSelector: appState.engineSelector)
case .checksums, .verifyChecksum:
handler = AppUrlChecksumHandler()
}

guard let handler else {
Expand Down Expand Up @@ -184,10 +186,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
let opensWindow = LaunchParameters.opensWindow
#if DEBUG
let showsExtractionDemo = ExtractionDemo.isRequested
let showsChecksumDemo = ProcessInfo.processInfo.environment["MACPACKER_DEBUG_CHECKSUM_FILE"] != nil
#else
let showsExtractionDemo = false
let showsChecksumDemo = false
#endif
let launchedToOpenSomething = opensWindow || showsExtractionDemo
let launchedForChecksums = pendingOpenURLs.contains { url in
guard url.scheme == UrlParser.appScheme,
let action = UrlParser().parse(appUrl: url)?.action else { return false }
return action == .checksums || action == .verifyChecksum
}
let launchedToOpenSomething = opensWindow || showsExtractionDemo || launchedForChecksums || showsChecksumDemo

// make sure that at least one window will be shown even if it is empty
if !launchedToOpenSomething {
Expand Down Expand Up @@ -219,6 +228,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
log.notice("applicationDidFinishLaunching done")

#if DEBUG
// Repeatable UI check with a disposable file, without enabling a Finder
// extension or granting access to a personal folder.
if let path = ProcessInfo.processInfo.environment["MACPACKER_DEBUG_CHECKSUM_FILE"] {
ChecksumWindowController.show(files: [URL(fileURLWithPath: path)], verifyFromClipboard: false)
}

// Debug-only end-to-end hook: MACPACKER_DEBUG_EXTRACT="<archive>|<destDir>"
// opens the archive headless and extracts it fully — lets the
// extraction progress window be exercised without UI scripting.
Expand Down
33 changes: 33 additions & 0 deletions MacPacker/Core/UrlHandling/AppUrlChecksumHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// AppUrlChecksumHandler.swift
// MacPacker
//

import Core
import FinderMenu
import Foundation
import tb

private let log = tb.Logger(subsystem: "app.MacPacker", category: "url")

/// Finder's file-checksum actions share one window; Verify starts with the
/// clipboard value, and Checksums starts with a blank comparison field.
class AppUrlChecksumHandler: AppUrlHandler {
func handle(appUrl: AppUrl, archiveWindowManager: ArchiveWindowManager) {
Task { @MainActor in
var checkedFolders: Set<URL> = []
for file in appUrl.files {
let folder = file.deletingLastPathComponent()
guard checkedFolders.insert(folder).inserted else { continue }
guard await FolderAccessStore.shared.ensureAccess(forFolder: folder) else {
log.error("No access to \(folder.lastPathComponent) — cannot calculate checksums")
return
}
}
ChecksumWindowController.show(
files: appUrl.files,
verifyFromClipboard: appUrl.action == .verifyChecksum
)
}
}
}
230 changes: 230 additions & 0 deletions MacPacker/Features/Checksums/ChecksumWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
//
// ChecksumWindowController.swift
// MacPacker
//

import AppKit
import Core
import SwiftUI

@MainActor
final class ChecksumWindowController: NSWindowController, NSWindowDelegate {
private static var retained: [ChecksumWindowController] = []

private let model: ChecksumWindowModel

private init(files: [URL], verifyFromClipboard: Bool) {
model = ChecksumWindowModel(
files: files,
expectedInput: verifyFromClipboard ? NSPasteboard.general.string(forType: .string) ?? "" : ""
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 780, height: 540),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = String(localized: "File Checksums", comment: "Title of the window that calculates and verifies file checksums")
window.isReleasedWhenClosed = false
super.init(window: window)
window.contentView = NSHostingView(rootView: ChecksumWindowView(model: model))
window.contentMinSize = NSSize(width: 690, height: 360)
window.setContentSize(NSSize(width: 780, height: 540))
window.delegate = self
window.center()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

static func show(files: [URL], verifyFromClipboard: Bool) {
guard !files.isEmpty else { return }
let controller = ChecksumWindowController(files: files, verifyFromClipboard: verifyFromClipboard)
retained.append(controller)
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
controller.model.start()
}

func windowWillClose(_ notification: Notification) {
model.cancel()
Self.retained.removeAll { $0 === self }
}
}

@MainActor
private final class ChecksumWindowModel: ObservableObject {
struct Row: Identifiable {
let id = UUID()
let file: URL
var checksums: FileChecksums?
var error: String?
}

@Published var rows: [Row]
@Published var expectedInput: String
private var task: Task<Void, Never>?

init(files: [URL], expectedInput: String) {
rows = files.map { Row(file: $0) }
self.expectedInput = expectedInput
}

var expected: ExpectedChecksum? {
ChecksumVerifier.expected(from: expectedInput)
}

func start() {
let files = rows.map(\.file)
task = Task.detached(priority: .userInitiated) { [weak self] in
for (index, file) in files.enumerated() {
if Task.isCancelled { return }
do {
let checksums = try await FileChecksumCalculator.calculate(file)
await self?.finish(index: index, checksums: checksums)
} catch is CancellationError {
return
} catch {
await self?.finish(index: index, error: error.localizedDescription)
}
}
}
}

func cancel() {
task?.cancel()
task = nil
}

func paste() {
expectedInput = NSPasteboard.general.string(forType: .string) ?? ""
}

func copy(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}

func saveSHA256(for row: Row) {
guard let checksum = row.checksums?.sha256 else { return }
let panel = NSSavePanel()
panel.nameFieldStringValue = row.file.lastPathComponent + ".sha256"
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do {
try "\(checksum) \(row.file.lastPathComponent)\n".write(
to: destination, atomically: true, encoding: .utf8
)
} catch {
NSApp.presentError(error)
}
}
}

private func finish(index: Int, checksums: FileChecksums) {
rows[index].checksums = checksums
}

private func finish(index: Int, error: String) {
rows[index].error = error
}
}

private struct ChecksumWindowView: View {
@ObservedObject var model: ChecksumWindowModel

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("File Checksums", comment: "Heading of the window for file checksums")
.font(.title2.bold())
Spacer()
Group {
if model.rows.count == 1 {
Text("1 file", comment: "One file being checked")
} else {
Text("\(model.rows.count) files", comment: "Number of files being checked")
}
}
.foregroundStyle(.secondary)
}

HStack {
TextField(
"Paste a CRC-32, MD5, SHA-1 or SHA-256 checksum",
text: $model.expectedInput
)
.textFieldStyle(.roundedBorder)
Button(action: model.paste) {
Text("Paste", comment: "Paste an expected checksum from the clipboard")
}
}
if !model.expectedInput.isEmpty && model.expected == nil {
Text("Copy one complete checksum to compare it with the selected files.",
comment: "Shown when clipboard text is not one recognizable checksum")
.foregroundStyle(.orange)
}

ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(model.rows) { row in
GroupBox {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(row.file.lastPathComponent)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if let checksums = row.checksums, let expected = model.expected {
Label(
expected.matches(checksums)
? String(localized: "Checksum matches", comment: "File matches copied checksum")
: String(localized: "Checksum does not match", comment: "File differs from copied checksum"),
systemImage: expected.matches(checksums) ? "checkmark.circle.fill" : "xmark.circle.fill"
)
.foregroundStyle(expected.matches(checksums) ? .green : .red)
}
}
if let checksums = row.checksums {
ForEach(ChecksumAlgorithm.allCases, id: \.self) { algorithm in
HStack(spacing: 8) {
Text(algorithm.rawValue)
.frame(width: 62, alignment: .leading)
Text(checksums.value(for: algorithm))
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
Spacer(minLength: 0)
Button {
model.copy(checksums.value(for: algorithm))
} label: {
Text("Copy", comment: "Copy this file checksum to the clipboard")
}
if algorithm == .sha256 {
Button {
model.saveSHA256(for: row)
} label: {
Text("Save .sha256…", comment: "Write this file's SHA-256 checksum to a checksum file")
}
}
}
}
} else if let error = row.error {
Text(error).foregroundStyle(.red)
} else {
ProgressView()
.controlSize(.small)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
.padding(20)
.frame(minWidth: 690, minHeight: 360)
}
}
4 changes: 4 additions & 0 deletions MacPacker/Features/Settings/IntegrationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ private struct FinderMenuItemToggle: View {
Text(.settingsCompressEachItemSeparately)
case .compressFolderContents:
Text(.settingsCompressFolderContents)
case .checksums:
Text("Checksums…", comment: "Optional Finder entry for calculating checksums of selected files")
case .verifyChecksum:
Text("Verify Checksum from Clipboard", comment: "Optional Finder entry for checking selected files against a copied checksum")
}
}
}
Expand Down
Loading