Skip to content
1 change: 1 addition & 0 deletions Sources/App/Main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class TuckApplication: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
let created = TuckPreferences.prepareKey()
WatchedFolderManager.shared.reload()
TuckLog.helperLogger.info("helper.launched pid=\(ProcessInfo.processInfo.processIdentifier) keyCreated=\(created) keyPresent=\(TuckPreferences.sharedKey != nil) settingsArg=\(CommandLine.arguments.contains("--settings"))")
if CommandLine.arguments.contains("--settings") { showSettings(); return }
// A Finder-triggered URL may arrive after didFinishLaunching. Never show the
Expand Down
3 changes: 3 additions & 0 deletions Sources/App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
case destinations = "Destinations"
case automation = "Automation"
case recipes = "Recipes"
case watchedFolders = "Watched Folders"
case applications = "Applications"
case general = "General"
var id: Self { self }
Expand All @@ -19,6 +20,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
case .destinations: "folder.badge.gearshape"
case .automation: "bolt.badge.clock"
case .recipes: "square.stack.3d.up"
case .watchedFolders: "folder.badge.clock"
case .applications: "terminal"
case .general: "gearshape"
}
Expand Down Expand Up @@ -54,6 +56,7 @@ struct SettingsView: View {
case .destinations: FavoriteSettingsView()
case .automation: CustomActionsSettingsView()
case .recipes: RecipeSettingsView()
case .watchedFolders: WatchedFoldersSettingsView()
case .applications: applicationsPane
case .general: generalPane
}
Expand Down
136 changes: 136 additions & 0 deletions Sources/App/Watch/WatchedFolderManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import AppKit
import Dispatch
import Darwin

/// Lightweight, opt-in, top-level folder observer. Nothing is scanned or
/// watched when all rules are disabled or globally paused. Existing files form
/// the baseline at startup; only stable subsequent changes trigger processing.
@MainActor
final class WatchedFolderManager {
static let shared = WatchedFolderManager()

private struct Stamp: Equatable {
let device: UInt64
let inode: UInt64
let bytes: UInt64
let modified: Date
}
private struct Watch {
let rule: WatchedFolderRule
let folder: URL
let source: DispatchSourceFileSystemObject
var known: [String: Stamp]
}
private var watches: [UUID: Watch] = [:]
private var checks = Set<UUID>()
private var observers: [NSObjectProtocol] = []

private init() {
let center = NotificationCenter.default
observers.append(center.addObserver(forName: WatchRulePreferences.changed,
object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.reload() }
})
let workspace = NSWorkspace.shared.notificationCenter
for event in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
observers.append(workspace.addObserver(forName: event, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.reload() }
})
}
}

func reload() {
for (_, watch) in watches { watch.source.cancel() }
watches.removeAll()
checks.removeAll()
guard !WatchRulePreferences.paused else { return }
let recipes = RecipePreferences.all
for rule in WatchRulePreferences.all where rule.enabled {
guard recipes.contains(where: { $0.id == rule.recipeID }),
let folder = try? rule.resolveFolder(),
let baseline = snapshot(folder) else { continue }
let handle = open(folder.path, O_EVTONLY)
guard handle >= 0 else { continue }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: handle,
eventMask: [.write, .rename, .delete, .attrib],
queue: .global(qos: .utility))
source.setEventHandler { [weak self] in
Task { @MainActor in self?.scheduleScan(id: rule.id) }
}
source.setCancelHandler { close(handle) }
watches[rule.id] = Watch(rule: rule, folder: folder, source: source,
known: baseline)
source.resume()
}
}

private func snapshot(_ folder: URL) -> [String: Stamp]? {
let fm = FileManager.default
guard let urls = try? fm.contentsOfDirectory(at: folder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey,
.isUbiquitousItemKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]), urls.count <= 10_000 else { return nil }
var result: [String: Stamp] = [:]
for url in urls where RecipeRunner.allowedImageFormats.contains(url.pathExtension.lowercased()) {
guard let v = try? url.resourceValues(
forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .isUbiquitousItemKey]),
v.isRegularFile == true, v.isSymbolicLink != true, v.isUbiquitousItem != true,
let attrs = try? fm.attributesOfItem(atPath: url.path),
let device = attrs[.systemNumber] as? NSNumber,
let inode = attrs[.systemFileNumber] as? NSNumber,
let size = attrs[.size] as? NSNumber,
let modified = attrs[.modificationDate] as? Date else { continue }
result[url.path] = Stamp(device: device.uint64Value,
inode: inode.uint64Value,
bytes: size.uint64Value, modified: modified)
}
return result
}

