Skip to content
Open
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 @@ -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": [
"330"
]
},
{
"type": "core",
"title": {
Expand Down
19 changes: 19 additions & 0 deletions MacPacker/Core/UrlHandling/AppUrlHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) ?? []
}
12 changes: 12 additions & 0 deletions MacPacker/Features/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 —
Expand Down
12 changes: 12 additions & 0 deletions MacPacker/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions Modules/Sources/Core/AppStorageKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions Modules/Sources/Core/ArchiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
}

Expand Down
37 changes: 37 additions & 0 deletions Modules/Tests/CoreTests/ExtractionProgressTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
}