Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Sources/App/Main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ final class TuckApplication: NSObject, NSApplicationDelegate {

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,
Expand Down Expand Up @@ -657,6 +659,26 @@ 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 }),
Expand Down
81 changes: 81 additions & 0 deletions Sources/App/Recipes/RecipeRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

enum RecipeRunner {
static let allowedImageFormats: Set<String> = ["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)
}
}
167 changes: 167 additions & 0 deletions Sources/App/Recipes/RecipeSettingsView.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
5 changes: 5 additions & 0 deletions Sources/App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
case activity = "Activity"
case destinations = "Destinations"
case automation = "Automation"
case recipes = "Recipes"
case applications = "Applications"
case general = "General"
var id: Self { self }
Expand All @@ -17,6 +18,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
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"
}
Expand Down Expand Up @@ -51,6 +53,7 @@ struct SettingsView: View {
case .activity: activityPane
case .destinations: FavoriteSettingsView()
case .automation: CustomActionsSettingsView()
case .recipes: RecipeSettingsView()
case .applications: applicationsPane
case .general: generalPane
}
Expand Down Expand Up @@ -186,6 +189,8 @@ struct SettingsView: View {
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" }
}
}

Expand Down
19 changes: 19 additions & 0 deletions Sources/Extension/FinderSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ final class TuckFinderSync: FIFinderSync {
item.action = #selector(inspectZIP(_:))
case .customActions:
item.submenu = makeCustomActionMenu()
case .recipes:
item.submenu = makeRecipeMenu()
}
root.addItem(item)
}
Expand Down Expand Up @@ -196,6 +198,23 @@ 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
Expand Down
6 changes: 5 additions & 1 deletion Sources/Shared/Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, customActions
case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations, duplicates, zipInspector, customActions, recipes
var id: String { rawValue }

var title: String {
Expand All @@ -20,6 +20,7 @@ enum TuckAction: String, CaseIterable, Identifiable {
case .duplicates: "Find Duplicates"
case .zipInspector: "Inspect ZIP"
case .customActions: "Custom Actions"
case .recipes: "Recipes"
}
}

Expand All @@ -38,6 +39,7 @@ enum TuckAction: String, CaseIterable, Identifiable {
case .duplicates: "square.on.square"
case .zipInspector: "archivebox"
case .customActions: "bolt"
case .recipes: "square.stack.3d.up"
}
}
}
Expand Down Expand Up @@ -81,6 +83,8 @@ enum TuckMenuModel {
return context == .archive
case .customActions:
return context.hasSelection && CustomActionPreferences.all.contains { $0.enabled }
case .recipes:
return context == .image && !RecipePreferences.all.isEmpty
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Shared/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ enum TuckPreferences {

static let defaults: [String] = [
"newFile", "openWith", "compress", "terminal",
"pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector", "customActions"
"pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector", "customActions", "recipes"
]

static var order: [String] {
Expand Down
Loading
Loading