Skip to content
Closed
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
84 changes: 84 additions & 0 deletions Sources/App/Automation/CustomActionRunner.swift
Original file line number Diff line number Diff line change
@@ -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).")
}
}
}
115 changes: 115 additions & 0 deletions Sources/App/Automation/CustomActionsSettingsView.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
4 changes: 3 additions & 1 deletion Sources/App/Compression/Toolchain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading