Files
Bitsy 8b751b6ea4 Fix hover crash on list changes, show first-walk progress, stream new roots
- Hover overlay: bounds-check the hovered row / column before asking the
  table for a cell view (NSTableView raised on a row removed by a reload,
  aborting the app), and refresh after the update pass instead of inside
  SwiftUI's updateNSView / constraints pass.
- Status bar details use a click-through hover panel (0.5 s, updated live)
  instead of .help, whose delay restarted at every refresh.
- First walks of root folders are announced: status bar "Indexing <folder>…
  N found" and per-folder status in Settings > Indexes > Folders, plus
  "Not available" for unreachable roots.
- New roots and folders stream into the index every 3 s while walked, on a
  dedicated queue so long walks never delay FSEvents updates.
- A new folder's own entry is merged only when its walk completes, so an
  interrupted walk is redone on the next launch or replay.
- CHANGELOG: 1.0.10.
2026-10-06 10:09:47 +02:00

1010 lines
40 KiB
Swift

import Foundation
import SwiftUI
import Combine
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
// Lets the DispatchSource timer compare a new stat() result against the
// previously seen value without touching any @MainActor state, so the
// poll only hops to main when the cache has actually changed on disk.
private final class BackgroundMtime: @unchecked Sendable {
private var mtime: Date?
private let lock = NSLock()
// returns true if the supplied mtime differs from the stored value
// (and updates the stored value), false otherwise
func updateIfChanged(_ inMtime: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
if mtime != inMtime {
mtime = inMtime
return true
}
return false
}
}
// Thread-safe accumulator for FSEvents batches. The FSEvents callback appends
// here from its own queue and only schedules a flush when none is pending, so
// a replay that delivers thousands of batches in a burst becomes a handful of
// coalesced flushes instead of thousands of main-queue hops.
private final class PendingChanges: @unchecked Sendable {
// changes received since the last drain
private var changes: [FSChange] = []
// true while a flush is scheduled but has not drained yet
private var flushScheduled = false
// how long changes are collected before a flush (follows AppActivity)
private var delaySeconds: TimeInterval = 2
// guards every field above
private let lock = NSLock()
// current collection delay before a flush
var coalesceDelay: TimeInterval {
lock.lock()
defer { lock.unlock() }
return delaySeconds
}
// changes the collection delay used for the next flushes
func setCoalesceDelay(_ inSeconds: TimeInterval) {
lock.lock()
delaySeconds = inSeconds
lock.unlock()
}
// true when changes are waiting to be flushed
var hasPending: Bool {
lock.lock()
defer { lock.unlock() }
return !changes.isEmpty
}
// appends a batch; returns true if the caller must schedule a flush
func append(inChanges: [FSChange]) -> Bool {
lock.lock()
defer { lock.unlock() }
changes.append(contentsOf: inChanges)
if flushScheduled { return false }
flushScheduled = true
return true
}
// takes every pending change and re-arms flush scheduling
func drain() -> [FSChange] {
lock.lock()
defer { lock.unlock() }
let vResult = changes
changes = []
flushScheduled = false
return vResult
}
// discards pending changes (used when the watcher is torn down)
func reset() {
_ = drain()
}
}
// AppModel is the shared backing state for the application.
// It owns the in-memory file index, drives the background indexer and the
// FSEvents watcher, and exposes the active sort descriptor. The per-window
// search query and filtered/visible slice live in WindowSearchModel so two
// windows can run independent searches against this same shared index.
//
// Threading rule of thumb: this class is @MainActor so all state is written
// from main. Heavy work (LZ4 (de)compression, filtering, sorting, pruning,
// filesystem walks and stats) happens on background queues; the result is
// then merged back on main. Snapshots of value types (Array, IndexState)
// cross thread boundaries via Swift COW semantics.
//
// The record array is deliberately NOT @Published: every mutation of a
// @Published array emits the whole array, and any subscriber that buffers
// those values (receive(on:)) forces a full copy of the index per change -
// that is what used to blow up to tens of GB during an FSEvents replay.
// Views observe the small @Published counters; search models listen to the
// payload-free recordsChanged signal and read allRecords when they refilter.
@MainActor
final class AppModel: ObservableObject {
// the canonical index (records + id lookup), main-thread only
private var index = IndexState()
// every entry observed so far, in no particular order
var allRecords: RecordStore { index.records }
// fires on main after allRecords changed (no payload by design)
let recordsChanged = PassthroughSubject<Void, Never>()
// when the index last changed; read by the status bar on its own
// refresh tick, so deliberately not @Published
private(set) var lastIndexChangeAt: Date?
// total number of entries indexed (used for the status bar)
@Published private(set) var indexedCount: Int = 0
// true while a full reindex is in progress
@Published private(set) var isIndexing: Bool = false
// true while the cache file is being loaded into memory at startup
@Published private(set) var isLoadingCache: Bool = false
// true when this process owns the index (got the lock or built-in mode)
@Published private(set) var isIndexer: Bool = false
// root folders being indexed for the first time (or re-read entirely),
// with how many entries were found so far: shown in the status bar and
// next to the folder in Settings
@Published private(set) var scans: [ScanProgress] = []
// one announced walk of a root folder
struct ScanProgress: Identifiable, Equatable {
// canonical path of the folder being walked
let path: String
// entries found so far
var found: Int
// identity for SwiftUI lists
var id: String { path }
// folder name for display ("Spirtech2k")
var name: String { (path as NSString).lastPathComponent }
}
// how often the index is written to disk while running (seconds)
private let kAutosaveSeconds: TimeInterval = 30
// delay before a roots / exclusions edit is applied to the live index
private let kConfigDebounceSeconds: TimeInterval = 1.0
private let prefs = Preferences.shared
// background queues isolated by concern - keeps the slow stuff off main
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility)
// subtree walks (new roots, new or rescanned folders): separate from
// indexQueue so a long walk never delays live updates
private let walkQueue = DispatchQueue(label: "allofit.walk", qos: .utility)
// while walking a new root or folder, what's found so far is added to
// the index this often
private let kWalkStreamSeconds: TimeInterval = 3
// watcher used in indexer mode for live updates
private let watcher = FileWatcher()
// watcher used in reader mode to detect cache file refreshes
private let cacheWatcher = FileWatcher()
// FSEvents batches waiting to be resolved and applied (indexer mode)
private let pendingChanges = PendingChanges()
// roots the watcher was started with; their own records need no parent
private var watchedRoots: Set<String> = []
// exclusions the running watcher resolves changes with
private var currentExclusions: ExclusionMatcher?
// subtree walks started but not merged yet
private var rescansInFlight = 0
// event id to save while rescans are in flight: the id from before the
// changes that triggered them, so a quit or crash mid-walk replays those
// changes instead of losing the folders' contents for good
private var rescanFloorEventId: UInt64?
// a reader-mode reload skipped (throttled or no window visible)
private var pendingReaderReload = false
// service mode the current role was started with (see activeServiceMode)
private(set) var bootMode: Preferences.ServiceMode = .none
// bumped whenever the whole index is replaced, so a subtree rescan that
// started against the previous index doesn't merge into the new one
private var indexGeneration = 0
// autosave timer when running in indexer mode
private var autosaveTimer: Timer?
// true when the index has changed since the last save
private var dirty = false
// last FSEvents event id known to be reflected in the index
private var lastEventId: UInt64 = 0
// process-wide indexer mutex (nil until acquired)
private var indexerLock: IndexerLock?
// guards start() from running twice across window close/reopen
private var hasStarted = false
// subscriptions to Preferences / app lifecycle
private var cancellables: Set<AnyCancellable> = []
// true while a privileged service operation is in flight (Reindex / Clear)
@Published private(set) var isWorking: Bool = false
// last user-facing status string for the Settings buttons
@Published private(set) var workMessage: String = ""
// polling backup for reader-mode cache changes; FSEvents alone can miss
// updates if the watched directory didn't exist when the stream started.
// Uses DispatchSourceTimer on a background queue (not Timer on the main
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// minimum gap between two reader-mode reloads. Prevents the Table from
// being re-rendered while the user is mid-click. 1 second is well below
// any human-perceptible "stale" threshold but caps the worst-case churn
// during heavy filesystem activity.
private let kMinReloadInterval: TimeInterval = 1.0
// timestamp of the last reloadFromCache() that actually swapped data in
private var lastReloadAt: Date?
// wires up the quit and settings observers (loading happens in start)
init() {
// the cache file is loaded off-main in start() so the window
// appears instantly; here we only wire up observers.
// Persist on Cmd+Q: the autosave only runs every 30 s and an async
// save would be killed with the process.
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
MainActor.assumeIsolated {
self?.saveCacheNow()
}
}
.store(in: &cancellables)
// slow index updates down while the app isn't being looked at, and
// catch up at once when a window comes back
AppActivity.shared.$level
.removeDuplicates()
.sink { [weak self] vLevel in
self?.activityChanged(inLevel: vLevel)
}
.store(in: &cancellables)
// the delays are user settings: apply edits right away
Publishers.Merge3(
prefs.$updateDelayForeground,
prefs.$updateDelayBackground,
prefs.$updateDelayHidden
)
.dropFirst(3)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.activityChanged(inLevel: AppActivity.shared.level)
}
.store(in: &cancellables)
// apply roots / exclusions / volume edits to the live index
Publishers.Merge4(
prefs.$rootPaths.map { _ in () },
prefs.$excludedPaths.map { _ in () },
prefs.$includeMountedVolumes.map { _ in () },
prefs.$includeNetworkVolumes.map { _ in () }
)
.dropFirst(4)
.debounce(for: .seconds(kConfigDebounceSeconds), scheduler: DispatchQueue.main)
.sink { [weak self] in
self?.applyConfigurationChange()
}
.store(in: &cancellables)
}
// the service mode actually in effect: the saved mode, but only if that
// service is really installed. A mode saved without a working install
// (or a service removed behind the app's back) falls back to indexing
// in-process instead of waiting forever on a cache nobody writes.
var activeServiceMode: Preferences.ServiceMode {
switch prefs.serviceMode {
case .none: return .none
case .userAgent: return ServiceInstaller.isInstalled(inScope: .userAgent) ? .userAgent : .none
case .rootDaemon: return ServiceInstaller.isInstalled(inScope: .rootDaemon) ? .rootDaemon : .none
}
}
// kicks off background activity for the first time. Subsequent calls are
// no-ops so we don't double-bootstrap when the user reopens the window.
// To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
// call switchToCurrentMode() instead.
func start() {
if hasStarted { return }
hasStarted = true
bootstrap()
}
// re-runs the mode-detection + cache-load + watcher-setup sequence.
// Used after the user changes the service installation state so the GUI
// can hot-swap between indexer and reader without a full app restart.
func switchToCurrentMode() {
NSLog("[Allofit GUI] switching mode (serviceMode=%@)", prefs.serviceMode.rawValue)
bootstrap()
}
// stops all watchers/timers and releases the indexer lock, leaving the
// model ready for a fresh bootstrap()
private func tearDownActiveMode() {
watcher.stop()
cacheWatcher.stop()
pendingChanges.reset()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollSource?.cancel()
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
}
// determines indexer vs reader role, loads the cache from the appropriate
// path, and starts the right watcher set. Safe to call repeatedly.
private func bootstrap() {
tearDownActiveMode()
let vMode = activeServiceMode
bootMode = vMode
if vMode != prefs.serviceMode {
NSLog("[Allofit GUI] service mode %@ saved but not installed, indexing in-process", prefs.serviceMode.rawValue)
}
if vMode != .none {
isIndexer = false
} else {
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
if vLock.tryLock() {
indexerLock = vLock
isIndexer = true
} else {
isIndexer = false
NSLog("[Allofit GUI] indexer lock held by another process, running as reader")
}
}
isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: vMode)
let vIsIndexer = isIndexer
let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path })
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
vMode.rawValue,
vIsIndexer ? "true" : "false",
vCacheURL.path)
ioQueue.async { [weak self] in
// decompress + parse + reconcile off-main (the expensive part)
let vCache = IndexStore.load(from: vCacheURL)
var vState = IndexState(inRecords: vCache?.records ?? [])
// as the indexer, drop what no longer matches the configuration
// (removed roots, new exclusions, orphans from older versions)
let vPruned = vIsIndexer ? vState.prune(inRoots: vRoots, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden) : 0
DispatchQueue.main.async {
guard let vSelf = self else { return }
if let vCache = vCache {
NSLog("[Allofit GUI] loaded %d records from cache (%d pruned)",
vState.count, vPruned)
vSelf.lastEventId = vCache.lastEventId
} else {
NSLog("[Allofit GUI] cache load returned nil (file missing or invalid)")
vSelf.lastEventId = 0
}
vSelf.replaceIndex(inState: vState)
vSelf.dirty = vPruned > 0
vSelf.isLoadingCache = false
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
vSelf.startReaderMode()
}
}
}
}
// manually triggered cache reload; surfaced as a button in the Cache tab
// so users can verify the GUI reads what the daemon wrote. Bypasses
// reloadFromCache's rate-limit / same-eventId guards since the user
// explicitly asked.
func forceReloadCache() {
NSLog("[Allofit GUI] manual reload triggered")
lastReloadAt = nil
lastEventId = 0
reloadFromCache()
}
// reindexes from scratch on the next idle moment
func reindex() {
guard isIndexer, !isIndexing else { return }
isIndexing = true
indexedCount = 0
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vStartId = UInt64(FSEventsGetCurrentEventId())
let vIoQueue = ioQueue
// partial results only when there is nothing to show yet (first
// run): a manual rebuild keeps the old index visible until the end
let vPublishPartial = index.count == 0
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
// the results fill in while the walk runs (instead of an empty
// window until the end of a scan that can take minutes)
let kPartialPublishSeconds: TimeInterval = 3
var vLastPublish = Date()
for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block
autoreleasepool {
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
vAccumulated.append(contentsOf: vBatch)
let vCount = vAccumulated.count
let vNow = Date()
guard vPublishPartial, vNow.timeIntervalSince(vLastPublish) >= kPartialPublishSeconds else {
DispatchQueue.main.async { self?.indexedCount = vCount }
return
}
vLastPublish = vNow
let vPartial = IndexState(inRecords: vAccumulated)
DispatchQueue.main.async {
guard let vSelf = self, vSelf.isIndexing else { return }
vSelf.replaceIndex(inState: vPartial)
}
}
}
}
let vState = IndexState(inRecords: vAccumulated)
// persist off-main (on the save queue, so it never races another
// save) before bouncing back: main never sees the LZ4 cost
vIoQueue.sync {
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
}
DispatchQueue.main.async {
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
}
}
}
// dispatches a reindex appropriate for the current mode. In built-in mode
// it triggers an in-process FileIndexer pass. In service mode it stops
// the daemon, deletes its cache, and starts the daemon again so the
// daemon's fresh process performs the scan from scratch. The privileged
// portion runs on a detached task so the main thread (and the password
// dialog from NSAppleScript) doesn't freeze the ui.
func performReindex() async {
if isIndexer {
reindex()
return
}
await runPrivilegedAction(inLabel: "Reindexing") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
// removes the on-disk cache, with the right privilege escalation per mode.
// In built-in mode the file is owned by the current user, so a plain
// removeItem suffices. In service mode the cache may be owned by root and
// the daemon would re-write it on its next autosave, so we tunnel the
// delete through the same stop/delete/start admin script.
func performClearCache() async {
switch activeServiceMode {
case .none:
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
workMessage = "Cache cleared."
if isIndexer { reindex() }
case .userAgent, .rootDaemon:
await runPrivilegedAction(inLabel: "Clearing cache") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
}
// installs a launchd service for the current serviceMode preference,
// off-main so the password dialog doesn't freeze the GUI. On success
// the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
// inMode is the mode chosen in Settings: it is only saved once the
// install succeeded, so a failed or cancelled install changes nothing
func performInstallService(inMode: Preferences.ServiceMode) async {
let vOk = await runPrivilegedAction(inLabel: "Installing service", inMode: inMode) { vScope, _ in
try ServiceInstaller.install(inScope: vScope)
}
if vOk {
prefs.serviceMode = inMode
// give launchd a moment to bring the daemon up before we switch
// the GUI into reader mode and start watching the cache file
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService(inMode: Preferences.ServiceMode? = nil) async {
await runPrivilegedAction(inLabel: "Stopping service", inMode: inMode) { vScope, _ in
try ServiceInstaller.stop(inScope: vScope)
}
}
// starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write.
func performStartService(inMode: Preferences.ServiceMode? = nil) async {
let vOk = await runPrivilegedAction(inLabel: "Starting service", inMode: inMode) { vScope, _ in
try ServiceInstaller.start(inScope: vScope)
}
if vOk {
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService(inMode: Preferences.ServiceMode? = nil) async {
let vMode = inMode ?? prefs.serviceMode
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service", inMode: vMode) { vScope, _ in
try ServiceInstaller.uninstall(inScope: vScope)
}
if vOk {
// back to the built-in indexer when the active service went away
if vMode == prefs.serviceMode {
prefs.serviceMode = .none
}
// give launchd a moment to actually tear the daemon down so its
// indexer lock is released before the GUI tries to grab it
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// shared helper: runs a service-mode operation on a detached queue while
// publishing isWorking/workMessage so the ui can show progress. Returns
// true if the action ran without throwing.
@discardableResult
// inMode defaults to the saved mode (the installed service)
private func runPrivilegedAction(inLabel: String,
inMode: Preferences.ServiceMode? = nil,
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool {
let vMode = inMode ?? prefs.serviceMode
guard vMode != .none else { return false }
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
let vUrl = IndexStore.cacheURL(forServiceMode: vMode)
// flush UserDefaults so the daemon reads up-to-date settings from
// our plist after it restarts (cfprefsd can buffer writes for minutes)
Preferences.flushToDisk()
isWorking = true
workMessage = "\(inLabel)…"
do {
try await Task.detached(priority: .userInitiated) {
try inBody(vScope, vUrl)
}.value
workMessage = "\(inLabel): done."
isWorking = false
return true
} catch {
workMessage = "\(inLabel) failed: \(error.localizedDescription)"
NSLog("[Allofit] %@ failed: %@", inLabel, "\(error)")
isWorking = false
return false
}
}
// writes the current index to disk (autosave, window close, replay end).
// Snapshots on main (cheap COW), compresses off-main.
// inForce saves even when nothing changed (to persist replay progress)
func saveCache(inForce: Bool = false) {
guard isIndexer, dirty || inForce else { return }
let vRecords = index.records
// lastEventId tracks what is actually merged into the index; the
// stream's latest id can be ahead (changes still being resolved),
// and saving that would make the next launch skip those changes.
// While subtree walks are pending, the id from before them is kept.
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.async {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
}
}
// synchronous save used at quit; ioQueue.sync also waits for any
// asynchronous save still in flight so the newest state wins
private func saveCacheNow() {
guard isIndexer, dirty else { return }
let vRecords = index.records
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.sync {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
}
}
// ===========================
// MARK: Indexer mode
// ===========================
// starts live updates after the cache was loaded. Falls back to a full
// reindex when there is no cache, and to background rescans when the
// saved event id can't be replayed or a configured root is missing.
private func startIndexerMode() {
startAutosaveTimer()
if index.count == 0 {
reindex()
return
}
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
let vCurrentId = UInt64(FSEventsGetCurrentEventId())
if lastEventId == 0 || lastEventId > vCurrentId {
// the id is from another FSEvents database (volume erased,
// cache copied from another Mac...): replay would silently
// deliver nothing, so re-walk everything in the background
// while the cached results stay searchable
NSLog("[Allofit GUI] saved event id %llu not replayable (current %llu), rescanning roots",
lastEventId, vCurrentId)
lastEventId = vCurrentId
startWatching(inSinceWhen: vCurrentId)
// floor 0: an interrupted walk makes the next launch start over
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0, inAnnounce: true)
return
}
startWatching(inSinceWhen: lastEventId)
// roots added since the cache was written have never been walked
let vMissing = vRoots.filter { !index.contains(inPath: $0) }
if !vMissing.isEmpty {
// nothing below a missing root is indexed: stream it in
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId, inNewFolders: true, inAnnounce: true)
}
}
// starts the FSEvents watcher from inSinceWhen. Batches are coalesced in
// pendingChanges, resolved (stat'ed) on indexQueue, and only the final
// in-memory merge runs on main - so replaying days of history after a
// relaunch neither floods the main queue nor freezes the ui.
private func startWatching(inSinceWhen: UInt64) {
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
watchedRoots = Set(vRoots)
pendingChanges.reset()
let vPending = pendingChanges
let vQueue = indexQueue
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
currentExclusions = vMatcher
watcher.start(
inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { [weak self] vChanges in
guard vPending.append(inChanges: vChanges) else { return }
vQueue.asyncAfter(deadline: .now() + vPending.coalesceDelay) { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
}
// resolves everything pending (on the calling background queue) and
// merges the result on main
private nonisolated static func flush(inPending: PendingChanges,
inExclusions: ExclusionMatcher,
inModel: AppModel?) {
let vChanges = inPending.drain()
// an early catch-up flush may already have taken everything
guard !vChanges.isEmpty else { return }
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: inExclusions)
DispatchQueue.main.async { [weak inModel] in
inModel?.applyResolvedChanges(inResolved: vResolved)
}
}
// adapts the update cadence to the visibility level (delays from
// Settings > Performance). Longer collection windows when nobody looks
// mean fewer wake-ups and fewer stats, since repeated writes to one file
// collapse into one check. When the app becomes more visible (or a
// delay is shortened), pending changes are applied right away.
private func activityChanged(inLevel: AppActivity.Level) {
// reader mode: a reload skipped while hidden happens now
if inLevel != .hidden && pendingReaderReload && !isIndexer {
pendingReaderReload = false
reloadFromCache()
}
let vDelay: TimeInterval
switch inLevel {
case .foreground: vDelay = prefs.updateDelayForeground
case .background: vDelay = prefs.updateDelayBackground
case .hidden: vDelay = prefs.updateDelayHidden
}
let vPrevious = pendingChanges.coalesceDelay
pendingChanges.setCoalesceDelay(vDelay)
guard vDelay < vPrevious, isIndexer, pendingChanges.hasPending, let vMatcher = currentExclusions else { return }
let vPending = pendingChanges
indexQueue.async { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
// saves the index every kAutosaveSeconds while it has unsaved changes
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
// .common mode so the timer fires even while SwiftUI is busy
let vTimer = Timer(timeInterval: kAutosaveSeconds, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let vSelf = self, vSelf.dirty else { return }
vSelf.saveCache()
}
}
RunLoop.main.add(vTimer, forMode: .common)
autosaveTimer = vTimer
}
// merges a resolved set of FSEvents changes into the index (pure
// in-memory work; the stats were done off-main by IndexState.resolve)
private func applyResolvedChanges(inResolved: ResolvedChanges) {
guard isIndexer else { return }
let vEventIdBefore = lastEventId
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
lastEventId = max(lastEventId, inResolved.maxEventId)
if vResult.changed {
dirty = true
publishRecords()
}
if !vResult.rescans.isEmpty {
kickRescanSubtrees(inPaths: vResult.rescans, inFloorEventId: vEventIdBefore)
}
if !vResult.newFolders.isEmpty {
kickRescanSubtrees(inPaths: vResult.newFolders, inFloorEventId: vEventIdBefore, inNewFolders: true)
}
// persist right after a relaunch replay finishes so the next launch
// resumes from here instead of replaying the same history again
if inResolved.historyDone {
NSLog("[Allofit GUI] FSEvents history replay done, saving cache")
saveCache(inForce: true)
}
}
// re-walks the given subtrees in the background, then swaps their
// content in on main. Used for kernel-requested rescans, folders that
// were moved/renamed into the tree, and newly added roots.
// inFloorEventId is saved instead of lastEventId until the walk is
// merged. inNewFolders: the folders just appeared, so their records are
// merged without the O(n) removal pass of a kernel-requested rescan.
// inAnnounce lists the walk in `scans` (root folders only: the small
// folders appearing all the time would make the status bar flicker)
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false, inAnnounce: Bool = false) {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return }
if inAnnounce {
for vRoot in vRoots where !scans.contains(where: { $0.path == vRoot }) {
scans.append(ScanProgress(path: vRoot, found: 0))
}
}
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
rescansInFlight += 1
let vStreamSeconds = kWalkStreamSeconds
// own queue: a long walk (a whole network share) must not hold up
// the FSEvents updates resolved on indexQueue
walkQueue.async { [weak self] in
var vRecords: [FileRecord] = []
var vLastStream = Date()
var vLastProgress = Date()
// the walked folders' own entries are only merged at the very end,
// so an interrupted walk leaves them "missing" and gets redone
let vRootIds = Set(vRoots.map { FileRecord.pathHash($0) })
var vHeldRoots: [FileRecord] = []
for vPath in vRoots {
var vFound = 0
// per-subtree autoreleasepool keeps the walk's autoreleased
// URL/stat objects from accumulating across subtrees
autoreleasepool {
FileIndexer.walkRoot(inRoot: URL(fileURLWithPath: vPath), inExclusions: vMatcher) { vBatch in
vRecords.append(contentsOf: vBatch)
vFound += vBatch.count
if inAnnounce && Date().timeIntervalSince(vLastProgress) >= 0.5 {
vLastProgress = Date()
let vCount = vFound
DispatchQueue.main.async {
self?.updateScan(inPath: vPath, inFound: vCount)
}
}
// new folders / roots: nothing below them is indexed
// yet, so what's found so far can be shown right away
guard inNewFolders, Date().timeIntervalSince(vLastStream) >= vStreamSeconds else { return }
vLastStream = Date()
vHeldRoots.append(contentsOf: vRecords.filter { vRootIds.contains($0.id) })
let vChunk = vRecords.filter { !vRootIds.contains($0.id) }
vRecords = []
DispatchQueue.main.async {
self?.mergeWalked(inRecords: vChunk, inGeneration: vGeneration)
}
}
}
}
let vFinal = vRecords + vHeldRoots
DispatchQueue.main.async {
guard let vSelf = self else { return }
if inAnnounce {
vSelf.scans.removeAll { vRoots.contains($0.path) }
}
vSelf.rescansInFlight -= 1
if vSelf.rescansInFlight <= 0 {
vSelf.rescansInFlight = 0
vSelf.rescanFloorEventId = nil
}
if inNewFolders {
vSelf.mergeWalked(inRecords: vFinal, inGeneration: vGeneration)
} else {
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
vSelf.dirty = true
vSelf.publishRecords()
}
}
}
}
// publishes the progress of an announced walk
private func updateScan(inPath: String, inFound: Int) {
guard let vIndex = scans.firstIndex(where: { $0.path == inPath }) else { return }
scans[vIndex].found = inFound
}
// adds walked records below folders that weren't indexed (no removal
// pass), unless the index was replaced since the walk started
private func mergeWalked(inRecords: [FileRecord], inGeneration: Int) {
guard isIndexer, indexGeneration == inGeneration, !inRecords.isEmpty else { return }
index.mergeNewFolders(inRecords: inRecords)
dirty = true
publishRecords()
}
// applies edited roots / exclusions / volume options to the live index
// without a full reindex: prunes what fell out of scope, walks roots
// that are new, and restarts the watcher on the new root set
private func applyConfigurationChange() {
guard isIndexer, !isIndexing, !isLoadingCache else { return }
NSLog("[Allofit GUI] configuration changed, reconciling index")
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
let vRootSet = Set(vRoots)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vState = index
let vGeneration = indexGeneration
// replay point: changes merged after this snapshot (and those whose
// subtree walks are still running) would be lost when the pruned
// copy replaces the live index, so the watcher restarts from here
let vSnapshotEventId = rescanFloorEventId ?? lastEventId
watcher.stop()
indexQueue.async { [weak self] in
var vPruned = vState
let vRemoved = vPruned.prune(inRoots: vRootSet, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden)
DispatchQueue.main.async {
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
vSelf.replaceIndex(inState: vPruned)
vSelf.dirty = true
vSelf.lastEventId = vSnapshotEventId
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty {
vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId, inNewFolders: true, inAnnounce: true)
}
}
}
}
// drops paths (and what's below them) from the index right away. Used
// after Move to Trash from Allofit: the watcher ignores this process's
// own file operations, so FSEvents would never report the removal
func forget(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
if index.removeSubtrees(inPaths: inPaths) > 0 {
dirty = true
publishRecords()
}
}
// (re)adds paths this app created or moved back itself (undo of Move to
// Trash), which FSEvents doesn't report to it. Resolved like FSEvents
// changes, so restored folders get walked too.
func notice(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
let vChanges = inPaths.map { FSChange(path: $0, flags: 0, eventId: 0) }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
indexQueue.async { [weak self] in
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
DispatchQueue.main.async {
self?.applyResolvedChanges(inResolved: vResolved)
}
}
}
// ===========================
// MARK: Reader mode
// ===========================
// follows the cache written by another indexer (service or second instance)
private func startReaderMode() {
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
// FSEvents-based watcher for low-latency updates. We watch the
// parent dir (FSEvents needs a real path) but only fire the reload
// when the cache file itself changed - other files in the dir
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
// trigger main.async hops, which contributed to dropped clicks.
cacheWatcher.start(inRoots: [vDir]) { [weak self] vChanges in
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
guard vCacheChanged else { return }
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
DispatchQueue.main.async { [weak self] in
self?.reloadFromCache()
}
}
// Background-queue polling backup at 2s. The stat() runs off main,
// and a lock-guarded background-side mtime tracker means we ONLY
// hop to main when the cache actually changed - the steady state
// puts zero work on the main runloop, leaving NSTableView's click
// handling uninterrupted.
cachePollSource?.cancel()
let vPolledPath = vUrl.path
let vBgMtime = BackgroundMtime() // captured by the closure
let vSource = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
vSource.schedule(deadline: .now() + 2.0, repeating: 2.0)
vSource.setEventHandler { [weak self] in
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vPolledPath),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
// Background-side change detection; only proceeds when mtime moved
guard vBgMtime.updateIfChanged(vMtime) else { return }
DispatchQueue.main.async {
self?.reloadFromCache()
}
}
vSource.resume()
cachePollSource = vSource
}
// reloads the on-disk cache off-main and swaps it in atomically.
//
// Two guards keep this from disrupting Table interactions:
// 1. lastEventId comparison - the daemon advances its event id every
// time it actually persists new state, so a matching id means the
// records on disk are identical to what we have and we can bail
// before touching any published state.
// 2. A 1-second floor between reloads. Even when changes do happen,
// we don't need to re-render the whole list at FSEvents' rate -
// the next tick will catch any newer state.
private func reloadFromCache() {
guard !isIndexer else { return }
// nobody looks: reload once a window shows up again
if AppActivity.shared.level == .hidden {
pendingReaderReload = true
return
}
let vNow = Date()
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
// throttled: catch up when the interval ends instead of
// dropping the change (the mtime poll has already seen it)
if !pendingReaderReload {
pendingReaderReload = true
let vWait = kMinReloadInterval - vNow.timeIntervalSince(vLast)
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
guard let vSelf = self, vSelf.pendingReaderReload else { return }
vSelf.pendingReaderReload = false
vSelf.reloadFromCache()
}
}
return
}
pendingReaderReload = false
lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vCurrentEventId = lastEventId
let vCurrentCount = index.count
ioQueue.async { [weak self] in
guard let vCache = IndexStore.load(from: vUrl) else { return }
// short-circuit when the on-disk content matches what we already
// have - common when the daemon resaves on a noise-only FSEvents
// burst (e.g. spotlight reindexing, temp files in /var). The
// count check lets partial saves of a first scan through (the
// daemon keeps the event id at 0 until the scan completes).
if vCache.lastEventId == vCurrentEventId && vCache.records.count == vCurrentCount { return }
let vState = IndexState(inRecords: vCache.records)
DispatchQueue.main.async {
guard let vSelf = self, !vSelf.isIndexer else { return }
vSelf.lastEventId = vCache.lastEventId
vSelf.replaceIndex(inState: vState)
}
}
}
// ===========================
// MARK: Internals
// ===========================
// installs a whole new index (cache load, reindex, reload, reconcile)
private func replaceIndex(inState: IndexState) {
index = inState
indexGeneration &+= 1
publishRecords()
}
// notifies observers that the records changed
private func publishRecords() {
lastIndexChangeAt = Date()
if indexedCount != index.count {
indexedCount = index.count
}
recordsChanged.send()
}
// installs a freshly-built index and starts watching for changes
private func applyFreshIndex(inState: IndexState, inEventId: UInt64) {
replaceIndex(inState: inState)
lastEventId = inEventId
isIndexing = false
dirty = false
if isIndexer {
startWatching(inSinceWhen: inEventId)
}
}
}