Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to Forel are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

- Settings now links to Forel's GitHub repository from the About screen.
- Added a Settings option to hide Forel's menu bar icon.
- Opening Forel from Finder or Spotlight now always brings its main window forward.
- Added visible drag-and-drop action order controls so actions run in the sequence you set.
- Added a one-time Hazel migration assistant that detects Hazel, opens it, and guides rule export and import.
- Added rule import and export, including compatible import of Hazel rule exports with clear review warnings for unsupported parts.

### Added
- Rules can now match only when none of their conditions match, and can pause between actions for a chosen number of seconds.
- Added sorting, one-way folder sync, FTP/SFTP/WebDAV upload, Finder metadata and file-state actions, archiving, script and Automator runners, Finder reveal/open/alias actions, rule-flow controls, and per-rule notifications.
- Added conditions for Finder comments, paths, folder item counts, last-opened dates, image dimensions, photo capture dates, PDF page counts, and advanced Spotlight metadata.

## [1.0.8] - 2026-07-24

### Fixed
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,11 @@ History / Undo (SQLite)
- [x] Shortcuts actions
- [x] Open App actions
- [x] Validate actions / conditions before save
- [ ] Export / Import rules
- [ ] Toggle extension hidden / visible
- [ ] Compress actions
- [ ] Compress actions
- [ ] Sync actions
- [ ] Upload actions
- [x] Import / export rules (including Hazel imports with compatibility warnings)
- [x] Toggle extension hidden / visible
- [x] Archive / compress actions
- [x] Sync actions
- [x] Upload actions
- [x] Native notifications on rule actions
- [ ] AI features

Expand Down
28 changes: 19 additions & 9 deletions Sources/ForelApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@

import AppKit

/// Closing the window hides it instead of quitting; Forel keeps running in
/// the menu bar. Quit is only available from the status item menu.
/// Closing the main window hides it instead of quitting; Forel keeps running
/// in the background.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var statusBarController: StatusBarController?
private var model: AppModel?
private var updater: UpdaterManager?
private weak var mainWindow: NSWindow?

/// `@NSApplicationDelegateAdaptor` requires a zero-argument initializer;
/// the app's model/updater are handed in afterward once SwiftUI has
Expand Down Expand Up @@ -83,11 +84,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}

private func configureMainWindow() {
guard let window = NSApp.windows.first(where: { !($0 is NSPanel) }) else { return }
guard let window = mainWindow ?? NSApp.windows.first(where: { !($0 is NSPanel) }) else { return }
configureMainWindow(window)
}

private func configureMainWindow(_ window: NSWindow) {
mainWindow = window
window.delegate = self
window.title = "Forel"
window.titleVisibility = .hidden
Expand All @@ -113,9 +115,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
return true
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard !flag else { return true }
openMainWindow()
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows _: Bool) -> Bool {
// Finder and Spotlight send a reopen event when Forel is already
// running. Always surface the actual main window, even if Settings is
// the only currently-visible window.
if openMainWindow() {
return false
}
return true
}

Expand All @@ -124,13 +130,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
statusBarController = StatusBarController(
model: model,
updater: updater,
window: NSApp.windows.first
window: mainWindow ?? NSApp.windows.first
)
}

