diff --git a/Sources/App/Main.swift b/Sources/App/Main.swift index e47a91b..fcf09ea 100644 --- a/Sources/App/Main.swift +++ b/Sources/App/Main.swift @@ -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 diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 50cdf0a..ba1cd60 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -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 } @@ -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" } @@ -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 } diff --git a/Sources/App/Watch/WatchedFolderManager.swift b/Sources/App/Watch/WatchedFolderManager.swift new file mode 100644 index 0000000..8204572 --- /dev/null +++ b/Sources/App/Watch/WatchedFolderManager.swift @@ -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) + } + } + } +} diff --git a/Sources/App/Watch/WatchedFoldersSettingsView.swift b/Sources/App/Watch/WatchedFoldersSettingsView.swift new file mode 100644 index 0000000..5c4c5ac --- /dev/null +++ b/Sources/App/Watch/WatchedFoldersSettingsView.swift @@ -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 } + } +} diff --git a/Sources/Shared/Recipes.swift b/Sources/Shared/Recipes.swift index ff42fe7..6d673ed 100644 --- a/Sources/Shared/Recipes.swift +++ b/Sources/Shared/Recipes.swift @@ -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), @@ -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) } } diff --git a/Sources/Shared/WatchRules.swift b/Sources/Shared/WatchRules.swift new file mode 100644 index 0000000..8f8565b --- /dev/null +++ b/Sources/Shared/WatchRules.swift @@ -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() + 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) + } +} diff --git a/Tests/WatchedFolderTests.swift b/Tests/WatchedFolderTests.swift new file mode 100644 index 0000000..cdf5202 --- /dev/null +++ b/Tests/WatchedFolderTests.swift @@ -0,0 +1,53 @@ +import Foundation + +@main +struct WatchedFolderTests { + @MainActor + static func main() { + typealias Gate = WatchedFolderManager + let now = Date(timeIntervalSince1970: 1_800_000_000) + let file = "/tmp/incoming/image.png" + let partial = Gate.Stamp(device: 1, inode: 2, bytes: 100, + modified: now.addingTimeInterval(-0.1)) + let complete = Gate.Stamp(device: 1, inode: 2, bytes: 700, + modified: now.addingTimeInterval(-3)) + let pending = Gate.decide(known: [:], first: [file: partial], + second: [file: complete], now: now) + assert(pending.ready.isEmpty) + assert(pending.known[file] == nil, "Unstable files must not be marked as processed.") + assert(pending.retry) + + let ready = Gate.decide(known: pending.known, first: [file: complete], + second: [file: complete], now: now) + assert(ready.ready == [file]) + assert(ready.known[file] == complete) + assert(!ready.retry) + let repeatScan = Gate.decide(known: ready.known, first: [file: complete], + second: [file: complete], now: now) + assert(repeatScan.ready.isEmpty, "Identical filesystem notifications must not duplicate work.") + assert(!repeatScan.retry) + + let recentlyModified = Gate.Stamp(device: 1, inode: 3, bytes: 800, + modified: now.addingTimeInterval(-0.2)) + let young = Gate.decide(known: [:], first: [file: recentlyModified], + second: [file: recentlyModified], now: now) + assert(young.ready.isEmpty && young.retry) + let later = Gate.decide(known: young.known, first: [file: recentlyModified], + second: [file: recentlyModified], + now: now.addingTimeInterval(2)) + assert(later.ready == [file] && !later.retry) + + let renamed = "/tmp/incoming/renamed.png" + let renameEvent = Gate.decide(known: ready.known, + first: [renamed: complete], + second: [renamed: complete], now: now) + assert(renameEvent.ready.isEmpty, + "Renaming an already observed file must not enqueue another recipe.") + assert(renameEvent.known[renamed] == complete) + assert(!renameEvent.retry) + + let vanished = Gate.decide(known: ready.known, first: [:], second: [:], now: now) + assert(vanished.ready.isEmpty && vanished.known.isEmpty && !vanished.retry) + print("WatchedFolderTests passed") + } +}