Skip to content
Merged
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
181 changes: 181 additions & 0 deletions Sources/App/Watch/WatchedFolderManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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()

struct Stamp: Hashable {
let device: UInt64
let inode: UInt64
let bytes: UInt64
let modified: Date
}

struct Decision {
let ready: [String]
let known: [String: Stamp]
let retry: Bool
}

/// Only acknowledge stamps that survived both observations and are old
/// enough to be complete. Changes still arriving remain pending.
static func decide(known: [String: Stamp], first: [String: Stamp],
second: [String: Stamp], now: Date) -> Decision {
let observedBefore = Set(known.values)
var acknowledged = known.filter { second[$0.key] != nil }
var ready: [String] = []
for (path, stamp) in first where known[path] != stamp {
guard let later = second[path], later == stamp, later.bytes > 0,
now.timeIntervalSince(later.modified) >= 1 else { continue }
acknowledged[path] = later
// A rename of a previously seen file keeps its device/inode,
// bytes and modification date. Acknowledge its new path without
// treating the rename as another newly arrived file.
if !observedBefore.contains(later) { ready.append(path) }
}
return Decision(ready: ready.sorted(), known: acknowledged,
retry: second.contains { acknowledged[$0.key] != $0.value })
}
private struct Watch {
let generation: UUID
let rule: WatchedFolderRule
let folder: URL
let source: DispatchSourceFileSystemObject
var known: [String: Stamp]
}
private var watches: [UUID: Watch] = [:]
private var checks: [UUID: 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() }
})
observers.append(center.addObserver(forName: RecipePreferences.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(generation: UUID(), 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 let watch = watches[id], checks[id] != watch.generation else { return }
let generation = watch.generation
checks[id] = generation
Task { [weak self] in
try? await Task.sleep(nanoseconds: 900_000_000)
await self?.checkChanges(id: id, generation: generation)
}
}

private func checkChanges(id: UUID, generation: UUID) async {
defer {
// Do not erase the state for a newer watcher if this task was
// suspended while an old rule was disabled or reloaded.
if checks[id] == generation { checks.removeValue(forKey: id) }
}
guard let watch = watches[id], watch.generation == generation,
!WatchRulePreferences.paused else { return }
guard let first = snapshot(watch.folder) else { return }
let changed = first.filter { watch.known[$0.key] != $0.value }
guard !changed.isEmpty else {
watches[id]?.known = first
return
}

// The second observation must match; an unstable download stays
// pending instead of being marked processed and forgotten.
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let live = watches[id], live.generation == generation,
!WatchRulePreferences.paused else { return }
guard let second = snapshot(watch.folder) else { return }
let recipe = RecipePreferences.all.first { $0.id == watch.rule.recipeID }

let decision = Self.decide(known: watch.known, first: first,
second: second, now: Date())
if let recipe {
for path in decision.ready {
guard watches[id]?.generation == generation,
!WatchRulePreferences.paused else { return }
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)
}
}
}
guard watches[id]?.generation == generation else { return }
watches[id]?.known = decision.known
if decision.retry {
Task { [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
guard let self, self.watches[id]?.generation == generation else { return }
// Defer beyond the current check's cleanup so the next scan
// obtains the guard and cannot be lost.
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 }
}
}
2 changes: 2 additions & 0 deletions Sources/Shared/Recipes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct SavedRecipe: Codable, Sendable, Identifiable {
}

enum RecipePreferences {
static let changed = Notification.Name("TuckSavedRecipesDidChange")
private static let key = "savedRecipesV1"
static var all: [SavedRecipe] {
guard let data = TuckPreferences.store.data(forKey: key),
Expand All @@ -51,5 +52,6 @@ enum RecipePreferences {
throw CocoaError(.fileWriteInvalidFileName)
}
TuckPreferences.store.set(try JSONEncoder().encode(recipes), forKey: key)
NotificationCenter.default.post(name: changed, object: nil)
}
}
Loading
Loading