private func openMainWindow() {
let targetWindow = NSApp.windows.first { !($0 is NSPanel) }
@discardableResult
private func openMainWindow() -> Bool {
guard let targetWindow = mainWindow ?? NSApp.windows.first(where: { !($0 is NSPanel) }) else {
return false
}
WindowActivation.activateSoon(targetWindow, showsDockIcon: model?.showDockIcon != false)
return true
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
Expand Down
65 changes: 65 additions & 0 deletions Sources/ForelApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class AppModel: ObservableObject {
@Published var detailRoute: DetailRoute = .rules
@Published var accentPreset: AccentPreset = .default
@Published var showDockIcon: Bool = true
@Published var showMenuBarIcon: Bool = true
@Published var watcherNotificationsEnabled: Bool = true
@Published var historyMaxDays: Int = 30
/// Bumped whenever the accent colour changes, so views can force a full
Expand All @@ -59,6 +60,7 @@ final class AppModel: ObservableObject {
private var runNowMessageId: UUID?
@Published private(set) var isPreviewing = false
@Published var previewResult: PreviewResult?
@Published var showHazelImportAssistant = false
@Published private var ruleExpansionPreferences = RuleExpansionPreferences()

let db: Database
Expand All @@ -69,6 +71,7 @@ final class AppModel: ObservableObject {
private var historyCleanupTimer: AnyCancellable?
private var pendingWatcherNotification = PendingWatcherNotification()
private var watcherNotificationTask: Task<Void, Never>?
private let hazelAppURL: URL?

init() throws {
let appSupportRoot = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
Expand All @@ -79,6 +82,7 @@ final class AppModel: ObservableObject {

let db = try Database(path: dbPath)
self.db = db
self.hazelAppURL = Self.locateHazel()
self.coordinator = WatcherCoordinator(db: db)
self.coordinator.onActivity = { [weak self] summary in
Task { @MainActor in
Expand All @@ -101,6 +105,9 @@ final class AppModel: ObservableObject {
let storedShowDockIcon = db.withLock { db in try? db.getSetting("show_dock_icon") }
self.showDockIcon = storedShowDockIcon.map { $0 == "1" } ?? true

let storedShowMenuBarIcon = db.withLock { db in try? db.getSetting("show_menu_bar_icon") }
self.showMenuBarIcon = storedShowMenuBarIcon.map { $0 == "1" } ?? true

let storedWatcherNotificationsEnabled = db.withLock { db in try? db.getSetting("watcher_notifications_enabled") }
self.watcherNotificationsEnabled = storedWatcherNotificationsEnabled.map { $0 == "1" } ?? true

Expand All @@ -110,10 +117,19 @@ final class AppModel: ObservableObject {
self.ruleExpansionPreferences = db.withLock { db in RuleExpansionPreferences.load(from: db) }

reloadFolders()
let hazelPromptSeen = db.withLock { db in (try? db.getSetting("hazel_import_prompt_seen")) == "1" }
showHazelImportAssistant = !hazelPromptSeen && folders.isEmpty && hazelAppURL != nil
startWatchingEnabledFolders()
startHistoryCleanupTimer()
}

private static func locateHazel() -> URL? {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.noodlesoft.Hazel")
?? ["/Applications/Hazel.app", "/Applications/Setapp/Hazel.app"]
.map(URL.init(fileURLWithPath:))
.first(where: { FileManager.default.fileExists(atPath: $0.path) })
}

/// Forel's bundle identifier moved from `com.forel.app` (`.app` isn't a
/// valid reverse-DNS component on macOS) to `com.lab421.forel`. Existing
/// users have their database and settings under the old identifier's
Expand Down Expand Up @@ -167,6 +183,11 @@ final class AppModel: ObservableObject {
applyDockIconPreference(keepingWindowsVisible: true)
}

func setShowMenuBarIcon(_ enabled: Bool) {
showMenuBarIcon = enabled
db.withLock { db in try? db.setSetting("show_menu_bar_icon", enabled ? "1" : "0") }
}

func setWatcherNotificationsEnabled(_ enabled: Bool) {
watcherNotificationsEnabled = enabled
db.withLock { db in try? db.setSetting("watcher_notifications_enabled", enabled ? "1" : "0") }
Expand Down Expand Up @@ -476,6 +497,50 @@ final class AppModel: ObservableObject {
reloadRules()
}

func exportRules(to url: URL) {
do {
let data = try RuleTransfer.exportForel(rules)
try data.write(to: url, options: .atomic)
showRunNowMessage("Exported \(rules.count) rule\(rules.count == 1 ? "" : "s")")
} catch {
showError(error)
}
}

@discardableResult
func importRules(from url: URL) -> Bool {
guard let folderId = selectedFolderId else { return false }
do {
let result = try RuleTransfer.importRules(from: Data(contentsOf: url), folderId: folderId)
try db.withLock { db in
for rule in result.rules { try db.insertRule(rule) }
}
reloadRules()
if result.issues.isEmpty {
showRunNowMessage("Imported \(result.rules.count) rule\(result.rules.count == 1 ? "" : "s")")
} else {
alertTitle = "Imported with review needed"
errorMessage = "Imported \(result.rules.count) rule\(result.rules.count == 1 ? "" : "s"). Rules with unsupported Hazel parts were disabled.\n\n" + result.issues.map { "\($0.ruleName): \($0.message)" }.joined(separator: "\n")
}
return true
} catch {
showError(error)
return false
}
}

func openHazel() {
guard let hazelAppURL else { return }
NSWorkspace.shared.openApplication(at: hazelAppURL, configuration: .init()) { _, error in
if let error { Task { @MainActor in self.showError(error) } }
}
}

func finishHazelImportAssistant() {
db.withLock { db in try? db.setSetting("hazel_import_prompt_seen", "1") }
showHazelImportAssistant = false
}

/// Runs all enabled rules in the selected folder against every file
/// currently in it (a manual "Run Now"), bounded by each rule's scope.
func runNow() {
Expand Down
15 changes: 15 additions & 0 deletions Sources/ForelApp/MenuBar/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class StatusBarController: NSObject {
private var globalDismissMonitor: Any?
private var pausedSubscription: AnyCancellable?
private var updateSubscription: AnyCancellable?
private var visibilitySubscription: AnyCancellable?

init(model: AppModel, updater: UpdaterManager, window: NSWindow?) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
Expand All @@ -41,6 +42,8 @@ final class StatusBarController: NSObject {
self.window = window
super.init()

statusItem.isVisible = model.showMenuBarIcon

if let button = statusItem.button {
button.image = Self.glyph(paused: model.paused, updateAvailable: updater.updateAvailable)
button.image?.isTemplate = false
Expand All @@ -55,12 +58,24 @@ final class StatusBarController: NSObject {
guard let self else { return }
self.refreshGlyph(paused: self.model.paused)
}
visibilitySubscription = model.$showMenuBarIcon
.removeDuplicates()
.sink { [weak self] isVisible in
self?.setVisible(isVisible)
}
}

private func refreshGlyph(paused: Bool) {
statusItem.button?.image = Self.glyph(paused: paused, updateAvailable: updater.updateAvailable)
}

private func setVisible(_ isVisible: Bool) {
if !isVisible {
closePopover()
}
statusItem.isVisible = isVisible
}

@objc private func togglePopover() {
if let popover, popover.isShown {
popover.performClose(nil)
Expand Down
38 changes: 38 additions & 0 deletions Sources/ForelApp/Views/ActionInsertionDropDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Forel - A native macOS file-automation app
// Copyright (C) 2026 Lab421

import SwiftUI
import UniformTypeIdentifiers

/// Handles a local action reorder without consuming drops from other apps.
struct ActionInsertionDropDelegate: DropDelegate {
let insertionIndex: Int
@Binding var draggedActionId: String?
@Binding var activeInsertionIndex: Int?
let move: (String, Int) -> Void

func dropEntered(info: DropInfo) {
guard draggedActionId != nil else { return }
activeInsertionIndex = insertionIndex
}

func dropUpdated(info: DropInfo) -> DropProposal? {
guard draggedActionId != nil else { return nil }
activeInsertionIndex = insertionIndex
return DropProposal(operation: .move)
}

func performDrop(info: DropInfo) -> Bool {
defer {
draggedActionId = nil
activeInsertionIndex = nil
}
guard let actionId = draggedActionId else { return false }
move(actionId, insertionIndex)
return true
}

func dropExited(info: DropInfo) {
if activeInsertionIndex == insertionIndex { activeInsertionIndex = nil }
}
}
4 changes: 4 additions & 0 deletions Sources/ForelApp/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ struct ContentView: View {
} message: {
Text(model.errorMessage ?? "")
}
.sheet(isPresented: $model.showHazelImportAssistant) {
HazelImportAssistantView()
.environmentObject(model)
}
.tint(ForelTheme.accent)
// ForelTheme.accent is a plain static var, not observable; bumping the
// identity here forces every descendant to rebuild and re-read it.
Expand Down
Loading