private func scheduleScan(id: UUID) {
guard watches[id] != nil, checks.insert(id).inserted else { return }
Task { [weak self] in
try? await Task.sleep(nanoseconds: 900_000_000)
await self?.checkChanges(id: id)
}
}

private func checkChanges(id: UUID) async {
defer { checks.remove(id) }
guard let watch = watches[id], !WatchRulePreferences.paused else { return }
guard let first = snapshot(watch.folder) else { return }
let new = first.filter { watch.known[$0.key] != $0.value }
guard !new.isEmpty else {
watches[id]?.known = first
return
}
// A copied/downloaded image must be stable over a second before any
// recipe begins. Never process a partial/in-progress download.
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let live = watches[id], live.folder == watch.folder else { return }
guard let second = snapshot(watch.folder) else { return }
let recipe = RecipePreferences.all.first { $0.id == watch.rule.recipeID }
if let recipe {
for (path, stamp) in new where second[path] == stamp &&
stamp.bytes > 0 && Date().timeIntervalSince(stamp.modified) >= 1 {
if watches[id]?.known[path] == stamp { continue }
let input = path
JobManager.shared.enqueue(title: "Watched: \(watch.rule.name)",
operation: "watchedRecipe", paths: [input], canUndo: true) { token, progress in
try RecipeRunner.execute(recipe, paths: [input],
shouldCancel: { token.isCancelled }, progress: progress)
}
}
}
watches[id]?.known = second
// Files still arriving may not emit another directory event when only
// bytes change; check one more time after a slow writer is observed.
if new.contains(where: { second[$0.key] != $0.value }) {
Task { [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
self?.scheduleScan(id: id)
}
}
}
}
111 changes: 111 additions & 0 deletions Sources/App/Watch/WatchedFoldersSettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import AppKit
import SwiftUI

