diff --git a/Sources/App/Automation/CustomActionRunner.swift b/Sources/App/Automation/CustomActionRunner.swift new file mode 100644 index 0000000..5eec687 --- /dev/null +++ b/Sources/App/Automation/CustomActionRunner.swift @@ -0,0 +1,84 @@ +import Foundation + +enum CustomActionRunner { + enum ActionFailure: LocalizedError { + case missing + case incompatible + case cancelled + case invalidExecutable + case failed(String) + var errorDescription: String? { + switch self { + case .missing: "The action was removed or disabled." + case .incompatible: "The selection is not compatible with this action." + case .cancelled: "The custom action was cancelled." + case .invalidExecutable: "The executable is unavailable, not a regular file, or lacks execute permission." + case .failed(let message): message + } + } + } + + static func supports(_ action: CustomFinderAction, paths: [String]) -> Bool { + guard action.enabled, action.valid, !paths.isEmpty, paths.count <= 256 else { return false } + let fm = FileManager.default + for path in paths { + guard path.hasPrefix("/"), + let values = try? URL(fileURLWithPath: path).resourceValues( + forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]), + values.isSymbolicLink != true else { return false } + if action.selection == .files && values.isRegularFile != true { return false } + if action.selection == .folders && values.isDirectory != true { return false } + if values.isRegularFile != true && values.isDirectory != true { return false } + } + _ = fm + return true + } + + static func execute(_ action: CustomFinderAction, paths: [String], + shouldCancel: @escaping @Sendable () -> Bool) throws { + guard supports(action, paths: paths) else { throw ActionFailure.incompatible } + if shouldCancel() { throw CancellationError() } + + let tool: Tool + let args: [String] + switch action.kind { + case .executable: + let url = URL(fileURLWithPath: action.target).standardizedFileURL + guard url.path == action.target, + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, values.isSymbolicLink != true, + FileManager.default.isExecutableFile(atPath: url.path) else { + throw ActionFailure.invalidExecutable + } + tool = Tool(name: action.title, url: url) + args = paths // Each path is exactly one argv element, not shell-interpolated. + case .shortcut: + guard paths.count == 1 else { + throw ActionFailure.failed("macOS Shortcuts accepts a single input file per invocation. Select one item.") + } + tool = Tool(name: "shortcuts", url: URL(fileURLWithPath: "/usr/bin/shortcuts")) + args = ["run", action.target, "--input-path", paths[0]] + } + let home = FileManager.default.homeDirectoryForCurrentUser.path + let environment = [ + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": home, + "TMPDIR": NSTemporaryDirectory(), + "LANG": "en_US.UTF-8" + ] + do { + _ = try Toolchain.run(tool, args, timeout: 300, + shouldCancel: shouldCancel, + workingDirectory: URL(fileURLWithPath: home), + environment: environment) + } catch ToolError.cancelled { + throw CancellationError() + } catch ToolError.timedOut { + throw ActionFailure.failed("The action exceeded its five-minute time limit.") + } catch ToolError.failed(let status, _) { + // Never store arbitrary script output in durable history: it can + // contain tokens, paths, cookies, or user credentials. + throw ActionFailure.failed("The action exited with status \(status).") + } + } +} diff --git a/Sources/App/Automation/CustomActionsSettingsView.swift b/Sources/App/Automation/CustomActionsSettingsView.swift new file mode 100644 index 0000000..2ea0ce1 --- /dev/null +++ b/Sources/App/Automation/CustomActionsSettingsView.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI + +@MainActor +struct CustomActionsSettingsView: View { + @State private var actions = CustomActionPreferences.all + @State private var name = "" + @State private var target = "" + @State private var kind: CustomFinderAction.Kind = .executable + @State private var selection: CustomFinderAction.Selection = .files + @State private var needsConfirmation = true + @State private var errorText = "" + @State private var showingError = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Custom Actions").font(.largeTitle.weight(.semibold)) + Text("Only actions you explicitly configure can run. Scripts receive selected paths as separate arguments, never as a shell command.") + .font(.callout).foregroundStyle(.secondary) + GroupBox("Saved Actions") { + VStack(alignment: .leading, spacing: 10) { + if actions.isEmpty { + Text("No custom actions configured.") + .foregroundStyle(.secondary) + } + ForEach(actions) { item in + HStack { + Toggle(isOn: Binding( + get: { actions.first(where: { $0.id == item.id })?.enabled ?? false }, + set: { enabled in change(item.id) { $0.enabled = enabled } } + )) { + Text(item.title).fontWeight(.medium) + } + Spacer() + Text(item.kind == .shortcut ? "Shortcut" : "Executable") + .font(.caption).foregroundStyle(.secondary) + Text(item.selection.rawValue.capitalized) + .font(.caption).foregroundStyle(.secondary) + Button { + actions.removeAll { $0.id == item.id } + save() + } label: { Image(systemName: "minus.circle") } + .accessibilityLabel("Remove \(item.title)") + } + Text(item.target).lineLimit(1).truncationMode(.middle) + .font(.caption2).foregroundStyle(.secondary) + } + }.padding(8) + } + GroupBox("Add Action") { + VStack(alignment: .leading, spacing: 10) { + TextField("Action title", text: $name) + Picker("Kind", selection: $kind) { + Text("Executable").tag(CustomFinderAction.Kind.executable) + Text("macOS Shortcut").tag(CustomFinderAction.Kind.shortcut) + } + Picker("Selection", selection: $selection) { + Text("Files").tag(CustomFinderAction.Selection.files) + Text("Folders").tag(CustomFinderAction.Selection.folders) + Text("Files or folders").tag(CustomFinderAction.Selection.any) + } + HStack { + TextField(kind == .shortcut ? "Exact Shortcut name" : "Full executable path", text: $target) + if kind == .executable { + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK { target = panel.url?.path ?? "" } + } + } + } + Toggle("Always confirm before running", isOn: $needsConfirmation) + HStack { + Spacer() + Button("Add Action") { add() } + .disabled(actions.count >= 16 || name.isEmpty || target.isEmpty) + } + }.padding(10) + } + Text("Up to 16 user-created actions. The executable must have permission to run and must remain available. Scripts run for up to five minutes with restricted environment variables; they can still change your files, so only add code you trust.") + .font(.caption).foregroundStyle(.secondary) + } + .alert("Unable to save action", isPresented: $showingError) { + Button("OK", role: .cancel) {} + } message: { Text(errorText) } + } + + private func change(_ id: UUID, update: (inout CustomFinderAction) -> Void) { + guard let i = actions.firstIndex(where: { $0.id == id }) else { return } + update(&actions[i]) + save() + } + + private func add() { + let action = CustomFinderAction(id: UUID(), + title: name.trimmingCharacters(in: .whitespacesAndNewlines), + kind: kind, target: target, selection: selection, + requiresConfirmation: needsConfirmation, enabled: true) + guard action.valid else { + errorText = "Enter a nonempty name and either an absolute executable path or Shortcut name." + showingError = true + return + } + actions.append(action) + save() + if !showingError { name = ""; target = "" } + } + + private func save() { + do { try CustomActionPreferences.save(actions) } + catch { errorText = error.localizedDescription; showingError = true } + } +} diff --git a/Sources/App/Compression/Toolchain.swift b/Sources/App/Compression/Toolchain.swift index a8773f7..a426f68 100644 --- a/Sources/App/Compression/Toolchain.swift +++ b/Sources/App/Compression/Toolchain.swift @@ -104,7 +104,8 @@ enum Toolchain { static func run(_ tool: Tool, _ arguments: [String], timeout: TimeInterval = 300, shouldCancel: (() -> Bool)? = nil, - workingDirectory: URL? = nil) throws -> ToolResult { + workingDirectory: URL? = nil, + environment: [String: String]? = nil) throws -> ToolResult { guard (shouldCancel?() ?? false) == false else { throw ToolError.cancelled } let capture = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("tuck-tool-\(UUID().uuidString)", isDirectory: true) @@ -126,6 +127,7 @@ enum Toolchain { let process = Process() process.executableURL = tool.url process.arguments = arguments + if let environment { process.environment = environment } if let workingDirectory { process.currentDirectoryURL = workingDirectory } process.standardOutput = outHandle process.standardError = errHandle diff --git a/Sources/App/Main.swift b/Sources/App/Main.swift index 9370c65..e98c793 100644 --- a/Sources/App/Main.swift +++ b/Sources/App/Main.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Foundation +import PDFKit @main enum TuckEntry { @@ -136,6 +137,8 @@ final class TuckApplication: NSObject, NSApplicationDelegate { case "pdfRotate": let deg = request.argument.flatMap(Int.init) ?? 90 runPDFRotate(paths: request.paths, degrees: deg) + case "pdfReorder": + runPDFReorder(paths: request.paths) case "pdfOCR": runPDFOCR(paths: request.paths) case "pdfExtractImages": @@ -171,6 +174,9 @@ final class TuckApplication: NSObject, NSApplicationDelegate { case "inspectZIP": ZIPWindow.show(paths: request.paths) + case "customAction": + runCustomAction(paths: request.paths, argument: request.argument) + case "copyTo", "moveTo": runTransfer(paths: request.paths, argument: request.argument, mode: request.operation == "moveTo" ? .move : .copy) @@ -202,7 +208,10 @@ final class TuckApplication: NSObject, NSApplicationDelegate { window.dismiss() switch job.status { case .completed: - self?.report("Created \(job.outputs.count) output file(s). Open Settings → Activity to review results.", title: title) + let summary = job.outputs.isEmpty + ? "Action completed. Open Settings → Activity to review the record." + : "Created \(job.outputs.count) output file(s). Open Settings → Activity to review results." + self?.report(summary, title: title) case .partial: self?.report(job.progressDetail + "\nSee Activity for the failed filenames and reasons.", title: "\(title) partially completed") case .failed: @@ -351,14 +360,67 @@ final class TuckApplication: NSObject, NSApplicationDelegate { // MARK: - PDF Toolkit Handlers + private func promptOrder(labels: [String], title: String) -> [Int]? { + guard !labels.isEmpty else { return nil } + let alert = NSAlert() + alert.messageText = title + alert.informativeText = "Enter each 1-based index once, in your preferred order:\n" + + labels.prefix(16).enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n") + + (labels.count > 16 ? "\n… and \(labels.count - 16) more" : "") + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + let field = NSTextField(string: (1...labels.count).map(String.init).joined(separator: ",")) + field.placeholderString = "1,2,3" + field.frame = NSRect(x: 0, y: 0, width: 360, height: 24) + alert.accessoryView = field + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let tokens = field.stringValue.split(separator: ",", omittingEmptySubsequences: false) + let numbers = tokens.compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + guard numbers.count == labels.count, + numbers.sorted() == Array(1...labels.count) else { + report("List every index exactly once, separated by commas.", title: "Invalid Order") + return nil + } + return numbers.map { $0 - 1 } + } + private func runPDFMerge(paths: [String]) { + guard !paths.isEmpty else { return } let urls = paths.map { URL(fileURLWithPath: $0) } + let indices: [Int] + if paths.count > 1 { + guard let chosen = promptOrder(labels: urls.map(\.lastPathComponent), + title: "Arrange PDF Merge Inputs") else { return } + indices = chosen + } else { indices = [0] } + let ordered = indices.map { urls[$0] } queueOperation(title: "Merge into PDF", operation: "pdfMerge", paths: paths) { token, progress in - let merged = try PDFToolkit.merge(urls: urls, progress: progress, shouldCancel: { token.isCancelled }) + let merged = try PDFToolkit.merge(urls: ordered, progress: progress, + shouldCancel: { token.isCancelled }) return JobOutcome(outputs: [merged]) } } + private func runPDFReorder(paths: [String]) { + guard paths.count == 1, let path = paths.first else { + report("Select one PDF to reorder its pages.", title: "Reorder PDF") + return + } + let url = URL(fileURLWithPath: path) + guard let pdf = PDFDocument(url: url), pdf.pageCount > 0, + !pdf.isEncrypted, !pdf.isLocked else { + report("The PDF is locked, encrypted, or unreadable.", title: "Reorder PDF") + return + } + guard let order = promptOrder(labels: (1...pdf.pageCount).map { "Page \($0)" }, + title: "Reorder PDF Pages") else { return } + queueOperation(title: "Reorder PDF pages", operation: "pdfReorder", paths: [path]) { token, progress in + if token.isCancelled { throw CancellationError() } + let output = try PDFToolkit.reorder(url: url, pageOrder: order, progress: progress) + return JobOutcome(outputs: [output]) + } + } + private func runPDFSplit(paths: [String], customRange: Bool) { guard let first = paths.first else { return } let url = URL(fileURLWithPath: first) @@ -595,6 +657,33 @@ final class TuckApplication: NSObject, NSApplicationDelegate { title: "Copied to Clipboard") } + private func runCustomAction(paths: [String], argument: String?) { + guard let value = argument, let uuid = UUID(uuidString: value), + let action = CustomActionPreferences.all.first(where: { $0.id == uuid }), + CustomActionRunner.supports(action, paths: paths) else { + report("This custom action is missing, disabled, or incompatible with the selected items.", + title: "Custom Action Unavailable") + return + } + if action.requiresConfirmation { + let alert = NSAlert() + alert.messageText = "Run \(action.title)?" + alert.informativeText = "\(action.kind == .shortcut ? "Shortcut" : "Executable"): \(action.target)\nSelected: \(paths.count) item(s)\n\nOnly run executable code you trust." + alert.alertStyle = .warning + alert.addButton(withTitle: "Run Action") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + } + queueOperation(title: "Run \(action.title)", operation: "customAction", + paths: paths, canUndo: false) { token, progress in + progress(0, "Running user-configured action…") + try CustomActionRunner.execute(action, paths: paths, + shouldCancel: { token.isCancelled }) + progress(1, "Action completed") + return JobOutcome() + } + } + private func runTransfer(paths: [String], argument: String?, mode: TransferMode) { guard let argument else { return } let folder: URL @@ -645,31 +734,66 @@ final class TuckApplication: NSObject, NSApplicationDelegate { let alert = NSAlert() alert.messageText = "Advanced Batch Rename" - alert.informativeText = "Configure find and replace for selected filenames." + alert.informativeText = "Choose a rule. Find/Regex: pattern + replacement. Prefix/Suffix: prefix + suffix. Number: prefix + start,digits. Extension: new extension in the first field." alert.addButton(withTitle: "Preview") alert.addButton(withTitle: "Cancel") let stack = NSStackView() stack.orientation = .vertical - stack.spacing = 6 - let findField = NSTextField(string: "") - findField.placeholderString = "Find text" - let replaceField = NSTextField(string: "") - replaceField.placeholderString = "Replace with" - stack.addArrangedSubview(findField) - stack.addArrangedSubview(replaceField) - stack.frame = NSRect(x: 0, y: 0, width: 330, height: 60) + stack.spacing = 8 + let mode = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 340, height: 26), pullsDown: false) + mode.addItems(withTitles: ["Find & Replace", "Regex Replace", "Prefix / Suffix", + "Numbered Sequence", "Change Extension"]) + let first = NSTextField(string: "") + first.placeholderString = "Pattern / prefix / new extension" + let second = NSTextField(string: "") + second.placeholderString = "Replacement / suffix / start,digits" + stack.addArrangedSubview(mode) + stack.addArrangedSubview(first) + stack.addArrangedSubview(second) + stack.frame = NSRect(x: 0, y: 0, width: 340, height: 94) alert.accessoryView = stack NSApp.activate(ignoringOtherApps: true) guard alert.runModal() == .alertFirstButtonReturn else { return } - guard !findField.stringValue.isEmpty else { - report("Enter a nonempty search term before renaming.", title: "Invalid Rename") + let rule: RenameRule + switch mode.indexOfSelectedItem { + case 0: + guard !first.stringValue.isEmpty else { + report("Find text cannot be empty.", title: "Invalid Rename") + return + } + rule = RenameRule(op: .findReplace(find: first.stringValue, + replace: second.stringValue, isRegex: false)) + case 1: + do { _ = try NSRegularExpression(pattern: first.stringValue) } + catch { + report(error.localizedDescription, title: "Invalid Regular Expression") + return + } + rule = RenameRule(op: .findReplace(find: first.stringValue, + replace: second.stringValue, isRegex: true)) + case 2: + guard !first.stringValue.isEmpty || !second.stringValue.isEmpty else { + report("Enter a prefix or suffix.", title: "Invalid Rename") + return + } + rule = RenameRule(op: .prefixSuffix(prefix: first.stringValue, suffix: second.stringValue)) + case 3: + let numbers = second.stringValue.isEmpty ? ["1", "3"] : + second.stringValue.split(separator: ",").map(String.init) + guard numbers.count == 2, let start = Int(numbers[0]), let digits = Int(numbers[1]), + start >= 0, digits >= 1, digits <= 12 else { + report("Enter start,digits (example: 1,3).", title: "Invalid Numbering") + return + } + rule = RenameRule(op: .numberedSequence(prefix: first.stringValue, + start: start, digits: digits)) + case 4: + rule = RenameRule(op: .changeExtension(newExtension: first.stringValue)) + default: return } - - let rule = RenameRule(op: .findReplace( - find: findField.stringValue, replace: replaceField.stringValue, isRegex: false)) let plan = AdvancedRenameToolkit.preview(urls: urls, rule: rule) let collisions = plan.filter(\.hasCollision) guard collisions.isEmpty else { diff --git a/Sources/App/PDF/PDFToolkit.swift b/Sources/App/PDF/PDFToolkit.swift index 35876d1..dfc3404 100644 --- a/Sources/App/PDF/PDFToolkit.swift +++ b/Sources/App/PDF/PDFToolkit.swift @@ -291,9 +291,19 @@ public enum PDFToolkit { guard let doc = PDFDocument(url: url) else { throw PDFMergeError.unreadableFile(url.lastPathComponent) } + guard !doc.isEncrypted, !doc.isLocked else { + throw PDFMergeError.encryptedFile(url.lastPathComponent) + } + guard doc.pageCount > 0, + pageOrder.count == doc.pageCount, + pageOrder.sorted() == Array(0..= 0, origPos < doc.pageCount, let page = doc.page(at: origPos) else { continue } + guard let page = doc.page(at: origPos) else { + throw PDFMergeError.unreadableFile("Page \(origPos + 1) is unreadable.") + } reorderedDoc.insert(page, at: newPos) } guard reorderedDoc.pageCount > 0 else { throw PDFMergeError.noInputs } diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index fcda3e0..1d8bbd6 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -6,6 +6,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case compression = "Compression" case activity = "Activity" case destinations = "Destinations" + case automation = "Automation" case applications = "Applications" case general = "General" var id: Self { self } @@ -15,6 +16,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case .compression: "arrow.down.right.and.arrow.up.left" case .activity: "clock.arrow.circlepath" case .destinations: "folder.badge.gearshape" + case .automation: "bolt.badge.clock" case .applications: "terminal" case .general: "gearshape" } @@ -48,6 +50,7 @@ struct SettingsView: View { case .compression: compressionPane case .activity: activityPane case .destinations: FavoriteSettingsView() + case .automation: CustomActionsSettingsView() case .applications: applicationsPane case .general: generalPane } @@ -154,6 +157,7 @@ struct SettingsView: View { Menu(action.title) { Button("Merge into PDF…") { lastPreview = "PDF Toolkit → Merge into PDF" } Button("Split by Page Range…") { lastPreview = "PDF Toolkit → Split by Page Range" } + Button("Reorder Pages…") { lastPreview = "PDF Toolkit → Reorder PDF pages" } Button("OCR (Recognize Text)…") { lastPreview = "PDF Toolkit → OCR" } } case .imageToolkit: @@ -180,6 +184,8 @@ struct SettingsView: View { Button(action.title) { lastPreview = "Find exact duplicates in selected folders" } case .zipInspector: Button(action.title) { lastPreview = "Browse or extract ZIP entries" } + case .customActions: + Button(action.title) { lastPreview = "Run a user-configured action (confirmation required)" } } } diff --git a/Sources/Extension/FinderSync.swift b/Sources/Extension/FinderSync.swift index 084ac2e..079f1d2 100644 --- a/Sources/Extension/FinderSync.swift +++ b/Sources/Extension/FinderSync.swift @@ -21,6 +21,7 @@ final class TuckFinderSync: FIFinderSync { static let pdfOCR = 406 static let pdfExtractImages = 407 static let pdfToImages = 408 + static let pdfReorder = 409 // Image Toolkit tags static let imgToJPEG = 500 @@ -159,6 +160,8 @@ final class TuckFinderSync: FIFinderSync { item.action = #selector(findDuplicates(_:)) case .zipInspector: item.action = #selector(inspectZIP(_:)) + case .customActions: + item.submenu = makeCustomActionMenu() } root.addItem(item) } @@ -193,6 +196,39 @@ final class TuckFinderSync: FIFinderSync { return root } + private func makeCustomActionMenu() -> NSMenu { + let menu = NSMenu(title: "Custom Actions") + let selection = menuSelection ?? selected + let contextPaths = selection.map(\.path) + for configured in CustomActionPreferences.all where configured.enabled { + // Test file/folder types only. Do not recurse folders or probe + // executables while Finder is constructing a context menu. + guard !contextPaths.isEmpty, contextPaths.count <= 256 else { continue } + let compatible = selection.allSatisfy { url in + guard let values = try? url.resourceValues( + forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]), + values.isSymbolicLink != true else { return false } + switch configured.selection { + case .files: return values.isRegularFile == true + case .folders: return values.isDirectory == true + case .any: return values.isRegularFile == true || values.isDirectory == true + } + } + guard compatible else { continue } + let item = menu.addItem(withTitle: configured.title, + action: #selector(customAction(_:)), keyEquivalent: "") + item.representedObject = configured.id.uuidString + } + return menu + } + + @objc func customAction(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + let (selection, _) = resolveContext() + guard !selection.isEmpty else { return } + dispatch("customAction", paths: selection.map(\.path), argument: id) + } + private func makeFileMenu() -> NSMenu { let menu = NSMenu(title: "New File") for (index, template) in TuckMenuModel.templates.enumerated() { @@ -253,6 +289,9 @@ final class TuckFinderSync: FIFinderSync { let rotateItem = menu.addItem(withTitle: "Rotate Pages", action: nil, keyEquivalent: "") rotateItem.submenu = rotateSubmenu + let reorder = menu.addItem(withTitle: "Reorder Pages…", action: #selector(pdfAction(_:)), keyEquivalent: "") + reorder.tag = Tag.pdfReorder + menu.addItem(.separator()) let ocr = menu.addItem(withTitle: "OCR (Recognize Text)…", action: #selector(pdfAction(_:)), keyEquivalent: "") ocr.tag = Tag.pdfOCR @@ -416,6 +455,8 @@ final class TuckFinderSync: FIFinderSync { dispatch("pdfExtractImages", paths: paths) case Tag.pdfToImages: dispatch("pdfToImages", paths: paths) + case Tag.pdfReorder: + dispatch("pdfReorder", paths: paths) default: break } diff --git a/Sources/Shared/Actions.swift b/Sources/Shared/Actions.swift index 14a6660..81c7e82 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, duplicates, zipInspector + case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations, duplicates, zipInspector, customActions var id: String { rawValue } var title: String { @@ -19,6 +19,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .destinations: "Copy / Move To" case .duplicates: "Find Duplicates" case .zipInspector: "Inspect ZIP" + case .customActions: "Custom Actions" } } @@ -36,6 +37,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .destinations: "folder.badge.plus" case .duplicates: "square.on.square" case .zipInspector: "archivebox" + case .customActions: "bolt" } } } @@ -77,6 +79,8 @@ enum TuckMenuModel { return context == .other case .zipInspector: return context == .archive + case .customActions: + return context.hasSelection && CustomActionPreferences.all.contains { $0.enabled } } } } diff --git a/Sources/Shared/CustomActions.swift b/Sources/Shared/CustomActions.swift new file mode 100644 index 0000000..7033b5b --- /dev/null +++ b/Sources/Shared/CustomActions.swift @@ -0,0 +1,43 @@ +import Foundation + +/// User-configured action. Never synthesize a shell command: paths are handed +/// to an executable or Shortcut as argument-array elements. +struct CustomFinderAction: Identifiable, Codable, Sendable { + enum Kind: String, Codable, Sendable { case executable, shortcut } + enum Selection: String, Codable, Sendable { case files, folders, any } + let id: UUID + var title: String + var kind: Kind + var target: String + var selection: Selection + var requiresConfirmation: Bool + var enabled: Bool + + var valid: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + title.utf8.count <= 70 && + !target.isEmpty && target.utf8.count <= 2048 && + !target.contains("\0") && + (kind != .executable || target.hasPrefix("/")) + } +} + +enum CustomActionPreferences { + private static let key = "customActionDefinitionsV1" + static var all: [CustomFinderAction] { + guard let data = TuckPreferences.store.data(forKey: key), + let decoded = try? JSONDecoder().decode([CustomFinderAction].self, from: data) else { + return [] + } + var seen = Set() + return Array(decoded.filter { $0.valid && seen.insert($0.id).inserted }.prefix(16)) + } + + static func save(_ actions: [CustomFinderAction]) throws { + guard actions.count <= 16, actions.allSatisfy(\.valid), + Set(actions.map(\.id)).count == actions.count else { + throw CocoaError(.fileWriteInvalidFileName) + } + TuckPreferences.store.set(try JSONEncoder().encode(actions), forKey: key) + } +} diff --git a/Sources/Shared/Preferences.swift b/Sources/Shared/Preferences.swift index d1a88a0..a0bd38e 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", "duplicates", "zipInspector" + "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector", "customActions" ] static var order: [String] { diff --git a/Tests/CustomActionTests.swift b/Tests/CustomActionTests.swift new file mode 100644 index 0000000..e5a4a28 --- /dev/null +++ b/Tests/CustomActionTests.swift @@ -0,0 +1,35 @@ +import Foundation + +@main +struct CustomActionTests { + static func main() throws { + let fm = FileManager.default + let folder = fm.temporaryDirectory.appendingPathComponent( + "tuck-action-tests-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: folder) } + let input = folder.appendingPathComponent("semi;quote' file.txt") + try Data("untouched".utf8).write(to: input) + let action = CustomFinderAction( + id: UUID(), title: "Safe test executable", kind: .executable, + target: "/usr/bin/true", selection: .files, + requiresConfirmation: true, enabled: true) + assert(action.valid) + assert(CustomActionRunner.supports(action, paths: [input.path])) + try CustomActionRunner.execute(action, paths: [input.path], shouldCancel: { false }) + let content = try String(contentsOf: input, encoding: .utf8) + assert(content == "untouched") + var cancelled = false + do { try CustomActionRunner.execute(action, paths: [input.path], shouldCancel: { true }) } + catch is CancellationError { cancelled = true } + assert(cancelled) + let unsafe = CustomFinderAction(id: UUID(), title: "Bad", kind: .executable, + target: "echo hello", selection: .any, requiresConfirmation: true, enabled: true) + assert(!unsafe.valid) + let folderOnly = CustomFinderAction(id: UUID(), title: "Folder", kind: .executable, + target: "/usr/bin/true", selection: .folders, + requiresConfirmation: true, enabled: true) + assert(!CustomActionRunner.supports(folderOnly, paths: [input.path])) + print("CustomActionTests passed") + } +}