diff --git a/Forel.entitlements b/Forel.entitlements
index d4dc965..b73d58b 100644
--- a/Forel.entitlements
+++ b/Forel.entitlements
@@ -4,6 +4,8 @@
com.apple.security.automation.apple-events
+ com.apple.security.network.client
+
com.apple.security.personal-information.photos-library
diff --git a/Package.resolved b/Package.resolved
index 94444bc..d69a036 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,6 +1,14 @@
{
- "originHash" : "6a52ce3a8c505da1d0b43bc0a234ca222fd5934b94a1b0173fa9bb152f4512b0",
+ "originHash" : "4635c24d818c123c67a5775b9ea03e9669a5cb0303be43e84ec0e6ec81144137",
"pins" : [
+ {
+ "identity" : "licensing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lab421/licensing.git",
+ "state" : {
+ "revision" : "1569b2038595862084a466d8dea806b360642608"
+ }
+ },
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
diff --git a/Package.swift b/Package.swift
index 40a245d..e526922 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,24 +3,29 @@ import PackageDescription
let package = Package(
name: "Forel",
- platforms: [.macOS(.v13)],
+ platforms: [.macOS(.v14)],
products: [
.library(name: "ForelCore", targets: ["ForelCore"]),
.executable(name: "ForelApp", targets: ["ForelApp"]),
],
dependencies: [
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.18"),
+ .package(url: "https://github.com/lab421/licensing.git", revision: "1569b2038595862084a466d8dea806b360642608"),
],
targets: [
.target(
name: "ForelCore",
dependencies: [
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
+ .product(name: "Lab421Licensing", package: "licensing"),
]
),
.executableTarget(
name: "ForelApp",
- dependencies: ["ForelCore"],
+ dependencies: [
+ "ForelCore",
+ .product(name: "Lab421Licensing", package: "licensing"),
+ ],
resources: [.copy("Resources")]
),
.testTarget(
diff --git a/README.md b/README.md
index 62b1e84..58c3b13 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ File matching and processing happen **locally on your Mac**. Forel does not uplo
- **Rule-based automation** — Create flexible rules combining filename patterns, file types, sizes, dates, tags, and Finder color labels.
- **Content matching & OCR** — Match text inside documents, images, and scanned PDFs using local extraction and on-device OCR. See [Content matching](#content-matching) for supported formats and limits.
-- **Folder watching** — Monitor any number of folders in real time with native macOS FSEvents.
+- **Folder watching** — Monitor folders in real time with native macOS FSEvents: up to 2 in Free, unlimited in Pro.
- **Dry Run & Run Now** — Preview matching files and planned actions without changing files, then run rules manually on existing files when ready.
- **History & Undo** — Review action results and undo supported actions when it is safe to do so.
- **Menu bar app** — Forel lives in your menu bar. Toggle individual rules on/off without opening the main window.
@@ -85,6 +85,36 @@ File matching and processing happen **locally on your Mac**. Forel does not uplo
## Installation
+Forel Free is available indefinitely without a license. Pro is a paid license; there is no trial.
+
+### Free and Pro
+
+| Feature | Free | Pro |
+|---|---|---|
+| Watched folders | 2, including disabled folders | Unlimited |
+| Files processed | 300 per calendar month | Unlimited |
+| Rules per folder and actions per rule | Unlimited | Unlimited |
+| Background watching and Run Now | Included | Included |
+| Move, copy, rename, tags, labels, trash, delete, uncompress, open app | Included | Included |
+| Filename, type, size, dates, tags, and download metadata conditions | Included | Included |
+| Text matching in documents and PDFs with a text layer | Included | Included |
+| On-device OCR for images and scanned PDFs | — | Included |
+| Custom scripts and Apple Shortcuts | Included | Included |
+| Imports into Photos, Music, and TV | — | Included |
+| Dry Run preview, history, and supported Undo | Included | Included |
+| File processing on your Mac | Included | Included |
+| [Scheduler](#roadmap) | — | Planned |
+| [Sync actions](#roadmap) | — | Planned |
+| [AI features](#roadmap) | — | Planned |
+
+**Free:** organize your everyday folders automatically with rules, custom scripts, and Apple Shortcuts. **Pro:** automate all your folders, process unlimited files, and recognize scanned documents.
+
+[View pricing and buy Forel Pro](https://forel-app.github.io/), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Monthly file usage is visible in the app; attempting a Pro action or reaching a quota explains the benefit and offers a purchase or activation link.
+
+Free includes **2 watched folders and 300 processed files per calendar month**, with unlimited rules and actions. Each item counts once per run when an action is applied; Dry Run does not count. The allowance renews on the first day of each month. At the limit, files stay in place and automatic processing resumes after renewal or Pro activation.
+
+Switching back to Free keeps your configuration. Reorder folders to choose which 2 remain available. OCR for images and scanned PDFs, and media library imports, require Pro.
+
### Homebrew
```bash
@@ -219,13 +249,13 @@ History / Undo (SQLite)
- [ ] Export / Import rules
- [ ] Toggle extension hidden / visible
- [ ] Move folder based on criteria
-- [ ] Scheduler
+- [ ] Scheduler — **Pro · Coming soon**
- [ ] Copy rules accros watching folders
- [ ] Compress actions
-- [ ] Sync actions
+- [ ] Sync actions — **Pro · Coming soon**
- [ ] Dynamic destination folders with date tokens (e.g. `~/Screenshots/{year}/{month}`)
- [ ] Upload actions
-- [ ] AI features
+- [ ] AI features — **Pro · Coming soon**
## Content matching
@@ -233,6 +263,8 @@ The **Contents** condition matches text found *inside* files. Everything is read
locally — no cloud, OCR runs on-device. When a file's text can't be read, the Dry
Run tells you why.
+Document text matching is available in Free and Pro. Vision OCR for images and scanned PDFs requires Pro; Free never runs OCR. The technical extraction limits below apply to both plans where the feature is available.
+
```
File path
│
diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift
index e36d061..8f208eb 100644
--- a/Sources/ForelApp/AppDelegate.swift
+++ b/Sources/ForelApp/AppDelegate.swift
@@ -15,6 +15,9 @@
// along with this program. If not, see .
import AppKit
+import Combine
+import ForelCore
+import Lab421Licensing
/// Closing the window hides it instead of quitting; Forel keeps running in
/// the menu bar. Quit is only available from the status item menu.
@@ -29,6 +32,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var statusBarController: StatusBarController?
private var model: AppModel?
private var updater: UpdaterManager?
+ let license = ForelLicenseManager(
+ isEnabled: BuildCapabilities.current.usesDirectLicensing,
+ productID: ForelLicensing.productID,
+ appName: ForelLicensing.appName,
+ deviceNameFallback: "This Mac",
+ secureStore: ForelKeychainLicenseStore(
+ service: ForelLicensing.keychainService,
+ userDefaultsSuiteName: ForelLicensing.userDefaultsSuite
+ ),
+ deviceIdentifierProvider: HardwareDeviceIdentifierProvider(userDefaultsSuiteName: ForelLicensing.userDefaultsSuite),
+ offlineTokenPublicKeyRaw: ForelLicensing.productionPublicKeyRaw,
+ baseURL: ForelLicensing.baseURL
+ )
+ let settingsNavigation = SettingsNavigation()
+ private var subscriptions = Set()
/// The window hosting `ContentView`, reported by `ForelMacApp` through
/// `mainWindowDidAttach`. Tracked explicitly rather than looked up in
/// `NSApp.windows`, which also holds the menu bar item's own window and
@@ -48,6 +66,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
model.applyDockIconPreference()
setUpStatusBar()
showMainWindowOnFirstLaunch()
+
+ model.setLicenseAccessLimited(!license.hasAccess)
+ // `dropFirst()` skips the state already accounted for above, so a
+ // fresh subscriber doesn't double-apply the state it was created
+ // with — matching the `hasAccess` snapshot immediately before this.
+ license.$state
+ .removeDuplicates()
+ .dropFirst()
+ .sink { [weak self] state in
+ guard let self else { return }
+ self.model?.setLicenseAccessLimited(state == .free)
+ }
+ .store(in: &subscriptions)
+ license.start()
}
/// Called whenever `ContentView` lands in a window — at launch, or later
@@ -151,10 +183,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
statusBarController = StatusBarController(
model: model,
updater: updater,
- onOpenMainWindow: { [weak self] in self?.openMainWindow() }
+ license: license,
+ onOpenMainWindow: { [weak self] in self?.openMainWindow() },
+ onOpenLicense: { [weak self] in self?.prepareLicenseSettings() }
)
}
+ /// Selects the License tab and activates Forel before the quick panel
+ /// opens the Settings scene using SwiftUI's openSettings action.
+ private func prepareLicenseSettings() {
+ settingsNavigation.selectedCategory = .license
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
private func openMainWindow() {
guard let mainWindow else {
// Started as a login item, Forel has no main window yet; reopening
diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift
index f7cb6c8..a50dc07 100644
--- a/Sources/ForelApp/AppModel.swift
+++ b/Sources/ForelApp/AppModel.swift
@@ -67,10 +67,35 @@ final class AppModel: ObservableObject {
private var runNowMessageId: UUID?
@Published private(set) var isPreviewing = false
@Published var previewResult: PreviewResult?
+ @Published var proUpgrade: ProUpgradeContext?
@Published private var ruleExpansionPreferences = RuleExpansionPreferences()
+ @Published private(set) var licenseAccessIsLimited = true
+ var isPro: Bool { !licenseAccessIsLimited }
+ var canAddFolder: Bool { FreePlan.canAddFolder(count: folders.count, isPro: isPro) }
+ var canAddRule: Bool { selectedFolderId != nil && selectedFolderIsAllowed }
+ var monthlyFileUsage: MonthlyFileUsage? {
+ monthlyUsage.monthlyFileUsage()
+ }
+ var selectedFolderIsAllowed: Bool {
+ guard let selectedFolderId else { return false }
+ return FreePlan.allows(folderID: selectedFolderId, folders: folders, isPro: isPro)
+ }
+ var planNotice: String? {
+ guard !isPro, selectedFolderId != nil else { return nil }
+ if !selectedFolderIsAllowed { return "This folder is kept for Pro. Free supports the first 2 watched folders; reorder folders or activate Pro to use it." }
+ if monthlyFileUsage?.isExhausted == true {
+ return FreePlanError.monthlyFileLimit.localizedDescription
+ }
+ return nil
+ }
+ private func executionRules(for folder: WatchedFolder) -> [Rule] {
+ db.withLock { db in (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] }
+ }
let db: Database
+ private let monthlyUsage: MonthlyFileUsageStore
private let coordinator: WatcherCoordinator
+ private var registeredWatchPaths = Set()
private let historyPageSize = 200
private let previewMatchLimit = 500
private let ruleRunStatsWindowDays = 30
@@ -92,9 +117,12 @@ final class AppModel: ObservableObject {
try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true)
let dbPath = appSupport.appendingPathComponent("forel.db").path
+ let usagePreferences = UserDefaults(suiteName: "com.lab421.forel.usage") ?? .standard
+ let monthlyUsage = MonthlyFileUsageStore(preferences: usagePreferences)
+ self.monthlyUsage = monthlyUsage
let db = try Database(path: dbPath)
self.db = db
- self.coordinator = WatcherCoordinator(db: db)
+ self.coordinator = WatcherCoordinator(db: db, monthlyUsage: monthlyUsage)
self.coordinator.onActivity = { [weak self] summary in
Task { @MainActor in
self?.queueWatcherNotification(summary)
@@ -110,6 +138,12 @@ final class AppModel: ObservableObject {
self?.bumpWatcherHistoryRevision()
}
}
+ self.coordinator.onMonthlyQuotaReached = { [weak self] in
+ Task { @MainActor in self?.bumpWatcherHistoryRevision() }
+ }
+ self.coordinator.onProcessingError = { [weak self] message in
+ Task { @MainActor in self?.showNotice(title: "Could not process this file", message: message) }
+ }
// Default to paused (watching off) on a fresh install — a brand-new
// user hasn't set up any rules yet, and starting to watch folders
// immediately means triggering permission prompts and risking rule-less
@@ -134,8 +168,8 @@ final class AppModel: ObservableObject {
self.ruleExpansionPreferences = db.withLock { db in RuleExpansionPreferences.load(from: db) }
+ coordinator.setProAccess(isPro)
reloadFolders()
- startWatchingEnabledFolders()
startHistoryCleanupTimer()
observeVolumeMounts()
}
@@ -335,6 +369,7 @@ final class AppModel: ObservableObject {
reloadHistory()
}
reloadRules()
+ refreshWatching()
}
func reloadRules() {
@@ -414,12 +449,22 @@ final class AppModel: ObservableObject {
return folders.first(where: { $0.id == historyFolderFilterId })?.path
}
- private func startWatchingEnabledFolders() {
- guard !paused else { return }
- let allFolders = db.withLock { db in (try? db.listFolders()) ?? [] }
- for folder in allFolders where folder.enabled {
- coordinator.add(folder.path)
- }
+ private var isWatchingAllowed: Bool { !paused }
+
+ func setLicenseAccessLimited(_ limited: Bool) {
+ licenseAccessIsLimited = limited
+ coordinator.setProAccess(!limited)
+ refreshWatching()
+ reloadRules()
+ }
+
+ private func refreshWatching() {
+ let paths = Set(folders.filter {
+ isWatchingAllowed && $0.enabled && FreePlan.allows(folderID: $0.id, folders: folders, isPro: isPro)
+ }.map(\.path))
+ for path in registeredWatchPaths.subtracting(paths) { coordinator.remove(path) }
+ for path in paths.subtracting(registeredWatchPaths) { coordinator.add(path) }
+ registeredWatchPaths = paths
}
func addFolder(path: String) {
@@ -435,10 +480,13 @@ final class AppModel: ObservableObject {
return
}
+ guard canAddFolder else {
+ proUpgrade = .folders
+ return
+ }
let folder = WatchedFolder(path: normalizedPath)
do {
- try db.withLock { db in try db.insertFolder(folder) }
- if !paused { coordinator.add(normalizedPath) }
+ try db.withLock { db in try db.insertFolder(folder, isPro: isPro) }
reloadFolders()
} catch {
showError(error)
@@ -490,10 +538,6 @@ final class AppModel: ObservableObject {
do {
try db.withLock { db in try db.updateFolderPath(folder.id, path: normalizedPath) }
- if folder.enabled, !paused {
- coordinator.remove(folder.path)
- coordinator.add(normalizedPath)
- }
reloadFolders()
reloadRules()
if historyFolderFilterId == folder.id {
@@ -505,12 +549,11 @@ final class AppModel: ObservableObject {
}
func toggleFolder(_ folder: WatchedFolder, enabled: Bool) {
- db.withLock { db in try? db.toggleFolder(folder.id, enabled: enabled) }
- if enabled, !paused {
- coordinator.add(folder.path)
- } else {
- coordinator.remove(folder.path)
+ if enabled, !FreePlan.allows(folderID: folder.id, folders: folders, isPro: isPro) {
+ proUpgrade = .folders
+ return
}
+ db.withLock { db in try? db.toggleFolder(folder.id, enabled: enabled) }
reloadFolders()
}
@@ -519,20 +562,29 @@ final class AppModel: ObservableObject {
reloadFolders()
}
- func saveRule(_ rule: Rule) {
+ @discardableResult
+ func saveRule(_ rule: Rule) -> Bool {
+ if !isPro, rule.enabled, let feature = FreePlan.requiredFeature(for: rule) {
+ proUpgrade = .feature(feature)
+ return false
+ }
+ let exists = rules.contains { $0.id == rule.id }
+ if !exists, !canAddRule {
+ proUpgrade = .folders
+ return false
+ }
do {
- let isUpdate = rules.contains(where: { $0.id == rule.id })
try db.withLock { db in
- if isUpdate {
- try db.updateRule(rule)
+ let existing = try db.listRules(folderId: rule.folderId)
+ if existing.contains(where: { $0.id == rule.id }) {
+ try db.updateRule(rule, isPro: isPro)
} else {
- try db.insertRule(rule)
+ try db.insertRule(rule, isPro: isPro)
}
}
reloadRules()
- } catch {
- showError(error)
- }
+ return true
+ } catch { showError(error); return false }
}
func deleteRule(_ rule: Rule) {
@@ -542,7 +594,18 @@ final class AppModel: ObservableObject {
}
func toggleRule(_ rule: Rule, enabled: Bool) {
- db.withLock { db in try? db.toggleRule(rule.id, enabled: enabled) }
+ if enabled, !isPro {
+ guard FreePlan.allows(folderID: rule.folderId, folders: folders, isPro: false) else {
+ proUpgrade = .folders
+ return
+ }
+ }
+ if enabled, !isPro, let feature = FreePlan.requiredFeature(for: rule) {
+ proUpgrade = .feature(feature)
+ return
+ }
+ do { try db.withLock { db in try db.toggleRule(rule.id, enabled: enabled) } }
+ catch { showError(error) }
reloadRules()
}
@@ -557,34 +620,52 @@ final class AppModel: ObservableObject {
func runNow() {
guard !isRunningNow else { return }
guard let folder = folders.first(where: { $0.id == selectedFolderId }) else { return }
- let folderRules = rules.filter(\.enabled)
+ let folderRules = executionRules(for: folder)
guard !folderRules.isEmpty else {
showRunNowMessage(RunNowMessage(text: "Run complete — no enabled rules", hasFailures: false, folderId: folder.id))
return
}
isRunningNow = true
+ let isPro = self.isPro
+ let usage = monthlyUsage
Task {
defer { isRunningNow = false }
- let allHistory = await Task.detached(priority: .userInitiated) {
+ let result = await Task.detached(priority: .userInitiated) {
let maxDepth = RuleEngine.maxRuleDepth(folderRules)
let entries = RuleEngine.walkEntries(root: folder.path, maxDepth: maxDepth)
let batchId = UUID().uuidString
var allHistory: [HistoryEntry] = []
+ var limitReached = false
+ var errorMessage: String?
for entry in entries {
- let (_, history) = RuleEngine.run(path: entry.path, depth: entry.depth, rules: folderRules, batchId: batchId, root: folder.path)
- allHistory.append(contentsOf: history)
+ do {
+ let result = try usage.runWithFreePlan(path: entry.path, depth: entry.depth, rules: folderRules,
+ batchId: batchId, root: folder.path, isPro: isPro)
+ allHistory.append(contentsOf: result.history)
+ } catch FreePlanError.monthlyFileLimit {
+ limitReached = true
+ break
+ } catch {
+ errorMessage = error.localizedDescription
+ break
+ }
}
- return allHistory
+ return (history: allHistory, limitReached: limitReached, error: errorMessage)
}.value
+ let allHistory = result.history
if !allHistory.isEmpty {
db.withLock { db in try? db.insertHistoryEntries(allHistory) }
}
reloadHistory()
showRunNowMessage(RunNowMessage(
- text: RunSummary.message(for: allHistory),
- hasFailures: allHistory.contains { $0.status == .failed },
+ text: result.limitReached
+ ? "Run paused — monthly Free limit reached. Remaining files stay in place."
+ : RunSummary.message(for: allHistory),
+ hasFailures: result.limitReached || result.error != nil || allHistory.contains { $0.status == .failed },
folderId: folder.id
))
+ if result.limitReached { proUpgrade = .monthlyFiles }
+ if let error = result.error { showNotice(title: "Could not complete Run Now", message: error) }
}
}
@@ -634,13 +715,14 @@ final class AppModel: ObservableObject {
previewResult = PreviewResult(filesScanned: 0, matches: [])
return
}
- let folderRules = rules.filter(\.enabled)
+ let folderRules = executionRules(for: folder)
guard !folderRules.isEmpty else {
previewResult = PreviewResult(filesScanned: 0, matches: [])
return
}
let matchLimit = previewMatchLimit
isPreviewing = true
+ let isPro = self.isPro
Task {
defer { isPreviewing = false }
previewResult = await Task.detached(priority: .userInitiated) {
@@ -650,7 +732,7 @@ final class AppModel: ObservableObject {
var reachedMatchLimit = false
RuleEngine.forEachEntry(root: folder.path, maxDepth: maxDepth) { entry in
filesScanned += 1
- guard let match = RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: folderRules) else {
+ guard let match = RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: folderRules, isPro: isPro) else {
return
}
if matches.count < matchLimit {
@@ -671,13 +753,14 @@ final class AppModel: ObservableObject {
/// The enabled rules and watched root currently covering `path`, if
/// watching is actually active there — empty/`nil` when paused or the
- /// folder is disabled, since the watcher wouldn't reprocess anything in
- /// that case regardless of what the rules say.
+ /// folder is disabled, since the watcher
+ /// wouldn't reprocess anything in that case regardless of what the rules
+ /// say.
private func activeRules(coveringRestorePath path: String) -> (rules: [Rule], watchedRoot: String?) {
- guard !paused else { return ([], nil) }
+ guard isWatchingAllowed else { return ([], nil) }
return db.withLock { db -> (rules: [Rule], watchedRoot: String?) in
guard let folder = try? db.folderForPath(path), folder.enabled else { return ([], nil) }
- let rules = (try? db.listRules(folderId: folder.id))?.filter(\.enabled) ?? []
+ let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? []
return (rules, folder.path)
}
}
@@ -688,7 +771,7 @@ final class AppModel: ObservableObject {
func undo(_ entry: HistoryEntry) {
guard entry.status == .applied else { return }
let context = activeRules(coveringRestorePath: entry.originalPath)
- switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot) {
+ switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot, isPro: isPro) {
case .unsafe(let reason):
showNotice(title: "Can't undo this action", message: reason)
case .safe:
@@ -720,7 +803,7 @@ final class AppModel: ObservableObject {
continue
}
let context = activeRules(coveringRestorePath: entry.originalPath)
- switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot) {
+ switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot, isPro: isPro) {
case .unsafe(let reason):
failures.append("\((entry.originalPath as NSString).lastPathComponent): \(reason)")
case .safe:
@@ -742,20 +825,11 @@ final class AppModel: ObservableObject {
func togglePaused() {
paused.toggle()
let isPaused = paused
- let allFolders = db.withLock { db -> [WatchedFolder] in
- try? db.setSetting("paused", isPaused ? "1" : "0")
- return (try? db.listFolders()) ?? []
- }
- for folder in allFolders {
- if paused {
- coordinator.remove(folder.path)
- } else if folder.enabled {
- coordinator.add(folder.path)
- }
- }
+ db.withLock { db in try? db.setSetting("paused", isPaused ? "1" : "0") }
+ refreshWatching()
}
- private func showNotice(title: String, message: String) {
+ func showNotice(title: String, message: String) {
alertTitle = title
errorMessage = message
}
diff --git a/Sources/ForelApp/Design/Components.swift b/Sources/ForelApp/Design/Components.swift
index 5046a68..7c22bbd 100644
--- a/Sources/ForelApp/Design/Components.swift
+++ b/Sources/ForelApp/Design/Components.swift
@@ -198,25 +198,37 @@ struct ForelSwitch: View {
struct QuickFolderRow: View {
let folder: WatchedFolder
@Binding var isOn: Bool
+ var isBlocked = false
var body: some View {
HStack(spacing: 10) {
ZStack {
- RoundedRectangle(cornerRadius: 7, style: .continuous).fill(ForelTheme.accent.opacity(0.18))
- Image(systemName: "folder.fill").font(.system(size: 12)).foregroundStyle(ForelTheme.accent)
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .fill(isBlocked ? ForelTheme.secondaryText.opacity(0.08) : ForelTheme.accent.opacity(0.18))
+ Image(systemName: "folder.fill")
+ .font(.system(size: 12))
+ .foregroundStyle(isBlocked ? ForelTheme.secondaryText : ForelTheme.accent)
}
.frame(width: 26, height: 26)
Text((folder.path as NSString).lastPathComponent)
.font(.system(size: 13))
- .foregroundStyle(ForelTheme.primaryText)
+ .foregroundStyle(isBlocked ? ForelTheme.secondaryText : ForelTheme.primaryText)
.lineLimit(1)
Spacer()
- ForelSwitch(isOn: $isOn, compact: true)
+ if isBlocked {
+ Label("Pro", systemImage: "lock.fill")
+ .font(.caption)
+ .foregroundStyle(ForelTheme.pro)
+ }
+ ForelSwitch(isOn: isBlocked ? .constant(false) : $isOn, compact: true)
+ .disabled(isBlocked)
+ .opacity(isBlocked ? 0.5 : 1)
}
.padding(.vertical, 7)
.padding(.horizontal, 14)
+ .help(isBlocked ? "Reorder folders or activate Pro to use this folder." : folder.path)
}
}
diff --git a/Sources/ForelApp/Design/Theme.swift b/Sources/ForelApp/Design/Theme.swift
index 2822b11..43eeb26 100644
--- a/Sources/ForelApp/Design/Theme.swift
+++ b/Sources/ForelApp/Design/Theme.swift
@@ -25,6 +25,8 @@ enum ForelTheme {
dark: NSColor(red: 0.07, green: 0.07, blue: 0.10, alpha: 1))
/// Mutable so the user can change it from Settings; defaults to the same
static var accent: Color = AccentPreset.blue.color
+ static let pro = Color(light: NSColor(red: 0.40, green: 0.20, blue: 0.72, alpha: 1),
+ dark: NSColor(red: 0.75, green: 0.62, blue: 1.0, alpha: 1))
static let success = Color(red: 0.20, green: 0.78, blue: 0.35)
static let danger = Color(red: 1.0, green: 0.27, blue: 0.23)
static let primaryText = Color(light: NSColor(red: 0.10, green: 0.11, blue: 0.14, alpha: 1),
diff --git a/Sources/ForelApp/ForelMacApp.swift b/Sources/ForelApp/ForelMacApp.swift
index c081ebd..2e12853 100644
--- a/Sources/ForelApp/ForelMacApp.swift
+++ b/Sources/ForelApp/ForelMacApp.swift
@@ -36,6 +36,8 @@ struct ForelMacApp: App {
ContentView()
.environmentObject(model)
.environmentObject(updater)
+ .environmentObject(appDelegate.license)
+ .environmentObject(appDelegate.settingsNavigation)
.frame(minWidth: 960, minHeight: 520)
.background(WindowAccessor { appDelegate.mainWindowDidAttach($0) })
.onAppear {
@@ -73,6 +75,8 @@ struct ForelMacApp: App {
SettingsView()
.environmentObject(model)
.environmentObject(updater)
+ .environmentObject(appDelegate.license)
+ .environmentObject(appDelegate.settingsNavigation)
}
.windowResizability(.contentSize)
}
diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift
new file mode 100644
index 0000000..a1215f4
--- /dev/null
+++ b/Sources/ForelApp/License/ForelLicensing.swift
@@ -0,0 +1,51 @@
+// Forel - A native macOS file-automation app
+// Copyright (C) 2026 Lab421
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+import Foundation
+import ForelCore
+
+/// Wires the shared Lab421Licensing package to Forel's own identifiers: its
+/// `product_id` on the license server, its Keychain service, its
+/// `UserDefaults` suite, and the signing key for that server's offline
+/// tokens. Other apps sharing the same license server use the same package
+/// with their own values here.
+enum ForelLicensing {
+ static let productID = "forel"
+ static let appName = "Forel"
+ static let keychainService = "com.lab421.forel.license"
+ static let userDefaultsSuite: String = {
+ let suite = "com.lab421.forel.licensing"
+ ForelLicensePreferences.migrate(to: suite, from: "com.lab421.forel", service: keychainService)
+ return suite
+ }()
+ static let purchaseURL = URL(string: "https://forel-app.github.io/")!
+
+ static let baseURL: URL = {
+ switch BuildFlavor.current {
+ case .development:
+ URL(string: "http://localhost:8787/api/v1/license")!
+ case .adHoc, .appStore:
+ URL(string: "https://api.ombrelle.app/api/v1/license")!
+ }
+ }()
+
+ /// Raw 32-byte Ed25519 public key matching the `LICENSE_SIGNING_PRIVATE_KEY`
+ /// secret on the license server. Rotating that key invalidates every cached
+ /// token signed with the old one, forcing every device back online once.
+ static let productionPublicKeyRaw = Data(
+ base64Encoded: "U0CobIap9nWvOjGXe0Xg8jY37k86j1l2bJTny4GYsNI="
+ )!
+}
diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift
new file mode 100644
index 0000000..fe49d78
--- /dev/null
+++ b/Sources/ForelApp/License/LicenseSettingsView.swift
@@ -0,0 +1,117 @@
+import SwiftUI
+import AppKit
+import ForelCore
+
+struct LicenseSettingsView: View {
+ @EnvironmentObject var license: ForelLicenseManager
+ @Environment(\.openURL) private var openURL
+ @State private var licenseKey = ""
+ @State private var showingDeactivationConfirmation = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ SectionLabel(title: "License Status")
+ GlassCard {
+ HStack(spacing: 12) {
+ Image(systemName: statusSymbol)
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(statusColor)
+ .frame(width: 28)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(statusTitle)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(ForelTheme.primaryText)
+ Text(statusSubtitle)
+ .font(.system(size: 11))
+ .foregroundStyle(ForelTheme.secondaryText)
+ }
+ Spacer()
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ }
+
+ if case .pro = license.state {
+ SectionLabel(title: "Licensed Device")
+ GlassCard {
+ SettingsActionRow(
+ title: license.deviceName,
+ subtitle: license.licenseKey,
+ buttonTitle: "Deactivate",
+ action: { showingDeactivationConfirmation = true }
+ )
+ }
+ } else {
+ SectionLabel(title: "Monthly usage")
+ MonthlyFileUsageView(onUpgrade: { openURL(ForelLicensing.purchaseURL) })
+ SectionLabel(title: "Automate more with Pro")
+ ProBenefitsView()
+ SectionLabel(title: "Activate License")
+ GlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ GlassField(placeholder: "License Key", text: $licenseKey)
+ .onSubmit { activate() }
+
+ HStack {
+ Button("Buy Forel Pro") {
+ openURL(ForelLicensing.purchaseURL)
+ }
+ .buttonStyle(.link)
+
+ Spacer()
+
+ Button("Activate", action: activate)
+ .buttonStyle(.borderedProminent)
+ .disabled(
+ licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || license.isWorking
+ )
+ }
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ }
+ }
+
+ if let lastError = license.lastError {
+ Text(lastError)
+ .font(.system(size: 11))
+ .foregroundStyle(Color.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ if license.isWorking {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ .frame(maxWidth: 460)
+ .confirmationDialog(
+ "Are you sure ?",
+ isPresented: $showingDeactivationConfirmation
+ ) {
+ Button("Deactivate", role: .destructive) {
+ Task { await license.deactivate() }
+ }
+ Button("Cancel", role: .cancel) {}
+ }
+ }
+
+ private func activate() {
+ Task {
+ await license.activate(licenseKey: licenseKey)
+ if case .pro = license.state {
+ licenseKey = ""
+ }
+ }
+ }
+
+ private var statusSymbol: String { license.hasAccess ? "checkmark.seal.fill" : "person.crop.circle" }
+ private var statusColor: Color { license.hasAccess ? .green : ForelTheme.secondaryText }
+ private var statusTitle: String { license.hasAccess ? "Forel Pro" : "Forel Free" }
+ private var statusSubtitle: String {
+ license.hasAccess
+ ? "Unlimited folders and files, with OCR and media imports."
+ : "Free forever: 2 watched folders and 300 processed files per month, with unlimited rules and actions. No trial."
+ }
+}
diff --git a/Sources/ForelApp/License/MonthlyFileUsageView.swift b/Sources/ForelApp/License/MonthlyFileUsageView.swift
new file mode 100644
index 0000000..fc6b39c
--- /dev/null
+++ b/Sources/ForelApp/License/MonthlyFileUsageView.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+import ForelCore
+
+struct MonthlyFileUsageView: View {
+ @EnvironmentObject private var model: AppModel
+ var onUpgrade: (() -> Void)?
+
+ var body: some View {
+ TimelineView(.periodic(from: .now, by: 60)) { _ in
+ if let usage = model.monthlyFileUsage {
+ Button {
+ if let onUpgrade { onUpgrade() }
+ else { model.proUpgrade = .monthlyFiles }
+ } label: {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("\(usage.processed)/\(FreePlan.monthlyFileLimit) files this month")
+ .font(.caption.monospacedDigit())
+ ProgressView(value: Double(usage.processed), total: Double(FreePlan.monthlyFileLimit))
+ .tint(usage.isExhausted ? ForelTheme.pro : ForelTheme.accent)
+ }
+ .foregroundStyle(ForelTheme.secondaryText)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help("Renews \(usage.resetsAt.formatted(date: .abbreviated, time: .omitted)). Dry Run does not count. Discover Pro for unlimited processing.")
+ }
+ }
+ }
+}
diff --git a/Sources/ForelApp/License/ProUpgradeView.swift b/Sources/ForelApp/License/ProUpgradeView.swift
new file mode 100644
index 0000000..054f05c
--- /dev/null
+++ b/Sources/ForelApp/License/ProUpgradeView.swift
@@ -0,0 +1,83 @@
+import AppKit
+import SwiftUI
+import ForelCore
+
+enum ProUpgradeContext: Identifiable {
+ case folders, monthlyFiles, feature(ProFeature)
+ var id: String {
+ switch self {
+ case .folders: "folders"
+ case .monthlyFiles: "monthly-files"
+ case .feature(let feature): feature.id
+ }
+ }
+ var title: String {
+ switch self {
+ case .folders: "Automate more folders"
+ case .monthlyFiles: "Keep processing with Pro"
+ case .feature(let feature): feature.title
+ }
+ }
+ var message: String {
+ switch self {
+ case .folders: "Free includes 2 watched folders. With Pro, organize your projects, invoices, and downloads with unlimited folders."
+ case .monthlyFiles: "Free includes 300 processed files per calendar month, shared across all folders. Activate Pro for unlimited processing, or resume next month. Dry Run remains available and your remaining files stay in place."
+ case .feature(let feature): feature.benefit
+ }
+ }
+}
+
+struct ProUpgradeView: View {
+ let context: ProUpgradeContext
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
+ @Environment(\.openSettings) private var openSettings
+ @EnvironmentObject private var navigation: SettingsNavigation
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ Label("Forel Pro", systemImage: "sparkles")
+ .font(.headline)
+ .foregroundStyle(ForelTheme.pro)
+ Text(context.title).font(.title2.bold())
+ Text(context.message)
+ .foregroundStyle(ForelTheme.secondaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ ProBenefitsView()
+ Text("Free stays free. Pro is a paid license, with no trial.")
+ .font(.caption)
+ .foregroundStyle(ForelTheme.secondaryText)
+ HStack {
+ Button("Continue in Free") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ Button("Activate a license") {
+ navigation.selectedCategory = .license
+ dismiss()
+ openSettings()
+ }
+ Button("View pricing & buy Pro") {
+ openURL(ForelLicensing.purchaseURL)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(24)
+ .frame(width: 520)
+ .foregroundStyle(ForelTheme.primaryText)
+ .background(ForelTheme.background)
+ }
+}
+
+struct ProBenefitsView: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ Label("Unlimited watched folders", systemImage: "folder.badge.plus")
+ Label("Unlimited files processed", systemImage: "doc.on.doc")
+ Label("OCR for images and scanned PDFs", systemImage: "text.viewfinder")
+ Label("Photos, Music, and TV imports", systemImage: "photo.on.rectangle")
+ }
+ .font(.callout)
+ .foregroundStyle(ForelTheme.primaryText)
+ }
+}
diff --git a/Sources/ForelApp/MenuBar/StatusBarController.swift b/Sources/ForelApp/MenuBar/StatusBarController.swift
index 5b0b015..22d240c 100644
--- a/Sources/ForelApp/MenuBar/StatusBarController.swift
+++ b/Sources/ForelApp/MenuBar/StatusBarController.swift
@@ -18,6 +18,7 @@ import AppKit
import SwiftUI
import Combine
import ForelCore
+import Lab421Licensing
/// Menu-bar item: Forel glyph with a green/red status dot. Clicking it shows
/// a SwiftUI quick panel (`QuickPanelView`) styled as a dark glass popover,
@@ -27,18 +28,23 @@ final class StatusBarController: NSObject {
private let statusItem: NSStatusItem
private let model: AppModel
private let updater: UpdaterManager
+ private let license: ForelLicenseManager
private let onOpenMainWindow: () -> Void
private var popover: NSPopover?
private var localDismissMonitor: Any?
private var globalDismissMonitor: Any?
private var pausedSubscription: AnyCancellable?
private var updateSubscription: AnyCancellable?
+ private var licenseSubscription: AnyCancellable?
+ private var onOpenLicense: (() -> Void)?
- init(model: AppModel, updater: UpdaterManager, onOpenMainWindow: @escaping () -> Void) {
+ init(model: AppModel, updater: UpdaterManager, license: ForelLicenseManager, onOpenMainWindow: @escaping () -> Void, onOpenLicense: (() -> Void)? = nil) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
self.model = model
self.updater = updater
+ self.license = license
self.onOpenMainWindow = onOpenMainWindow
+ self.onOpenLicense = onOpenLicense
super.init()
if let button = statusItem.button {
@@ -55,6 +61,10 @@ final class StatusBarController: NSObject {
guard let self else { return }
self.refreshGlyph(paused: self.model.paused)
}
+ licenseSubscription = license.$state.sink { [weak self] _ in
+ guard let self else { return }
+ self.refreshGlyph(paused: self.model.paused)
+ }
}
private func refreshGlyph(paused: Bool) {
@@ -67,18 +77,27 @@ final class StatusBarController: NSObject {
return
}
- let panel = QuickPanelView(
+ var panel = QuickPanelView(
onOpenMainWindow: { [weak self] in
self?.popover?.performClose(nil)
self?.onOpenMainWindow()
},
onQuit: { NSApp.terminate(nil) }
)
- .environmentObject(model)
- .environmentObject(updater)
+ panel.onOpenLicense = { [weak self] in
+ self?.popover?.performClose(nil)
+ self?.onOpenLicense?()
+ }
+
+ let hostingController = NSHostingController(
+ rootView: panel
+ .environmentObject(model)
+ .environmentObject(updater)
+ .environmentObject(license)
+ )
let newPopover = NSPopover()
- newPopover.contentViewController = NSHostingController(rootView: panel)
+ newPopover.contentViewController = hostingController
newPopover.behavior = .transient
newPopover.delegate = self
popover = newPopover
diff --git a/Sources/ForelApp/Views/ContentView.swift b/Sources/ForelApp/Views/ContentView.swift
index a49a110..6419908 100644
--- a/Sources/ForelApp/Views/ContentView.swift
+++ b/Sources/ForelApp/Views/ContentView.swift
@@ -26,11 +26,20 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 280, ideal: 270, max: 320)
.modifier(SidebarToggleRemovalModifier())
} detail: {
- switch model.detailRoute {
- case .rules:
- RuleListView()
- case .history:
- HistoryView()
+ Group {
+ switch model.detailRoute {
+ case .rules:
+ RuleListView()
+ case .history:
+ HistoryView()
+ }
+ }
+ .overlay(alignment: .bottomTrailing) {
+ if !model.isPro {
+ Button("Discover Pro") { model.proUpgrade = .folders }
+ .buttonStyle(.link)
+ .padding(16)
+ }
}
}
.toolbarBackground(ForelTheme.background, for: .windowToolbar)
@@ -40,6 +49,9 @@ struct ContentView: View {
Text(model.errorMessage ?? "")
}
.tint(ForelTheme.accent)
+ .sheet(item: $model.proUpgrade) { context in
+ ProUpgradeView(context: context)
+ }
// ForelTheme.accent is a plain static var, not observable; bumping the
// identity here forces every descendant to rebuild and re-read it.
.id(model.accentVersion)
diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift
index 83dface..cde42d9 100644
--- a/Sources/ForelApp/Views/QuickPanelView.swift
+++ b/Sources/ForelApp/Views/QuickPanelView.swift
@@ -17,6 +17,7 @@
import AppKit
import SwiftUI
import ForelCore
+import Lab421Licensing
/// The menu-bar quick panel: header with status badge, a "Watching" master
/// switch, watched-folder toggles, and an activity summary. Deep editing
@@ -25,8 +26,11 @@ import ForelCore
struct QuickPanelView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var updater: UpdaterManager
+ @EnvironmentObject var license: ForelLicenseManager
+ @Environment(\.openSettings) private var openSettings
let onOpenMainWindow: () -> Void
let onQuit: () -> Void
+ var onOpenLicense: (() -> Void)?
@State private var totalRuleCount = 0
var body: some View {
@@ -65,7 +69,11 @@ struct QuickPanelView: View {
GlassCard {
VStack(spacing: 0) {
ForEach(model.folders, id: \.id) { folder in
- QuickFolderRow(folder: folder, isOn: folderBinding(folder))
+ QuickFolderRow(
+ folder: folder,
+ isOn: folderBinding(folder),
+ isBlocked: !FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro)
+ )
if folder.id != model.folders.last?.id {
Divider().overlay(ForelTheme.divider).padding(.leading, 50)
}
@@ -114,10 +122,27 @@ struct QuickPanelView: View {
HStack(alignment: .top, spacing: 12) {
BrandMark(size: 38)
VStack(alignment: .leading, spacing: 6) {
- Text("Forel")
- .font(.system(size: 17, weight: .bold))
- .foregroundStyle(ForelTheme.primaryText)
+ HStack(spacing: 7) {
+ Text("Forel")
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(ForelTheme.primaryText)
+ Button(action: showLicenseSettings) {
+ Text(license.hasAccess ? "Pro" : "Free")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(license.hasAccess ? ForelTheme.pro : ForelTheme.primaryText)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 3)
+ .background(Capsule().fill(license.hasAccess ? ForelTheme.pro.opacity(0.16) : ForelTheme.surface))
+ .overlay(Capsule().strokeBorder(license.hasAccess ? ForelTheme.pro.opacity(0.35) : ForelTheme.surfaceBorder, lineWidth: 1))
+ .accessibilityLabel(license.hasAccess ? "Forel Pro" : "Forel Free")
+ }
+ .buttonStyle(.plain)
+ .help(license.hasAccess ? "Manage your Pro license" : "Discover Forel Pro")
+ }
StatusBadge(active: !model.paused)
+ if !license.hasAccess {
+ MonthlyFileUsageView(onUpgrade: showLicenseSettings)
+ }
}
Spacer(minLength: 0)
}
@@ -127,6 +152,11 @@ struct QuickPanelView: View {
totalRuleCount = model.db.withLock { db in (try? db.countRules()) ?? 0 }
}
+ private func showLicenseSettings() {
+ onOpenLicense?()
+ openSettings()
+ }
+
private var watchingBinding: Binding {
Binding(get: { !model.paused }, set: { enabled in
if enabled == model.paused {
diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift
index cec8ad5..1744e26 100644
--- a/Sources/ForelApp/Views/RuleEditorView.swift
+++ b/Sources/ForelApp/Views/RuleEditorView.swift
@@ -25,6 +25,7 @@ struct RuleEditorView: View {
@State private var rule: Rule
@State private var showValidationErrors = false
@State private var errorDismissTask: Task?
+ @State private var upgradeContext: ProUpgradeContext?
@EnvironmentObject private var model: AppModel
private let preferredHeight: CGFloat
let onSave: (Rule) -> Void
@@ -82,7 +83,7 @@ struct RuleEditorView: View {
placeholder("No conditions — this rule matches every file in scope.")
}
ForEach($rule.conditions, id: \.id) { $condition in
- ConditionRow(condition: $condition) {
+ ConditionRow(condition: $condition, onRequestUpgrade: { upgradeContext = .feature($0) }) {
rule.conditions.removeAll { $0.id == condition.id }
}
}
@@ -92,6 +93,9 @@ struct RuleEditorView: View {
HStack {
SectionLabel(title: "Actions")
+ Text("\(rule.actions.count)")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(ForelTheme.secondaryText)
Spacer()
Button {
rule.actions.append(Action(ruleId: rule.id, kind: .moveToFolder, params: .object(["destination": .string("")]), position: Int64(rule.actions.count)))
@@ -106,7 +110,7 @@ struct RuleEditorView: View {
placeholder("No actions yet — add at least one to make this rule do something.")
}
ForEach($rule.actions, id: \.id) { $action in
- ActionRow(action: $action) {
+ ActionRow(action: $action, onRequestUpgrade: { upgradeContext = .feature($0) }) {
rule.actions.removeAll { $0.id == action.id }
}
}
@@ -137,6 +141,10 @@ struct RuleEditorView: View {
showValidationErrors = false
}
} else {
+ if !model.isPro, rule.enabled, let feature = FreePlan.requiredFeature(for: rule) {
+ upgradeContext = .feature(feature)
+ return
+ }
showValidationErrors = false
errorDismissTask?.cancel()
onSave(rule)
@@ -151,6 +159,7 @@ struct RuleEditorView: View {
.frame(width: 880, height: preferredHeight)
.background(ForelTheme.background)
.background(WindowActivationBridge(showsDockIcon: model.showDockIcon))
+ .sheet(item: $upgradeContext) { context in ProUpgradeView(context: context) }
}
private var conditionIssues: [RuleValidator.Issue] {
@@ -320,6 +329,8 @@ private struct ScopeEditor: View {
private struct ConditionRow: View {
@Binding var condition: Condition
+ @EnvironmentObject private var model: AppModel
+ let onRequestUpgrade: (ProFeature) -> Void
let onDelete: () -> Void
var body: some View {
@@ -349,6 +360,15 @@ private struct ConditionRow: View {
.foregroundStyle(ForelTheme.secondaryText)
.padding(.leading, 2)
}
+ if condition.kind == .contents, !model.isPro {
+ HStack {
+ Text("Document text is included in Free. Images and scanned PDFs require Pro OCR.")
+ .font(.caption)
+ .foregroundStyle(ForelTheme.secondaryText)
+ Button("Unlock OCR") { onRequestUpgrade(.ocr) }
+ .buttonStyle(.link)
+ }
+ }
}
}
@@ -476,6 +496,8 @@ private struct ConditionOperatorMenu: View {
private struct ActionKindMenu: View {
@Binding var selection: ActionKind
+ @EnvironmentObject private var model: AppModel
+ let onRequestUpgrade: (ProFeature) -> Void
var body: some View {
RuleSelectMenu(title: selection.label) {
@@ -484,12 +506,15 @@ private struct ActionKindMenu: View {
Section(title) {
ForEach(group.kinds, id: \.self) { kind in
Button {
- selection = kind
+ select(kind)
} label: {
HStack {
Image(systemName: kind.iconSystemName)
.frame(width: 16)
Text(kind.label)
+ if !model.isPro, FreePlan.requiredFeature(for: kind) != nil {
+ Label("Pro", systemImage: "lock.fill")
+ }
}
}
}
@@ -497,12 +522,15 @@ private struct ActionKindMenu: View {
} else {
ForEach(group.kinds, id: \.self) { kind in
Button {
- selection = kind
+ select(kind)
} label: {
HStack {
Image(systemName: kind.iconSystemName)
.frame(width: 16)
Text(kind.label)
+ if !model.isPro, FreePlan.requiredFeature(for: kind) != nil {
+ Label("Pro", systemImage: "lock.fill")
+ }
}
}
}
@@ -510,6 +538,14 @@ private struct ActionKindMenu: View {
}
}
}
+
+ private func select(_ kind: ActionKind) {
+ if !model.isPro, let feature = FreePlan.requiredFeature(for: kind) {
+ onRequestUpgrade(feature)
+ } else {
+ selection = kind
+ }
+ }
}
private struct StringSelectMenu: View {
@@ -795,15 +831,24 @@ private struct KindValuePicker: View {
private struct ActionRow: View {
@Binding var action: Action
+ @EnvironmentObject private var model: AppModel
+ let onRequestUpgrade: (ProFeature) -> Void
let onDelete: () -> Void
@State private var showingOptions = false
var body: some View {
HStack(alignment: .center, spacing: 12) {
- ActionKindMenu(selection: kindBinding)
+ ActionKindMenu(selection: kindBinding, onRequestUpgrade: onRequestUpgrade)
.frame(minWidth: 160, alignment: .leading)
- actionParams
+ Group {
+ if !model.isPro, let feature = FreePlan.requiredFeature(for: action.kind) {
+ Button("Unlock \(feature.title)") { onRequestUpgrade(feature) }
+ .buttonStyle(.link)
+ } else {
+ actionParams
+ }
+ }
.frame(minWidth: 200, alignment: .leading)
.frame(minHeight: 32)
.layoutPriority(1)
diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift
index 0cfe4ce..a898ace 100644
--- a/Sources/ForelApp/Views/RuleListView.swift
+++ b/Sources/ForelApp/Views/RuleListView.swift
@@ -31,8 +31,7 @@ struct RuleListView: View {
content
.sheet(item: $editingRule) { rule in
RuleEditorView(rule: rule, preferredHeight: editorHeight(for: proxy.size.height)) { saved in
- model.saveRule(saved)
- editingRule = nil
+ if model.saveRule(saved) { editingRule = nil }
} onCancel: {
editingRule = nil
}
@@ -46,6 +45,19 @@ struct RuleListView: View {
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
header
+ if let notice = model.planNotice {
+ HStack {
+ Text(notice)
+ .font(.caption)
+ .foregroundStyle(ForelTheme.secondaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ Button("Discover Pro") {
+ model.proUpgrade = model.selectedFolderIsAllowed ? .monthlyFiles : .folders
+ }
+ .buttonStyle(.link)
+ }
+ }
if model.paused, model.selectedFolderId != nil {
pausedBanner
}
@@ -67,13 +79,27 @@ struct RuleListView: View {
} else {
List {
ForEach(Array(model.rules.enumerated()), id: \.element.id) { index, rule in
- RuleCard(
+ VStack(alignment: .leading, spacing: 4) {
+ if !model.selectedFolderIsAllowed || (!model.isPro && FreePlan.requiredFeature(for: rule) != nil) {
+ Button("Kept for Pro — not running") {
+ if let feature = FreePlan.requiredFeature(for: rule) {
+ model.proUpgrade = .feature(feature)
+ } else {
+ model.proUpgrade = .folders
+ }
+ }
+ .buttonStyle(.link)
+ .font(.caption)
+ .foregroundStyle(ForelTheme.secondaryText)
+ }
+ RuleCard(
rule: rule,
order: index + 1,
isExpanded: model.isRuleExpanded(rule),
onToggleExpanded: { model.toggleRuleExpanded(rule) },
onEdit: { editingRule = rule }
- )
+ )
+ }
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
@@ -138,7 +164,7 @@ struct RuleListView: View {
title: selectedFolder.map { ($0.path as NSString).lastPathComponent } ?? "Rules",
subtitle: model.selectedFolderId == nil
? "Select a folder to manage its rules"
- : "\(model.rules.count) rule\(model.rules.count == 1 ? "" : "s") · drag to reorder",
+ : "\(model.rules.count) rules · drag to reorder",
systemImage: selectedFolder == nil ? nil : "folder.fill"
) {
Button {
@@ -155,12 +181,17 @@ struct RuleListView: View {
HStack(spacing: 8) {
Button {
guard let folderId = model.selectedFolderId else { return }
+ guard model.canAddRule else {
+ model.proUpgrade = .folders
+ return
+ }
editingRule = Rule(folderId: folderId, name: "New Rule")
} label: {
Label("New Rule", systemImage: "plus")
}
.buttonStyle(PrimaryButtonStyle())
- .disabled(model.selectedFolderId == nil)
+ .disabled(!model.canAddRule)
+ .opacity(model.canAddRule ? 1 : 0.45)
Button {
model.runNow()
@@ -175,7 +206,8 @@ struct RuleListView: View {
}
}
.buttonStyle(SecondaryButtonStyle())
- .disabled(model.selectedFolderId == nil || model.isRunningNow)
+ .disabled(!model.selectedFolderIsAllowed || model.isRunningNow)
+ .opacity(model.selectedFolderIsAllowed ? 1 : 0.45)
Button {
model.preview()
@@ -190,7 +222,8 @@ struct RuleListView: View {
}
}
.buttonStyle(SecondaryButtonStyle())
- .disabled(model.selectedFolderId == nil || model.isPreviewing)
+ .disabled(!model.selectedFolderIsAllowed || model.isPreviewing)
+ .opacity(model.selectedFolderIsAllowed ? 1 : 0.45)
Spacer()
}
@@ -248,18 +281,26 @@ struct RuleListView: View {
Text("No rules yet")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(ForelTheme.primaryText)
- Text("Create a rule to automate files in this folder.")
+ Text(model.canAddRule
+ ? "Create a rule to automate files in this folder."
+ : "Reorder folders or activate Pro to add rules in this folder.")
.font(.system(size: 11))
.foregroundStyle(ForelTheme.secondaryText)
.multilineTextAlignment(.center)
Button {
guard let folderId = model.selectedFolderId else { return }
+ guard model.canAddRule else {
+ model.proUpgrade = .folders
+ return
+ }
editingRule = Rule(folderId: folderId, name: "New Rule")
} label: {
Label("New Rule", systemImage: "plus")
}
.buttonStyle(PrimaryButtonStyle())
.padding(.top, 8)
+ .disabled(!model.canAddRule)
+ .opacity(model.canAddRule ? 1 : 0.45)
}
}
.frame(maxWidth: .infinity)
@@ -310,6 +351,7 @@ private struct RuleCard: View {
.toggleStyle(.switch)
.tint(ForelTheme.accent)
.controlSize(.small)
+ .disabled(!model.selectedFolderIsAllowed)
Button(action: onToggleExpanded) {
VStack(alignment: .leading, spacing: 3) {
@@ -365,7 +407,7 @@ private struct RuleCard: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder)
)
- .opacity(rule.enabled ? 1 : 0.6)
+ .opacity(rule.enabled && model.selectedFolderIsAllowed ? 1 : 0.6)
}
private func metaPill(icon: String, text: String) -> some View {
@@ -378,7 +420,7 @@ private struct RuleCard: View {
private var enabledBinding: Binding {
- Binding(get: { rule.enabled }, set: { model.toggleRule(rule, enabled: $0) })
+ Binding(get: { rule.enabled && model.selectedFolderIsAllowed }, set: { model.toggleRule(rule, enabled: $0) })
}
}
diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift
index 96901f3..b75158e 100644
--- a/Sources/ForelApp/Views/SettingsView.swift
+++ b/Sources/ForelApp/Views/SettingsView.swift
@@ -15,6 +15,17 @@
// along with this program. If not, see .
import SwiftUI
+import ForelCore
+import Lab421Licensing
+
+/// Shared across the app delegate and `SettingsView` so code outside SwiftUI
+/// (e.g. the status item's "Activate" action, or `AppDelegate` reacting to a
+/// denied license) can select a specific settings tab before opening the
+/// `Settings` scene.
+@MainActor
+final class SettingsNavigation: ObservableObject {
+ @Published var selectedCategory: SettingsCategory = .general
+}
/// Modern macOS Settings layout: a category sidebar on the left (matching
/// System Settings' own convention), detail content on the right — instead
@@ -22,8 +33,9 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var updater: UpdaterManager
+ @EnvironmentObject var license: ForelLicenseManager
+ @EnvironmentObject var navigation: SettingsNavigation
@State private var launchAtLogin = LoginItem.isEnabled
- @State private var selectedCategory: SettingsCategory = .general
var body: some View {
HStack(spacing: 0) {
@@ -45,8 +57,8 @@ struct SettingsView: View {
ForEach(SettingsCategory.allCases) { category in
SettingsSidebarRow(
category: category,
- isSelected: selectedCategory == category,
- action: { selectedCategory = category }
+ isSelected: navigation.selectedCategory == category,
+ action: { navigation.selectedCategory = category }
)
}
Spacer(minLength: 0)
@@ -59,11 +71,14 @@ struct SettingsView: View {
private var detail: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
- switch selectedCategory {
+ switch navigation.selectedCategory {
case .general:
generalPane
case .permissions:
PermissionsSection()
+ case .license:
+ LicenseSettingsView()
+ .environmentObject(license)
case .updates:
updatesPane
case .about:
@@ -251,8 +266,8 @@ struct SettingsView: View {
}
}
-private enum SettingsCategory: CaseIterable, Identifiable {
- case general, permissions, updates, about
+enum SettingsCategory: CaseIterable, Identifiable {
+ case general, permissions, license, updates, about
var id: Self { self }
@@ -260,6 +275,7 @@ private enum SettingsCategory: CaseIterable, Identifiable {
switch self {
case .general: "General"
case .permissions: "Permissions"
+ case .license: "License"
case .updates: "Updates"
case .about: "About"
}
@@ -269,19 +285,17 @@ private enum SettingsCategory: CaseIterable, Identifiable {
switch self {
case .general: "gearshape.fill"
case .permissions: "lock.shield.fill"
+ case .license: "key.fill"
case .updates: "arrow.triangle.2.circlepath"
case .about: "info.circle.fill"
}
}
- /// A fixed, distinctly-colored badge per category (as in System Settings)
- /// rather than the single theme accent — it's what makes the sidebar
- /// scannable at a glance, so it intentionally doesn't follow the user's
- /// chosen accent color.
var tint: Color {
switch self {
case .general: Color(red: 0.36, green: 0.34, blue: 0.90)
case .permissions: Color(red: 0.95, green: 0.55, blue: 0.13)
+ case .license: Color(red: 0.20, green: 0.45, blue: 0.80)
case .updates: Color(red: 0.20, green: 0.70, blue: 0.45)
case .about: Color(red: 0.31, green: 0.55, blue: 0.95)
}
diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift
index c7f78ce..993079a 100644
--- a/Sources/ForelApp/Views/SidebarView.swift
+++ b/Sources/ForelApp/Views/SidebarView.swift
@@ -91,6 +91,8 @@ struct SidebarView: View {
Spacer(minLength: 0)
+ if !model.isPro { MonthlyFileUsageView() }
+
HStack {
if #available(macOS 14.0, *) {
SettingsFooterLink()
@@ -140,6 +142,8 @@ struct SidebarView: View {
private func folderCard(_ folder: WatchedFolder) -> some View {
let isSelected = model.selectedFolderId == folder.id
+ let isAllowed = FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro)
+ let isActive = folder.enabled && isAllowed
return HStack(spacing: 10) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 11))
@@ -147,17 +151,17 @@ struct SidebarView: View {
ZStack {
RoundedRectangle(cornerRadius: 7, style: .continuous)
- .fill(ForelTheme.accent.opacity(folder.enabled ? 0.18 : 0.08))
+ .fill(isActive ? ForelTheme.accent.opacity(0.18) : ForelTheme.secondaryText.opacity(0.08))
Image(systemName: "folder.fill")
.font(.system(size: 12))
- .foregroundStyle(folder.enabled ? ForelTheme.accent : ForelTheme.secondaryText)
+ .foregroundStyle(isActive ? ForelTheme.accent : ForelTheme.secondaryText)
}
.frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 1) {
Text((folder.path as NSString).lastPathComponent)
.font(.system(size: 13, weight: .medium))
- .foregroundStyle(ForelTheme.primaryText)
+ .foregroundStyle(isAllowed ? ForelTheme.primaryText : ForelTheme.secondaryText)
.lineLimit(1)
Text((folder.path as NSString).deletingLastPathComponent)
.font(.system(size: 10))
@@ -168,18 +172,26 @@ struct SidebarView: View {
Spacer(minLength: 4)
- ForelSwitch(isOn: enabledBinding(folder), compact: true)
+ if !isAllowed {
+ Label("Pro", systemImage: "lock.fill")
+ .font(.caption)
+ .foregroundStyle(ForelTheme.pro)
+ }
+ ForelSwitch(isOn: isAllowed ? enabledBinding(folder) : .constant(false), compact: true)
+ .disabled(!isAllowed)
+ .opacity(isAllowed ? 1 : 0.5)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(isSelected ? ForelTheme.accent.opacity(0.14) : ForelTheme.surface)
+ .fill(isSelected ? (isAllowed ? ForelTheme.accent : ForelTheme.secondaryText).opacity(0.14) : ForelTheme.surface)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
- .strokeBorder(isSelected ? ForelTheme.accent.opacity(0.5) : ForelTheme.surfaceBorder)
+ .strokeBorder(isSelected ? (isAllowed ? ForelTheme.accent : ForelTheme.secondaryText).opacity(0.5) : ForelTheme.surfaceBorder)
)
+ .help(isAllowed ? folder.path : "Reorder folders or activate Pro to use this folder.")
.contentShape(Rectangle())
.onTapGesture {
model.selectedFolderId = folder.id
@@ -229,6 +241,10 @@ struct SidebarView: View {
}
private func addFolder() {
+ guard model.canAddFolder else {
+ model.proUpgrade = .folders
+ return
+ }
if let path = FolderPicker.choose() {
model.addFolder(path: path)
}
diff --git a/Sources/ForelCore/Engine/ConditionEvaluator.swift b/Sources/ForelCore/Engine/ConditionEvaluator.swift
index e0cf038..366e9f9 100644
--- a/Sources/ForelCore/Engine/ConditionEvaluator.swift
+++ b/Sources/ForelCore/Engine/ConditionEvaluator.swift
@@ -19,7 +19,7 @@ import Darwin
public enum ConditionEvaluator {
/// Returns true if the file at `path` satisfies the condition.
- public static func evaluate(_ condition: Condition, path: String) -> Bool {
+ public static func evaluate(_ condition: Condition, path: String, allowOCR: Bool = true) -> Bool {
let url = URL(fileURLWithPath: path)
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
@@ -79,7 +79,7 @@ public enum ConditionEvaluator {
}
case .contents:
- return evaluateContents(condition, path: path).matched
+ return evaluateContents(condition, path: path, allowOCR: allowOCR).matched
case .createdAt:
guard let attrs else { return false }
@@ -114,8 +114,8 @@ public enum ConditionEvaluator {
/// uses the extra detail to explain what was read; `evaluate` discards it.
/// When no text can be extracted every operator is `false`, so a rule never
/// matches on content it could not read (including the negative operators).
- static func evaluateContents(_ condition: Condition, path: String) -> (matched: Bool, strategy: ContentStrategy, message: String?) {
- let result = ContentExtractor.extract(path: path)
+ static func evaluateContents(_ condition: Condition, path: String, allowOCR: Bool = true) -> (matched: Bool, strategy: ContentStrategy, message: String?) {
+ let result = ContentExtractor.extract(path: path, allowOCR: allowOCR)
if let text = result.text {
return (matchString(condition.operator, text, condition.value), result.strategy, result.message)
}
diff --git a/Sources/ForelCore/Engine/ContentExtractor.swift b/Sources/ForelCore/Engine/ContentExtractor.swift
index 98e2e95..552bbac 100644
--- a/Sources/ForelCore/Engine/ContentExtractor.swift
+++ b/Sources/ForelCore/Engine/ContentExtractor.swift
@@ -113,7 +113,7 @@ public enum ContentExtractor {
/// Runs the ordered extraction pipeline for `path`, dispatching on the file
/// extension. Always returns a result; unsupported or empty files yield
/// `text: nil` with `strategy: .none`.
- public static func extract(path: String) -> ContentExtraction {
+ public static func extract(path: String, allowOCR: Bool = true) -> ContentExtraction {
let ext = (path as NSString).pathExtension.lowercased()
let size = fileSize(path)
@@ -122,7 +122,7 @@ public enum ContentExtractor {
}
switch ext {
case "pdf":
- return extractPDF(path: path, size: size)
+ return extractPDF(path: path, size: size, allowOCR: allowOCR)
case "rtf":
return extractAttributed(path: path, documentType: .rtf, strategy: .rtf)
case "rtfd":
@@ -140,6 +140,9 @@ public enum ContentExtractor {
return extractIWork(path: path, size: size)
default:
if imageExtensions.contains(ext) {
+ guard allowOCR else {
+ return ContentExtraction(text: nil, strategy: .imageOCR, message: "Text recognition in images requires Forel Pro.")
+ }
return extractImageOCR(path: path, size: size)
}
if spotlightFallbackExtensions.contains(ext) {
@@ -245,7 +248,7 @@ public enum ContentExtractor {
// MARK: - PDF
- private static func extractPDF(path: String, size: UInt64) -> ContentExtraction {
+ private static func extractPDF(path: String, size: UInt64, allowOCR: Bool) -> ContentExtraction {
if size > pdfMaxBytes {
return ContentExtraction(text: nil, strategy: .none, message: "PDF exceeds the 100 MB limit.")
}
@@ -257,6 +260,9 @@ public enum ContentExtractor {
return ContentExtraction(text: text, strategy: .pdfText)
}
// No text layer — likely a scanned PDF. OCR the rendered pages.
+ guard allowOCR else {
+ return ContentExtraction(text: nil, strategy: .pdfOCR, message: "Text recognition in scanned PDFs requires Forel Pro.")
+ }
let ocr = pdfOCRText(from: doc)
if !ocr.isEmpty {
return ContentExtraction(text: ocr, strategy: .pdfOCR)
diff --git a/Sources/ForelCore/Engine/RuleEngine.swift b/Sources/ForelCore/Engine/RuleEngine.swift
index cf8416e..a8e26d9 100644
--- a/Sources/ForelCore/Engine/RuleEngine.swift
+++ b/Sources/ForelCore/Engine/RuleEngine.swift
@@ -112,7 +112,7 @@ public enum RuleEngine {
/// status) with `previewFile` by going through `ActionExecutor.plan`
/// first, then acting on it — so Dry Run, Run Now, and the watcher can
/// never see a different outcome for the same file and rules.
- public static func run(path: String, depth: Int, rules: [Rule], batchId: String, root: String? = nil) -> (matched: [String], history: [HistoryEntry]) {
+ public static func run(path: String, depth: Int, rules: [Rule], batchId: String, root: String? = nil, isPro: Bool = true) -> (matched: [String], history: [HistoryEntry]) {
guard !SystemFileFilter.isExcluded((path as NSString).lastPathComponent), !FileReadiness.isMarkedBusy(path) else {
return ([], [])
}
@@ -141,7 +141,8 @@ public enum RuleEngine {
for ruleIndex in target.startRuleIndex.. FilePreview? {
+ public static func previewFile(path: String, depth: Int, rules: [Rule], isPro: Bool = true) -> FilePreview? {
guard !SystemFileFilter.isExcluded((path as NSString).lastPathComponent), !FileReadiness.isMarkedBusy(path) else {
return nil
}
@@ -216,8 +217,9 @@ public enum RuleEngine {
for ruleIndex in target.startRuleIndex.. [ConditionPreview] {
+ private static func conditionPreviews(_ rule: Rule, path: String, allowOCR: Bool) -> [ConditionPreview] {
rule.conditions.map { condition in
// `contents` runs the extraction pipeline once and reports which
// strategy was used, so the Dry Run can show what was actually read.
if condition.kind == .contents {
- let result = ConditionEvaluator.evaluateContents(condition, path: path)
+ let result = ConditionEvaluator.evaluateContents(condition, path: path, allowOCR: allowOCR)
let detail: String
if let message = result.message {
detail = "\(result.strategy.label) — \(message)"
@@ -285,7 +287,7 @@ public enum RuleEngine {
kind: condition.kind,
operator_: condition.operator,
value: condition.value,
- matched: ConditionEvaluator.evaluate(condition, path: path)
+ matched: ConditionEvaluator.evaluate(condition, path: path, allowOCR: allowOCR)
)
}
}
@@ -306,16 +308,16 @@ public enum RuleEngine {
/// `.any` are order-independent. Used by `run`, which only needs a
/// yes/no answer; `previewFile` keeps the original order and evaluates
/// every condition on purpose, to show each one's result in the Dry Run.
- private static func ruleMatches(_ rule: Rule, path: String, depth: Int) -> Bool {
+ private static func ruleMatches(_ rule: Rule, path: String, depth: Int, allowOCR: Bool) -> Bool {
guard ruleInScope(rule, depth: depth) else { return false }
if rule.conditions.isEmpty { return true }
let ordered = rule.conditions.sorted { evaluationCost($0.kind) < evaluationCost($1.kind) }
switch rule.conditionMatch {
case .all:
- return ordered.allSatisfy { ConditionEvaluator.evaluate($0, path: path) }
+ return ordered.allSatisfy { ConditionEvaluator.evaluate($0, path: path, allowOCR: allowOCR) }
case .any:
- return ordered.contains { ConditionEvaluator.evaluate($0, path: path) }
+ return ordered.contains { ConditionEvaluator.evaluate($0, path: path, allowOCR: allowOCR) }
}
}
diff --git a/Sources/ForelCore/Engine/UndoSafety.swift b/Sources/ForelCore/Engine/UndoSafety.swift
index 45c0610..7d002bf 100644
--- a/Sources/ForelCore/Engine/UndoSafety.swift
+++ b/Sources/ForelCore/Engine/UndoSafety.swift
@@ -34,7 +34,7 @@ public enum UndoChecker {
/// Without this check, undoing a move back into a folder an active rule
/// still watches would just have the watcher immediately redo what the
/// user asked to undo.
- public static func evaluate(_ entry: HistoryEntry, activeRules: [Rule] = [], watchedRoot: String? = nil) -> UndoSafety {
+ public static func evaluate(_ entry: HistoryEntry, activeRules: [Rule] = [], watchedRoot: String? = nil, isPro: Bool = true) -> UndoSafety {
guard entry.reversible else {
return .unsafe(reason: "This action cannot be undone.")
}
@@ -68,7 +68,7 @@ public enum UndoChecker {
// Copy-undo only deletes the copy — the original is never restored
// anywhere, so there's nothing for a rule to reprocess.
- if !isCopyUndo(undo), let ruleName = ruleThatWouldReprocess(entry, activeRules: activeRules, watchedRoot: watchedRoot) {
+ if !isCopyUndo(undo), let ruleName = ruleThatWouldReprocess(entry, activeRules: activeRules, watchedRoot: watchedRoot, isPro: isPro) {
return .unsafe(reason: "The rule \"\(ruleName)\" would immediately reprocess this file once it's restored.")
}
@@ -118,10 +118,10 @@ public enum UndoChecker {
/// exist there yet; conditions that read the file itself (size, tags,
/// dates, contents) just won't match before the restore actually
/// happens, which only means a rule keyed on those won't be caught here.
- private static func ruleThatWouldReprocess(_ entry: HistoryEntry, activeRules: [Rule], watchedRoot: String?) -> String? {
+ private static func ruleThatWouldReprocess(_ entry: HistoryEntry, activeRules: [Rule], watchedRoot: String?, isPro: Bool) -> String? {
guard let watchedRoot, !activeRules.isEmpty else { return nil }
guard let depth = RuleEngine.pathDepth(root: watchedRoot, path: entry.originalPath) else { return nil }
- guard let preview = RuleEngine.previewFile(path: entry.originalPath, depth: depth, rules: activeRules) else { return nil }
+ guard let preview = RuleEngine.previewFile(path: entry.originalPath, depth: depth, rules: activeRules, isPro: isPro) else { return nil }
return preview.rules.first { $0.actions.contains { $0.status == .wouldRun } }?.ruleName
}
}
diff --git a/Sources/ForelCore/License/BuildFlavor.swift b/Sources/ForelCore/License/BuildFlavor.swift
new file mode 100644
index 0000000..3b5361b
--- /dev/null
+++ b/Sources/ForelCore/License/BuildFlavor.swift
@@ -0,0 +1,54 @@
+#if FOREL_BUILD_DEVELOPMENT && FOREL_BUILD_AD_HOC
+#error("Only one Forel build flavor can be enabled")
+#endif
+#if FOREL_BUILD_DEVELOPMENT && FOREL_BUILD_APP_STORE
+#error("Only one Forel build flavor can be enabled")
+#endif
+#if FOREL_BUILD_AD_HOC && FOREL_BUILD_APP_STORE
+#error("Only one Forel build flavor can be enabled")
+#endif
+
+public enum BuildFlavor: String, CaseIterable, Sendable {
+ case development
+ case adHoc = "ad-hoc"
+ case appStore = "app-store"
+
+ public static var current: BuildFlavor {
+ #if FOREL_BUILD_APP_STORE
+ return .appStore
+ #elseif FOREL_BUILD_AD_HOC
+ return .adHoc
+ #else
+ return .development
+ #endif
+ }
+
+ public var capabilities: BuildCapabilities {
+ switch self {
+ case .development:
+ BuildCapabilities(
+ usesDirectLicensing: true,
+ supportsSelfUpdates: true
+ )
+ case .adHoc:
+ BuildCapabilities(
+ usesDirectLicensing: true,
+ supportsSelfUpdates: true
+ )
+ case .appStore:
+ BuildCapabilities(
+ usesDirectLicensing: false,
+ supportsSelfUpdates: false
+ )
+ }
+ }
+}
+
+public struct BuildCapabilities: Equatable, Sendable {
+ public let usesDirectLicensing: Bool
+ public let supportsSelfUpdates: Bool
+
+ public static var current: BuildCapabilities {
+ BuildFlavor.current.capabilities
+ }
+}
diff --git a/Sources/ForelCore/License/ForelKeychainLicenseStore.swift b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift
new file mode 100644
index 0000000..b7d4bd8
--- /dev/null
+++ b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Lab421Licensing
+import LocalAuthentication
+import Security
+
+/// Free launches skip the Keychain once we know no paid license is saved.
+/// Legacy records are discovered without prompting. A previously activated
+/// Pro license can request access again after the app's signature changes.
+@MainActor
+public final class ForelKeychainLicenseStore: LicenseSecureStoring {
+ private let defaults: UserDefaults
+ private let presenceKey: String
+ private let secureStore: any LicenseSecureStoring
+ private let loadWithoutPrompt: () throws -> LicenseRecord?
+
+ public convenience init(service: String, account: String = "direct-license", userDefaultsSuiteName: String) {
+ self.init(
+ service: service,
+ account: account,
+ defaults: UserDefaults(suiteName: userDefaultsSuiteName) ?? .standard,
+ secureStore: KeychainLicenseStore(service: service, account: account),
+ loadWithoutPrompt: { try Self.readWithoutPrompt(service: service, account: account) }
+ )
+ }
+
+ init(service: String, account: String = "direct-license", defaults: UserDefaults,
+ secureStore: any LicenseSecureStoring, loadWithoutPrompt: @escaping () throws -> LicenseRecord?) {
+ self.defaults = defaults
+ presenceKey = "\(service).\(account).hasPaidLicense"
+ self.secureStore = secureStore
+ self.loadWithoutPrompt = loadWithoutPrompt
+ }
+
+ public func load() throws -> LicenseRecord? {
+ let hasSavedLicense = defaults.object(forKey: presenceKey) as? Bool
+ if hasSavedLicense == false { return nil }
+ // Only a known paid license may prompt for access. The file-based
+ // Keychain can require new authorization after an ad-hoc rebuild.
+ // Migration from an old trial must remain silent.
+ let record = try hasSavedLicense == true ? secureStore.load() : loadWithoutPrompt()
+ let hasPaidLicense = Self.hasPaidLicense(record)
+ defaults.set(hasPaidLicense, forKey: presenceKey)
+ return hasPaidLicense ? record : nil
+ }
+
+ public func save(_ record: LicenseRecord) throws {
+ try secureStore.save(record)
+ defaults.set(Self.hasPaidLicense(record), forKey: presenceKey)
+ }
+
+ private static func hasPaidLicense(_ record: LicenseRecord?) -> Bool {
+ guard let key = record?.licenseKey, !key.isEmpty else { return false }
+ return record?.licenseIsValid != false
+ }
+
+ private static func readWithoutPrompt(service: String, account: String) throws -> LicenseRecord? {
+ let query = startupQuery(service: service, account: account)
+ var result: CFTypeRef?
+ let status = try withoutUserInteraction {
+ SecItemCopyMatching(query as CFDictionary, &result)
+ }
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess, let data = result as? Data else {
+ throw ReadError(status: status)
+ }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try decoder.decode(LicenseRecord.self, from: data)
+ }
+
+ static func startupQuery(service: String, account: String) -> [String: Any] {
+ let context = LAContext()
+ context.interactionNotAllowed = true
+ return [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecUseAuthenticationContext as String: context,
+ ]
+ }
+
+ #if compiler(>=6.4)
+ @diagnose(DeprecatedDeclaration, as: ignored, reason: "The file-based login Keychain still requires this API to suppress prompts (FB16959400).")
+ #endif
+ static func withoutUserInteraction(_ operation: () throws -> T) throws -> T {
+ // LAContext only suppresses authentication for the data-protection
+ // Keychain. The file-based login Keychain used by KeychainLicenseStore
+ // still needs this legacy API (see Chromium's FB16959400 workaround).
+ // Keep the synchronous read on MainActor and restore the previous
+ // setting before an explicit activation can access the Keychain.
+ var wasAllowed: DarwinBoolean = false
+ let getStatus = SecKeychainGetUserInteractionAllowed(&wasAllowed)
+ guard getStatus == errSecSuccess else { throw ReadError(status: getStatus) }
+ let setStatus = SecKeychainSetUserInteractionAllowed(false)
+ guard setStatus == errSecSuccess else { throw ReadError(status: setStatus) }
+ defer { SecKeychainSetUserInteractionAllowed(wasAllowed.boolValue) }
+ return try operation()
+ }
+
+ private struct ReadError: LocalizedError {
+ let status: OSStatus
+
+ var errorDescription: String? {
+ if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled {
+ return "Activate your Pro license again in License settings to allow access to its saved Keychain record."
+ }
+ return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)."
+ }
+ }
+}
diff --git a/Sources/ForelCore/License/ForelLicenseManager.swift b/Sources/ForelCore/License/ForelLicenseManager.swift
new file mode 100644
index 0000000..07d2eaa
--- /dev/null
+++ b/Sources/ForelCore/License/ForelLicenseManager.swift
@@ -0,0 +1,131 @@
+import Combine
+import Foundation
+import Lab421Licensing
+
+public enum ForelLicenseState: Equatable, Sendable {
+ case free
+ case pro
+}
+
+/// Paid-license access only. Free needs neither registration nor connectivity.
+@MainActor
+public final class ForelLicenseManager: ObservableObject {
+ @Published public private(set) var state: ForelLicenseState = .free
+ @Published public private(set) var isWorking = false
+ @Published public private(set) var lastError: String?
+ public var hasAccess: Bool { state == .pro }
+ public var licenseKey: String? { record?.licenseKey }
+ public let deviceName: String
+ private let enabled: Bool
+ private let productID: String
+ private let deviceID: String
+ private let publicKey: Data
+ private let store: any LicenseSecureStoring
+ private let client: any LicenseAPIClientProtocol
+ private var record: LicenseRecord?
+ private var refreshTimer: Timer?
+ private var nextValidationAt = Date().addingTimeInterval(24 * 60 * 60)
+
+ public init(isEnabled: Bool, productID: String, appName: String, deviceNameFallback: String,
+ secureStore: any LicenseSecureStoring, deviceIdentifierProvider: any DeviceIdentifierProviding,
+ offlineTokenPublicKeyRaw: Data, baseURL: URL, client: (any LicenseAPIClientProtocol)? = nil) {
+ enabled = isEnabled
+ self.productID = productID
+ deviceID = deviceIdentifierProvider.deviceID
+ deviceName = Host.current().localizedName ?? deviceNameFallback
+ publicKey = offlineTokenPublicKeyRaw
+ store = secureStore
+ self.client = client ?? LicenseAPIClient(baseURL: baseURL)
+ guard enabled else { return }
+ do { record = try store.load() } catch { lastError = "Could not read your saved license: \(error.localizedDescription)" }
+ refreshState()
+ }
+
+ public func start() {
+ guard enabled, record?.licenseKey != nil else { return }
+ Task { await validate() }
+ }
+
+ private func accepts(_ token: String, key: String) -> Bool {
+ guard let payload = try? OfflineLicenseToken.verify(token, deviceID: deviceID, now: Date(), publicKeyRaw: publicKey) else { return false }
+ return payload.productID == productID && payload.licenseKey == key && !key.isEmpty
+ }
+
+ private func refreshState() {
+ refreshTimer?.invalidate()
+ refreshTimer = nil
+ guard let record, record.licenseIsValid != false, let key = record.licenseKey,
+ let token = record.licenseToken, accepts(token, key: key) else { state = .free; return }
+ state = .pro
+ // Recheck signed expiry while the app stays open. Network validation
+ // is occasional and never required for the Free plan.
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.refreshState()
+ if self.state == .pro, Date() >= self.nextValidationAt {
+ await self.validate()
+ }
+ }
+ }
+ }
+
+ public func activate(licenseKey: String) async {
+ guard enabled, !isWorking else { return }
+ let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { lastError = "Enter a license key to activate Forel Pro."; return }
+ isWorking = true
+ lastError = nil
+ defer { isWorking = false }
+ do {
+ guard let token = try await client.activate(licenseKey: key, deviceID: deviceID, deviceName: deviceName, deviceProfile: nil),
+ accepts(token, key: key) else { lastError = "The server did not return a valid Forel Pro license."; return }
+ let next = LicenseRecord(lastObservedAt: Date(), licenseKey: key, licenseIsValid: true, lastValidatedAt: Date(), licenseToken: token)
+ try store.save(next)
+ record = next
+ refreshState()
+ } catch { lastError = "Could not activate Forel Pro: \(error.localizedDescription)" }
+ }
+
+ public func validate() async {
+ guard enabled, !isWorking, let key = record?.licenseKey else { return }
+ nextValidationAt = Date().addingTimeInterval(24 * 60 * 60)
+ isWorking = true
+ defer { isWorking = false }
+ do {
+ switch try await client.validate(licenseKey: key, deviceID: deviceID) {
+ case .success(let token):
+ guard let token, accepts(token, key: key) else { lastError = "The server did not return a valid Forel Pro license."; return }
+ let next = LicenseRecord(lastObservedAt: Date(), licenseKey: key, licenseIsValid: true, lastValidatedAt: Date(), licenseToken: token)
+ try store.save(next)
+ record = next
+ lastError = nil
+ case .failure:
+ record?.licenseIsValid = false
+ refreshState()
+ if let record { try store.save(record) }
+ lastError = "Your license is no longer valid. Forel remains available in Free mode."
+ }
+ refreshState()
+ } catch {
+ refreshState()
+ lastError = "Could not verify your license. A valid saved license still works offline."
+ }
+ }
+
+ public func deactivate() async {
+ guard enabled, !isWorking, let key = record?.licenseKey else { return }
+ isWorking = true
+ defer { isWorking = false }
+ do {
+ try await client.deactivate(licenseKey: key, deviceID: deviceID)
+ record?.licenseIsValid = false
+ state = .free
+ refreshTimer?.invalidate()
+ refreshTimer = nil
+ try store.save(LicenseRecord(lastObservedAt: Date()))
+ record = nil
+ lastError = nil
+ } catch { lastError = "Could not finish deactivating your license: \(error.localizedDescription)" }
+ }
+}
diff --git a/Sources/ForelCore/License/ForelLicensePreferences.swift b/Sources/ForelCore/License/ForelLicensePreferences.swift
new file mode 100644
index 0000000..3651b8a
--- /dev/null
+++ b/Sources/ForelCore/License/ForelLicensePreferences.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+/// Moves licensing hints to a dedicated suite without changing the saved license
+/// or a fallback device identifier used for activation.
+public enum ForelLicensePreferences {
+ public static func migrate(to suiteName: String, from legacyDomainName: String, service: String) {
+ guard let defaults = UserDefaults(suiteName: suiteName) else { return }
+ let legacyValues = UserDefaults.standard.persistentDomain(forName: legacyDomainName) ?? [:]
+ for key in ["\(service).direct-license.hasPaidLicense", "device.fallbackIdentifier"] {
+ if defaults.object(forKey: key) == nil, let value = legacyValues[key] {
+ defaults.set(value, forKey: key)
+ }
+ }
+ }
+}
diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift
new file mode 100644
index 0000000..c1fe156
--- /dev/null
+++ b/Sources/ForelCore/License/FreePlan.swift
@@ -0,0 +1,88 @@
+import Foundation
+
+public enum ProFeature: String, CaseIterable, Identifiable, Sendable {
+ case ocr, libraryImports
+ public var id: String { rawValue }
+ public var title: String {
+ switch self {
+ case .ocr: "Text recognition (OCR)"
+ case .libraryImports: "Media library imports"
+ }
+ }
+ public var benefit: String {
+ switch self {
+ case .ocr: "Organize scanned PDFs and images by the text they contain. Recognition runs locally on your Mac."
+ case .libraryImports: "Import matching files directly into Photos, Music, and TV."
+ }
+ }
+}
+
+/// Applies the same Free allowance to previews, manual runs, and watching.
+/// Folder allowances use the persisted order, including disabled entries.
+public enum FreePlan {
+ public static let folderLimit = 2
+ public static let monthlyFileLimit = 300
+
+ public static func requiredFeature(for action: ActionKind) -> ProFeature? {
+ switch action {
+ case .importToLibrary: .libraryImports
+ default: nil
+ }
+ }
+
+ public static func requiredFeature(for rule: Rule) -> ProFeature? {
+ return rule.actions.compactMap { requiredFeature(for: $0.kind) }.first
+ }
+
+ public static func validate(_ rule: Rule, isPro: Bool) throws {
+ if !isPro, rule.enabled, let feature = requiredFeature(for: rule) {
+ throw FreePlanError.feature(feature)
+ }
+ }
+
+ public static func canAddFolder(count: Int, isPro: Bool) -> Bool {
+ isPro || count < folderLimit
+ }
+
+ public static func allows(folderID: String, folders: [WatchedFolder], isPro: Bool) -> Bool {
+ isPro || folders.prefix(folderLimit).contains { $0.id == folderID }
+ }
+
+}
+
+public enum FreePlanError: Error, LocalizedError, Equatable {
+ case folderLimit, proFolder, monthlyFileLimit
+ case feature(ProFeature)
+ public var errorDescription: String? {
+ switch self {
+ case .folderLimit: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another."
+ case .proFolder: "This folder is outside the Free allowance. Reorder folders or activate Pro to add rules."
+ case .monthlyFileLimit: "Forel Free has reached its limit of 300 processed files this month. Processing resumes next month, or activate Pro for unlimited files. Your remaining files stay in place."
+ case .feature(let feature): "\(feature.title) requires Forel Pro. This rule is kept but cannot run in Free."
+ }
+ }
+}
+
+extension Database {
+ public func insertFolder(_ folder: WatchedFolder, isPro: Bool) throws {
+ guard FreePlan.canAddFolder(count: try listFolders().count, isPro: isPro) else { throw FreePlanError.folderLimit }
+ try insertFolder(folder)
+ }
+
+ public func insertRule(_ rule: Rule, isPro: Bool) throws {
+ try FreePlan.validate(rule, isPro: isPro)
+ guard FreePlan.allows(folderID: rule.folderId, folders: try listFolders(), isPro: isPro) else { throw FreePlanError.proFolder }
+ try insertRule(rule)
+ }
+
+ public func updateRule(_ rule: Rule, isPro: Bool) throws {
+ try FreePlan.validate(rule, isPro: isPro)
+ try updateRule(rule)
+ }
+
+ public func executionRules(folderID: String, isPro: Bool) throws -> [Rule] {
+ guard FreePlan.allows(folderID: folderID, folders: try listFolders(), isPro: isPro) else { return [] }
+ return try listRules(folderId: folderID)
+ .filter { $0.enabled && (isPro || FreePlan.requiredFeature(for: $0) == nil) }
+ }
+}
diff --git a/Sources/ForelCore/License/MonthlyFileUsage.swift b/Sources/ForelCore/License/MonthlyFileUsage.swift
new file mode 100644
index 0000000..d919dde
--- /dev/null
+++ b/Sources/ForelCore/License/MonthlyFileUsage.swift
@@ -0,0 +1,113 @@
+import Foundation
+
+public struct MonthlyFileUsage: Equatable, Sendable {
+ let month: String
+ public let processed: Int
+ public let resetsAt: Date
+ public var isExhausted: Bool { processed >= FreePlan.monthlyFileLimit }
+
+ init(processed: Int = 0, at date: Date, calendar: Calendar = .current) {
+ var gregorian = Calendar(identifier: .gregorian)
+ gregorian.timeZone = calendar.timeZone
+ let components = gregorian.dateComponents([.year, .month], from: date)
+ month = String(format: "%04d-%02d", components.year!, components.month!)
+ self.processed = processed
+ resetsAt = gregorian.dateInterval(of: .month, for: date)!.end
+ }
+}
+
+/// Free usage and pending files live outside SQLite so the main database
+/// stays compatible with earlier Forel versions. Share one store between
+/// manual and automatic processing to reserve slots consistently.
+public final class MonthlyFileUsageStore: @unchecked Sendable {
+ private let preferences: UserDefaults?
+ private let lock = NSLock()
+ private var reservations: [String: Int] = [:]
+ private var inMemoryCounts: [String: Int] = [:]
+ private var inMemoryPendingFiles: Set = []
+ private let pendingFilesKey = "free.quotaPendingFiles"
+
+ /// Pass the app's dedicated preferences for persistence. Omitting them
+ /// keeps isolated core clients and tests in memory.
+ public init(preferences: UserDefaults? = nil) {
+ self.preferences = preferences
+ }
+
+ public func monthlyFileUsage(at date: Date = Date(), calendar: Calendar = .current) -> MonthlyFileUsage {
+ let period = MonthlyFileUsage(at: date, calendar: calendar)
+ return withLock {
+ MonthlyFileUsage(processed: processed(in: period.month), at: date, calendar: calendar)
+ }
+ }
+
+ /// Shared by Run Now and both watcher paths. A file's complete action chain
+ /// occupies one slot, released when no action was actually applied.
+ public func runWithFreePlan(path: String, depth: Int, rules: [Rule], batchId: String,
+ root: String? = nil, isPro: Bool, at date: Date = Date(),
+ calendar: Calendar = .current) throws -> (matched: [String], history: [HistoryEntry]) {
+ if isPro {
+ return RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: true)
+ }
+ let period = MonthlyFileUsage(at: date, calendar: calendar)
+ try withLock {
+ let pending = reservations[period.month, default: 0]
+ guard processed(in: period.month) + pending < FreePlan.monthlyFileLimit else {
+ throw FreePlanError.monthlyFileLimit
+ }
+ reservations[period.month] = pending + 1
+ }
+ let result = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: false)
+ withLock {
+ defer { reservations[period.month, default: 0] -= 1 }
+ if result.history.contains(where: { $0.status == .applied }) {
+ let count = processed(in: period.month) + 1
+ if let preferences {
+ preferences.set(count, forKey: countKey(for: period.month))
+ } else {
+ inMemoryCounts[period.month] = count
+ }
+ }
+ }
+ return result
+ }
+
+ func queueQuotaPendingFile(_ path: String) {
+ withLock {
+ var paths = pendingFiles()
+ if paths.insert(path).inserted { savePendingFiles(paths) }
+ }
+ }
+
+ func removeQuotaPendingFile(_ path: String) {
+ withLock {
+ var paths = pendingFiles()
+ if paths.remove(path) != nil { savePendingFiles(paths) }
+ }
+ }
+
+ func quotaPendingFiles() -> [String] {
+ withLock { pendingFiles().sorted() }
+ }
+
+ private func countKey(for month: String) -> String { "free.monthlyFileUsage.\(month)" }
+
+ private func processed(in month: String) -> Int {
+ max(0, preferences?.integer(forKey: countKey(for: month)) ?? inMemoryCounts[month, default: 0])
+ }
+
+ private func pendingFiles() -> Set {
+ if let preferences { return Set(preferences.stringArray(forKey: pendingFilesKey) ?? []) }
+ return inMemoryPendingFiles
+ }
+
+ private func savePendingFiles(_ paths: Set) {
+ if let preferences { preferences.set(paths.sorted(), forKey: pendingFilesKey) }
+ else { inMemoryPendingFiles = paths }
+ }
+
+ private func withLock(_ body: () throws -> T) rethrows -> T {
+ lock.lock()
+ defer { lock.unlock() }
+ return try body()
+ }
+}
diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift
index 343b33c..d6bcca0 100644
--- a/Sources/ForelCore/Persistence/Database.swift
+++ b/Sources/ForelCore/Persistence/Database.swift
@@ -687,6 +687,12 @@ public final class Database: @unchecked Sendable {
)
}
+ func deleteWatchedPathState(_ path: String) throws {
+ let stmt = try statement("DELETE FROM watched_path_state WHERE path=?1")
+ stmt.bind(1, path)
+ try stmt.runToCompletion()
+ }
+
public func upsertWatchedPathState(_ state: WatchedPathState) throws {
let stmt = try statement(
"""
diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift
index 63b4d85..34a3da4 100644
--- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift
+++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift
@@ -28,7 +28,16 @@ public struct WatcherActivitySummary: Equatable, Sendable {
/// `watcher::on_event` / `load_folder_and_rules_for_path`.
public final class WatcherCoordinator: @unchecked Sendable {
private let db: Database
+ private let monthlyUsage: MonthlyFileUsageStore
private let watcher: FileWatcher
+ private var isPro = false
+ private let now: @Sendable () -> Date
+ private var quotaRetryScheduled = false
+
+ public func setProAccess(_ enabled: Bool) {
+ db.withLock { _ in isPro = enabled }
+ processingQueue.async { [weak self] in self?.retryQuotaPendingFiles() }
+ }
/// FSEvents callbacks must return promptly. File operations can take an
/// arbitrary amount of time, especially when moving across volumes or to
/// a network share, so process their immutable event snapshots here
@@ -54,6 +63,8 @@ public final class WatcherCoordinator: @unchecked Sendable {
public var onVolumeUnavailable: (@Sendable (VolumeUnavailableEvent) -> Void)?
/// Called whenever an automatic run records history, failures included.
public var onHistoryRecorded: (@Sendable () -> Void)?
+ public var onMonthlyQuotaReached: (@Sendable () -> Void)?
+ public var onProcessingError: (@Sendable (String) -> Void)?
/// Files to run the rules on again, by the /Volumes name of the disk or
/// share they wait for. Only touched on `processingQueue`; not persisted.
var waitingForVolume: [String: Set] = [:]
@@ -70,9 +81,12 @@ public final class WatcherCoordinator: @unchecked Sendable {
var hasDeferredDescendants = false
}
- public init(db: Database, settleInterval: TimeInterval = 1.0) {
+ public init(db: Database, monthlyUsage: MonthlyFileUsageStore = MonthlyFileUsageStore(),
+ settleInterval: TimeInterval = 1.0, now: @escaping @Sendable () -> Date = { Date() }) {
self.db = db
+ self.monthlyUsage = monthlyUsage
self.settleInterval = settleInterval
+ self.now = now
var watcherRef: FileWatcher!
watcherRef = FileWatcher(onEvent: { _ in })
self.watcher = watcherRef
@@ -85,6 +99,7 @@ public final class WatcherCoordinator: @unchecked Sendable {
watcher.add(path)
processingQueue.async { [weak self] in
self?.watchedRoots.insert(path)
+ self?.retryQuotaPendingFiles()
}
}
@@ -230,19 +245,18 @@ public final class WatcherCoordinator: @unchecked Sendable {
guard FileManager.default.fileExists(atPath: path) else { return }
guard retrying || hasPathChangedSinceLastEvaluation(path) else { return }
- guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in
+ guard let (folder, rules, isPro) = db.withLock({ db -> (WatchedFolder, [Rule], Bool)? in
guard let folder = try? db.folderForPath(path) else { return nil }
- let rules = (try? db.listRules(folderId: folder.id)) ?? []
- return (folder, rules)
+ let rules = (try? db.executionRules(folderID: folder.id, isPro: self.isPro)) ?? []
+ guard !rules.isEmpty else { return nil }
+ return (folder, rules, self.isPro)
}) else { return }
beginProcessing(root: folder.path)
defer { endProcessing(root: folder.path) }
guard let depth = RuleEngine.pathDepth(root: folder.path, path: path) else { return }
- let batchId = UUID().uuidString
- let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: folder.path)
- record(matched: matched, history: history, path: path, rules: rules)
+ runAndRecord(path: path, depth: depth, rules: rules, watchedRoot: folder.path, isPro: isPro)
}
private func beginProcessing(root: String) {
@@ -317,10 +331,11 @@ public final class WatcherCoordinator: @unchecked Sendable {
return
}
- guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in
+ guard let (folder, rules, isPro) = db.withLock({ db -> (WatchedFolder, [Rule], Bool)? in
guard let folder = try? db.folderForPath(root) else { return nil }
- let rules = (try? db.listRules(folderId: folder.id)) ?? []
- return (folder, rules)
+ let rules = (try? db.executionRules(folderID: folder.id, isPro: self.isPro)) ?? []
+ guard !rules.isEmpty else { return nil }
+ return (folder, rules, self.isPro)
}) else { return }
guard let rootDepth = RuleEngine.pathDepth(root: folder.path, path: root) else { return }
@@ -333,7 +348,7 @@ public final class WatcherCoordinator: @unchecked Sendable {
if waitsForSettledFiles {
awaitSettled(path, includingContents: false)
} else {
- handle(path: path, depth: depth, rules: rules, watchedRoot: folder.path)
+ handle(path: path, depth: depth, rules: rules, watchedRoot: folder.path, isPro: isPro)
}
}
@@ -357,13 +372,68 @@ public final class WatcherCoordinator: @unchecked Sendable {
}
}
- private func handle(path: String, depth: Int, rules: [Rule], watchedRoot: String) {
+ private func handle(path: String, depth: Int, rules: [Rule], watchedRoot: String, isPro: Bool) {
guard FileManager.default.fileExists(atPath: path) else { return }
guard hasPathChangedSinceLastEvaluation(path) else { return }
- let batchId = UUID().uuidString
- let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: watchedRoot)
- record(matched: matched, history: history, path: path, rules: rules)
+ runAndRecord(path: path, depth: depth, rules: rules, watchedRoot: watchedRoot, isPro: isPro)
+ }
+
+ private func runAndRecord(path: String, depth: Int, rules: [Rule], watchedRoot: String, isPro: Bool) {
+ do {
+ let result = try monthlyUsage.runWithFreePlan(path: path, depth: depth, rules: rules,
+ batchId: UUID().uuidString, root: watchedRoot, isPro: isPro, at: now())
+ monthlyUsage.removeQuotaPendingFile(path)
+ record(matched: result.matched, history: result.history, path: path, rules: rules)
+ } catch FreePlanError.monthlyFileLimit {
+ monthlyUsage.queueQuotaPendingFile(path)
+ onMonthlyQuotaReached?()
+ scheduleQuotaRetry()
+ } catch {
+ onProcessingError?(error.localizedDescription)
+ }
+ }
+
+ /// Kept in preferences so a restart does not lose files waiting for quota.
+ /// Only still-watched paths are resumed, including after a Pro activation.
+ func retryQuotaPendingFiles() {
+ guard !watchedRoots.isEmpty else { return }
+ let pro = db.withLock { _ in isPro }
+ guard pro || !monthlyUsage.monthlyFileUsage(at: now()).isExhausted else {
+ scheduleQuotaRetry()
+ return
+ }
+ let paths = monthlyUsage.quotaPendingFiles()
+ for path in paths where watchedRoots.contains(where: { Self.isPath(path, under: $0) }) {
+ guard FileManager.default.fileExists(atPath: path) else {
+ monthlyUsage.removeQuotaPendingFile(path)
+ continue
+ }
+ // A prior failure (e.g. a disconnected disk) may have cached the
+ // unchanged file before its retry hit the quota. It still needs
+ // another evaluation, through the normal settling checks.
+ do {
+ try db.withLock { try $0.deleteWatchedPathState(path) }
+ } catch {
+ onProcessingError?(error.localizedDescription)
+ continue
+ }
+ handle(event: .pathArrived(path), waitsForSettledFiles: true)
+ }
+ }
+
+ private func scheduleQuotaRetry() {
+ guard !quotaRetryScheduled else { return }
+ quotaRetryScheduled = true
+ let period = MonthlyFileUsage(at: now())
+ // Recheck hourly too, so waking the Mac or changing its time zone
+ // cannot leave an obsolete reset deadline scheduled for a whole month.
+ let delay = max(1, min(3600, period.resetsAt.timeIntervalSince(now())))
+ processingQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard let self else { return }
+ self.quotaRetryScheduled = false
+ self.retryQuotaPendingFiles()
+ }
}
/// Persists what a run on `path` did and tells whoever listens.
diff --git a/Tests/ForelCoreTests/ContentExtractorTests.swift b/Tests/ForelCoreTests/ContentExtractorTests.swift
index 579fb51..8f31032 100644
--- a/Tests/ForelCoreTests/ContentExtractorTests.swift
+++ b/Tests/ForelCoreTests/ContentExtractorTests.swift
@@ -69,6 +69,87 @@ import AppKit
}
}
+ @Test(arguments: [false, true])
+ func textPDFRunsInBothPlansAcrossPreviewManualAndWatcher(isPro: Bool) throws {
+ let dir = TempDir()
+ let file = dir.path + "/invoice.pdf"
+ makeTextPDF(at: file, text: "Invoice total 1234 EUR")
+ #expect(ContentExtractor.extract(path: file, allowOCR: isPro).strategy == .pdfText)
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "Invoice")
+ rule.conditions = [makeCondition(.contents, .contains, "1234", ruleId: rule.id)]
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.pdf")]), ruleId: rule.id)]
+ try db.insertRule(rule, isPro: isPro)
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil)
+ #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro).history.first?.status == .applied)
+ makeTextPDF(at: file, text: "Invoice total 1234 EUR")
+ try FileManager.default.removeItem(atPath: dir.path + "/sorted.pdf")
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(isPro)
+ watcher.handle(path: file)
+ #expect(try db.listHistory().first?.status == .applied)
+ }
+
+ @Test(arguments: ["image", "scan"])
+ func freeNeverRunsOCROrMatchesNegativeContentsAcrossAllPaths(kind: String) throws {
+ let dir = TempDir()
+ let file = dir.path + (kind == "image" ? "/invoice.png" : "/invoice.pdf")
+ if kind == "image" { makeTextImage(at: file, text: "INVOICE 2026") }
+ else { makeScannedPDF(at: file, text: "INVOICE 2026") }
+ let extraction = ContentExtractor.extract(path: file, allowOCR: false)
+ #expect(extraction.text == nil)
+ #expect(extraction.message?.contains("Pro") == true)
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "Contents")
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.dat")]), ruleId: rule.id)]
+ for op in [Operator.contains, .doesNotContain, .isNot] {
+ rule.conditions = [makeCondition(.contents, op, "INVOICE", ruleId: rule.id)]
+ #expect(!ConditionEvaluator.evaluate(rule.conditions[0], path: file, allowOCR: false))
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) == nil)
+ #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "free", isPro: false).history.isEmpty)
+ }
+ try db.insertRule(rule, isPro: false)
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(false)
+ watcher.handle(path: file)
+ watcher.handle(event: .rescanSubtree(dir.path))
+ #expect(try db.listHistory().isEmpty)
+ #expect(FileManager.default.fileExists(atPath: file))
+ }
+
+ @Test(arguments: ["image", "scan"])
+ func proOCRIsConsistentAcrossPreviewManualRunAndWatcher(kind: String) throws {
+ let dir = TempDir()
+ let ext = kind == "image" ? "png" : "pdf"
+ let file = dir.path + "/invoice.\(ext)"
+ func createFile() {
+ if kind == "image" { makeTextImage(at: file, text: "INVOICE 2026") }
+ else { makeScannedPDF(at: file, text: "INVOICE 2026") }
+ }
+ createFile()
+ // Some CI hosts cannot run Vision. When available, verify all paths.
+ guard ContentExtractor.extract(path: file).text != nil else { return }
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "OCR invoice")
+ rule.conditions = [makeCondition(.contents, .contains, "INVOICE", ruleId: rule.id)]
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.\(ext)")]), ruleId: rule.id)]
+ try db.insertRule(rule, isPro: true)
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: true) != nil)
+ #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: true).history.first?.status == .applied)
+ try FileManager.default.removeItem(atPath: dir.path + "/sorted.\(ext)")
+ createFile()
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(true)
+ watcher.handle(event: .rescanSubtree(dir.path))
+ #expect(try db.listHistory().first?.status == .applied)
+ }
+
@Test func rtfDocumentIsExtracted() throws {
let dir = TempDir()
let rtf = (dir.path as NSString).appendingPathComponent("memo.rtf")
diff --git a/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift b/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift
new file mode 100644
index 0000000..6fa0ff1
--- /dev/null
+++ b/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift
@@ -0,0 +1,223 @@
+import Foundation
+import Lab421Licensing
+import LocalAuthentication
+import Security
+import Testing
+@testable import ForelCore
+
+@Suite @MainActor struct ForelKeychainLicenseStoreTests {
+ @Test func freeLaunchOnlyChecksOnceAndNeverUsesInteractiveLoad() throws {
+ let fixture = Fixture()
+ #expect(try fixture.store.load() == nil)
+ #expect(fixture.silentReads == 1)
+ #expect(try fixture.restartedStore().load() == nil)
+ #expect(fixture.silentReads == 1)
+ #expect(fixture.backend.interactiveReads == 0)
+ #expect(fixture.backend.writes == 0)
+ }
+
+ @Test func oldTrialRecordDoesNotRequireKeychainOnLaterLaunches() throws {
+ let fixture = Fixture()
+ fixture.backend.record = LicenseRecord(lastObservedAt: Date(), trialIsValid: false, trialToken: "old-trial")
+ #expect(try fixture.store.load() == nil)
+ #expect(try fixture.restartedStore().load() == nil)
+ #expect(fixture.silentReads == 1)
+ #expect(fixture.backend.writes == 0)
+ #expect(fixture.backend.record?.trialToken == "old-trial")
+ }
+
+ @Test func existingPaidRecordIsRestoredWithoutLosingIt() throws {
+ let fixture = Fixture()
+ let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token")
+ fixture.backend.record = record
+ #expect(try fixture.store.load() == record)
+ #expect(try fixture.restartedStore().load() == record)
+ #expect(fixture.silentReads == 1)
+ #expect(fixture.backend.interactiveReads == 1)
+ }
+
+ @Test func activatedLicenseCanRequestAccessAfterLocalRebuild() throws {
+ let fixture = Fixture()
+ let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token")
+ try fixture.store.save(record)
+ fixture.readError = TestError.denied
+ #expect(try fixture.restartedStore().load() == record)
+ #expect(fixture.silentReads == 0)
+ #expect(fixture.backend.interactiveReads == 1)
+ }
+
+ @Test func activatingAndReturningToFreeUpdatesStartupAccess() throws {
+ let fixture = Fixture()
+ #expect(try fixture.store.load() == nil)
+ let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token")
+ try fixture.store.save(record)
+ #expect(try fixture.restartedStore().load() == record)
+ let readsBeforeDeactivation = fixture.silentReads
+ try fixture.store.save(LicenseRecord(lastObservedAt: Date()))
+ #expect(try fixture.restartedStore().load() == nil)
+ #expect(fixture.silentReads == readsBeforeDeactivation)
+ }
+
+ @Test func revokedLicenseSkipsKeychainOnRestart() throws {
+ let fixture = Fixture()
+ try fixture.store.save(LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: false))
+ #expect(try fixture.restartedStore().load() == nil)
+ #expect(fixture.silentReads == 0)
+ }
+
+ @Test func deniedAccessNeverFallsBackToInteractiveReadOrDiscardsTheRecord() throws {
+ let fixture = Fixture()
+ fixture.backend.record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true)
+ fixture.readError = TestError.denied
+ #expect(throws: TestError.denied) { try fixture.store.load() }
+ #expect(throws: TestError.denied) { try fixture.restartedStore().load() }
+ #expect(fixture.backend.interactiveReads == 0)
+ #expect(fixture.backend.writes == 0)
+ fixture.readError = nil
+ #expect(try fixture.restartedStore().load()?.licenseKey == "PRO")
+ }
+
+ @Test func failedSaveDoesNotMarkAnExistingPaidLicenseAsFree() throws {
+ let fixture = Fixture()
+ let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true)
+ try fixture.store.save(record)
+ fixture.backend.saveError = TestError.denied
+ #expect(throws: TestError.denied) { try fixture.store.save(LicenseRecord(lastObservedAt: Date())) }
+ #expect(try fixture.restartedStore().load() == record)
+ }
+
+ @Test func startupQueryForbidsAuthenticationDialogs() throws {
+ let query = ForelKeychainLicenseStore.startupQuery(service: "test-service", account: "test-account")
+ let context = try #require(query[kSecUseAuthenticationContext as String] as? LAContext)
+ #expect(context.interactionNotAllowed)
+ #expect(query[kSecAttrService as String] as? String == "test-service")
+ #expect(query[kSecAttrAccount as String] as? String == "test-account")
+ }
+
+ @Test func loginKeychainInteractionIsDisabledDuringReadAndRestoredAfterwards() throws {
+ var initial: DarwinBoolean = false
+ #expect(SecKeychainGetUserInteractionAllowed(&initial) == errSecSuccess)
+ let result = try ForelKeychainLicenseStore.withoutUserInteraction {
+ var allowed: DarwinBoolean = true
+ #expect(SecKeychainGetUserInteractionAllowed(&allowed) == errSecSuccess)
+ #expect(!allowed.boolValue)
+ return "saved-record"
+ }
+ #expect(result == "saved-record")
+ var restored: DarwinBoolean = false
+ #expect(SecKeychainGetUserInteractionAllowed(&restored) == errSecSuccess)
+ #expect(restored.boolValue == initial.boolValue)
+ }
+
+ @Test func loginKeychainInteractionIsRestoredAfterReadFailure() throws {
+ var initial: DarwinBoolean = false
+ #expect(SecKeychainGetUserInteractionAllowed(&initial) == errSecSuccess)
+ #expect(throws: TestError.denied) {
+ try ForelKeychainLicenseStore.withoutUserInteraction {
+ var allowed: DarwinBoolean = true
+ #expect(SecKeychainGetUserInteractionAllowed(&allowed) == errSecSuccess)
+ #expect(!allowed.boolValue)
+ throw TestError.denied
+ }
+ }
+ var restored: DarwinBoolean = false
+ #expect(SecKeychainGetUserInteractionAllowed(&restored) == errSecSuccess)
+ #expect(restored.boolValue == initial.boolValue)
+ }
+
+ @Test func fileBasedKeychainCannotPromptForUnlockOrItemAccess() throws {
+ // Use an isolated file-based Keychain, never the user's login items.
+ // Disable interaction for setup too, so a failing test cannot prompt.
+ try ForelKeychainLicenseStore.withoutUserInteraction {
+ let path = FileManager.default.temporaryDirectory
+ .appendingPathComponent("forel-test-\(UUID().uuidString).keychain").path
+ let password = UUID().uuidString
+ var created: SecKeychain?
+ let createStatus = password.withCString {
+ SecKeychainCreate(path, UInt32(password.utf8.count), $0, false, nil, &created)
+ }
+ try #require(createStatus == errSecSuccess)
+ let keychain = try #require(created)
+ defer { SecKeychainDelete(keychain) }
+
+ let service = "forel.test.\(UUID().uuidString)"
+ let data = Data("test-record".utf8)
+ let item: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: "test-account",
+ kSecValueData as String: data,
+ kSecUseKeychain as String: keychain,
+ ]
+ try #require(SecItemAdd(item as CFDictionary, nil) == errSecSuccess)
+ var query = ForelKeychainLicenseStore.startupQuery(service: service, account: "test-account")
+ query[kSecMatchSearchList as String] = [keychain]
+ var result: CFTypeRef?
+ try #require(SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess)
+ #expect(result as? Data == data)
+
+ // Model an item belonging to a different executable/signature.
+ var trusted: SecTrustedApplication?
+ try #require(SecTrustedApplicationCreateFromPath("/usr/bin/false", &trusted) == errSecSuccess)
+ var access: SecAccess?
+ try #require(SecAccessCreate("Forel test" as CFString, [try #require(trusted)] as CFArray, &access) == errSecSuccess)
+ var restrictedItem = item
+ restrictedItem[kSecAttrAccount as String] = "restricted-account"
+ restrictedItem[kSecAttrAccess as String] = try #require(access)
+ try #require(SecItemAdd(restrictedItem as CFDictionary, nil) == errSecSuccess)
+ var restrictedQuery = ForelKeychainLicenseStore.startupQuery(service: service, account: "restricted-account")
+ restrictedQuery[kSecMatchSearchList as String] = [keychain]
+ result = nil
+ let deniedStatus = SecItemCopyMatching(restrictedQuery as CFDictionary, &result)
+ #expect(deniedStatus == errSecInteractionNotAllowed || deniedStatus == errSecAuthFailed)
+ #expect(result == nil)
+
+ try #require(SecKeychainLock(keychain) == errSecSuccess)
+ result = nil
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ #expect(status == errSecInteractionNotAllowed || status == errSecAuthFailed)
+ #expect(result == nil)
+ }
+ }
+}
+
+private enum TestError: Error { case denied }
+
+@MainActor private final class Fixture {
+ private let suite = "com.lab421.forel.tests.\(UUID().uuidString)"
+ private let defaults: UserDefaults
+ let backend = Backend()
+ var silentReads = 0
+ var readError: TestError?
+ lazy var store = restartedStore()
+
+ init() { defaults = UserDefaults(suiteName: suite)! }
+
+ func restartedStore() -> ForelKeychainLicenseStore {
+ ForelKeychainLicenseStore(service: "test", defaults: defaults, secureStore: backend) { [unowned self] in
+ silentReads += 1
+ if let readError { throw readError }
+ return backend.record
+ }
+ }
+
+ deinit { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) }
+}
+
+@MainActor private final class Backend: LicenseSecureStoring {
+ var record: LicenseRecord?
+ var interactiveReads = 0
+ var writes = 0
+ var saveError: TestError?
+
+ func load() throws -> LicenseRecord? {
+ interactiveReads += 1
+ return record
+ }
+
+ func save(_ record: LicenseRecord) throws {
+ if let saveError { throw saveError }
+ writes += 1
+ self.record = record
+ }
+}
diff --git a/Tests/ForelCoreTests/ForelLicenseManagerTests.swift b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift
new file mode 100644
index 0000000..d650759
--- /dev/null
+++ b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift
@@ -0,0 +1,137 @@
+import CryptoKit
+import Foundation
+import Lab421Licensing
+import Testing
+@testable import ForelCore
+
+@Suite @MainActor struct ForelLicenseManagerTests {
+ @Test func freeWorksWithoutContactingTheServer() async {
+ let client = PaidClient()
+ let manager = manager(store: Store(), client: client)
+ manager.start()
+ await Task.yield()
+ #expect(manager.state == .free)
+ #expect(await client.calls == 0)
+ }
+
+ @Test func freeRestartSkipsKeychainAndProStillRestoresOffline() async throws {
+ let suite = "com.lab421.forel.tests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let backing = Store()
+ var startupReadsDenied = false
+ func secureStore() -> ForelKeychainLicenseStore {
+ ForelKeychainLicenseStore(service: "test", defaults: defaults, secureStore: backing) {
+ if startupReadsDenied { throw URLError(.userAuthenticationRequired) }
+ return try backing.load()
+ }
+ }
+ let key = Curve25519.Signing.PrivateKey()
+ let client = PaidClient(token: try token(key: key))
+ let free = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation)
+ #expect(free.state == .free)
+ #expect(backing.loads == 1)
+ let restartedFree = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation)
+ restartedFree.start()
+ await Task.yield()
+ #expect(restartedFree.state == .free)
+ #expect(backing.loads == 1)
+ #expect(await client.calls == 0)
+ await restartedFree.activate(licenseKey: "PRO-KEY")
+ #expect(restartedFree.state == .pro)
+ // A new local signature cannot read silently, but a previously
+ // activated license must still restore with authorization, offline.
+ startupReadsDenied = true
+ await client.setUnavailable()
+ let restartedPro = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation)
+ #expect(restartedPro.state == .pro)
+ #expect(restartedPro.lastError == nil)
+ #expect(backing.loads == 2)
+ await client.setValid()
+ await restartedPro.deactivate()
+ let reads = backing.loads
+ #expect(manager(store: secureStore(), client: client).state == .free)
+ #expect(backing.loads == reads)
+ }
+
+ @Test func activationOfflineUseRevocationAndDeactivation() async throws {
+ let key = Curve25519.Signing.PrivateKey()
+ let store = Store()
+ let client = PaidClient(token: try token(key: key))
+ let manager = manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation)
+ await manager.activate(licenseKey: "PRO-KEY")
+ #expect(manager.state == .pro)
+ #expect(store.record?.licenseToken != nil)
+ let restarted = self.manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation)
+ #expect(restarted.state == .pro)
+ await client.setUnavailable()
+ await restarted.validate()
+ #expect(restarted.state == .pro)
+ await client.setRevoked()
+ await restarted.validate()
+ #expect(restarted.state == .free)
+ #expect(self.manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation).state == .free)
+ await client.setValid()
+ await restarted.activate(licenseKey: "PRO-KEY")
+ await restarted.deactivate()
+ #expect(restarted.state == .free)
+ #expect(store.record?.licenseKey == nil)
+ }
+
+ @Test(arguments: ["other-product", "missing-key", "other-device", "expired"])
+ func rejectsSignedTokensWithoutForelProRights(kind: String) async throws {
+ let key = Curve25519.Signing.PrivateKey()
+ let client = PaidClient(token: try token(key: key, kind: kind))
+ let manager = manager(store: Store(), client: client, publicKey: key.publicKey.rawRepresentation)
+ await manager.activate(licenseKey: "PRO-KEY")
+ #expect(manager.state == .free)
+ #expect(manager.lastError != nil)
+ }
+
+ private func manager(store: any LicenseSecureStoring, client: PaidClient, publicKey: Data = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation) -> ForelLicenseManager {
+ ForelLicenseManager(isEnabled: true, productID: "forel", appName: "Forel", deviceNameFallback: "Mac", secureStore: store,
+ deviceIdentifierProvider: Device(), offlineTokenPublicKeyRaw: publicKey,
+ baseURL: URL(string: "http://localhost:8787/api/v1/license")!, client: client)
+ }
+
+ private func token(key: Curve25519.Signing.PrivateKey, kind: String = "valid") throws -> String {
+ var payload: [String: Any] = ["product_id": kind == "other-product" ? "other" : "forel",
+ "device_id": kind == "other-device" ? "other" : "device"]
+ if kind != "missing-key" { payload["license_key"] = "PRO-KEY" }
+ if kind == "expired" { payload["valid_until"] = "2020-01-01T00:00:00.000Z" }
+ let data = try JSONSerialization.data(withJSONObject: payload)
+ return data.base64EncodedString() + "." + (try key.signature(for: data)).base64EncodedString()
+ }
+}
+
+@MainActor private final class Store: LicenseSecureStoring {
+ var record: LicenseRecord?
+ var loads = 0
+ func load() throws -> LicenseRecord? { loads += 1; return record }
+ func save(_ record: LicenseRecord) throws { self.record = record }
+}
+private struct Device: DeviceIdentifierProviding { let deviceID = "device" }
+private actor PaidClient: LicenseAPIClientProtocol {
+ var calls = 0
+ var unavailable = false
+ var revoked = false
+ let token: String?
+ init(token: String? = nil) { self.token = token }
+ func setUnavailable() { unavailable = true }
+ func setRevoked() { unavailable = false; revoked = true }
+ func setValid() { unavailable = false; revoked = false }
+ func activate(licenseKey: String, deviceID: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws -> String? { calls += 1; return token }
+ func validate(licenseKey: String, deviceID: String) async throws -> Result {
+ calls += 1
+ if unavailable { throw URLError(.notConnectedToInternet) }
+ return revoked ? .failure(.licenseDisabled) : .success(token)
+ }
+ func deactivate(licenseKey: String, deviceID: String) async throws { calls += 1 }
+ func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws {
+ Issue.record("Free must not register an evaluation period")
+ }
+ func trialStatus(deviceID: String, product: String) async throws -> Result {
+ Issue.record("Free must not request an evaluation period")
+ return .failure(.unknown)
+ }
+}
diff --git a/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift b/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift
new file mode 100644
index 0000000..d36298b
--- /dev/null
+++ b/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift
@@ -0,0 +1,36 @@
+import Foundation
+import Testing
+@testable import ForelCore
+
+@Suite struct ForelLicensePreferencesTests {
+ @Test(arguments: [false, true])
+ func migrationPreservesFreeAndPaidStartupHintsAndDeviceIdentity(hasPaidLicense: Bool) throws {
+ let legacy = "forel.preferences.legacy.\(UUID().uuidString)"
+ let suite = "forel.preferences.new.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defer {
+ UserDefaults.standard.removePersistentDomain(forName: legacy)
+ defaults.removePersistentDomain(forName: suite)
+ }
+ let presenceKey = "test-service.direct-license.hasPaidLicense"
+ UserDefaults.standard.setPersistentDomain([
+ presenceKey: hasPaidLicense,
+ "device.fallbackIdentifier": "existing-device-id",
+ "unrelated-setting": "keep-in-original-domain",
+ ], forName: legacy)
+
+ ForelLicensePreferences.migrate(to: suite, from: legacy, service: "test-service")
+ #expect(defaults.object(forKey: presenceKey) as? Bool == hasPaidLicense)
+ #expect(defaults.string(forKey: "device.fallbackIdentifier") == "existing-device-id")
+ #expect(defaults.object(forKey: "unrelated-setting") == nil)
+ #expect(UserDefaults.standard.persistentDomain(forName: legacy)?[presenceKey] as? Bool == hasPaidLicense)
+
+ // Deactivation or a new device identity in the new suite must take
+ // precedence over stale values on the following launch.
+ defaults.set(!hasPaidLicense, forKey: presenceKey)
+ defaults.set("new-device-id", forKey: "device.fallbackIdentifier")
+ ForelLicensePreferences.migrate(to: suite, from: legacy, service: "test-service")
+ #expect(defaults.object(forKey: presenceKey) as? Bool == !hasPaidLicense)
+ #expect(defaults.string(forKey: "device.fallbackIdentifier") == "new-device-id")
+ }
+}
diff --git a/Tests/ForelCoreTests/FreePlanTests.swift b/Tests/ForelCoreTests/FreePlanTests.swift
new file mode 100644
index 0000000..fee4fa8
--- /dev/null
+++ b/Tests/ForelCoreTests/FreePlanTests.swift
@@ -0,0 +1,231 @@
+import Foundation
+import Testing
+@testable import ForelCore
+
+@Suite struct FreePlanTests {
+ @Test func persistenceLimitsFoldersButAllowsUnlimitedRulesInFree() throws {
+ let db = try Database(path: ":memory:")
+ let first = WatchedFolder(path: "/tmp/first")
+ try db.insertFolder(first, isPro: false)
+ try db.insertFolder(WatchedFolder(path: "/tmp/second"), isPro: false)
+ #expect(throws: FreePlanError.folderLimit) {
+ try db.insertFolder(WatchedFolder(path: "/tmp/third"), isPro: false)
+ }
+ for index in 0..<50 {
+ try db.insertRule(makeRule(folderId: first.id, name: "Disabled \(index)", enabled: false), isPro: false)
+ }
+ var edited = try #require(db.listRules(folderId: first.id).first)
+ edited.name = "Edited in Free"
+ try db.updateRule(edited, isPro: false)
+ #expect(try db.listRules(folderId: first.id).first?.name == "Edited in Free")
+ let third = WatchedFolder(path: "/tmp/third", priority: 3)
+ try db.insertFolder(third, isPro: true)
+ #expect(throws: FreePlanError.proFolder) {
+ try db.insertRule(makeRule(folderId: third.id, name: "Blocked"), isPro: false)
+ }
+ #expect(try db.listFolders().count == 3)
+ #expect(try db.listRules(folderId: first.id).count == 50)
+ }
+ @Test func folderLimitIncludesDisabledEntriesAndProRemovesIt() throws {
+ #expect(FreePlan.canAddFolder(count: 1, isPro: false))
+ #expect(!FreePlan.canAddFolder(count: 2, isPro: false))
+ #expect(FreePlan.canAddFolder(count: 20, isPro: true))
+ let db = try Database(path: ":memory:")
+ try db.insertFolder(WatchedFolder(path: "/tmp/disabled", enabled: false), isPro: false)
+ try db.insertFolder(WatchedFolder(path: "/tmp/enabled"), isPro: false)
+ #expect(throws: FreePlanError.folderLimit) {
+ try db.insertFolder(WatchedFolder(path: "/tmp/third"), isPro: false)
+ }
+ }
+
+ @Test(arguments: [false, true])
+ func sixthRuleRunsInBothPlansAcrossPreviewManualRunAndWatcher(isPro: Bool) throws {
+ let dir = TempDir()
+ let file = dir.file("input.txt")
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ for index in 0..<6 {
+ var rule = makeRule(folderId: folder.id, name: "Rule \(index)", enabled: index == 5)
+ rule.priority = Int64(index)
+ rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)]
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id)]
+ try db.insertRule(rule, isPro: false)
+ }
+ let allowed = try db.executionRules(folderID: folder.id, isPro: isPro)
+ #expect(allowed.count == 1)
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: allowed, isPro: isPro) != nil)
+ let (_, history) = RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "manual", root: dir.path, isPro: isPro)
+ #expect(history.count == 1)
+ #expect(history.first?.status == .applied)
+ #expect(!FileManager.default.fileExists(atPath: file))
+ try FileManager.default.removeItem(atPath: dir.path + "/renamed.txt")
+ _ = dir.file("input.txt")
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(isPro)
+ watcher.handle(event: .rescanSubtree(dir.path))
+ #expect(try db.listHistory().count == 1)
+ #expect(try db.listHistory().first?.status == .applied)
+ #expect(!FileManager.default.fileExists(atPath: file))
+ #expect(try db.listRules(folderId: folder.id).count == 6)
+ #expect(try db.listRules(folderId: folder.id).last?.enabled == true)
+ }
+
+ @Test func thirdFolderIsPreservedButCannotRunInFree() throws {
+ let db = try Database(path: ":memory:")
+ let dir = TempDir()
+ let roots = (0..<3).map { dir.dir("Folder\($0)") }
+ for (index, path) in roots.enumerated() {
+ try db.insertFolder(WatchedFolder(path: path, priority: Int64(index)))
+ }
+ let folders = try db.listFolders()
+ let folder = folders[2]
+ let file = dir.file("Folder2/input.txt")
+ var rule = makeRule(folderId: folder.id, name: "Rename")
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id)]
+ try db.insertRule(rule)
+ #expect(!FreePlan.allows(folderID: folder.id, folders: folders, isPro: false))
+ let allowed = try db.executionRules(folderID: folder.id, isPro: false)
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: allowed) == nil)
+ #expect(RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "free", root: folder.path).1.isEmpty)
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(false)
+ watcher.handle(path: file)
+ #expect(try db.listHistory().isEmpty)
+ #expect(FileManager.default.fileExists(atPath: file))
+ watcher.setProAccess(true)
+ watcher.handle(path: file)
+ #expect(try db.listHistory().count == 1)
+ #expect(try db.listFolders().count == 3)
+ }
+
+ @Test(arguments: [ActionKind.importToLibrary])
+ func proActionsBlockTheWholeRuleAcrossAllPaths(kind: ActionKind) throws {
+ let dir = TempDir()
+ let file = dir.file("input.txt")
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "Pro action")
+ rule.actions = [
+ makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id),
+ makeAction(kind, .object([:]), position: 1, ruleId: rule.id),
+ ]
+ let feature = try #require(FreePlan.requiredFeature(for: kind))
+ #expect(throws: FreePlanError.feature(feature)) { try db.insertRule(rule, isPro: false) }
+ try db.insertRule(rule, isPro: true)
+ #expect(try db.executionRules(folderID: folder.id, isPro: false).isEmpty)
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) == nil)
+ #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "free", isPro: false).history.isEmpty)
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(false)
+ watcher.handle(path: file)
+ watcher.handle(event: .rescanSubtree(dir.path))
+ #expect(try db.listHistory().isEmpty)
+ #expect(FileManager.default.fileExists(atPath: file))
+ #expect(!FileManager.default.fileExists(atPath: dir.path + "/renamed.txt"))
+ let restored = try #require(db.listRules(folderId: folder.id).first)
+ #expect(restored.actions.map(\.kind) == rule.actions.map(\.kind))
+ #expect(restored.enabled)
+ #expect(throws: FreePlanError.feature(feature)) { try db.updateRule(restored, isPro: false) }
+ var draft = restored
+ draft.enabled = false
+ try db.updateRule(draft, isPro: false)
+ #expect(try db.listRules(folderId: folder.id).first?.actions.count == 2)
+ }
+
+ @Test(arguments: [false, true], [4, 12])
+ func longActionChainsRunInBothPlansAcrossAllPaths(isPro: Bool, actionCount: Int) throws {
+ let dir = TempDir()
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "Action chain")
+ rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)]
+ rule.actions = (0.. \"$FOREL_FILE.marker\"")]), ruleId: rule.id)]
+ try db.insertRule(rule, isPro: isPro)
+ rule.name = "Edited script"
+ try db.updateRule(rule, isPro: isPro)
+ #expect(FreePlan.requiredFeature(for: rule) == nil)
+ #expect(try db.executionRules(folderID: folder.id, isPro: isPro).first?.name == rule.name)
+ let usage = MonthlyFileUsageStore()
+ let file = dir.file("input.txt")
+ let marker = file + ".marker"
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil)
+ #expect(!FileManager.default.fileExists(atPath: marker))
+ #expect(usage.monthlyFileUsage().processed == 0)
+ let manual = try usage.runWithFreePlan(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro)
+ #expect(manual.history.first?.status == .applied)
+ #expect(FileManager.default.fileExists(atPath: marker))
+ #expect(usage.monthlyFileUsage().processed == (isPro ? 0 : 1))
+ try FileManager.default.removeItem(atPath: marker)
+ let watcher = WatcherCoordinator(db: db, monthlyUsage: usage)
+ watcher.setProAccess(isPro)
+ watcher.handle(path: file)
+ #expect(FileManager.default.fileExists(atPath: marker))
+ #expect(try db.listHistory().first?.status == .applied)
+ #expect(usage.monthlyFileUsage().processed == (isPro ? 0 : 2))
+ }
+
+ @Test(arguments: [false, true])
+ func shortcutsAreIncludedInBothPlansAcrossPersistencePreviewAndExecution(isPro: Bool) throws {
+ let dir = TempDir()
+ let db = try Database(path: ":memory:")
+ let folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder)
+ var rule = makeRule(folderId: folder.id, name: "Shortcut")
+ rule.actions = [makeAction(.runShortcut, .object([ActionParam.shortcutName: .string("Archive Invoice")]), ruleId: rule.id)]
+ try db.insertRule(rule, isPro: isPro)
+ #expect(FreePlan.requiredFeature(for: rule) == nil)
+ #expect(try db.executionRules(folderID: folder.id, isPro: isPro).first?.actions == rule.actions)
+ let file = dir.file("input.txt")
+ let preview = try #require(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro))
+ #expect(preview.rules.first?.actions.first?.status == .wouldRun)
+
+ // An unconfigured shortcut is skipped. Exercise execution without
+ // launching any of the user's installed Shortcuts.
+ rule.actions[0].params = .object([ActionParam.shortcutName: .string("")])
+ try db.updateRule(rule, isPro: isPro)
+ let manual = RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro)
+ #expect(manual.matched == [rule.name])
+ #expect(manual.history.first?.actionKind == .runShortcut)
+ #expect(manual.history.first?.status == .skipped)
+ let watcher = WatcherCoordinator(db: db)
+ watcher.setProAccess(isPro)
+ watcher.handle(path: file)
+ #expect(try db.listHistory().first?.actionKind == .runShortcut)
+ #expect(try db.listHistory().first?.status == .skipped)
+ #expect(FileManager.default.fileExists(atPath: file))
+ }
+
+}
diff --git a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift
new file mode 100644
index 0000000..05a25c4
--- /dev/null
+++ b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift
@@ -0,0 +1,356 @@
+import Foundation
+import Testing
+@testable import ForelCore
+
+@Suite struct MonthlyFileUsageTests {
+ @Test(arguments: ["manual", "watcher", "rescan"])
+ func threeHundredthFileRunsButNextFileStaysUntouched(path: String) throws {
+ let fixture = try Fixture()
+ try fixture.seed(299)
+ let file = fixture.dir.file("input-300.txt")
+ let next = fixture.dir.file("input-301.txt")
+ let watcher = fixture.watcher()
+ switch path {
+ case "manual":
+ let result = try fixture.run(file)
+ #expect(result.history.count == 1)
+ #expect(result.history.first?.status == .applied)
+ case "watcher": watcher.handle(path: file)
+ default: watcher.handle(event: .rescanSubtree(fixture.dir.path))
+ }
+ #expect(!FileManager.default.fileExists(atPath: file))
+ #expect(FileManager.default.fileExists(atPath: next))
+ #expect(try fixture.usage().processed == 300)
+ #expect(throws: FreePlanError.monthlyFileLimit) { _ = try fixture.run(next) }
+ watcher.handle(path: next)
+ watcher.handle(event: .rescanSubtree(fixture.dir.path))
+ #expect(FileManager.default.fileExists(atPath: next))
+ #expect(try fixture.usage().processed == 300)
+ #expect(fixture.usageStore.quotaPendingFiles().contains(next))
+
+ // Dry Run still shows matching rules at the limit and never consumes.
+ for _ in 0..<3 {
+ #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [fixture.rule], isPro: false) != nil)
+ }
+ #expect(try fixture.usage().processed == 300)
+ #expect(FileManager.default.fileExists(atPath: next))
+ }
+
+ @Test func onlyAppliedFilesCountAndActionChainsCountOnce() throws {
+ let fixture = try Fixture()
+ let ignored = fixture.dir.file("ignored.txt")
+ _ = try fixture.run(ignored)
+ #expect(try fixture.usage().processed == 0)
+ let file = fixture.dir.file("input.txt")
+ var noOp = fixture.rule
+ noOp.actions = [makeAction(.rename, .object(["pattern": .string("input.txt")]), ruleId: noOp.id)]
+ let skipped = try fixture.run(file, rule: noOp)
+ #expect(skipped.history.allSatisfy { $0.status == .skipped })
+ #expect(try fixture.usage().processed == 0)
+ var failed = fixture.rule
+ failed.actions = [makeAction(.moveToFolder, .object(["destination": .string("")]), ruleId: failed.id)]
+ let failure = try fixture.run(file, rule: failed)
+ #expect(failure.history.allSatisfy { $0.status == .failed })
+ #expect(try fixture.usage().processed == 0)
+
+ var chain = fixture.rule
+ chain.actions = [
+ makeAction(.rename, .object(["pattern": .string("step1.txt")]), position: 0, ruleId: chain.id),
+ makeAction(.rename, .object(["pattern": .string("step2.txt")]), position: 1, ruleId: chain.id),
+ makeAction(.moveToFolder, .object(["destination": .string("")]), position: 2, ruleId: chain.id),
+ ]
+ let result = try fixture.run(file, rule: chain)
+ #expect(result.history.map(\.status) == [.applied, .applied, .failed])
+ #expect(try fixture.usage().processed == 1)
+ }
+
+ @Test func counterSurvivesRestartAndHistoryCleanupAndRenewsAtLocalMonthBoundary() throws {
+ let dir = TempDir()
+ let preferences = Preferences()
+ let dbPath = dir.path + "/forel.db"
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = try #require(TimeZone(identifier: "Europe/Paris"))
+ let october = ISO8601DateFormatter().date(from: "2026-10-31T22:59:59Z")!
+ let november = october.addingTimeInterval(1)
+ var rule = makeRule(name: "rename")
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("done.txt")]), ruleId: rule.id)]
+ do {
+ let db = try Database(path: dbPath)
+ let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults)
+ let result = try usageStore.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule], batchId: "month",
+ isPro: false, at: october, calendar: calendar)
+ try db.insertHistoryEntries(result.history)
+ try db.exec("DELETE FROM action_history")
+ }
+ let reopened = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!)
+ #expect(reopened.monthlyFileUsage(at: october, calendar: calendar).processed == 1)
+ #expect(reopened.monthlyFileUsage(at: november, calendar: calendar).processed == 0)
+ #expect(reopened.monthlyFileUsage(at: october, calendar: calendar).resetsAt == november)
+ }
+
+ @Test(arguments: ["manual", "watcher", "rescan"])
+ func deletingDatabaseRestoresExhaustedAllowanceFromPreferences(path: String) throws {
+ let dir = TempDir()
+ let preferences = Preferences()
+ let dbPath = dir.path + "/forel.db"
+ let date = Date()
+ let period = MonthlyFileUsage(at: date)
+ let next = dir.file("input-301.txt")
+ let folder = WatchedFolder(path: dir.path)
+ var rule = makeRule(folderId: folder.id, name: "rename inputs")
+ rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)]
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("processed-{name}")]), ruleId: rule.id)]
+ do {
+ let db = try Database(path: dbPath)
+ try db.insertFolder(folder)
+ try db.insertRule(rule)
+ preferences.defaults.set(299, forKey: "free.monthlyFileUsage.\(period.month)")
+ let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults)
+ let result = try usageStore.runWithFreePlan(path: dir.file("input-300.txt"), depth: 0, rules: [rule],
+ batchId: "before-deletion", isPro: false, at: date)
+ #expect(result.history.first?.status == .applied)
+ let watcher = WatcherCoordinator(db: db, monthlyUsage: usageStore, now: { date })
+ watcher.handle(path: next)
+ #expect(usageStore.quotaPendingFiles() == [next])
+ }
+ try FileManager.default.removeItem(atPath: dbPath)
+ let restored = try Database(path: dbPath)
+ let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!)
+ try restored.insertFolder(folder)
+ try restored.insertRule(rule)
+ #expect(usageStore.quotaPendingFiles() == [next])
+ // Exercise admission before the UI has read the saved counter.
+ let watcher = WatcherCoordinator(db: restored, monthlyUsage: usageStore, now: { date })
+ switch path {
+ case "manual":
+ #expect(throws: FreePlanError.monthlyFileLimit) {
+ _ = try usageStore.runWithFreePlan(path: next, depth: 0, rules: [rule], batchId: "after-deletion",
+ isPro: false, at: date)
+ }
+ case "watcher": watcher.handle(path: next)
+ default: watcher.handle(event: .rescanSubtree(dir.path))
+ }
+ #expect(FileManager.default.fileExists(atPath: next))
+ #expect(usageStore.monthlyFileUsage(at: date).processed == 300)
+ #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [rule], isPro: false) != nil)
+ #expect(usageStore.monthlyFileUsage(at: date).processed == 300)
+ }
+
+ @Test func pendingFilesRemainUniqueAndTheirRemovalSurvivesRestart() {
+ let preferences = Preferences()
+ let store = MonthlyFileUsageStore(preferences: preferences.defaults)
+ store.queueQuotaPendingFile("/tmp/b.txt")
+ store.queueQuotaPendingFile("/tmp/a.txt")
+ store.queueQuotaPendingFile("/tmp/a.txt")
+ #expect(store.quotaPendingFiles() == ["/tmp/a.txt", "/tmp/b.txt"])
+ store.removeQuotaPendingFile("/tmp/a.txt")
+ let restarted = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!)
+ #expect(restarted.quotaPendingFiles() == ["/tmp/b.txt"])
+ restarted.removeQuotaPendingFile("/tmp/b.txt")
+ #expect(store.quotaPendingFiles().isEmpty)
+ }
+
+ @Test(arguments: [false, true])
+ func queuedWatcherFilesResumeAfterRenewalOrProActivation(pro: Bool) throws {
+ let fixture = try Fixture()
+ try fixture.seed(300)
+ let file = fixture.dir.file("input-pending.txt")
+ do {
+ let watcher = fixture.watcher()
+ watcher.handle(path: file)
+ #expect(FileManager.default.fileExists(atPath: file))
+ }
+ // A new store and coordinator model restarting with saved pending files.
+ let nextMonth = fixture.usageDate.resetsAt.addingTimeInterval(1)
+ try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file)
+ let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: fixture.preferences.name)!)
+ let watcher = WatcherCoordinator(db: fixture.db, monthlyUsage: usageStore, settleInterval: 0.05,
+ now: { pro ? fixture.date : nextMonth })
+ watcher.setProAccess(pro)
+ watcher.add(fixture.dir.path)
+ watcher.processingQueue.sync {}
+ Thread.sleep(forTimeInterval: 0.15)
+ #expect(FileManager.default.fileExists(atPath: file))
+ #expect(usageStore.quotaPendingFiles().contains(file))
+ try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file)
+ watcher.waitForPendingEvents()
+ #expect(!FileManager.default.fileExists(atPath: file))
+ #expect(usageStore.quotaPendingFiles().isEmpty)
+ #expect(try fixture.usage().processed == 300)
+ #expect(usageStore.monthlyFileUsage(at: nextMonth).processed == (pro ? 0 : 1))
+ watcher.remove(fixture.dir.path)
+ watcher.waitForPendingEvents()
+ }
+
+ @Test(arguments: [false, true], [ActionKind.moveToFolder, .copyToFolder])
+ func unchangedFilesWaitingForADiskResumeAfterQuotaIsLifted(pro: Bool, kind: ActionKind) throws {
+ let fixture = try Fixture()
+ let file = fixture.dir.file("input-invoice.txt", contents: "invoice")
+ let destination = fixture.dir.dir("ConnectedDisk")
+ let resultPath = destination + "/input-invoice.txt"
+ let volume = "ForelMissingShare-\(UUID().uuidString)"
+ var rule = fixture.rule
+ rule.actions = [makeAction(kind, .object(["destination": .string("/Volumes/\(volume)/Inbox")]), ruleId: rule.id)]
+ try fixture.db.updateRule(rule)
+ do {
+ let watcher = fixture.watcher()
+ watcher.handle(path: file)
+ #expect(try fixture.db.listHistory().map(\.status) == [.failed])
+ #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file))
+ #expect(try fixture.usage().processed == 0)
+
+ // Other files exhaust the allowance before the disk reconnects.
+ try fixture.seed(300)
+ // Model a reconnected disk with a reachable temporary folder,
+ // as in WatcherDisconnectedVolumeTests, without using /Volumes.
+ rule.actions[0].params = .object(["destination": .string(destination)])
+ try fixture.db.updateRule(rule)
+ watcher.volumeDidMount(atPath: "/Volumes/\(volume)")
+ watcher.waitForPendingEvents()
+ #expect(fixture.usageStore.quotaPendingFiles() == [file])
+ #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file))
+ }
+
+ #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) != nil)
+ #expect(try fixture.usage().processed == 300)
+ // Even an unchanged queued file may now be in use by a writer.
+ try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file)
+ #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file))
+ let resumedAt = pro ? fixture.date : fixture.usageDate.resetsAt.addingTimeInterval(1)
+ // A fresh store and coordinator verify that fingerprints in SQLite
+ // and pending files in preferences still allow the retry after restart.
+ let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: fixture.preferences.name)!)
+ let watcher = WatcherCoordinator(db: fixture.db, monthlyUsage: usageStore, settleInterval: 0.05, now: { resumedAt })
+ watcher.setProAccess(pro)
+ watcher.add(fixture.dir.path)
+ watcher.processingQueue.sync {}
+ Thread.sleep(forTimeInterval: 0.15)
+ #expect(FileManager.default.fileExists(atPath: file))
+ #expect(!FileManager.default.fileExists(atPath: resultPath))
+ #expect(usageStore.quotaPendingFiles() == [file])
+ try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file)
+ watcher.waitForPendingEvents()
+ #expect(FileManager.default.fileExists(atPath: resultPath))
+ #expect(FileManager.default.fileExists(atPath: file) == (kind == .copyToFolder))
+ #expect(usageStore.quotaPendingFiles().isEmpty)
+ #expect(try fixture.db.listHistory().filter { $0.status == .applied }.count == 1)
+ #expect(usageStore.monthlyFileUsage(at: resumedAt).processed == (pro ? 300 : 1))
+
+ // Normal duplicate events must still be deduplicated after the retry,
+ // including a Copy that leaves the source file in place.
+ watcher.processingQueue.sync { watcher.handle(path: file) }
+ #expect(try fixture.db.listHistory().count == 2)
+ #expect(try FileManager.default.contentsOfDirectory(atPath: destination) == ["input-invoice.txt"])
+ watcher.remove(fixture.dir.path)
+ watcher.waitForPendingEvents()
+ }
+
+ @Test func proProcessingBypassesAnExhaustedFreeAllowance() throws {
+ let fixture = try Fixture()
+ try fixture.seed(300)
+ let result = try fixture.usageStore.runWithFreePlan(path: fixture.dir.file("input-pro.txt"), depth: 0,
+ rules: [fixture.rule], batchId: "pro", isPro: true, at: fixture.date)
+ #expect(result.history.first?.status == .applied)
+ #expect(try fixture.usage().processed == 300)
+ }
+
+ @Test func simultaneousManualAndAutomaticRunsCannotSpendTheLastSlotTwice() throws {
+ let fixture = try Fixture()
+ try fixture.seed(299)
+ let first = fixture.dir.file("input-first.txt")
+ let second = fixture.dir.file("input-second.txt")
+ DispatchQueue.concurrentPerform(iterations: 2) { index in
+ _ = try? fixture.run(index == 0 ? first : second)
+ }
+ let remaining = [first, second].filter { FileManager.default.fileExists(atPath: $0) }
+ #expect(remaining.count == 1)
+ #expect(try fixture.usage().processed == 300)
+ }
+
+ @Test func usageSavedAboveTheNewLimitStaysBlockedUntilNextMonth() throws {
+ let fixture = try Fixture()
+ try fixture.seed(500)
+ let usage = MonthlyFileUsageStore(preferences: fixture.preferences.defaults)
+ let file = fixture.dir.file("input-existing-usage.txt")
+ #expect(usage.monthlyFileUsage(at: fixture.date).isExhausted)
+ #expect(throws: FreePlanError.monthlyFileLimit) {
+ _ = try usage.runWithFreePlan(path: file, depth: 0, rules: [fixture.rule],
+ batchId: "current-month", isPro: false, at: fixture.date)
+ }
+ #expect(FileManager.default.fileExists(atPath: file))
+ #expect(usage.monthlyFileUsage(at: fixture.date).processed == 500)
+ let nextMonth = fixture.usageDate.resetsAt.addingTimeInterval(1)
+ let result = try usage.runWithFreePlan(path: file, depth: 0, rules: [fixture.rule],
+ batchId: "next-month", isPro: false, at: nextMonth)
+ #expect(result.history.first?.status == .applied)
+ #expect(usage.monthlyFileUsage(at: nextMonth).processed == 1)
+ }
+
+ @Test func quotaProcessingKeepsTheDatabaseAtVersionEightForRollback() throws {
+ let dir = TempDir()
+ let preferences = Preferences()
+ let path = dir.path + "/forel.db"
+ let folder = WatchedFolder(path: dir.path)
+ var rule = makeRule(folderId: folder.id, name: "Existing rule")
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("processed.txt")]), ruleId: rule.id)]
+ do {
+ let db = try Database(path: path)
+ try db.insertFolder(folder)
+ try db.insertRule(rule)
+ #expect(try db.userVersion() == 8)
+ let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults)
+ let result = try usageStore.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule],
+ batchId: "quota", isPro: false)
+ try db.insertHistoryEntries(result.history)
+ #expect(usageStore.monthlyFileUsage().processed == 1)
+ }
+ let reopened = try Database(path: path)
+ #expect(Database.currentSchemaVersion == 8)
+ #expect(try reopened.userVersion() == 8)
+ #expect(try reopened.listRules(folderId: folder.id).first?.id == rule.id)
+ #expect(try reopened.listHistory().first?.status == .applied)
+ let quotaTables = try reopened.statement("SELECT name FROM sqlite_master WHERE name IN ('monthly_file_usage', 'quota_pending_files')")
+ #expect(try !quotaTables.step())
+ }
+
+ private final class Fixture: @unchecked Sendable {
+ let dir = TempDir()
+ let preferences = Preferences()
+ let db: Database
+ let usageStore: MonthlyFileUsageStore
+ let folder: WatchedFolder
+ let rule: Rule
+ let date = Date()
+ var usageDate: MonthlyFileUsage { MonthlyFileUsage(at: date) }
+
+ init() throws {
+ db = try Database(path: ":memory:")
+ usageStore = MonthlyFileUsageStore(preferences: preferences.defaults)
+ folder = WatchedFolder(path: dir.path)
+ try db.insertFolder(folder, isPro: false)
+ var rule = makeRule(folderId: folder.id, name: "rename inputs")
+ rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)]
+ rule.actions = [makeAction(.rename, .object(["pattern": .string("processed-{name}")]), ruleId: rule.id)]
+ self.rule = rule
+ try db.insertRule(rule, isPro: false)
+ }
+
+ func seed(_ count: Int) throws {
+ preferences.defaults.set(count, forKey: "free.monthlyFileUsage.\(usageDate.month)")
+ }
+
+ func usage() throws -> MonthlyFileUsage { usageStore.monthlyFileUsage(at: date) }
+ func watcher() -> WatcherCoordinator { WatcherCoordinator(db: db, monthlyUsage: usageStore, now: { self.date }) }
+ func run(_ path: String, rule: Rule? = nil) throws -> (matched: [String], history: [HistoryEntry]) {
+ try usageStore.runWithFreePlan(path: path, depth: 0, rules: [rule ?? self.rule], batchId: "manual", isPro: false, at: date)
+ }
+ }
+
+ private final class Preferences {
+ let name = "com.lab421.forel.tests.monthlyUsage.\(UUID().uuidString)"
+ let defaults: UserDefaults
+
+ init() { defaults = UserDefaults(suiteName: name)! }
+ deinit { defaults.removePersistentDomain(forName: name) }
+ }
+}
diff --git a/build.sh b/build.sh
index abc28dc..331aea4 100755
--- a/build.sh
+++ b/build.sh
@@ -4,7 +4,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
- ./build.sh [command] [arch] [version] [--sign] [--notarize]
+ ./build.sh [command] [flavor] [arch] [version] [--sign] [--notarize]
Commands:
dev Build in debug mode and run ForelApp locally
@@ -14,6 +14,9 @@ Commands:
release Alias for: build + package
Arguments:
+ flavor development, ad-hoc, or app-store
+ (dev defaults to development; build/package default to ad-hoc)
+ The test command is flavor-independent and accepts no flavor.
arch universal, arm64, or x86_64 (defaults to host architecture)
version Release version like v1.2.3
--sign Sign the app and DMG with a Developer ID Application certificate
@@ -23,11 +26,12 @@ Arguments:
Examples:
./build.sh
./build.sh dev
- ./build.sh package
+ ./build.sh test
+ ./build.sh build ad-hoc arm64
./build.sh package arm64 v1.2.3
- ./build.sh package arm64 v1.2.3 --sign
- ./build.sh package arm64 v1.2.3 --sign --notarize
- ./build.sh package universal v1.2.3 --sign --notarize
+ ./build.sh package ad-hoc arm64 v1.2.3 --sign
+ ./build.sh package ad-hoc arm64 v1.2.3 --sign --notarize
+ ./build.sh package ad-hoc universal v1.2.3 --sign --notarize
./build.sh release x86_64 v1.2.3
EOF
}
@@ -35,11 +39,12 @@ EOF
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$script_dir"
dist_dir="$repo_root/dist/release"
-bundle_root="${TMPDIR:-/tmp}/Forel.app"
-dev_bundle="${TMPDIR:-/tmp}/Forel-dev.app"
-staging_root="${TMPDIR:-/tmp}/Forel-dmg"
+temp_root="$(cd "${TMPDIR:-/tmp}" && pwd -P)"
+bundle_root="$temp_root/Forel.app"
+dev_bundle="$temp_root/Forel-dev.app"
+staging_root="$temp_root/Forel-dmg"
icon_source="$repo_root/Sources/ForelApp/Resources/AppIcon.png"
-iconset_dir="${TMPDIR:-/tmp}/Forel.iconset"
+iconset_dir="$temp_root/Forel.iconset"
icns_path="$repo_root/dist/Forel.icns"
entitlements_path="$repo_root/Forel.entitlements"
code_sign_identity="${CODE_SIGN_IDENTITY:--}"
@@ -161,7 +166,7 @@ notarize_and_staple() {
swift_release_build() {
local build_arch="$1"
- local build_args=(-c release)
+ local build_args=(-c release "${swift_flags[@]}")
if [[ "$build_arch" == "universal" ]]; then
build_args+=(--arch arm64 --arch x86_64)
@@ -186,6 +191,47 @@ if [[ $# -gt 0 ]]; then
esac
fi
+if [[ "$command" == "test" ]]; then
+ if [[ $# -gt 0 ]]; then
+ echo "The test command does not accept a build flavor." >&2
+ exit 1
+ fi
+ flavor="development"
+else
+ if [[ "$command" == "dev" ]]; then
+ flavor="development"
+ else
+ flavor="ad-hoc"
+ fi
+
+ if [[ $# -gt 0 ]]; then
+ case "$1" in
+ development|ad-hoc|app-store)
+ flavor="$1"
+ shift
+ ;;
+ esac
+ fi
+fi
+
+case "$flavor" in
+ development)
+ compilation_condition="FOREL_BUILD_DEVELOPMENT"
+ ;;
+ ad-hoc)
+ compilation_condition="FOREL_BUILD_AD_HOC"
+ ;;
+ app-store)
+ compilation_condition="FOREL_BUILD_APP_STORE"
+ ;;
+ *)
+ echo "Unsupported build flavor: $flavor" >&2
+ exit 1
+ ;;
+esac
+
+swift_flags=(-Xswiftc "-D$compilation_condition")
+
sign_requested=false
notarize_requested=false
positional_args=()
@@ -252,7 +298,7 @@ if [[ "$notarize_requested" == true ]]; then
echo "Notarization requires NOTARY_API_KEY_P8, NOTARY_KEY_ID, and NOTARY_ISSUER_ID" >&2
exit 1
fi
- notary_work="$(mktemp -d "${TMPDIR:-/tmp}/Forel-notary.XXXXXX")"
+ notary_work="$(mktemp -d "$temp_root/Forel-notary.XXXXXX")"
printf '%s' "$NOTARY_API_KEY_P8" | base64 --decode > "$notary_work/AuthKey.p8"
chmod 600 "$notary_work/AuthKey.p8"
fi
@@ -265,14 +311,15 @@ case "$command" in
# the bundle identity declared in Info.plist; an unbundled binary has none,
# so those checks get misattributed to whatever launched it (e.g. the
# terminal you're using) instead of Forel, making them unreliable to test.
- swift build
+ swift build "${swift_flags[@]}"
- build_products_dir="$(swift build --show-bin-path)"
+ build_products_dir="$(swift build "${swift_flags[@]}" --show-bin-path)"
assemble_app_bundle "$build_products_dir" "$dev_bundle"
contents_dir="$dev_bundle/Contents"
macos_dir="$contents_dir/MacOS"
- write_info_plist "$contents_dir" "0.0.0-dev"
+ write_info_plist "$contents_dir" "0.0.0-dev" ' ForelBuildFlavor
+ development'
# Ad-hoc sign so TCC has a stable bundle identity to attach grants to.
codesign --force --deep --sign - "$dev_bundle" >/dev/null 2>&1
@@ -289,6 +336,8 @@ case "$command" in
test)
cd "$repo_root"
+ # Tests use the development flavor (no direct licensing), so no
+ # compilation condition is passed — plain `swift test` works as before.
swift test
;;
@@ -343,7 +392,9 @@ case "$command" in
write_info_plist "$contents_dir" "$version_number" ' CFBundleIconFile
Forel
LSApplicationCategoryType
- public.app-category.productivity'
+ public.app-category.productivity
+ ForelBuildFlavor
+ '"$flavor"''
if [[ -f "$icns_path" ]]; then
cp "$icns_path" "$bundle_root/Contents/Resources/Forel.icns"