@MainActor
struct WatchedFoldersSettingsView: View {
@State private var rules = WatchRulePreferences.all
@State private var paused = WatchRulePreferences.paused
@State private var selectedRecipe: UUID?
@State private var errorText = ""
@State private var showError = false

var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Watched Folders").font(.largeTitle.weight(.semibold))
Text("Optional local automation while Tuck is running. Only new, stable files in explicitly selected folders are processed.")
.font(.subheadline).foregroundStyle(.secondary)
Toggle("Pause all watched folders", isOn: Binding(
get: { paused },
set: { value in paused = value; WatchRulePreferences.paused = value }
))
GroupBox("Rules") {
VStack(alignment: .leading, spacing: 10) {
if rules.isEmpty {
Text("No watched folders configured. Add a recipe first, then choose an input folder.")
.foregroundStyle(.secondary)
}
ForEach(rules) { rule in
HStack {
Toggle(isOn: Binding(
get: { rules.first(where: { $0.id == rule.id })?.enabled ?? false },
set: { value in
if let i = rules.firstIndex(where: { $0.id == rule.id }) {
rules[i].enabled = value
save()
}
}
)) {
Text(rule.name).fontWeight(.medium)
}
Spacer()
if let folder = try? rule.resolveFolder() {
Text(folder.path).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
} else {
Label("Missing folder", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
Button(role: .destructive) {
rules.removeAll { $0.id == rule.id }
save()
} label: { Image(systemName: "minus.circle") }
.accessibilityLabel("Remove \(rule.name)")
}
if let recipe = RecipePreferences.all.first(where: { $0.id == rule.recipeID }) {
Text("Recipe: \(recipe.title)").font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Recipe unavailable; rule will not run.")
.font(.caption2).foregroundStyle(.orange)
}
}
if !RecipePreferences.all.isEmpty {
HStack {
Picker("Recipe", selection: $selectedRecipe) {
Text("Choose a recipe").tag(UUID?.none)
ForEach(RecipePreferences.all) { recipe in
Text(recipe.title).tag(Optional(recipe.id))
}
}
Button("Add Folder…") { addFolder() }
.disabled(selectedRecipe == nil || rules.count >= 8)
}
}
}.padding(10)
}
Text("Rule limit: eight folders. Tuck watches direct child image files, skips symlinks and iCloud placeholders, waits for stable writes and excludes hidden/generated outputs. Existing files are not reprocessed on startup.")
.font(.caption).foregroundStyle(.secondary)
Text("Watch rules work while the Tuck app is running. Tuck does not install a background login service or watch any folder without explicit opt-in.")
.font(.caption).foregroundStyle(.secondary)
}
.onAppear {
if selectedRecipe == nil { selectedRecipe = RecipePreferences.all.first?.id }
}
.alert("Watched folder configuration failed", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: { Text(errorText) }
}

private func addFolder() {
guard let recipeID = selectedRecipe else { return }
let panel = NSOpenPanel()
panel.message = "Choose a folder to watch for stable new images"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let folder = panel.url else { return }
do {
guard !rules.contains(where: {
$0.recipeID == recipeID && (try? $0.resolveFolder()) == folder.standardizedFileURL
}) else { throw UndoError.unsafe("This folder and recipe are already watched.") }
let name = folder.lastPathComponent
rules.append(try WatchedFolderRule.create(name: name, folder: folder, recipeID: recipeID))
save()
} catch { errorText = error.localizedDescription; showError = true }
}

private func save() {
do { try WatchRulePreferences.save(rules) }
catch { errorText = error.localizedDescription; showError = true }
}
}
63 changes: 63 additions & 0 deletions Sources/Shared/WatchRules.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

struct WatchedFolderRule: Codable, Sendable, Identifiable {
var id: UUID = UUID()
var name: String
var directoryBookmark: Data
var recipeID: UUID
var enabled = true

static func create(name: String, folder: URL, recipeID: UUID) throws -> WatchedFolderRule {
guard (try folder.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true else {
throw CocoaError(.fileNoSuchFile)
}
return WatchedFolderRule(name: String(name.prefix(70)),
directoryBookmark: try folder.bookmarkData(options: [], includingResourceValuesForKeys: nil,
relativeTo: nil),
recipeID: recipeID)
}

func resolveFolder() throws -> URL {
var stale = false
let url = try URL(resolvingBookmarkData: directoryBookmark, options: [],
relativeTo: nil, bookmarkDataIsStale: &stale)
guard !stale, url.isFileURL,
(try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])).isDirectory == true,
(try url.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink != true else {
throw CocoaError(.fileNoSuchFile)
}
return url.standardizedFileURL
}

var valid: Bool { !name.isEmpty && name.utf8.count <= 70 && directoryBookmark.count <= 16_384 }
}

enum WatchRulePreferences {
static let changed = Notification.Name("TuckWatchedFolderRulesDidChange")
private static let key = "watchedFolderRulesV1"
private static let pauseKey = "watchedFoldersPaused"

static var paused: Bool {
get { TuckPreferences.store.bool(forKey: pauseKey) }
set {
TuckPreferences.store.set(newValue, forKey: pauseKey)
NotificationCenter.default.post(name: changed, object: nil)
}
}
static var all: [WatchedFolderRule] {
guard let data = TuckPreferences.store.data(forKey: key),
let decoded = try? JSONDecoder().decode([WatchedFolderRule].self, from: data) else {
return []
}
var seen = Set<UUID>()
return Array(decoded.filter { $0.valid && seen.insert($0.id).inserted }.prefix(8))
}
static func save(_ rules: [WatchedFolderRule]) throws {
guard rules.count <= 8, rules.allSatisfy(\.valid),
Set(rules.map(\.id)).count == rules.count else {
throw CocoaError(.fileWriteInvalidFileName)
}
TuckPreferences.store.set(try JSONEncoder().encode(rules), forKey: key)
NotificationCenter.default.post(name: changed, object: nil)
}
}