From c3b9bee499e2285397a43d5dc24b8f452a0ce847 Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:21:29 +0100 Subject: [PATCH 1/3] feat: move archives to Trash after extracting from Finder A new General setting, off by default: once Finder's Extract Here, Extract to or Extract to... has extracted an archive completely, the archive goes to the Trash without a further question. Every volume of a split set goes with it. A failed or cancelled extraction leaves the archive where it is, and the Trash is always the way back. --- .../Core/UrlHandling/AppUrlHandler.swift | 19 ++++++++++ .../Settings/GeneralSettingsView.swift | 12 ++++++ MacPacker/Localizable.xcstrings | 12 ++++++ Modules/Sources/Core/AppStorageKeys.swift | 4 ++ Modules/Sources/Core/ArchiveState.swift | 24 ++++++++++++ .../CoreTests/ExtractionProgressTests.swift | 37 +++++++++++++++++++ 6 files changed, 108 insertions(+) diff --git a/MacPacker/Core/UrlHandling/AppUrlHandler.swift b/MacPacker/Core/UrlHandling/AppUrlHandler.swift index 39c798d6..9bb727da 100644 --- a/MacPacker/Core/UrlHandling/AppUrlHandler.swift +++ b/MacPacker/Core/UrlHandling/AppUrlHandler.swift @@ -59,6 +59,11 @@ extension AppUrlHandler { break } + if state.lastExtractionState == .done, + UserDefaults.standard.bool(forKey: Keys.trashArchiveAfterFinderExtraction) { + trash(state.sourceFiles) + } + let added = folderContents(destination).filter { !before.contains($0) } if !added.isEmpty { return added @@ -71,6 +76,20 @@ extension AppUrlHandler { } } +/// Moves what was extracted to the Trash — never deletes it. One that cannot +/// be moved stays where it is; the extraction itself has succeeded. +@MainActor +private func trash(_ files: [URL]) { + for file in files { + do { + try FileManager.default.trashItem(at: file, resultingItemURL: nil) + log.notice("Moved extracted archive to the Trash", context: ["file": file.lastPathComponent]) + } catch { + log.error("Could not move \(file.lastPathComponent) to the Trash", context: ["error": error.localizedDescription]) + } + } +} + private func folderContents(_ folder: URL) -> [URL] { (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] } diff --git a/MacPacker/Features/Settings/GeneralSettingsView.swift b/MacPacker/Features/Settings/GeneralSettingsView.swift index f5c96a9e..f3a32526 100644 --- a/MacPacker/Features/Settings/GeneralSettingsView.swift +++ b/MacPacker/Features/Settings/GeneralSettingsView.swift @@ -20,6 +20,7 @@ struct GeneralSettingsView: View { @AppStorage(Keys.showMenuBarItem) var showMenuBarItem: Bool = false @AppStorage(Keys.rememberRecentArchives) var rememberRecentArchives: Bool = true @AppStorage(Keys.smartExtraction, store: .macPackerShared) var smartExtraction: Bool = true + @AppStorage(Keys.trashArchiveAfterFinderExtraction) var trashArchiveAfterFinderExtraction: Bool = false var body: some View { VStack(spacing: 8) { @@ -119,6 +120,17 @@ struct GeneralSettingsView: View { .padding(.leading, 8) .frame(width: 240, alignment: .leading) } + + HStack(alignment: .top) { + Text(.settingsTrashArchiveAfterFinderExtraction) + .frame(width: 200, alignment: .trailing) + + HStack { + Toggle(isOn: $trashArchiveAfterFinderExtraction) {} + } + .padding(.leading, 8) + .frame(width: 240, alignment: .leading) + } } .padding() // Turning it off is a privacy switch, so what was collected goes with it — diff --git a/MacPacker/Localizable.xcstrings b/MacPacker/Localizable.xcstrings index f64b139a..30ae4156 100644 --- a/MacPacker/Localizable.xcstrings +++ b/MacPacker/Localizable.xcstrings @@ -19830,6 +19830,18 @@ } } }, + "settingsTrashArchiveAfterFinderExtraction": { + "comment": "Setting: after Finder's Extract Here / Extract to has extracted an archive successfully, move the archive (every volume of a split set) to the Trash, without asking.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move archive to Trash after extracting from Finder" + } + } + } + }, "updatesCheckForUpdates": { "comment": "Allow the user to check for updates", "extractionState": "manual", diff --git a/Modules/Sources/Core/AppStorageKeys.swift b/Modules/Sources/Core/AppStorageKeys.swift index de564b58..7e404052 100644 --- a/Modules/Sources/Core/AppStorageKeys.swift +++ b/Modules/Sources/Core/AppStorageKeys.swift @@ -28,6 +28,10 @@ public enum Keys { public static func smartExtractionEnabled(in defaults: UserDefaults = .macPackerShared) -> Bool { defaults.object(forKey: smartExtraction) as? Bool ?? true } + /// Whether Finder's extract actions move the archive to the Trash once it + /// was extracted completely. Off by default; on, it does not ask — the + /// Trash is the way back. + public static let trashArchiveAfterFinderExtraction = "trashArchiveAfterFinderExtraction" // table settings public static let showParentRow = "showParentRow" diff --git a/Modules/Sources/Core/ArchiveState.swift b/Modules/Sources/Core/ArchiveState.swift index 36895d68..fa92e2b8 100644 --- a/Modules/Sources/Core/ArchiveState.swift +++ b/Modules/Sources/Core/ArchiveState.swift @@ -155,6 +155,10 @@ public class ArchiveState: ObservableObject { /// the app-wide center that feeds the extraction progress window; /// tests inject their own instance. public var progressCenter: ExtractionProgressCenter = .shared + /// How the last `extract(to:smart:)` ended; `nil` until one has. The + /// progress center drops finished jobs when its window closes, so a + /// caller that waits for the end reads it here. + public private(set) var lastExtractionState: ExtractionJob.State? /// Passwords the user gave, by the file they were given for. One window can /// hold more than one archive: an archive inside the archive opens in the /// same tree, extracted to a temp file, and each can have its own password. @@ -1109,6 +1113,21 @@ extension ArchiveState { return file } + /// The archive's file — for a split set, every volume of it in the + /// archive's folder. What goes to the Trash once it is extracted. + public var sourceFiles: [URL] { + url.map(files(ofArchiveAt:)) ?? [] + } + + func files(ofArchiveAt url: URL) -> [URL] { + let setName = splitSetName(for: url) + let folder = url.deletingLastPathComponent() + let siblings = (try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: nil)) ?? [] + let volumes = siblings.filter { splitSetName(for: $0) == setName } + return volumes.isEmpty ? [url] : volumes + } + public func open(url: URL) { // A second open supersedes the first. Without cancelling, the earlier // task keeps running against the same state. @@ -1734,6 +1753,7 @@ extension ArchiveState { /// - smart: see `extract(items:to:smart:)` public func extract(to destination: URL, smart: Bool) { isBusy = true + lastExtractionState = nil updateStatus(.processing) let jobId = progressCenter.begin( @@ -1785,16 +1805,20 @@ extension ArchiveState { to: target, onProgress: makeEngineProgress(jobId: jobId, cancelFlag: cancelFlag) ) + lastExtractionState = .done progressCenter.finish(jobId, .done) } catch is CancellationError { + lastExtractionState = .cancelled progressCenter.finish(jobId, .cancelled) } catch ArchiveError.passwordCancelled { let outcome = passwordCancelledOutcome() if case .failed(let message) = outcome { self.error = message } + lastExtractionState = outcome progressCenter.finish(jobId, outcome) } catch { extractLog.error(error) self.error = error.localizedDescription + lastExtractionState = .failed(error.localizedDescription) progressCenter.finish(jobId, .failed(error.localizedDescription)) } diff --git a/Modules/Tests/CoreTests/ExtractionProgressTests.swift b/Modules/Tests/CoreTests/ExtractionProgressTests.swift index 5c2fe878..2b706b72 100644 --- a/Modules/Tests/CoreTests/ExtractionProgressTests.swift +++ b/Modules/Tests/CoreTests/ExtractionProgressTests.swift @@ -436,5 +436,42 @@ extension AllCoreTests { #expect(center.jobs[0].state == .done) #expect(center.hasActiveJobs == false) } + + /// Finder's extract actions read the outcome here once the job has + /// ended — the progress window may already have dropped it — and move + /// the archive to the Trash only when it is `.done`. + @Test func extractFullArchiveRecordsItsOutcome() async throws { + let state = try await openedState() + state.progressCenter = ExtractionProgressCenter() + #expect(state.lastExtractionState == nil) + + let dest = try makeDest() + defer { try? FileManager.default.removeItem(at: dest) } + + await withCheckedContinuation { continuation in + state.onStatusChange = { status in + if status == .done { continuation.resume() } + } + state.extract(to: dest, smart: false) + } + #expect(state.lastExtractionState == .done) + } + + /// A plain archive is its own file; a split set is every volume of it + /// next to the one opened, and nothing else in the folder. + @Test func sourceFilesCoverEveryVolumeOfASplitSet() async throws { + let folder = try makeDest() + defer { try? FileManager.default.removeItem(at: folder) } + for name in ["set.z01", "set.z02", "set.zip", "other.zip", "set.txt"] { + FileManager.default.createFile(atPath: folder.appendingPathComponent(name).path, contents: Data()) + } + + let state = ArchiveState(catalog: ArchiveTypeCatalog(), engineSelector: ArchiveEngineSelector7zip()) + let volumes = state.files(ofArchiveAt: folder.appendingPathComponent("set.z02")) + #expect(Set(volumes.map(\.lastPathComponent)) == ["set.z01", "set.z02", "set.zip"]) + + let plain = try await openedState() + #expect(plain.sourceFiles.map(\.lastPathComponent) == ["defaultArchive.zip"]) + } } } From c55544942a79ff533c76f219ef644f43e98b58a0 Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:27:32 +0100 Subject: [PATCH 2/3] feat: changelog entry for moving archives to Trash (#316) --- Config/products/macpacker.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index 88459efc..94653d5d 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -113,6 +113,30 @@ "278" ] }, + { + "type": "feat", + "title": { + "en": "Move archives to Trash after extracting from Finder", + "zh-Hans": "从 Finder 解压后将归档移到废纸篓", + "fr": "Placer l’archive dans la corbeille après extraction depuis le Finder", + "de": "Archiv nach dem Entpacken im Finder in den Papierkorb legen", + "it": "Sposta l’archivio nel Cestino dopo l’estrazione dal Finder", + "ja": "Finder から展開した後にアーカイブをゴミ箱へ移動", + "fa": "انتقال آرشیو به سطل زباله پس از استخراج از Finder", + "pl": "Przenoś archiwum do kosza po rozpakowaniu z Findera", + "pt-BR": "Mover o arquivo para o Lixo após extrair pelo Finder", + "ru": "Перемещать архив в Корзину после извлечения из Finder", + "uk": "Переміщувати архів у Смітник після видобування з Finder", + "es-MX": "Mover el archivo a la Papelera tras extraer desde el Finder", + "ko": "Finder에서 압축 해제 후 아카이브를 휴지통으로 이동", + "nl": "Archief naar prullenmand na uitpakken in de Finder", + "tr": "Finder’dan çıkarınca arşivi Çöp Sepeti’ne taşı", + "vi": "Chuyển tệp nén vào Thùng rác sau khi giải nén từ Finder" + }, + "issues": [ + "316" + ] + }, { "type": "core", "title": { From 27b9c294a5c4808f1877287189dfe9b8ef0fffe2 Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:28:45 +0100 Subject: [PATCH 3/3] lang: point the changelog entry at #330 --- Config/products/macpacker.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index 94653d5d..0aaa9849 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -134,7 +134,7 @@ "vi": "Chuyển tệp nén vào Thùng rác sau khi giải nén từ Finder" }, "issues": [ - "316" + "330" ] }, {