From 41ce6fb1d0bf7c4de6e6d4826ff5896dcda56c40 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 02:59:33 +0530 Subject: [PATCH 1/7] Store explicit opt-in watched-folder rules with portable bookmarks --- Sources/Shared/WatchRules.swift | 63 +++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Sources/Shared/WatchRules.swift 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) + } +} From 5838636949ee8caf8cca9b729e27916ff690f63a Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:00:18 +0530 Subject: [PATCH 2/7] Add opt-in event-driven stable-file watchers with bounded scans and job integration --- Sources/App/Watch/WatchedFolderManager.swift | 131 +++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 Sources/App/Watch/WatchedFolderManager.swift diff --git a/Sources/App/Watch/WatchedFolderManager.swift b/Sources/App/Watch/WatchedFolderManager.swift new file mode 100644 index 0000000..e88a490 --- /dev/null +++ b/Sources/App/Watch/WatchedFolderManager.swift @@ -0,0 +1,131 @@ +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() + 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() 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: snapshot(folder)) + 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 [:] } + 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 } + let first = snapshot(watch.folder) + 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 } + let second = snapshot(watch.folder) + let recipe = RecipePreferences.all.first { $0.id == watch.rule.recipeID } + if let recipe { + for (path, stamp) in new where second[path] == stamp { + 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 }) { + scheduleScan(id: id) + } + } +} From ecc6f74a97f9fb21a697267179cf9c1ebac4a107 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:01:03 +0530 Subject: [PATCH 3/7] Add explicit opt-in watched-folder rules and global pause controls --- .../Watch/WatchedFoldersSettingsView.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Sources/App/Watch/WatchedFoldersSettingsView.swift 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 } + } +} From 9a5c7e6ef38e1dbee055a77fcd8b19e18f09fa04 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:01:14 +0530 Subject: [PATCH 4/7] Start opted-in folder observation on launch and expose rules in Settings --- Sources/App/Main.swift | 1 + 1 file changed, 1 insertion(+) 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 From 3f64371684c00ff0b55d2f5cf1ad0d2ed894a801 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:01:18 +0530 Subject: [PATCH 5/7] Start opted-in folder observation on launch and expose rules in Settings --- Sources/App/SettingsView.swift | 3 +++ 1 file changed, 3 insertions(+) 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 } From bd8f96fc3adcd3e6db45796bdf6b0b5f4e14efd8 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:03:03 +0530 Subject: [PATCH 6/7] Fail closed on oversized watcher directory and reschedule unstable writes --- Sources/App/Watch/WatchedFolderManager.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/App/Watch/WatchedFolderManager.swift b/Sources/App/Watch/WatchedFolderManager.swift index e88a490..e8f5afa 100644 --- a/Sources/App/Watch/WatchedFolderManager.swift +++ b/Sources/App/Watch/WatchedFolderManager.swift @@ -58,18 +58,22 @@ final class WatchedFolderManager { Task { @MainActor in self?.scheduleScan(id: rule.id) } } source.setCancelHandler { close(handle) } + guard let baseline = snapshot(folder) else { + source.cancel() + continue + } watches[rule.id] = Watch(rule: rule, folder: folder, source: source, - known: snapshot(folder)) + known: baseline) source.resume() } } - private func snapshot(_ folder: URL) -> [String: Stamp] { + 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 [:] } + 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( @@ -98,7 +102,7 @@ final class WatchedFolderManager { private func checkChanges(id: UUID) async { defer { checks.remove(id) } guard let watch = watches[id], !WatchRulePreferences.paused else { return } - let first = snapshot(watch.folder) + 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 @@ -108,7 +112,7 @@ final class WatchedFolderManager { // 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 } - let second = snapshot(watch.folder) + 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 { @@ -125,7 +129,10 @@ final class WatchedFolderManager { // 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 }) { - scheduleScan(id: id) + Task { [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) + self?.scheduleScan(id: id) + } } } } From 248a97de6745a5967b99e8691fb154f6b0a4d374 Mon Sep 17 00:00:00 2001 From: J Date: Sat, 17 Oct 2026 03:07:04 +0530 Subject: [PATCH 7/7] Only register viable watchers and wait for nonempty stable files --- Sources/App/Watch/WatchedFolderManager.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/App/Watch/WatchedFolderManager.swift b/Sources/App/Watch/WatchedFolderManager.swift index e8f5afa..59a3c64 100644 --- a/Sources/App/Watch/WatchedFolderManager.swift +++ b/Sources/App/Watch/WatchedFolderManager.swift @@ -47,7 +47,8 @@ final class WatchedFolderManager { 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() else { continue } + 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( @@ -58,10 +59,6 @@ final class WatchedFolderManager { Task { @MainActor in self?.scheduleScan(id: rule.id) } } source.setCancelHandler { close(handle) } - guard let baseline = snapshot(folder) else { - source.cancel() - continue - } watches[rule.id] = Watch(rule: rule, folder: folder, source: source, known: baseline) source.resume() @@ -115,7 +112,8 @@ final class WatchedFolderManager { 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 { + 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)",