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..59a3c64 --- /dev/null +++ b/Sources/App/Watch/WatchedFolderManager.swift @@ -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() + 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) + } + } + } +} 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/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) + } +}