From c68bd2f4f4dcd80ca23b109de1c1a965ebac6d41 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:16:52 +0530 Subject: [PATCH 01/20] Implement verified collision-safe copy and move transactions --- Sources/App/Favorites/FileTransfer.swift | 161 +++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 Sources/App/Favorites/FileTransfer.swift diff --git a/Sources/App/Favorites/FileTransfer.swift b/Sources/App/Favorites/FileTransfer.swift new file mode 100644 index 0000000..db3b6ad --- /dev/null +++ b/Sources/App/Favorites/FileTransfer.swift @@ -0,0 +1,161 @@ +import Foundation +import CryptoKit + +public enum TransferMode: String, Codable, Sendable { + case copy, move +} +public struct TransferSelection: Sendable { + public let source: URL + public let destination: URL +} + +/// All paths are explicit; collisions are resolved before publication, and +/// copies are first constructed at an unguessable staging location on the +/// destination volume. No existing destination is ever overwritten. +public enum FileTransfer { + private static let fm = FileManager.default + + public static func destination(for source: URL, in directory: URL) throws -> URL { + let parent = directory.standardizedFileURL + guard (try parent.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true else { + throw UndoError.unsafe("Destination is not an accessible folder.") + } + let stem = source.deletingPathExtension().lastPathComponent + let ext = source.pathExtension + for index in 0..<10_000 { + let suffix = index == 0 ? "" : "-\(index + 1)" + let name = "\(stem)\(suffix)\(ext.isEmpty ? "" : ".\(ext)")" + let target = parent.appendingPathComponent(name) + if !fm.fileExists(atPath: target.path) { return target } + } + throw UndoError.unsafe("Unable to allocate a collision-safe destination filename.") + } + + public static func transfer(paths: [String], to directory: URL, mode: TransferMode, + isCancelled: @escaping @Sendable () -> Bool = { false }, + progress: @escaping @Sendable (Double, String) -> Void = { _, _ in }) throws -> JobOutcome { + guard !paths.isEmpty else { throw UndoError.unsafe("Select at least one file or folder.") } + let sources = paths.map { URL(fileURLWithPath: $0).standardizedFileURL } + let destinationDir = directory.standardizedFileURL + guard Set(sources.map(\.path)).count == sources.count else { + throw UndoError.unsafe("The selection contains the same source more than once.") + } + var outputs: [URL] = [] + var failures: [JobFailure] = [] + for (index, source) in sources.enumerated() { + if isCancelled() { break } + progress(Double(index) / Double(sources.count), "Processing \(source.lastPathComponent)…") + do { + let output = try moveOrCopy(source, to: destinationDir, mode: mode, + isCancelled: isCancelled) + outputs.append(output) + } catch { + failures.append(JobFailure(inputPath: source.path, reason: error.localizedDescription)) + } + } + return JobOutcome(outputs: outputs, failures: failures) + } + + private static func moveOrCopy(_ source: URL, to folder: URL, mode: TransferMode, + isCancelled: @escaping @Sendable () -> Bool) throws -> URL { + guard !isCancelled() else { throw CancellationError() } + let src = source.standardizedFileURL + let dst = folder.standardizedFileURL + guard let attrs = try? fm.attributesOfItem(atPath: src.path), + let kind = attrs[.type] as? FileAttributeType, + kind == .typeRegular || kind == .typeDirectory else { + throw UndoError.unsafe("Only regular files and directories may be transferred.") + } + guard dst.path != src.path, !dst.path.hasPrefix(src.path + "/") else { + throw UndoError.unsafe("Cannot copy or move a folder into itself.") + } + if kind == .typeDirectory { + // Avoid allowing an attacker-controlled link in a copied hierarchy + // to turn a later traversal into a path escape. + try verifyNoSymlinks(src) + } + let originalProof = try treeProof(src) + let proposed = try destination(for: src, in: dst) + let staging = dst.appendingPathComponent(".tuck-transfer-\(UUID().uuidString)") + defer { try? fm.removeItem(at: staging) } + // A same-volume move has a single atomic rename; a cross-volume move + // must copy, verify, then delete only the unchanged original. + let srcVol = (attrs[.systemNumber] as? NSNumber)?.uint64Value + let destAttrs = try fm.attributesOfItem(atPath: dst.path) + let destVol = (destAttrs[.systemNumber] as? NSNumber)?.uint64Value + if mode == .move && srcVol != nil && srcVol == destVol { + guard !isCancelled() else { throw CancellationError() } + guard try treeProof(src) == originalProof else { + throw UndoError.unsafe("Source changed before the move. Nothing was moved.") + } + try fm.moveItem(at: src, to: proposed) + return proposed + } + + try fm.copyItem(at: src, to: staging) + guard !isCancelled() else { throw CancellationError() } + guard try treeProof(src) == originalProof, + try treeProof(staging) == originalProof else { + throw UndoError.unsafe("File content changed during transfer. Original remains intact.") + } + guard !fm.fileExists(atPath: proposed.path) else { + throw UndoError.unsafe("Destination appeared during transfer. Original remains intact.") + } + try fm.moveItem(at: staging, to: proposed) + if mode == .move { + // If verification fails, retain the source *and* created copy, + // and report this as a partial move rather than risking loss. + guard try treeProof(src) == originalProof else { + throw UndoError.unsafe("Source changed after copying; destination copy was retained and source was NOT removed.") + } + try fm.removeItem(at: src) + } + return proposed + } + + /// Each regular file is fully hashed. Directory manifests include relative + /// paths, sizes and hashes, so same-size but different-content trees differ. + private static func treeProof(_ url: URL) throws -> String { + let attrs = try fm.attributesOfItem(atPath: url.path) + let kind = attrs[.type] as? FileAttributeType + if kind == .typeRegular { + return try CopyAsToolkit.sha256(url: url) + } + guard kind == .typeDirectory else { + throw UndoError.unsafe("Unsupported file type in transfer.") + } + var lines: [String] = [] + guard let en = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey], + options: []) else { + throw UndoError.unsafe("Cannot verify directory tree.") + } + while let item = en.nextObject() as? URL { + let relative = String(item.path.dropFirst(url.path.count + 1)) + let values = try item.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]) + guard values.isSymbolicLink != true else { + throw UndoError.unsafe("Directory contains symlinks; transfer refused.") + } + if values.isRegularFile == true { + lines.append("F:\(relative):\(try CopyAsToolkit.sha256(url: item))") + } else if values.isDirectory == true { + lines.append("D:\(relative)") + } else { + throw UndoError.unsafe("Unsupported special file in directory.") + } + } + let manifest = Data(lines.sorted().joined(separator: "\n").utf8) + return SHA256.hash(data: manifest).map { String(format: "%02x", $0) }.joined() + } + + private static func verifyNoSymlinks(_ root: URL) throws { + guard let en = fm.enumerator(at: root, includingPropertiesForKeys: [.isSymbolicLinkKey], + options: []) else { + throw UndoError.unsafe("Cannot inspect source directory.") + } + while let url = en.nextObject() as? URL { + if (try url.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink == true { + throw UndoError.unsafe("Source tree contains symbolic links; transfer refused.") + } + } + } +} From 0880f437efa292ade11a23362b6a79d6b6e6c122 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:17:18 +0530 Subject: [PATCH 02/20] Retain published copies in history when cross-volume source removal fails --- Sources/App/Favorites/FileTransfer.swift | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/App/Favorites/FileTransfer.swift b/Sources/App/Favorites/FileTransfer.swift index db3b6ad..35a06d9 100644 --- a/Sources/App/Favorites/FileTransfer.swift +++ b/Sources/App/Favorites/FileTransfer.swift @@ -4,9 +4,10 @@ import CryptoKit public enum TransferMode: String, Codable, Sendable { case copy, move } -public struct TransferSelection: Sendable { - public let source: URL - public let destination: URL +public struct TransferPartialFailure: LocalizedError, Sendable { + public let output: URL + public let detail: String + public var errorDescription: String? { detail } } /// All paths are explicit; collisions are resolved before publication, and @@ -49,6 +50,9 @@ public enum FileTransfer { let output = try moveOrCopy(source, to: destinationDir, mode: mode, isCancelled: isCancelled) outputs.append(output) + } catch let partial as TransferPartialFailure { + outputs.append(partial.output) + failures.append(JobFailure(inputPath: source.path, reason: partial.detail)) } catch { failures.append(JobFailure(inputPath: source.path, reason: error.localizedDescription)) } @@ -105,10 +109,15 @@ public enum FileTransfer { if mode == .move { // If verification fails, retain the source *and* created copy, // and report this as a partial move rather than risking loss. - guard try treeProof(src) == originalProof else { - throw UndoError.unsafe("Source changed after copying; destination copy was retained and source was NOT removed.") + do { + guard try treeProof(src) == originalProof else { + throw UndoError.unsafe("Source changed after copying; source was NOT removed.") + } + try fm.removeItem(at: src) + } catch { + throw TransferPartialFailure(output: proposed, + detail: "Destination copy exists at \(proposed.path), but original was retained: \(error.localizedDescription)") } - try fm.removeItem(at: src) } return proposed } From 4a9bacb431210fa8b13428423356287975b3a0f2 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:17:43 +0530 Subject: [PATCH 03/20] Persist and resolve user-chosen destination bookmarks safely --- Sources/Shared/Favorites.swift | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Sources/Shared/Favorites.swift diff --git a/Sources/Shared/Favorites.swift b/Sources/Shared/Favorites.swift new file mode 100644 index 0000000..db45c11 --- /dev/null +++ b/Sources/Shared/Favorites.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Saved by the Settings app, read-only from Finder Sync. On a non-sandboxed +/// personal build, a regular bookmark provides persistent path resolution; +/// public sandboxed distributions need scoped-bookmark entitlements reviewed. +struct FavoriteDestination: Codable, Identifiable, Sendable { + let id: UUID + var title: String + let bookmark: Data + + static func create(title: String, url: URL) throws -> FavoriteDestination { + guard (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true else { + throw CocoaError(.fileReadUnsupportedScheme) + } + let data = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, + relativeTo: nil) + return FavoriteDestination(id: UUID(), title: title, bookmark: data) + } + + func resolve() throws -> URL { + var stale = false + let url = try URL(resolvingBookmarkData: bookmark, options: [], + relativeTo: nil, bookmarkDataIsStale: &stale) + guard !stale, + (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true else { + throw CocoaError(.fileNoSuchFile) + } + return url + } +} + +enum FavoritePreferences { + static let key = "favoriteDestinationRecords" + static var all: [FavoriteDestination] { + guard let data = TuckPreferences.store.data(forKey: key), + let value = try? JSONDecoder().decode([FavoriteDestination].self, from: data) else { + return [] + } + var seen = Set() + return Array(value.filter { + !$0.title.isEmpty && $0.title.utf8.count <= 70 && seen.insert($0.id).inserted + }.prefix(8)) + } + + static func write(_ items: [FavoriteDestination]) throws { + guard items.count <= 8 else { throw CocoaError(.fileWriteOutOfSpace) } + let data = try JSONEncoder().encode(items) + TuckPreferences.store.set(data, forKey: key) + } +} From 5319508ff65432749ebcb1fc84ad754e8cc46510 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:18:13 +0530 Subject: [PATCH 04/20] Add native Settings destination editor with bookmark persistence --- .../App/Favorites/FavoriteSettingsView.swift | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Sources/App/Favorites/FavoriteSettingsView.swift diff --git a/Sources/App/Favorites/FavoriteSettingsView.swift b/Sources/App/Favorites/FavoriteSettingsView.swift new file mode 100644 index 0000000..d99b1dc --- /dev/null +++ b/Sources/App/Favorites/FavoriteSettingsView.swift @@ -0,0 +1,96 @@ +import AppKit +import SwiftUI + +@MainActor +struct FavoriteSettingsView: View { + @State private var favorites = FavoritePreferences.all + @State private var error = "" + @State private var showError = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + Text("Destinations").font(.largeTitle.weight(.semibold)) + Text("Save folders for quick Copy To and Move To actions in Finder.") + .font(.subheadline).foregroundStyle(.secondary) + } + GroupBox("Favourite Folders") { + VStack(spacing: 12) { + if favorites.isEmpty { + Text("No folders saved. Add a destination to make it appear in Finder.") + .foregroundStyle(.secondary) + } + ForEach(favorites) { favorite in + HStack { + Image(systemName: "folder") + .foregroundStyle(.secondary) + TextField("Name", text: Binding( + get: { favorites.first(where: { $0.id == favorite.id })?.title ?? "" }, + set: { value in + guard let i = favorites.firstIndex(where: { $0.id == favorite.id }) else { return } + favorites[i].title = String(value.prefix(70)) + save() + })) + .accessibilityLabel("Destination name") + Spacer() + if let resolved = try? favorite.resolve() { + Text(resolved.path).font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([resolved]) + } + } else { + Label("Unavailable", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + Button(role: .destructive) { + favorites.removeAll { $0.id == favorite.id } + save() + } label: { + Image(systemName: "minus.circle") + }.accessibilityLabel("Remove \(favorite.title)") + }.padding(.vertical, 4) + } + HStack { + Spacer() + Button("Add Folder…") { addFolder() } + .disabled(favorites.count >= 8) + } + }.padding(12) + } + Text("Up to eight favourites. Moving files always asks for confirmation; existing filenames are never overwritten. Unavailable or disconnected destinations are refused instead of redirected.") + .font(.caption).foregroundStyle(.secondary) + Spacer() + } + .alert("Unable to update favourites", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { Text(error) } + } + + private func addFolder() { + let panel = NSOpenPanel() + panel.message = "Choose a folder to save as a Tuck destination" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + guard !favorites.contains(where: { (try? $0.resolve())?.standardizedFileURL == url.standardizedFileURL }) else { + throw UndoError.unsafe("That folder is already saved.") + } + favorites.append(try FavoriteDestination.create(title: url.lastPathComponent, url: url)) + save() + } catch { + self.error = error.localizedDescription + showError = true + } + } + + private func save() { + do { try FavoritePreferences.write(favorites) } + catch { + self.error = error.localizedDescription + showError = true + } + } +} From 2ff591b910c21d6360520d67b04e3e93c83f4017 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:20:14 +0530 Subject: [PATCH 05/20] Register saved folder destinations in native Settings and Finder action model --- Sources/Shared/Actions.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Shared/Actions.swift b/Sources/Shared/Actions.swift index 025d6c4..34a4a96 100644 --- a/Sources/Shared/Actions.swift +++ b/Sources/Shared/Actions.swift @@ -2,7 +2,7 @@ import Foundation enum TuckAction: String, CaseIterable, Identifiable { case newFile, openWith, compress, terminal - case pdfToolkit, imageToolkit, metadata, copyAs, rename + case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations var id: String { rawValue } var title: String { @@ -16,6 +16,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .metadata: "Inspect Metadata" case .copyAs: "Copy As" case .rename: "Advanced Rename" + case .destinations: "Copy / Move To" } } @@ -30,6 +31,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .metadata: "info.circle" case .copyAs: "doc.on.clipboard" case .rename: "textformat" + case .destinations: "folder.badge.plus" } } } @@ -65,6 +67,8 @@ enum TuckMenuModel { return context.hasSelection case .rename: return context.hasSelection + case .destinations: + return context.hasSelection } } } From 7ec7f7a4ab72e00e1935485a860f7a2da6f69775 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:20:19 +0530 Subject: [PATCH 06/20] Register saved folder destinations in native Settings and Finder action model --- Sources/Shared/Preferences.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shared/Preferences.swift b/Sources/Shared/Preferences.swift index 9c29df2..8c24268 100644 --- a/Sources/Shared/Preferences.swift +++ b/Sources/Shared/Preferences.swift @@ -24,7 +24,7 @@ enum TuckPreferences { static let defaults: [String] = [ "newFile", "openWith", "compress", "terminal", - "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename" + "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations" ] static var order: [String] { From ba290fb1275ebcd23d8e609247dfba75a48f77b3 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:20:22 +0530 Subject: [PATCH 07/20] Register saved folder destinations in native Settings and Finder action model --- Sources/App/SettingsView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index e010807..4210279 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -5,6 +5,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case menu = "Menu" case compression = "Compression" case activity = "Activity" + case destinations = "Destinations" case applications = "Applications" case general = "General" var id: Self { self } @@ -13,6 +14,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case .menu: "list.bullet" case .compression: "arrow.down.right.and.arrow.up.left" case .activity: "clock.arrow.circlepath" + case .destinations: "folder.badge.gearshape" case .applications: "terminal" case .general: "gearshape" } @@ -45,6 +47,7 @@ struct SettingsView: View { case .menu: menuPane case .compression: compressionPane case .activity: activityPane + case .destinations: FavoriteSettingsView() case .applications: applicationsPane case .general: generalPane } @@ -171,6 +174,8 @@ struct SettingsView: View { } case .rename: Button(action.title) { lastPreview = "Advanced Rename → Batch Pattern Rename" } + case .destinations: + Button(action.title) { lastPreview = "Copy or Move to a saved destination…" } } } From dff98316094d362bb3414e935e27bc82fc907465 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:20:40 +0530 Subject: [PATCH 08/20] Add lightweight Copy To / Move To Finder submenus for saved folders --- Sources/Extension/FinderSync.swift | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Sources/Extension/FinderSync.swift b/Sources/Extension/FinderSync.swift index 22ca41b..3038795 100644 --- a/Sources/Extension/FinderSync.swift +++ b/Sources/Extension/FinderSync.swift @@ -48,6 +48,10 @@ final class TuckFinderSync: FIFinderSync { // Rename tags static let renameBatch = 800 + static let copyFavorite = 900 + static let moveFavorite = 950 + static let chooseCopy = 990 + static let chooseMove = 991 } private var menuSelection: [URL]? @@ -147,6 +151,8 @@ final class TuckFinderSync: FIFinderSync { item.submenu = makeCopyAsMenu() case .rename: item.action = #selector(advancedRename(_:)) + case .destinations: + item.submenu = makeDestinationMenu() } root.addItem(item) } @@ -154,6 +160,33 @@ final class TuckFinderSync: FIFinderSync { return root.items.isEmpty ? nil : root } + private func makeDestinationMenu() -> NSMenu { + let root = NSMenu(title: "Copy / Move To") + let copy = NSMenu(title: "Copy To") + let move = NSMenu(title: "Move To") + for favorite in FavoritePreferences.all { + let a = copy.addItem(withTitle: favorite.title, action: #selector(destinationAction(_:)), keyEquivalent: "") + a.tag = Tag.copyFavorite + a.representedObject = favorite.id.uuidString + let b = move.addItem(withTitle: favorite.title, action: #selector(destinationAction(_:)), keyEquivalent: "") + b.tag = Tag.moveFavorite + b.representedObject = favorite.id.uuidString + } + if !FavoritePreferences.all.isEmpty { + copy.addItem(.separator()) + move.addItem(.separator()) + } + let chooseCopy = copy.addItem(withTitle: "Choose Folder…", action: #selector(destinationAction(_:)), keyEquivalent: "") + chooseCopy.tag = Tag.chooseCopy + let chooseMove = move.addItem(withTitle: "Choose Folder…", action: #selector(destinationAction(_:)), keyEquivalent: "") + chooseMove.tag = Tag.chooseMove + let ci = root.addItem(withTitle: "Copy To", action: nil, keyEquivalent: "") + ci.submenu = copy + let mi = root.addItem(withTitle: "Move To", action: nil, keyEquivalent: "") + mi.submenu = move + return root + } + private func makeFileMenu() -> NSMenu { let menu = NSMenu(title: "New File") for (index, template) in TuckMenuModel.templates.enumerated() { @@ -452,6 +485,18 @@ final class TuckFinderSync: FIFinderSync { dispatch("advancedRename", paths: paths) } + @objc func destinationAction(_ sender: NSMenuItem) { + let (selection, _) = resolveContext() + guard !selection.isEmpty else { return } + let copy = sender.tag == Tag.copyFavorite || sender.tag == Tag.chooseCopy + let move = sender.tag == Tag.moveFavorite || sender.tag == Tag.chooseMove + guard copy || move else { return } + let favorite = (sender.tag == Tag.chooseCopy || sender.tag == Tag.chooseMove) + ? "choose" : (sender.representedObject as? String) + guard let favorite else { return } + dispatch(copy ? "copyTo" : "moveTo", paths: selection.map(\.path), argument: favorite) + } + private func dispatch(_ operation: String, paths: [String], argument: String? = nil) { if paths.count > 256 { DispatchQueue.main.async { From d10602f50852ca8514aeb73df8a6137faf801d1f Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:21:03 +0530 Subject: [PATCH 09/20] Queue user-confirmed verified copy and move operations from Finder --- Sources/App/Main.swift | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Sources/App/Main.swift b/Sources/App/Main.swift index 1a13b34..62393f1 100644 --- a/Sources/App/Main.swift +++ b/Sources/App/Main.swift @@ -166,6 +166,10 @@ final class TuckApplication: NSObject, NSApplicationDelegate { let fmt = request.argument.flatMap(CopyAsFormat.init(rawValue:)) ?? .absolutePath runCopyAs(paths: request.paths, format: fmt) + case "copyTo", "moveTo": + runTransfer(paths: request.paths, argument: request.argument, + mode: request.operation == "moveTo" ? .move : .copy) + // MARK: - Advanced Rename case "advancedRename": runAdvancedRename(paths: request.paths) @@ -586,6 +590,50 @@ final class TuckApplication: NSObject, NSApplicationDelegate { title: "Copied to Clipboard") } + private func runTransfer(paths: [String], argument: String?, mode: TransferMode) { + guard let argument else { return } + let folder: URL + if argument == "choose" { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.message = "Choose destination for \(mode == .move ? "Move" : "Copy")" + guard panel.runModal() == .OK, let chosen = panel.url else { return } + folder = chosen + } else { + guard let id = UUID(uuidString: argument), + let favorite = FavoritePreferences.all.first(where: { $0.id == id }) else { + report("The chosen favourite is no longer available.", title: "Destination Missing") + return + } + do { folder = try favorite.resolve() } + catch { + report("The saved folder is missing or disconnected. Choose another destination.", + title: "Destination Unavailable") + return + } + } + if mode == .move { + let confirm = NSAlert() + confirm.messageText = "Move \(paths.count) selected item(s)?" + confirm.informativeText = "Destination: \(folder.path)\n\nTuck will never overwrite an existing item. Cross-volume moves copy, verify, then remove unchanged originals." + confirm.alertStyle = .warning + confirm.addButton(withTitle: "Move") + confirm.addButton(withTitle: "Cancel") + guard confirm.runModal() == .alertFirstButtonReturn else { return } + } + let undoable = mode == .copy && paths.allSatisfy { + (try? URL(fileURLWithPath: $0).resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + } + queueOperation(title: mode == .copy ? "Copy to Folder" : "Move to Folder", + operation: mode == .copy ? "copyTo" : "moveTo", paths: paths, + canUndo: undoable) { token, progress in + try FileTransfer.transfer(paths: paths, to: folder, mode: mode, + isCancelled: { token.isCancelled }, progress: progress) + } + } + private func runAdvancedRename(paths: [String]) { let urls = paths.map { URL(fileURLWithPath: $0) } guard !urls.isEmpty else { return } From 935921469b570e9b3c794751a360d5c44db661a5 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:21:24 +0530 Subject: [PATCH 10/20] Test verified copies, collisions, safe same-volume moves and self-copy refusal --- Tests/FileTransferTests.swift | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Tests/FileTransferTests.swift diff --git a/Tests/FileTransferTests.swift b/Tests/FileTransferTests.swift new file mode 100644 index 0000000..74c4692 --- /dev/null +++ b/Tests/FileTransferTests.swift @@ -0,0 +1,40 @@ +import Foundation + +@main +struct FileTransferTests { + static func main() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent( + "tuck-transfer-tests-\(UUID().uuidString)", isDirectory: true) + let input = root.appendingPathComponent("input", isDirectory: true) + let destination = root.appendingPathComponent("destination", isDirectory: true) + try fm.createDirectory(at: input, withIntermediateDirectories: true) + try fm.createDirectory(at: destination, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let original = input.appendingPathComponent("file with spaces.txt") + try Data("DO NOT MODIFY ORIGINAL".utf8).write(to: original) + let originalHash = try CopyAsToolkit.sha256(url: original) + let first = try FileTransfer.transfer(paths: [original.path], to: destination, + mode: .copy) + assert(first.failures.isEmpty && first.outputs.count == 1) + assert(fm.fileExists(atPath: original.path)) + assert(try CopyAsToolkit.sha256(url: first.outputs[0]) == originalHash) + let second = try FileTransfer.transfer(paths: [original.path], to: destination, mode: .copy) + assert(second.outputs.count == 1 && first.outputs[0] != second.outputs[0]) + assert(try CopyAsToolkit.sha256(url: original) == originalHash) + + let folder = input.appendingPathComponent("folder", isDirectory: true) + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + let child = folder.appendingPathComponent("contents.txt") + try Data("folder content".utf8).write(to: child) + let recursive = try FileTransfer.transfer(paths: [folder.path], + to: folder.appendingPathComponent("inner", isDirectory: true), mode: .copy) + assert(recursive.outputs.isEmpty && recursive.failures.count == 1) + assert(fm.fileExists(atPath: child.path)) + let moved = try FileTransfer.transfer(paths: [child.path], to: destination, mode: .move) + assert(moved.failures.isEmpty && moved.outputs.count == 1) + assert(!fm.fileExists(atPath: child.path)) + assert(String(data: try Data(contentsOf: moved.outputs[0]), encoding: .utf8) == "folder content") + print("FileTransferTests passed") + } +} From 91ca4a27c317c8d37c4cb73eff6cc8b933f1c078 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:21:36 +0530 Subject: [PATCH 11/20] Fix throwing assertions in transfer regression test --- Tests/FileTransferTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Tests/FileTransferTests.swift b/Tests/FileTransferTests.swift index 74c4692..c7512af 100644 --- a/Tests/FileTransferTests.swift +++ b/Tests/FileTransferTests.swift @@ -18,10 +18,12 @@ struct FileTransferTests { mode: .copy) assert(first.failures.isEmpty && first.outputs.count == 1) assert(fm.fileExists(atPath: original.path)) - assert(try CopyAsToolkit.sha256(url: first.outputs[0]) == originalHash) + let copiedHash = try CopyAsToolkit.sha256(url: first.outputs[0]) + assert(copiedHash == originalHash) let second = try FileTransfer.transfer(paths: [original.path], to: destination, mode: .copy) assert(second.outputs.count == 1 && first.outputs[0] != second.outputs[0]) - assert(try CopyAsToolkit.sha256(url: original) == originalHash) + let unchangedHash = try CopyAsToolkit.sha256(url: original) + assert(unchangedHash == originalHash) let folder = input.appendingPathComponent("folder", isDirectory: true) try fm.createDirectory(at: folder, withIntermediateDirectories: true) @@ -34,7 +36,8 @@ struct FileTransferTests { let moved = try FileTransfer.transfer(paths: [child.path], to: destination, mode: .move) assert(moved.failures.isEmpty && moved.outputs.count == 1) assert(!fm.fileExists(atPath: child.path)) - assert(String(data: try Data(contentsOf: moved.outputs[0]), encoding: .utf8) == "folder content") + let movedContent = try String(contentsOf: moved.outputs[0], encoding: .utf8) + assert(movedContent == "folder content") print("FileTransferTests passed") } } From 1d9683d68545d5048b950e62a6ef94f4b341705b Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:24:06 +0530 Subject: [PATCH 12/20] Combine tested ZIP, comparison and duplicate modules for integration review --- Sources/App/Compare/ComparisonView.swift | 179 ++++++++++++ Sources/App/Duplicates/DuplicateScanner.swift | 270 ++++++++++++++++++ Sources/App/ZIP/ZIPContentsView.swift | 136 +++++++++ Sources/App/ZIP/ZipInspector.swift | 211 ++++++++++++++ Tests/DuplicateTests.swift | 41 +++ Tests/ZipInspectorTests.swift | 54 ++++ 6 files changed, 891 insertions(+) create mode 100644 Sources/App/Compare/ComparisonView.swift create mode 100644 Sources/App/Duplicates/DuplicateScanner.swift create mode 100644 Sources/App/ZIP/ZIPContentsView.swift create mode 100644 Sources/App/ZIP/ZipInspector.swift create mode 100644 Tests/DuplicateTests.swift create mode 100644 Tests/ZipInspectorTests.swift diff --git a/Sources/App/Compare/ComparisonView.swift b/Sources/App/Compare/ComparisonView.swift new file mode 100644 index 0000000..8e4db61 --- /dev/null +++ b/Sources/App/Compare/ComparisonView.swift @@ -0,0 +1,179 @@ +import AppKit +import SwiftUI +import PDFKit +import ImageIO +import UniformTypeIdentifiers + +/// Optional, read-only result inspection. No re-encoding, no destructive actions, +/// and thumbnails capped to the visible preview resolution. +@MainActor +enum ComparisonWindow { + private static var windows: [UUID: NSWindow] = [:] + private static let images = Set(["jpg", "jpeg", "png", "heic", "tif", "tiff", "bmp", "webp"]) + static func supported(source: URL, output: URL) -> Bool { + let a = source.pathExtension.lowercased(), b = output.pathExtension.lowercased() + return (a == "pdf" && b == "pdf") || (images.contains(a) && images.contains(b)) + } + static func show(source: URL, output: URL) { + let id = UUID() + let view = ComparisonView(source: source, output: output) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 660), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Compare — \(source.lastPathComponent)" + window.contentViewController = NSHostingController(rootView: view) + window.center() + window.isReleasedWhenClosed = false + windows[id] = window + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, + object: window, queue: .main) { _ in + MainActor.assumeIsolated { _ = windows.removeValue(forKey: id) } + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +@MainActor +private struct ComparisonView: View { + let source: URL + let output: URL + @State private var page = 0 + @State private var split = false + @State private var fraction: Double = 0.5 + @State private var zoom: Double = 1 + + private var pdf: Bool { source.pathExtension.lowercased() == "pdf" && + output.pathExtension.lowercased() == "pdf" } + private var sourcePDF: PDFDocument? { pdf ? PDFDocument(url: source) : nil } + private var outputPDF: PDFDocument? { pdf ? PDFDocument(url: output) : nil } + private var pageLimit: Int { min(sourcePDF?.pageCount ?? 0, outputPDF?.pageCount ?? 0) } + + var body: some View { + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Before & After").font(.title2.weight(.semibold)) + Text("\(source.lastPathComponent) → \(output.lastPathComponent)") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Picker("Layout", selection: $split) { + Text("Side by side").tag(false) + Text("Slider").tag(true) + }.pickerStyle(.segmented).frame(width: 240) + Button("Reveal Output") { + NSWorkspace.shared.activateFileViewerSelecting([output]) + } + } + HStack(spacing: 12) { + Text("Original: \(sizeString(source))") + Text("Result: \(sizeString(output))") + if let before = bytes(source), let after = bytes(output), before > 0 { + Text(String(format: "%.1f%% smaller", (1 - Double(after) / Double(before)) * 100)) + } + Spacer() + if pdf { + Text("Pages: \(sourcePDF?.pageCount ?? 0) → \(outputPDF?.pageCount ?? 0)") + Button { page = max(0, page - 1) } label: { Image(systemName: "chevron.left") } + .disabled(page == 0) + Text("\(page + 1) / \(max(pageLimit, 1))") + Button { page = min(max(pageLimit - 1, 0), page + 1) } label: { + Image(systemName: "chevron.right") + }.disabled(page + 1 >= pageLimit) + } + }.font(.caption).foregroundStyle(.secondary) + HStack { + Text("Zoom") + Slider(value: $zoom, in: 0.5...2, step: 0.1).frame(width: 135) + Text("\(Int(zoom * 100))%").monospacedDigit() + Spacer() + if split { + Text("Before") + Slider(value: $fraction, in: 0...1) + .accessibilityLabel("Before after split position") + Text("After") + } + }.font(.caption) + GeometryReader { geometry in + let width = max(1, geometry.size.width) + let height = max(1, geometry.size.height) + if split { + ZStack(alignment: .leading) { + preview(output, name: "After").frame(width: width, height: height) + preview(source, name: "Before") + .frame(width: width, height: height) + .mask(alignment: .leading) { + Rectangle().frame(width: width * fraction) + } + Rectangle().fill(.primary.opacity(0.55)) + .frame(width: 1, height: height) + .offset(x: width * fraction) + } + .frame(width: width, height: height) + } else { + HStack(spacing: 12) { + preview(source, name: "Before") + preview(output, name: "After") + } + } + } + if pdf && (sourcePDF?.pageCount != outputPDF?.pageCount) { + Label("The two PDFs have different page counts. Check structure and text separately.", + systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.orange) + } + Text("Read-only preview; files are not recompressed or changed. PDFs show rasterized page previews, not selectable text validation.") + .font(.caption2).foregroundStyle(.secondary) + } + .padding(20).frame(minWidth: 720, minHeight: 480) + } + + @ViewBuilder private func preview(_ url: URL, name: String) -> some View { + VStack(spacing: 6) { + Text(name).font(.headline) + GeometryReader { geo in + ZStack { + RoundedRectangle(cornerRadius: 5) + .fill(Color(nsColor: .textBackgroundColor)) + if let image = thumbnail(url, maxDimension: 1600) { + Image(nsImage: image) + .resizable().interpolation(.high).scaledToFit() + .scaleEffect(zoom) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .accessibilityLabel("\(name) preview, \(url.lastPathComponent)") + } else { + ContentUnavailableView("Preview unavailable", + systemImage: "doc.questionmark", + description: Text("The file is missing, unsupported, encrypted, or unreadable.")) + } + }.clipShape(RoundedRectangle(cornerRadius: 5)) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func thumbnail(_ url: URL, maxDimension: Int) -> NSImage? { + if pdf { + guard let doc = PDFDocument(url: url), + page < doc.pageCount, let pdfPage = doc.page(at: page) else { return nil } + return pdfPage.thumbnail(of: NSSize(width: maxDimension, height: maxDimension), + for: .mediaBox) + } + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(src) > 0, + let image = CGImageSourceCreateThumbnailAtIndex(src, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + kCGImageSourceCreateThumbnailWithTransform: true + ] as CFDictionary) else { return nil } + return NSImage(cgImage: image, size: .zero) + } + private func bytes(_ url: URL) -> Int? { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.intValue + } + private func sizeString(_ url: URL) -> String { + guard let count = bytes(url) else { return "Unavailable" } + return ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file) + } +} diff --git a/Sources/App/Duplicates/DuplicateScanner.swift b/Sources/App/Duplicates/DuplicateScanner.swift new file mode 100644 index 0000000..ea347b4 --- /dev/null +++ b/Sources/App/Duplicates/DuplicateScanner.swift @@ -0,0 +1,270 @@ +import AppKit +import SwiftUI +import CryptoKit + +struct DuplicateGroup: Identifiable, Sendable { + let id: String + let size: UInt64 + let members: [OutputIdentity] + var reclaimable: UInt64 { size * UInt64(max(0, members.count - 1)) } +} + +/// Read-only scan: files are grouped by exact byte size and then full SHA-256. +/// Symlinks, packages and file references already pointing to the same inode +/// are deliberately excluded. No file is removed during scanning. +enum DuplicateScanner { + static func scan(_ roots: [URL], isCancelled: @escaping @Sendable () -> Bool, + progress: @escaping @Sendable (Int) -> Void) throws -> [DuplicateGroup] { + let fm = FileManager.default + let rootPaths = Set(roots.map { $0.standardizedFileURL.path }) + var candidates: [UInt64: [URL]] = [:] + var seenPaths = Set() + var seenInodes = Set() + var visited = 0 + for root in roots { + if isCancelled() { throw CancellationError() } + var isDir: ObjCBool = false + guard fm.fileExists(atPath: root.path, isDirectory: &isDir) else { continue } + let files: [URL] + if !isDir.boolValue { + files = [root] + } else { + guard let en = fm.enumerator(at: root, includingPropertiesForKeys: + [.isRegularFileKey, .isSymbolicLinkKey, .isPackageKey, .fileSizeKey], + options: [.skipsPackageDescendants, .skipsHiddenFiles]) else { continue } + var gathered: [URL] = [] + while let next = en.nextObject() as? URL { + if isCancelled() { throw CancellationError() } + if gathered.count >= 100_000 { + throw UndoError.unsafe("Scan exceeds 100,000 entries. Select a smaller folder.") + } + gathered.append(next) + } + files = gathered + } + for file in files { + if isCancelled() { throw CancellationError() } + let path = file.standardizedFileURL.path + guard seenPaths.insert(path).inserted, + let vals = try? file.resourceValues(forKeys: [ + .isRegularFileKey, .isSymbolicLinkKey, .isPackageKey, .fileSizeKey + ]), + vals.isRegularFile == true, vals.isSymbolicLink != true, + vals.isPackage != true, + let attrs = try? fm.attributesOfItem(atPath: path), + let dev = attrs[.systemNumber] as? NSNumber, + let inode = attrs[.systemFileNumber] as? NSNumber, + let size = attrs[.size] as? NSNumber else { continue } + let identity = "\(dev.uint64Value):\(inode.uint64Value)" + guard seenInodes.insert(identity).inserted else { continue } + candidates[size.uint64Value, default: []].append(file) + visited += 1 + if visited % 32 == 0 { progress(visited) } + } + } + _ = rootPaths + var groups: [DuplicateGroup] = [] + for (size, matches) in candidates where matches.count > 1 { + if isCancelled() { throw CancellationError() } + var byHash: [String: [OutputIdentity]] = [:] + for url in matches { + if isCancelled() { throw CancellationError() } + guard let proof = try? OutputIdentity.capture(url) else { continue } + byHash[proof.sha256, default: []].append(proof) + } + for (_, proofs) in byHash where proofs.count > 1 { + // Distinct file identities were checked before grouping. + groups.append(DuplicateGroup(id: UUID().uuidString, size: size, + members: proofs.sorted { $0.path < $1.path })) + } + } + return groups.sorted { $0.reclaimable > $1.reclaimable } + } +} + +@MainActor +private final class DuplicateModel: ObservableObject { + @Published var groups: [DuplicateGroup] = [] + @Published var visited = 0 + @Published var running = true + @Published var message = "Scanning selected folders…" + let token = CancellationFlag() + + func begin(_ roots: [URL]) { + let cancellation = token + Task.detached(priority: .utility) { [weak self] in + do { + let groups = try DuplicateScanner.scan(roots, + isCancelled: { cancellation.isCancelled }, + progress: { [weak self] count in + Task { await self?.recordProgress(count) } + }) + await self?.finished(groups) + } catch { + await self?.failed(error) + } + } + } + + private func recordProgress(_ count: Int) { visited = count } + private func finished(_ result: [DuplicateGroup]) { + groups = result + message = result.isEmpty ? "No exact duplicates found." : + "Found \(result.count) exact-content duplicate group(s)." + running = false + } + private func failed(_ error: Error) { + message = error is CancellationError ? "Scan cancelled." : error.localizedDescription + running = false + } +} + +@MainActor +enum DuplicateWindow { + private static var windows: [UUID: NSWindow] = [:] + static func show(paths: [String]) { + guard !paths.isEmpty else { return } + let id = UUID() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 650), + styleMask: [.titled, .resizable, .closable, .miniaturizable], + backing: .buffered, defer: false) + window.title = "Find Exact Duplicates" + window.contentViewController = NSHostingController(rootView: + DuplicatesView(roots: paths.map { URL(fileURLWithPath: $0) })) + window.center() + window.isReleasedWhenClosed = false + windows[id] = window + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, + object: window, queue: .main) { _ in + MainActor.assumeIsolated { _ = windows.removeValue(forKey: id) } + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +@MainActor +private struct DuplicatesView: View { + let roots: [URL] + @StateObject private var model = DuplicateModel() + @State private var selected = Set() + @State private var filter = "" + @State private var errorText = "" + @State private var showError = false + @State private var confirmTrash = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Exact Duplicates").font(.title2.weight(.semibold)) + Text(model.message).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if model.running { + ProgressView() + Text("\(model.visited) files").font(.caption).monospacedDigit() + Button("Cancel") { model.token.cancel() } + } + } + if !model.running && !model.groups.isEmpty { + HStack { + TextField("Filter paths", text: $filter) + Text("Potential savings: \(savingsLabel)") + .font(.caption).foregroundStyle(.secondary) + Button("Move Selected to Trash…") { confirmTrash = true } + .disabled(selected.isEmpty) + } + List { + ForEach(model.groups) { group in + Section("\(group.members.count) files · \(ByteCountFormatter.string(fromByteCount: Int64(group.size), countStyle: .file)) each") { + ForEach(group.members.filter { + filter.isEmpty || $0.path.localizedCaseInsensitiveContains(filter) + }, id: \.path) { proof in + HStack { + Toggle("", isOn: Binding( + get: { selected.contains(proof.path) }, + set: { value in + if value { selected.insert(proof.path) } + else { selected.remove(proof.path) } + } + )).labelsHidden() + Text(proof.path).font(.callout).lineLimit(2) + .textSelection(.enabled) + Spacer() + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([ + URL(fileURLWithPath: proof.path)]) + } + } + } + } + } + } + } else if !model.running { + ContentUnavailableView("Nothing to review", systemImage: "doc.on.doc", + description: Text(model.message)) + } + Text("Exact full-file SHA-256, not filename similarity. Skips symlinks and packages. Select only disposable copies for Trash; Tuck never chooses deletions automatically.") + .font(.caption2).foregroundStyle(.secondary) + } + .padding(20).frame(minWidth: 680, minHeight: 430) + .task { model.begin(roots) } + .confirmationDialog("Move \(selected.count) verified duplicate copies to Trash?", + isPresented: $confirmTrash) { + Button("Move Selected to Trash", role: .destructive) { + Task { + do { try await removeSelected() } + catch { errorText = error.localizedDescription; showError = true } + } + } + } message: { + Text("Tuck will refuse to remove every member of a group, and will recheck identities before moving anything.") + } + .alert("Duplicate cleanup refused", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { Text(errorText) } + } + + private var savingsLabel: String { + let bytes = model.groups.reduce(UInt64(0)) { $0 + $1.reclaimable } + return ByteCountFormatter.string(fromByteCount: Int64(clamping: bytes), countStyle: .file) + } + + private func removeSelected() async throws { + let marked = selected + let groups = model.groups + // Never trash every member in any group. + for group in groups { + guard group.members.filter({ marked.contains($0.path) }).count < group.members.count else { + throw UndoError.unsafe("At least one copy in every group must remain unselected.") + } + } + let proofs = groups.flatMap(\.members).filter { marked.contains($0.path) } + guard !proofs.isEmpty else { return } + // Full-file identity hashing and Trash I/O run away from the UI actor. + let removed = try await Task.detached(priority: .userInitiated) { + for proof in proofs { try proof.verifyUnchanged() } + var moved: [String] = [] + for proof in proofs { + do { + try proof.verifyUnchanged() + try FileManager.default.trashItem( + at: URL(fileURLWithPath: proof.path), resultingItemURL: nil) + moved.append(proof.path) + } catch { + throw UndoError.unsafe( + "Moved \(moved.count) item(s) to Trash before an error. Refresh results and inspect Trash: \(error.localizedDescription)") + } + } + return moved + }.value + selected.subtract(removed) + let removedSet = Set(removed) + model.groups = groups.compactMap { group in + let remaining = group.members.filter { !removedSet.contains($0.path) } + return remaining.count > 1 + ? DuplicateGroup(id: group.id, size: group.size, members: remaining) : nil + } + } +} diff --git a/Sources/App/ZIP/ZIPContentsView.swift b/Sources/App/ZIP/ZIPContentsView.swift new file mode 100644 index 0000000..445e22e --- /dev/null +++ b/Sources/App/ZIP/ZIPContentsView.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +@MainActor +enum ZIPWindow { + private static var windows: [UUID: NSWindow] = [:] + static func show(paths: [String]) { + guard let path = paths.first else { return } + let id = UUID() + let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 880, height: 650), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false) + win.title = "Inspect ZIP — \(URL(fileURLWithPath: path).lastPathComponent)" + win.contentViewController = NSHostingController(rootView: + ZIPContentsView(archive: URL(fileURLWithPath: path))) + win.center() + win.isReleasedWhenClosed = false + windows[id] = win + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, + object: win, queue: .main) { _ in + MainActor.assumeIsolated { _ = windows.removeValue(forKey: id) } + } + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +@MainActor +private struct ZIPContentsView: View { + let archive: URL + @State private var entries: [ZipEntry] = [] + @State private var selected: Set = [] + @State private var filter = "" + @State private var message = "Inspecting ZIP directory…" + @State private var loading = true + @State private var jobId: UUID? + @State private var latestOutput: URL? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("ZIP Contents").font(.title2.weight(.semibold)) + Text(message).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if loading { ProgressView() } + if let jobId { + Button("Cancel extraction") { JobManager.shared.cancel(jobId: jobId) } + } + } + if !entries.isEmpty { + HStack { + TextField("Search files", text: $filter) + Button("Extract Selected…") { chooseFolder(selectedOnly: true) } + .disabled(selected.isEmpty || jobId != nil) + Button("Extract All…") { chooseFolder(selectedOnly: false) } + .disabled(jobId != nil) + } + List { + ForEach(entries.filter { + filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter) + }) { entry in + HStack { + Toggle("", isOn: Binding( + get: { selected.contains(entry.name) }, + set: { on in if on { selected.insert(entry.name) } + else { selected.remove(entry.name) } } + )).labelsHidden() + Image(systemName: entry.isDirectory ? "folder" : "doc") + Text(entry.name).textSelection(.enabled).lineLimit(2) + Spacer() + Text(ByteCountFormatter.string(fromByteCount: Int64(entry.uncompressed), + countStyle: .file)) + .foregroundStyle(.secondary).monospacedDigit() + } + } + } + HStack { + Text("\(entries.count) entries") + Spacer() + Text("Selection: \(selected.count)") + if let latestOutput { + Button("Reveal Extracted Folder") { + NSWorkspace.shared.activateFileViewerSelecting([latestOutput]) + } + } + }.font(.caption).foregroundStyle(.secondary) + } else if !loading { + ContentUnavailableView("No entries to display", systemImage: "archivebox", + description: Text(message)) + } + Text("Safety limits: ZIP/Deflate only, no encrypted entries or symlinks, 2 GB maximum expanded data, 10,000 entries. Originals remain unchanged. Extraction uses a private staging folder and publishes only when validated.") + .font(.caption2).foregroundStyle(.secondary) + } + .padding(20).frame(minWidth: 680, minHeight: 450) + .task { + do { + let found = try await Task.detached(priority: .utility) { + try ZipInspector.inspect(archive) + }.value + entries = found + message = "\(found.count) safe ZIP entries available." + } catch { message = error.localizedDescription } + loading = false + } + } + + private func chooseFolder(selectedOnly: Bool) { + let panel = NSOpenPanel() + panel.message = "Select where Tuck should create a new extracted folder" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let destination = panel.url else { return } + let names = selectedOnly ? selected : nil + let id = JobManager.shared.enqueue( + title: "Extract ZIP", operation: "zipExtract", paths: [archive.path], canUndo: false, + onComplete: { record in + jobId = nil + message = record.progressDetail + if let path = record.outputs.first { latestOutput = URL(fileURLWithPath: path) } + }, + executor: { cancel, progress in + progress(0.1, "Validating archive…") + let output = try ZipInspector.extract( + archive: archive, destinationFolder: destination, selected: names, + shouldCancel: { cancel.isCancelled }) + progress(1, "Extraction completed") + return JobOutcome(outputs: [output]) + } + ) + jobId = id + message = "Extraction queued in Activity…" + } +} diff --git a/Sources/App/ZIP/ZipInspector.swift b/Sources/App/ZIP/ZipInspector.swift new file mode 100644 index 0000000..da71b65 --- /dev/null +++ b/Sources/App/ZIP/ZipInspector.swift @@ -0,0 +1,211 @@ +import Foundation + +public struct ZipEntry: Identifiable, Sendable { + public let name: String + public let uncompressed: UInt64 + public let compressed: UInt64 + public let isDirectory: Bool + public var id: String { name } +} + +/// ZIP central-directory reader; inspects only bounded metadata. +/// Encrypted, ZIP64, symlink, dangerous pathname and oversized payloads are +/// rejected *before* any extraction subprocess touches the destination. +public enum ZipInspector { + public enum Invalid: LocalizedError { + case malformed(String) + public var errorDescription: String? { + switch self { case .malformed(let text): text } + } + } + + private static let maxEntries = 10_000 + private static let maxCentralBytes = 16 * 1024 * 1024 + private static let maxExpandedBytes: UInt64 = 2_000_000_000 + private static let maxEntryBytes: UInt64 = 512_000_000 + + private static func uint(_ bytes: [UInt8], _ offset: Int, _ width: Int) -> UInt64? { + guard offset >= 0, width > 0, offset <= bytes.count - width else { return nil } + var value: UInt64 = 0 + for index in 0.. [ZipEntry] { + guard url.pathExtension.lowercased() == "zip" else { + throw Invalid.malformed("Only .zip archives are supported.") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let end = try handle.seekToEnd() + guard end >= 22 else { throw Invalid.malformed("Archive is too small for ZIP.") } + let tailLength = Int(min(end, 65_557)) + try handle.seek(toOffset: end - UInt64(tailLength)) + let tail = [UInt8](try handle.read(upToCount: tailLength) ?? Data()) + guard tail.count == tailLength else { throw Invalid.malformed("Cannot read ZIP footer.") } + var eocd: Int? + if tail.count >= 22 { + for index in stride(from: tail.count - 22, through: 0, by: -1) { + if uint(tail, index, 4) == 0x06054b50, + let comment = uint(tail, index + 20, 2), + index + 22 + Int(comment) == tail.count { + eocd = index + break + } + } + } + guard let eocd, + let disk = uint(tail, eocd + 4, 2), disk == 0, + let centralDisk = uint(tail, eocd + 6, 2), centralDisk == 0, + let diskEntries = uint(tail, eocd + 8, 2), + let entries = uint(tail, eocd + 10, 2), entries == diskEntries, + let length = uint(tail, eocd + 12, 4), + let start = uint(tail, eocd + 16, 4) else { + throw Invalid.malformed("Invalid, incomplete, or multi-disk ZIP central directory.") + } + guard entries <= UInt64(maxEntries), length <= UInt64(maxCentralBytes), + start <= end, length <= end - start, + entries != 0xFFFF, length != 0xFFFF_FFFF, start != 0xFFFF_FFFF else { + throw Invalid.malformed("ZIP64 or oversized archives are not supported for safe extraction.") + } + try handle.seek(toOffset: start) + let metadata = [UInt8](try handle.read(upToCount: Int(length)) ?? Data()) + guard metadata.count == Int(length) else { + throw Invalid.malformed("ZIP central directory is truncated.") + } + + var result: [ZipEntry] = [] + var unique = Set() + var position = 0 + var expanded: UInt64 = 0 + for _ in 0..> 16) & 0xF000 + guard mode != 0xA000 else { + throw Invalid.malformed("ZIP contains symbolic links; extraction refused.") + } + guard compressed != 0xFFFF_FFFF, uncompressed != 0xFFFF_FFFF, + uncompressed <= maxEntryBytes, + compressed > 0 || uncompressed == 0, + compressed == 0 || uncompressed <= compressed * 200, + expanded <= maxExpandedBytes - uncompressed else { + throw Invalid.malformed("Entry size or expansion ratio exceeds the extraction safety limit.") + } + expanded += uncompressed + let comparison = name.precomposedStringWithCanonicalMapping.folding( + options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) + guard unique.insert(comparison).inserted else { + throw Invalid.malformed("Archive contains colliding or duplicated filenames.") + } + result.append(ZipEntry(name: name, uncompressed: uncompressed, + compressed: compressed, isDirectory: name.hasSuffix("/"))) + position = endOfRecord + } + guard result.count == entries else { throw Invalid.malformed("Incomplete ZIP directory.") } + return result + } + + private static func validatePath(_ name: String) throws { + guard !name.hasPrefix("/"), !name.hasPrefix("\\"), + !name.contains("\\"), !name.contains("\0"), !name.contains(":"), + !name.contains("//"), name.utf8.count <= 1024, + !name.split(separator: "/").contains(where: { $0 == ".." || $0 == "." }), + !name.hasPrefix("~") else { + throw Invalid.malformed("ZIP includes an unsafe path and cannot be extracted.") + } + } + + /// Extract only preflight-validated entries into a fresh private staging + /// directory beside the destination. The destination folder is published + /// only after validation of the extracted file tree. + public static func extract(archive: URL, destinationFolder: URL, + selected: Set?, + shouldCancel: @escaping @Sendable () -> Bool = { false }) throws -> URL { + let listing = try inspect(archive) + let picked = listing.filter { selected == nil || selected!.contains($0.name) } + guard !picked.isEmpty else { throw Invalid.malformed("No ZIP entries selected.") } + guard !shouldCancel() else { throw CancellationError() } + let fm = FileManager.default + let stage = destinationFolder.appendingPathComponent( + ".tuck-zip-stage-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stage, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + defer { try? fm.removeItem(at: stage) } + let tool = Tool(name: "unzip", url: URL(fileURLWithPath: "/usr/bin/unzip")) + // The ZIP location is absolute. Arguments are passed individually, + // never interpolated into a shell command. + let arguments = ["-qq", "-n", archive.path] + picked.map(\.name) + ["-d", stage.path] + do { + try Toolchain.run(tool, arguments, timeout: 600, shouldCancel: shouldCancel) + } catch ToolError.cancelled { + throw CancellationError() + } + if shouldCancel() { throw CancellationError() } + let allowed = Set(picked.map(\.name)) + var extractedSize: UInt64 = 0 + guard let enumerator = fm.enumerator(at: stage, includingPropertiesForKeys: + [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey], options: []) else { + throw Invalid.malformed("Cannot inspect extracted files.") + } + while let file = enumerator.nextObject() as? URL { + let vals = try file.resourceValues(forKeys: + [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]) + guard vals.isSymbolicLink != true else { + throw Invalid.malformed("Unexpected symbolic link after ZIP extraction.") + } + let rootComponents = stage.standardizedFileURL.pathComponents + let fileComponents = file.standardizedFileURL.pathComponents + guard fileComponents.starts(with: rootComponents) else { + throw Invalid.malformed("Extraction escaped the staging folder.") + } + let relative = fileComponents.dropFirst(rootComponents.count).joined(separator: "/") + if vals.isRegularFile == true { + guard allowed.contains(relative), + let attrs = try? fm.attributesOfItem(atPath: file.path), + let n = attrs[.size] as? NSNumber else { + throw Invalid.malformed("Extractor wrote unexpected or unreadable content.") + } + let bytes = n.uint64Value + guard bytes <= maxEntryBytes, extractedSize <= maxExpandedBytes - bytes else { + throw Invalid.malformed("Extracted ZIP content exceeds its declared safety limit.") + } + extractedSize += bytes + } else if vals.isDirectory != true { + throw Invalid.malformed("Extractor produced unsupported file type.") + } + } + let name = archive.deletingPathExtension().lastPathComponent + let output = destinationFolder.appendingPathComponent( + "\(name)-extracted-\(UUID().uuidString.prefix(8))", isDirectory: true) + try fm.moveItem(at: stage, to: output) + return output + } +} diff --git a/Tests/DuplicateTests.swift b/Tests/DuplicateTests.swift new file mode 100644 index 0000000..b7f7752 --- /dev/null +++ b/Tests/DuplicateTests.swift @@ -0,0 +1,41 @@ +import Foundation + +@main +struct DuplicateTests { + static func main() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent( + "tuck-duplicate-tests-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let first = root.appendingPathComponent("one.txt") + let second = root.appendingPathComponent("two.txt") + let different = root.appendingPathComponent("different.txt") + try Data("12345".utf8).write(to: first) + try Data("12345".utf8).write(to: second) + try Data("54321".utf8).write(to: different) + let link = root.appendingPathComponent("link.txt") + try fm.createSymbolicLink(at: link, withDestinationURL: first) + let groups = try DuplicateScanner.scan([root], isCancelled: { false }, progress: { _ in }) + assert(groups.count == 1) + assert(groups[0].members.count == 2) + let expectedNames = Set(["one.txt", "two.txt"]) + assert(Set(groups[0].members.map { URL(fileURLWithPath: $0.path).lastPathComponent }) == expectedNames) + assert(groups[0].reclaimable == 5) + + // A changed file is no longer authorized by its old scan evidence. + let proof = groups[0].members.first! + try Data("new version".utf8).write(to: URL(fileURLWithPath: proof.path)) + var refused = false + do { try proof.verifyUnchanged() } + catch { refused = true } + assert(refused) + var cancelled = false + do { + _ = try DuplicateScanner.scan([root], isCancelled: { true }, progress: { _ in }) + } catch is CancellationError { cancelled = true } + assert(cancelled) + print("DuplicateTests passed") + } +} diff --git a/Tests/ZipInspectorTests.swift b/Tests/ZipInspectorTests.swift new file mode 100644 index 0000000..801a4f4 --- /dev/null +++ b/Tests/ZipInspectorTests.swift @@ -0,0 +1,54 @@ +import Foundation + +@main +struct ZipInspectorTests { + static func main() throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent( + "tuck-zip-inspect-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: tmp) } + let a = tmp.appendingPathComponent("first file.txt") + let b = tmp.appendingPathComponent("second.txt") + try Data("first".utf8).write(to: a) + try Data("second".utf8).write(to: b) + let archive = tmp.appendingPathComponent("test.zip") + try Toolchain.run(Tool(name: "zip", url: URL(fileURLWithPath: "/usr/bin/zip")), + ["-q", archive.path, a.lastPathComponent, b.lastPathComponent], + timeout: 15, workingDirectory: tmp) + let items = try ZipInspector.inspect(archive) + assert(items.count == 2 && items.map(\.name).contains("first file.txt")) + let exported = try ZipInspector.extract(archive: archive, destinationFolder: tmp, + selected: ["second.txt"]) + assert(fm.fileExists(atPath: exported.appendingPathComponent("second.txt").path)) + assert(!fm.fileExists(atPath: exported.appendingPathComponent("first file.txt").path)) + let content = try String(contentsOf: exported.appendingPathComponent("second.txt"), + encoding: .utf8) + assert(content == "second") + let exported2 = try ZipInspector.extract(archive: archive, destinationFolder: tmp, + selected: nil) + assert(exported != exported2 && fm.fileExists(atPath: exported2.appendingPathComponent("first file.txt").path)) + + // Change central entry name into a parent-directory traversal. No + // extraction should begin even if an external tool would normalize it. + var corrupted = [UInt8](try Data(contentsOf: archive)) + guard let cd = (0..= malicious.count { + corrupted.replaceSubrange(start..<(start + malicious.count), with: malicious) + let attack = tmp.appendingPathComponent("unsafe.zip") + try Data(corrupted).write(to: attack) + var rejected = false + do { _ = try ZipInspector.inspect(attack) } + catch { rejected = true } + assert(rejected, "Traversal must be refused before extraction.") + } + print("ZipInspectorTests passed") + } +} From 4e0a57b7d4c3146f1144066a491b54b7315763e1 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:24:50 +0530 Subject: [PATCH 13/20] Integrate ZIP, duplicate and comparison UX with saved Finder destinations --- Sources/Shared/Actions.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/Actions.swift b/Sources/Shared/Actions.swift index 34a4a96..14a6660 100644 --- a/Sources/Shared/Actions.swift +++ b/Sources/Shared/Actions.swift @@ -2,7 +2,7 @@ import Foundation enum TuckAction: String, CaseIterable, Identifiable { case newFile, openWith, compress, terminal - case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations + case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations, duplicates, zipInspector var id: String { rawValue } var title: String { @@ -17,6 +17,8 @@ enum TuckAction: String, CaseIterable, Identifiable { case .copyAs: "Copy As" case .rename: "Advanced Rename" case .destinations: "Copy / Move To" + case .duplicates: "Find Duplicates" + case .zipInspector: "Inspect ZIP" } } @@ -32,15 +34,17 @@ enum TuckAction: String, CaseIterable, Identifiable { case .copyAs: "doc.on.clipboard" case .rename: "textformat" case .destinations: "folder.badge.plus" + case .duplicates: "square.on.square" + case .zipInspector: "archivebox" } } } enum TuckContext: String, CaseIterable, Identifiable { - case desktop, folder, image, pdf, mixed, other + case desktop, folder, image, pdf, mixed, archive, other var id: String { rawValue } var title: String { rawValue.capitalized } - var hasSelection: Bool { self == .image || self == .pdf || self == .mixed || self == .other } + var hasSelection: Bool { self == .image || self == .pdf || self == .mixed || self == .archive || self == .other } } enum TuckMenuModel { @@ -69,6 +73,10 @@ enum TuckMenuModel { return context.hasSelection case .destinations: return context.hasSelection + case .duplicates: + return context == .other + case .zipInspector: + return context == .archive } } } From 68074fa7a3ce1cf268b91a2dfe57e3daa2e7ecb4 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:24:53 +0530 Subject: [PATCH 14/20] Integrate ZIP, duplicate and comparison UX with saved Finder destinations --- Sources/Shared/Preferences.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shared/Preferences.swift b/Sources/Shared/Preferences.swift index 8c24268..d1a88a0 100644 --- a/Sources/Shared/Preferences.swift +++ b/Sources/Shared/Preferences.swift @@ -24,7 +24,7 @@ enum TuckPreferences { static let defaults: [String] = [ "newFile", "openWith", "compress", "terminal", - "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations" + "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector" ] static var order: [String] { From 5ace9e572cc3d6c19ec3426bc5127c9b132ff479 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:24:56 +0530 Subject: [PATCH 15/20] Integrate ZIP, duplicate and comparison UX with saved Finder destinations --- Sources/App/SettingsView.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 4210279..fcda3e0 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -176,6 +176,10 @@ struct SettingsView: View { Button(action.title) { lastPreview = "Advanced Rename → Batch Pattern Rename" } case .destinations: Button(action.title) { lastPreview = "Copy or Move to a saved destination…" } + case .duplicates: + Button(action.title) { lastPreview = "Find exact duplicates in selected folders" } + case .zipInspector: + Button(action.title) { lastPreview = "Browse or extract ZIP entries" } } } @@ -321,6 +325,18 @@ struct SettingsView: View { } .buttonStyle(.bordered).controlSize(.small) } + if job.items.count == 1, job.outputs.count == 1, + !job.isUndone, (job.operation == "compress" || job.operation == "compressCustom"), + let input = job.items.first?.inputPath, + let output = job.outputs.first, + ComparisonWindow.supported(source: URL(fileURLWithPath: input), + output: URL(fileURLWithPath: output)) { + Button("Compare") { + ComparisonWindow.show(source: URL(fileURLWithPath: input), + output: URL(fileURLWithPath: output)) + } + .buttonStyle(.bordered).controlSize(.small) + } if !job.outputs.isEmpty && !job.isUndone { Button("Reveal Output") { NSWorkspace.shared.activateFileViewerSelecting( From f724f6571c5a68889f59e8069dacf945fd9ee7a5 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:24:59 +0530 Subject: [PATCH 16/20] Integrate ZIP, duplicate and comparison UX with saved Finder destinations --- Sources/App/Main.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/App/Main.swift b/Sources/App/Main.swift index 62393f1..9370c65 100644 --- a/Sources/App/Main.swift +++ b/Sources/App/Main.swift @@ -166,6 +166,11 @@ final class TuckApplication: NSObject, NSApplicationDelegate { let fmt = request.argument.flatMap(CopyAsFormat.init(rawValue:)) ?? .absolutePath runCopyAs(paths: request.paths, format: fmt) + case "findDuplicates": + DuplicateWindow.show(paths: request.paths) + case "inspectZIP": + ZIPWindow.show(paths: request.paths) + case "copyTo", "moveTo": runTransfer(paths: request.paths, argument: request.argument, mode: request.operation == "moveTo" ? .move : .copy) From a274db16b4f2a5f93ed4264c701e2fde4621651c Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:25:03 +0530 Subject: [PATCH 17/20] Integrate ZIP, duplicate and comparison UX with saved Finder destinations --- Sources/Extension/FinderSync.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Sources/Extension/FinderSync.swift b/Sources/Extension/FinderSync.swift index 3038795..084ac2e 100644 --- a/Sources/Extension/FinderSync.swift +++ b/Sources/Extension/FinderSync.swift @@ -118,6 +118,8 @@ final class TuckFinderSync: FIFinderSync { let imageExts = ["jpg", "jpeg", "png", "heic", "tif", "tiff", "bmp", "webp", "gif"] if kind == .contextualMenuForContainer { context = target?.standardizedFileURL.path == FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop").path ? .desktop : .folder + } else if !selection.isEmpty && selection.allSatisfy({ $0.pathExtension.lowercased() == "zip" }) { + context = .archive } else if !selection.isEmpty && selection.allSatisfy({ $0.pathExtension.lowercased() == "pdf" }) { context = .pdf } else if !selection.isEmpty && selection.allSatisfy({ imageExts.contains($0.pathExtension.lowercased()) }) { @@ -153,6 +155,10 @@ final class TuckFinderSync: FIFinderSync { item.action = #selector(advancedRename(_:)) case .destinations: item.submenu = makeDestinationMenu() + case .duplicates: + item.action = #selector(findDuplicates(_:)) + case .zipInspector: + item.action = #selector(inspectZIP(_:)) } root.addItem(item) } @@ -497,6 +503,20 @@ final class TuckFinderSync: FIFinderSync { dispatch(copy ? "copyTo" : "moveTo", paths: selection.map(\.path), argument: favorite) } + @objc func findDuplicates(_ sender: NSMenuItem) { + let (selection, _) = resolveContext() + let folders = selection.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + if !folders.isEmpty { dispatch("findDuplicates", paths: folders.map(\.path)) } + } + + @objc func inspectZIP(_ sender: NSMenuItem) { + let (selection, _) = resolveContext() + let archives = selection.filter { $0.pathExtension.lowercased() == "zip" } + if !archives.isEmpty { dispatch("inspectZIP", paths: archives.map(\.path)) } + } + private func dispatch(_ operation: String, paths: [String], argument: String? = nil) { if paths.count > 256 { DispatchQueue.main.async { From 7b3519b109e6524f01c0159af3af8b76aa8b824e Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:25:50 +0530 Subject: [PATCH 18/20] Remove Swift 6 non-Sendable global FileManager state from transfers --- Sources/App/Favorites/FileTransfer.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/App/Favorites/FileTransfer.swift b/Sources/App/Favorites/FileTransfer.swift index 35a06d9..470fea0 100644 --- a/Sources/App/Favorites/FileTransfer.swift +++ b/Sources/App/Favorites/FileTransfer.swift @@ -14,9 +14,9 @@ public struct TransferPartialFailure: LocalizedError, Sendable { /// copies are first constructed at an unguessable staging location on the /// destination volume. No existing destination is ever overwritten. public enum FileTransfer { - private static let fm = FileManager.default public static func destination(for source: URL, in directory: URL) throws -> URL { + let fm = FileManager.default let parent = directory.standardizedFileURL guard (try parent.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true else { throw UndoError.unsafe("Destination is not an accessible folder.") @@ -62,6 +62,7 @@ public enum FileTransfer { private static func moveOrCopy(_ source: URL, to folder: URL, mode: TransferMode, isCancelled: @escaping @Sendable () -> Bool) throws -> URL { + let fm = FileManager.default guard !isCancelled() else { throw CancellationError() } let src = source.standardizedFileURL let dst = folder.standardizedFileURL @@ -125,6 +126,7 @@ public enum FileTransfer { /// Each regular file is fully hashed. Directory manifests include relative /// paths, sizes and hashes, so same-size but different-content trees differ. private static func treeProof(_ url: URL) throws -> String { + let fm = FileManager.default let attrs = try fm.attributesOfItem(atPath: url.path) let kind = attrs[.type] as? FileAttributeType if kind == .typeRegular { @@ -157,6 +159,7 @@ public enum FileTransfer { } private static func verifyNoSymlinks(_ root: URL) throws { + let fm = FileManager.default guard let en = fm.enumerator(at: root, includingPropertiesForKeys: [.isSymbolicLinkKey], options: []) else { throw UndoError.unsafe("Cannot inspect source directory.") From f0520a129565c15e0c6e3a32631aa2a6496d2564 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:29:47 +0530 Subject: [PATCH 19/20] Update Finder action visibility regression for integrated tools --- Tests/MenuModelTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/MenuModelTests.swift b/Tests/MenuModelTests.swift index a6dbb87..43585ba 100644 --- a/Tests/MenuModelTests.swift +++ b/Tests/MenuModelTests.swift @@ -15,9 +15,11 @@ struct MenuModelTests { } TuckPreferences.order = ["terminal", "compress", "openWith", "newFile"] let visibleInImage = TuckMenuModel.visible(in: .image).map(\.id) - assert(visibleInImage == ["terminal", "compress", "openWith", "imageToolkit", "metadata", "copyAs", "rename"]) + assert(visibleInImage == ["terminal", "compress", "openWith", "imageToolkit", "metadata", "copyAs", "rename", "destinations"]) assert(!visibleInImage.contains("newFile")) assert(!visibleInImage.contains("pdfToolkit")) + assert(TuckMenuModel.visible(in: .archive).contains(.zipInspector)) + assert(TuckMenuModel.visible(in: .other).contains(.duplicates)) assert(!TuckMenuModel.visible(in: .folder).contains(where: { $0.id == "compress" })) TuckPreferences.setEnabled("terminal", to: false) assert(!TuckMenuModel.visible(in: .image).contains(where: { $0.id == "terminal" })) From e1b5e8b1c2ab0fc66620f8c0656e36389630909b Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:46:31 +0530 Subject: [PATCH 20/20] Verify surviving duplicate copies before any Trash operation --- Sources/App/Duplicates/DuplicateScanner.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/App/Duplicates/DuplicateScanner.swift b/Sources/App/Duplicates/DuplicateScanner.swift index ea347b4..28e7bc3 100644 --- a/Sources/App/Duplicates/DuplicateScanner.swift +++ b/Sources/App/Duplicates/DuplicateScanner.swift @@ -241,10 +241,13 @@ private struct DuplicatesView: View { } } let proofs = groups.flatMap(\.members).filter { marked.contains($0.path) } + let survivors = groups.flatMap(\.members).filter { !marked.contains($0.path) } guard !proofs.isEmpty else { return } - // Full-file identity hashing and Trash I/O run away from the UI actor. + // Verify at least one untouched surviving copy in each group; a file + // deleted or changed since scanning must not be counted as a backup. + // Full-file hashing and Trash I/O run off the main actor. let removed = try await Task.detached(priority: .userInitiated) { - for proof in proofs { try proof.verifyUnchanged() } + for proof in proofs + survivors { try proof.verifyUnchanged() } var moved: [String] = [] for proof in proofs { do {