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..e47a91b 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,11 @@ final class TuckApplication: NSObject, NSApplicationDelegate { case "inspectZIP": ZIPWindow.show(paths: request.paths) + case "customAction": + runCustomAction(paths: request.paths, argument: request.argument) + case "runRecipe": + runSavedRecipe(paths: request.paths, argument: request.argument) + case "copyTo", "moveTo": runTransfer(paths: request.paths, argument: request.argument, mode: request.operation == "moveTo" ? .move : .copy) @@ -202,7 +210,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 +362,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 +659,53 @@ final class TuckApplication: NSObject, NSApplicationDelegate { title: "Copied to Clipboard") } + private func runSavedRecipe(paths: [String], argument: String?) { + guard let raw = argument, let id = UUID(uuidString: raw), + let recipe = RecipePreferences.all.first(where: { $0.id == id }) else { + report("The saved recipe is missing or invalid.", title: "Recipe Unavailable") + return + } + let prompt = NSAlert() + prompt.messageText = "Run \(recipe.title) on \(paths.count) item(s)?" + prompt.informativeText = recipe.steps.map { $0.title }.joined(separator: " → ") + + "\n\nOriginals are untouched. Final copies appear in an export folder beside the originals." + prompt.addButton(withTitle: "Run Recipe") + prompt.addButton(withTitle: "Cancel") + guard prompt.runModal() == .alertFirstButtonReturn else { return } + queueOperation(title: "Recipe: \(recipe.title)", operation: "runRecipe", + paths: paths, canUndo: true) { token, progress in + try RecipeRunner.execute(recipe, paths: paths, + shouldCancel: { token.isCancelled }, progress: progress) + } + } + + 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 +756,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/Recipes/RecipeRunner.swift b/Sources/App/Recipes/RecipeRunner.swift new file mode 100644 index 0000000..1242f9b --- /dev/null +++ b/Sources/App/Recipes/RecipeRunner.swift @@ -0,0 +1,135 @@ +import Foundation + +struct RecipeStep: Codable, Sendable, Identifiable { + enum Kind: String, Codable, Sendable { case resizeLongestEdge, convert, stripMetadata } + var id: UUID = UUID() + var kind: Kind + var value: String = "" + var valid: Bool { + switch kind { + case .resizeLongestEdge: + guard let n = Int(value) else { return false } + return (16...4096).contains(n) + case .convert: + return ["jpeg", "png", "heic", "tiff", "webp"].contains(value) + case .stripMetadata: return value.isEmpty + } + } + var title: String { + switch kind { + case .resizeLongestEdge: "Resize longest edge to \(value)px" + case .convert: "Convert to \(value.uppercased())" + case .stripMetadata: "Remove supported metadata" + } + } +} + +struct SavedRecipe: Codable, Sendable, Identifiable { + var id: UUID = UUID() + var title: String + var steps: [RecipeStep] + var valid: Bool { + !title.trimmingCharacters(in: .whitespaces).isEmpty && + title.utf8.count <= 70 && + (1...8).contains(steps.count) && steps.allSatisfy(\.valid) + } +} + +enum RecipePreferences { + private static let key = "savedRecipesV1" + static var all: [SavedRecipe] { + guard let data = TuckPreferences.store.data(forKey: key), + let recipes = try? JSONDecoder().decode([SavedRecipe].self, from: data) else { + return [] + } + var seen = Set() + return Array(recipes.filter { $0.valid && seen.insert($0.id).inserted }.prefix(12)) + } + static func save(_ recipes: [SavedRecipe]) throws { + guard recipes.count <= 12, recipes.allSatisfy(\.valid), + Set(recipes.map(\.id)).count == recipes.count else { + throw CocoaError(.fileWriteInvalidFileName) + } + TuckPreferences.store.set(try JSONEncoder().encode(recipes), forKey: key) + } +} + +enum RecipeRunner { + static let allowedImageFormats: Set = ["jpg","jpeg","png","heic","tif","tiff","webp","bmp"] + + static func execute(_ recipe: SavedRecipe, paths: [String], + shouldCancel: @escaping @Sendable () -> Bool, + progress: @escaping @Sendable (Double,String) -> Void) throws -> JobOutcome { + guard recipe.valid, !paths.isEmpty, paths.count <= 256 else { + throw UndoError.unsafe("The saved recipe or input selection is invalid.") + } + var outputs: [URL] = [] + var failures: [JobFailure] = [] + for (index, path) in paths.enumerated() { + if shouldCancel() { break } + let source = URL(fileURLWithPath: path) + progress(Double(index) / Double(paths.count), source.lastPathComponent) + do { + guard allowedImageFormats.contains(source.pathExtension.lowercased()), + (try source.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])).isRegularFile == true, + (try source.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink != true else { + throw UndoError.unsafe("This recipe accepts regular image files only.") + } + outputs.append(try process(recipe, source: source, shouldCancel: shouldCancel)) + } catch { + if !shouldCancel() { + failures.append(JobFailure(inputPath: path, reason: error.localizedDescription)) + } + } + } + return JobOutcome(outputs: outputs, failures: failures) + } + + private static func process(_ recipe: SavedRecipe, source: URL, + shouldCancel: @escaping @Sendable () -> Bool) throws -> URL { + let fm = FileManager.default + guard Spill.root().path.hasPrefix("/Volumes/") else { + throw UndoError.unsafe("Connect a writable external scratch volume before running a recipe.") + } + let work = try WorkArea(beside: source) + defer { work.clean() } + // Recipes can create many encoded intermediates; require a mounted + // external scratch volume instead of falling back to internal storage. + guard work.root.path.hasPrefix("/Volumes/"), + fm.fileExists(atPath: work.root.path) else { + throw UndoError.unsafe("A mounted external scratch volume is required for recipes.") + } + let fileBytes = (try? fm.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.intValue ?? 1_000_000 + try work.requireSpace(bytes: min(fileBytes, 1_000_000_000)) + var current = work.makeTemporary("recipe-source-\(UUID().uuidString)", + extension: source.pathExtension) + try fm.copyItem(at: source, to: current) + for step in recipe.steps { + if shouldCancel() { throw CancellationError() } + let next: URL + switch step.kind { + case .resizeLongestEdge: + guard let edge = Int(step.value) else { + throw UndoError.unsafe("Invalid resize step.") + } + next = try ImageToolkit.resize(url: current, mode: .longestEdge(edge), + shouldCancel: shouldCancel) + case .convert: + next = try ImageToolkit.convert(url: current, targetFormat: step.value, + shouldCancel: shouldCancel) + case .stripMetadata: + next = try MetadataToolkit.strip(url: current) + } + guard fm.fileExists(atPath: next.path) else { + throw UndoError.unsafe("Recipe step did not create its expected output.") + } + current = work.track(next) + } + if shouldCancel() { throw CancellationError() } + let export = source.deletingLastPathComponent().appendingPathComponent("export", isDirectory: true) + try fm.createDirectory(at: export, withIntermediateDirectories: true) + let outputOrigin = export.appendingPathComponent(source.lastPathComponent) + return try OutputName.publish(current, beside: outputOrigin, suffix: "-export", + extraExtension: current.pathExtension) + } +} diff --git a/Sources/App/Recipes/RecipeSettingsView.swift b/Sources/App/Recipes/RecipeSettingsView.swift new file mode 100644 index 0000000..b69fdbe --- /dev/null +++ b/Sources/App/Recipes/RecipeSettingsView.swift @@ -0,0 +1,167 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +@MainActor +struct RecipeSettingsView: View { + @State private var recipes = RecipePreferences.all + @State private var selectedID: UUID? + @State private var newTitle = "Export for Web" + @State private var stepKind: RecipeStep.Kind = .resizeLongestEdge + @State private var stepValue = "1600" + @State private var errorText = "" + @State private var showError = false + + private var current: Int? { recipes.firstIndex { $0.id == selectedID } } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Recipes").font(.largeTitle.weight(.semibold)) + Text("Reusable, non-destructive image steps. Originals are unchanged; final copies go into an export folder.") + .font(.subheadline).foregroundStyle(.secondary) + GroupBox("Saved Recipes") { + VStack(alignment: .leading, spacing: 8) { + ForEach(recipes) { recipe in + HStack { + Button { + selectedID = recipe.id + } label: { + HStack { + Image(systemName: recipe.id == selectedID ? "checkmark.circle.fill" : "circle") + Text(recipe.title) + Spacer() + } + }.buttonStyle(.plain) + Button { + guard recipes.count < 12 else { return } + let copy = SavedRecipe(title: recipe.title + " copy", steps: recipe.steps) + recipes.append(copy) + selectedID = copy.id + save() + } label: { Image(systemName: "plus.square.on.square") } + .accessibilityLabel("Duplicate \(recipe.title)") + Button(role: .destructive) { + recipes.removeAll { $0.id == recipe.id } + if selectedID == recipe.id { selectedID = nil } + save() + } label: { Image(systemName: "minus.circle") } + .accessibilityLabel("Delete \(recipe.title)") + } + } + HStack { + TextField("New recipe name", text: $newTitle) + Button("New Recipe") { + guard recipes.count < 12 else { return } + let starter = SavedRecipe(title: newTitle, steps: [ + RecipeStep(kind: .resizeLongestEdge, value: "1600"), + RecipeStep(kind: .convert, value: "jpeg") + ]) + recipes.append(starter) + selectedID = starter.id + save() + }.disabled(newTitle.isEmpty || recipes.count >= 12) + } + HStack { + Button("Import JSON…") { importRecipe() } + Button("Export JSON…") { exportRecipe() }.disabled(current == nil) + Spacer() + } + }.padding(10) + } + if let index = current { + GroupBox("Steps — \(recipes[index].title)") { + VStack(alignment: .leading, spacing: 10) { + TextField("Recipe name", text: Binding( + get: { recipes[index].title }, + set: { recipes[index].title = $0; save() } + )) + List { + ForEach(recipes[index].steps) { step in + HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.tertiary) + Text(step.title) + Spacer() + Button { + guard recipes[index].steps.count > 1 else { return } + recipes[index].steps.removeAll { $0.id == step.id } + save() + } label: { Image(systemName: "minus.circle") } + .disabled(recipes[index].steps.count <= 1) + .accessibilityLabel("Remove \(step.title)") + } + } + .onMove { offsets, target in + recipes[index].steps.move(fromOffsets: offsets, toOffset: target) + save() + } + } + .frame(height: 150) + HStack { + Picker("Add step", selection: $stepKind) { + Text("Resize longest edge").tag(RecipeStep.Kind.resizeLongestEdge) + Text("Convert format").tag(RecipeStep.Kind.convert) + Text("Strip metadata").tag(RecipeStep.Kind.stripMetadata) + }.frame(maxWidth: 270) + if stepKind != .stripMetadata { + TextField(stepKind == .convert ? "jpeg / png / heic / tiff / webp" : "1600", + text: $stepValue) + .frame(maxWidth: 180) + } + Button("Add Step") { + guard let index = current else { return } + let step = RecipeStep(kind: stepKind, + value: stepKind == .stripMetadata ? "" : stepValue) + guard step.valid && recipes[index].steps.count < 8 else { + errorText = "Invalid step, or recipe already contains eight steps." + showError = true + return + } + recipes[index].steps.append(step) + save() + } + } + Text("Drag to reorder. Maximum eight steps per recipe, 12 recipes total. Format availability depends on installed native/optional codecs.") + .font(.caption).foregroundStyle(.secondary) + }.padding(10) + } + } + Text("Recipes contain typed built-in actions only. Imported JSON cannot launch scripts, Shortcuts or network requests.") + .font(.caption2).foregroundStyle(.secondary) + } + .onAppear { if selectedID == nil { selectedID = recipes.first?.id } } + .alert("Unable to update recipe", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { Text(errorText) } + } + + private func save() { + do { try RecipePreferences.save(recipes) } + catch { errorText = error.localizedDescription; showError = true } + } + private func importRecipe() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let data = try Data(contentsOf: url) + guard data.count <= 64_000 else { throw UndoError.unsafe("JSON is too large.") } + let parsed = try JSONDecoder().decode(SavedRecipe.self, from: data) + guard parsed.valid, recipes.count < 12 else { + throw UndoError.unsafe("Unsupported or invalid recipe, or the recipe limit was reached.") + } + let copy = SavedRecipe(title: parsed.title, steps: parsed.steps) + recipes.append(copy); selectedID = copy.id; save() + } catch { errorText = error.localizedDescription; showError = true } + } + private func exportRecipe() { + guard let index = current else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "tuck-recipe.json" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try JSONEncoder().encode(recipes[index]).write(to: url, options: .atomic) } + catch { errorText = error.localizedDescription; showError = true } + } +} diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index fcda3e0..50cdf0a 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -6,6 +6,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case compression = "Compression" case activity = "Activity" case destinations = "Destinations" + case automation = "Automation" + case recipes = "Recipes" case applications = "Applications" case general = "General" var id: Self { self } @@ -15,6 +17,8 @@ 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 .recipes: "square.stack.3d.up" case .applications: "terminal" case .general: "gearshape" } @@ -48,6 +52,8 @@ struct SettingsView: View { case .compression: compressionPane case .activity: activityPane case .destinations: FavoriteSettingsView() + case .automation: CustomActionsSettingsView() + case .recipes: RecipeSettingsView() case .applications: applicationsPane case .general: generalPane } @@ -154,6 +160,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 +187,10 @@ 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)" } + case .recipes: + Button(action.title) { lastPreview = "Run a saved non-destructive image pipeline" } } } diff --git a/Sources/Extension/FinderSync.swift b/Sources/Extension/FinderSync.swift index 084ac2e..bfaa0d0 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,10 @@ final class TuckFinderSync: FIFinderSync { item.action = #selector(findDuplicates(_:)) case .zipInspector: item.action = #selector(inspectZIP(_:)) + case .customActions: + item.submenu = makeCustomActionMenu() + case .recipes: + item.submenu = makeRecipeMenu() } root.addItem(item) } @@ -193,6 +198,56 @@ final class TuckFinderSync: FIFinderSync { return root } + private func makeRecipeMenu() -> NSMenu { + let menu = NSMenu(title: "Recipes") + for recipe in RecipePreferences.all { + let item = menu.addItem(withTitle: recipe.title, + action: #selector(runRecipe(_:)), keyEquivalent: "") + item.representedObject = recipe.id.uuidString + } + return menu + } + + @objc func runRecipe(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + let (selection, _) = resolveContext() + guard !selection.isEmpty else { return } + dispatch("runRecipe", paths: selection.map(\.path), argument: id) + } + + 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 +308,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 +474,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..0d8551d 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, recipes var id: String { rawValue } var title: String { @@ -19,6 +19,8 @@ enum TuckAction: String, CaseIterable, Identifiable { case .destinations: "Copy / Move To" case .duplicates: "Find Duplicates" case .zipInspector: "Inspect ZIP" + case .customActions: "Custom Actions" + case .recipes: "Recipes" } } @@ -36,6 +38,8 @@ enum TuckAction: String, CaseIterable, Identifiable { case .destinations: "folder.badge.plus" case .duplicates: "square.on.square" case .zipInspector: "archivebox" + case .customActions: "bolt" + case .recipes: "square.stack.3d.up" } } } @@ -77,6 +81,10 @@ enum TuckMenuModel { return context == .other case .zipInspector: return context == .archive + case .customActions: + return context.hasSelection && CustomActionPreferences.all.contains { $0.enabled } + case .recipes: + return context == .image && !RecipePreferences.all.isEmpty } } } 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..e5a12a4 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", "recipes" ] 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") + } +}