6 Commits
Author SHA1 Message Date
Bitsy 147c4a9353 Fix memory blow-up on relaunch, Everything-style search, index correctness
Memory / relaunch
- FSEvents replay after a relaunch no longer copies the whole index per
  changed file: allRecords is no longer @Published (each element mutation
  emitted the full array and a buffering subscriber kept every copy alive,
  reaching 10+ GB). Changes are coalesced, stat'ed off-main and merged once.
- Save the cache when the history replay finishes and on quit, and save the
  event id actually merged, so the next launch doesn't replay days again.
- Views (App scene, ContentView, PreviewPane) no longer observe AppModel, so
  index updates don't re-render the table or reload the Quick Look preview.
- Break retain cycles in the FSEvents watcher callbacks.

Search (Everything syntax)
- Space = AND, | = OR, ! = NOT, quotes, ext:, file:, folder:.
- Terms with / match the full path; path wildcards are folder-aware
  (* within a folder name, ** across folders), e.g.
  "some/folder/**/path" IMG_????.heic
- Byte-level matching (memmem / native glob) instead of NSRegularExpression,
  parallel filter, top-N selection instead of a full sort, total match count
  in the status bar, near-instant typing debounce.

Index correctness
- New IndexState shared by the GUI and the service: stable path-derived ids,
  O(1) swap removal, folder removal drops its subtree (no ghost entries after
  moving a folder to the Trash), folders moved/renamed in are rescanned,
  parent-must-be-indexed rule matching the walker (hidden/package content).
- Unicode-normalized path ids (no duplicates for accented names).
- Reconcile on load and on settings change: prune removed roots, exclusions
  and orphans; scan new roots; rescan when the saved event id is unusable.
- Service resumes from its cache instead of rescanning everything on start.
- Shared parent-path strings, cheaper exclusion checks, deduped roots.

Tests
- Swift Testing target for the search syntax, index rules, top-N selection
  and cache round trip; run in CI.
2026-10-01 17:45:44 +02:00
Bitsy 4600e28b9c style: replace em dashes with plain punctuation 2026-08-09 21:46:08 +02:00
Bitsy 29aa518a9e Implement CLI for service control: add commands for install, uninstall, start, stop, mode, and status 2026-06-18 15:17:58 +02:00
Bitsy 4e050decff Fix output directory path in build-app.sh: change Outputs to outputs for consistency 2026-06-18 14:45:57 +02:00
Bitsy 64437d70fb Refactor build scripts and update paths: move build-app.sh and build-dmg.sh to scripts/ directory, adjust output paths, and update README instructions 2026-06-18 14:02:42 +02:00
Bitsy 9f4d47a25a Add MIT License file to the repository 2026-06-18 13:34:38 +02:00
45 changed files with 2435 additions and 912 deletions
+3
View File
@@ -25,3 +25,6 @@ jobs:
- name: Build release
run: swift build -c release
- name: Test
run: swift test
+8 -6
View File
@@ -39,20 +39,22 @@ jobs:
env:
ALLOFIT_VERSION: ${{ steps.version.outputs.version }}
ALLOFIT_BUILD: ${{ github.run_number }}
run: ./build-app.sh
run: ./scripts/build-app.sh
- name: Zip Allofit.app (ditto preserves bundle metadata + signatures)
working-directory: outputs
run: |
ditto -c -k --keepParent --sequesterRsrc \
Allofit.app \
"Allofit-${{ steps.version.outputs.version }}.app" \
"Allofit-${{ steps.version.outputs.version }}.zip"
- name: Build DMG (drag-to-install with /Applications shortcut)
env:
ALLOFIT_VERSION: ${{ steps.version.outputs.version }}
run: ./build-dmg.sh --skip-build
run: ./scripts/build-dmg.sh --skip-build
- name: SHA-256 checksums
working-directory: outputs
run: |
shasum -a 256 \
"Allofit-${{ steps.version.outputs.version }}.zip" \
@@ -67,6 +69,6 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
Allofit-${{ steps.version.outputs.version }}.zip
Allofit-${{ steps.version.outputs.version }}.dmg
Allofit-${{ steps.version.outputs.version }}.sha256
outputs/Allofit-${{ steps.version.outputs.version }}.zip
outputs/Allofit-${{ steps.version.outputs.version }}.dmg
outputs/Allofit-${{ steps.version.outputs.version }}.sha256
+3 -4
View File
@@ -10,11 +10,10 @@ xcuserdata/
DerivedData/
*.xcuserstate
# Build output produced by ./build-app.sh and ./build-dmg.sh
Allofit.app/
*.dmg
# Build output produced by ./scripts/build-app.sh and ./scripts/build-dmg.sh
outputs/
# Service log files (also live in /tmp but local copies happen during dev)
# service log files (also live in /tmp but local copies happen during dev)
allofit-service.log
allofit-service.err
View File
+10 -1
View File
@@ -23,7 +23,16 @@ let package = Package(
targets: [
.executableTarget(
name: "Allofit",
path: "Sources/Allofit",
path: "sources",
swiftSettings: [
.swiftLanguageMode(.v5)
]
),
// unit tests for the search syntax, index rules and cache format
.testTarget(
name: "AllofitTests",
dependencies: ["Allofit"],
path: "tests",
swiftSettings: [
.swiftLanguageMode(.v5)
]
+19 -5
View File
@@ -18,12 +18,24 @@ Requires macOS 15 (Sequoia) or newer.
## Search
Same syntax as Everything:
| Type… | …to match |
|---|---|
| `report` | any name containing "report" |
| `report` | any name containing "report" (case-insensitive) |
| `annual report` | names containing "annual" **and** "report" |
| `Start*.pdf` | starts with "Start", ends with ".pdf" |
| `IMG_????.heic` | "IMG_" + exactly 4 chars + ".heic" |
| `*.png \| *.jpg` | OR — png or jpg |
| `*.png \| *.jpg` | OR - png or jpg (OR binds tighter than the space AND) |
| `report !draft` | NOT - names with "report" but without "draft" |
| `"my file"` | quotes keep spaces inside one term |
| `ext:pdf;docx` | by extension |
| `file:` / `folder:` | files only / folders only, alone or as a prefix (`folder:build`) |
| `src/main` | a term with `/` matches against the full path |
| `"some/folder/**/path" IMG_????.heic` | path wildcards: `*` stays inside one folder name, `**` spans any number of folders (zero too) |
| `photos/**/` | a trailing `/` means anything inside that folder |
The list shows the first 2,000 results in the chosen order; the status bar shows the total count.
## Shortcuts
@@ -43,11 +55,13 @@ Root daemon mode needs **Full Disk Access** granted to its binary in **System Se
## Build from source
```bash
./build-app.sh # produces Allofit.app
./build-dmg.sh # produces Allofit-0.0.0.dmg
./scripts/build-app.sh # produces outputs/Allofit-0.0.0.app
./scripts/build-dmg.sh # produces outputs/Allofit-0.0.0.dmg
```
`./clean.sh` wipes services, caches, prefs, and build artifacts for a fresh start.
(The version is stamped into both the filename and the bundle's Info.plist. Pass `--version 1.2.3` or set `ALLOFIT_VERSION=1.2.3` to override.)
`./scripts/clean.sh` wipes services, caches, prefs, and build artifacts (including `outputs/`) for a fresh start.
## License
@@ -1,33 +0,0 @@
import Foundation
// ExclusionMatcher tests filesystem paths against a configured exclusion list.
// A path is excluded when it matches an exclusion exactly or sits beneath one
// of the configured prefix directories.
struct ExclusionMatcher {
// normalized exclusion prefixes (no trailing slash, tilde expanded)
private let prefixes: [String]
// builds a matcher from raw user-entered exclusion strings
init(inExclusions: [String]) {
prefixes = inExclusions.map { vRaw in
let vExpanded = (vRaw as NSString).expandingTildeInPath
let vStandard = (vExpanded as NSString).standardizingPath
if vStandard.hasSuffix("/") && vStandard.count > 1 {
return String(vStandard.dropLast())
}
return vStandard
}
}
// returns true when inPath matches an exclusion exactly or is below one
func isExcluded(inPath: String) -> Bool {
if prefixes.isEmpty { return false }
let vPath = (inPath as NSString).standardizingPath
for vEx in prefixes {
if vPath == vEx { return true }
if vPath.hasPrefix(vEx + "/") { return true }
}
return false
}
}
-60
View File
@@ -1,60 +0,0 @@
import Foundation
// FileRecord stores the minimum metadata needed to display and search a single
// filesystem entry. Keeping only names and small fixed-size fields lets the
// in-memory index scale to hundreds of thousands of entries.
struct FileRecord: Identifiable, Hashable {
// stable identifier used for SwiftUI list diffing
let id: UInt64
// last path component (file or directory name)
let name: String
// parent directory absolute path
let parentPath: String
// size in bytes (0 for directories)
let size: Int64
// creation timestamp
let dateCreated: Date
// last modification timestamp
let dateModified: Date
// true if this entry is a directory
let isDirectory: Bool
// lowercased name cached once for case-insensitive substring matching
let nameLower: String
init(id: UInt64,
name: String,
parentPath: String,
size: Int64,
dateCreated: Date,
dateModified: Date,
isDirectory: Bool) {
self.id = id
self.name = name
self.parentPath = parentPath
self.size = size
self.dateCreated = dateCreated
self.dateModified = dateModified
self.isDirectory = isDirectory
self.nameLower = name.lowercased()
}
// returns the absolute full path computed from parent and name
var fullPath: String {
if parentPath.isEmpty { return name }
if parentPath == "/" { return "/" + name }
return parentPath + "/" + name
}
}
// Sort modes for FileRecord lists. Defined at file scope so both Preferences
// (which persists the last choice) and AppModel can reference it without a
// circular dependency.
enum FileSortDescriptor: String, CaseIterable, Identifiable {
case nameAscending, nameDescending
case sizeAscending, sizeDescending
case createdAscending, createdDescending
case modifiedAscending, modifiedDescending
case pathAscending, pathDescending
var id: String { rawValue }
}
-93
View File
@@ -1,93 +0,0 @@
import Foundation
// SearchEngine matches a user query against a FileRecord's name.
//
// Without wildcards a query is a fast case-insensitive substring match.
// With * or ? wildcards it compiles to an anchored regex so "Start*.pdf"
// behaves like Everything: anchored at both ends and matched against the
// file name (not the full path).
//
// A query may contain "|" as a top-level OR separator, so:
// *.png | *.jpg | *.gif
// matches anything ending in .png, .jpg or .gif. Whitespace around the
// pipes is ignored; empty alternatives are dropped.
struct SearchEngine {
// one alternative of the OR-separated query
private enum Submatcher {
// pre-lowercased substring for case-insensitive contains()
case substring(String)
// compiled anchored regex translated from a *? wildcard pattern
case regex(NSRegularExpression)
}
// all OR-alternatives; the query matches if any submatcher matches
private let matchers: [Submatcher]
// builds an engine for the provided query string
init(inQuery: String) {
let vTrimmed = inQuery.trimmingCharacters(in: .whitespacesAndNewlines)
if vTrimmed.isEmpty {
self.matchers = []
return
}
// split on top-level pipes (no escape mechanism - pipe is reserved)
let vParts = vTrimmed
.split(separator: "|", omittingEmptySubsequences: true)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var vResult: [Submatcher] = []
for vPart in vParts {
let vHasWildcards = vPart.contains("*") || vPart.contains("?")
if !vHasWildcards {
vResult.append(.substring(vPart.lowercased()))
} else {
let vPattern = SearchEngine.wildcardToRegex(inPattern: vPart)
if let vRegex = try? NSRegularExpression(pattern: vPattern, options: [.caseInsensitive]) {
vResult.append(.regex(vRegex))
}
}
}
self.matchers = vResult
}
// true when this engine has any pattern that will filter results
var isActive: Bool { !matchers.isEmpty }
// returns true if the record's name matches any of the OR-alternatives
func match(inRecord: FileRecord) -> Bool {
for vMatcher in matchers {
switch vMatcher {
case .substring(let vNeedle):
if inRecord.nameLower.contains(vNeedle) { return true }
case .regex(let vRegex):
let vName = inRecord.name
let vRange = NSRange(vName.startIndex..., in: vName)
if vRegex.firstMatch(in: vName, options: [], range: vRange) != nil {
return true
}
}
}
return false
}
// converts an Everything-style wildcard pattern into an anchored regex
private static func wildcardToRegex(inPattern: String) -> String {
var vResult = "^"
for vChar in inPattern {
switch vChar {
case "*":
vResult.append(".*")
case "?":
vResult.append(".")
case ".", "+", "(", ")", "[", "]", "{", "}", "^", "$", "|", "\\":
vResult.append("\\")
vResult.append(vChar)
default:
vResult.append(vChar)
}
}
vResult.append("$")
return vResult
}
}
-15
View File
@@ -1,15 +0,0 @@
import Foundation
// Main is the binary's entry point. It routes between two modes based on
// command-line arguments: --service starts the headless indexer/watcher
// daemon, anything else launches the SwiftUI GUI application.
@main
struct Main {
static func main() {
if CommandLine.arguments.contains("--service") {
AllofitService.run()
} else {
AllofitApp.main()
}
}
}
@@ -1,279 +0,0 @@
import Foundation
import CoreServices
import Darwin
// AllofitService is the headless runtime invoked by launchd when the binary
// is launched with the --service argument. It builds an initial index, then
// listens to FSEvents and writes the cache to disk every few seconds.
//
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
// (a leftover launchd job, or someone running --service manually) exits
// quietly instead of fighting for the cache file.
enum AllofitService {
// Shared mutable state across the FSEvents callback queue and the
// autosave loop. Wrapped in a class so closures capture by reference
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
// Sendable because every access goes through the NSLock below.
private final class State: @unchecked Sendable {
var records: [FileRecord] = []
var pathIndex: [String: Int] = [:]
var dirty: Bool = false
let lock = NSLock()
}
// runs the indexer/watcher loop forever (never returns)
static func run() -> Never {
// ensure files we create are world-readable (root daemon writes the
// cache; the GUI runs as the user and must be able to read it)
umask(0o022)
NSLog("[Allofit] service starting (uid=\(getuid()))")
// figure out whether we are the root daemon writing to /Library or
// the user agent writing to ~/Library, then acquire the lock
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
if !vLock.tryLock() {
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
exit(0)
}
let vPrefs = Preferences.shared
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs)
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
// log the actual configuration so the user can verify owner-prefs sync
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
NSLog("[Allofit] roots: %@", vRoots.map { $0.path }.joined(separator: ", "))
NSLog("[Allofit] excluded paths (%d): %@",
vPrefs.excludedPaths.count,
vPrefs.excludedPaths.joined(separator: ", "))
let vState = State()
// initial scan: streaming the walker through the shared state lock so
// the autosave thread (every 3s) can write partial progress while we
// continue walking. Without this, large filesystems leave the cache
// empty for many minutes and the GUI shows nothing.
//
// Both the per-root and per-batch callback bodies run inside their
// own autoreleasepool so the autoreleased NSURL / NSDate / NSNumber
// from the file enumeration don't pile up until the entire scan
// finishes - on a million-file scan that "pile" was peaking at
// well over a gig of dead allocations before the main thread's
// runloop got a chance to drain.
let vStartId = UInt64(FSEventsGetCurrentEventId())
for vRoot in vRoots {
autoreleasepool {
NSLog("[Allofit] scanning %@", vRoot.path)
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
autoreleasepool {
vState.lock.lock()
for vRec in vBatch {
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
if vState.pathIndex[vRec.fullPath] == nil {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
}
}
vState.dirty = true
vState.lock.unlock()
}
}
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
}
}
// force one save right after the scan finishes, so the GUI sees a
// stable count even if no FSEvents come in for a while afterwards
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
// FSEvents watcher
let vWatcher = FileWatcher()
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
vWatcher.start(
inRoots: vRoots.map { $0.path },
inSinceWhen: FSEventStreamEventId(vStartId)
) { vChanges in
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
vChanges.count,
vChanges.first?.path ?? "—")
vState.lock.lock()
defer { vState.lock.unlock() }
var vRescanPrefixes: [String] = []
var vAdded = 0
var vUpdated = 0
// batch removals into a set so we do one bulk allRecords pass
// and rebuild pathIndex once per FSEvents batch, instead of
// O(records) per individual removal - that nested rebuild was
// dominating CPU and turning into the daemon's memory churn
var vRemoved: Set<String> = []
// Per-change autoreleasepool: a large FSEvents batch (e.g.
// `rm -rf` of a deep tree) can deliver thousands of paths in
// one callback. Each path's NSURL + reachability check + the
// makeRecord internals autorelease - per-change drain keeps
// peak memory bounded by a single record's worth, not the
// whole batch.
for vChange in vChanges {
autoreleasepool {
if vMatcher.isExcluded(inPath: vChange.path) { return }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
return
}
// skip paths we've already queued for removal in this batch
if vRemoved.contains(vChange.path) { return }
let vUrl = URL(fileURLWithPath: vChange.path)
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
if let vIdx = vState.pathIndex[vRec.fullPath] {
vState.records[vIdx] = vRec
vUpdated += 1
} else {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
vAdded += 1
}
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
}
}
}
if !vRemoved.isEmpty {
// single bulk removeAll + single pathIndex rebuild
vState.records.removeAll { vRemoved.contains($0.fullPath) }
rebuildPathIndex(vState)
}
if vAdded + vUpdated + vRemoved.count > 0 {
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
vAdded, vUpdated, vRemoved.count, vState.records.count)
}
if !vRescanPrefixes.isEmpty {
NSLog("[Allofit] rescanning \(vRescanPrefixes.count) subtree(s) (history lost)")
let vNormalized = vRescanPrefixes.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
vState.records.removeAll { vRec in
let vP = vRec.fullPath
for vPre in vNormalized where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
return true
}
return false
}
for vPath in vRescanPrefixes {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: vMatcher
)
vState.records.append(contentsOf: vList)
}
rebuildPathIndex(vState)
}
vState.dirty = true
}
// periodic save loop (background thread). 3-second check interval so
// new files appear in the GUI within a few seconds of being created.
// Every N saves we also compact the in-memory containers so Swift's
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file
// activity. RSS is logged each save so the trajectory is visible.
//
// CRITICAL: each iteration runs inside its own autoreleasepool. The
// outer GCD block has an autorelease pool that drains when the block
// returns - which for our `while true` never happens. Without an
// inner pool, every save's autoreleased NSData (returned by
// .compressed(using: .lz4) and friends) accumulates forever and the
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20
var vSavesSinceCompact = 0
while true {
sleep(3)
autoreleasepool {
vState.lock.lock()
let vShouldSave = vState.dirty
let vSnapshot = vState.records
vState.dirty = false
vState.lock.unlock()
if !vShouldSave { return }
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB())
IndexStore.save(
inRecords: vSnapshot,
inLastEventId: vWatcher.latestEventId
)
vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0
compactContainers(vState)
}
}
}
}
// block forever on the runloop so launchd keeps us alive
RunLoop.current.run()
exit(0)
}
// rebuilds pathIndex from records. Used after a bulk allRecords mutation
// (batched removal or subtree rescan) - much cheaper than incrementally
// maintaining pathIndex during the mutation, and the reserveCapacity
// lets the dict size to its target without re-bucketing on each insert.
private static func rebuildPathIndex(_ inState: State) {
var vIndex: [String: Int] = [:]
vIndex.reserveCapacity(inState.records.count)
for (vI, vR) in inState.records.enumerated() {
vIndex[vR.fullPath] = vI
}
inState.pathIndex = vIndex
}
// recreates records and pathIndex with capacities matched to their
// actual element count. Swift Array/Dict only grow their backing
// allocations under churn, never auto-shrink, so a daemon that's
// been processing FSEvents for days can hold huge dead capacity
// (records.capacity >> records.count) that shows up as multi-GB
// RSS. Forcing fresh containers reclaims it.
private static func compactContainers(_ inState: State) {
inState.lock.lock()
defer { inState.lock.unlock() }
let vBefore = processFootprintMB()
// Array(_:) creates a fresh array sized exactly to the source -
// the old buffer's slack capacity is released
let vCompactRecords = Array(inState.records)
var vCompactIndex: [String: Int] = [:]
vCompactIndex.reserveCapacity(vCompactRecords.count)
for (vI, vR) in vCompactRecords.enumerated() {
vCompactIndex[vR.fullPath] = vI
}
inState.records = vCompactRecords
inState.pathIndex = vCompactIndex
let vAfter = processFootprintMB()
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
vCompactRecords.count, vBefore, vAfter)
}
// resident-memory size in MB matching Activity Monitor's "Memory" column
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
// of pages owned by the task minus shared/clean pages.
private static func processFootprintMB() -> Double {
var vInfo = task_vm_info_data_t()
var vCount = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
}
}
if vResult == KERN_SUCCESS {
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
}
return -1
}
}
-112
View File
@@ -1,112 +0,0 @@
import Foundation
import SwiftUI
import Combine
// WindowSearchModel owns the per-window slice of the search/index pipeline:
// the query the user types, the sort descriptor chosen by clicking a column
// header in THIS window, and the filtered+sorted+capped slice of
// AppModel.allRecords currently shown here. Each new window gets its own
// instance so two windows can run independent searches and independent
// sorts against the same shared index.
//
// AppModel remains the single source of truth for allRecords. We subscribe
// to it via Combine and rebuild the visible slice on a debounced background
// task whenever any input changes.
@MainActor
final class WindowSearchModel: ObservableObject {
// the user-entered search query; refilter is debounced via scheduleFilter
@Published var query: String = "" {
didSet { scheduleFilter() }
}
// the sort descriptor chosen by clicking a column header. Per-window:
// clicking the Size header in window A no longer re-sorts window B.
// Persisted to prefs (last-write-wins) so a fresh window opens with
// the most recently chosen sort.
@Published var sortDescriptor: FileSortDescriptor {
didSet {
Preferences.shared.lastSort = sortDescriptor
scheduleFilter()
}
}
// the filtered, sorted and capped records currently shown in the table
@Published private(set) var visibleRecords: [FileRecord] = []
// strong ref to the shared index; the per-window WindowSearchModel does
// not outlive its window, so the shared model has a longer lifetime
private let model: AppModel
// Combine subscription to AppModel's @Published allRecords
private var cancellables: Set<AnyCancellable> = []
// pending filter+sort task, cancelled if a newer one supersedes it
private var filterTask: Task<Void, Never>?
// maximum rows handed to SwiftUI Table for snappy scrolling
private let kMaxVisibleRows = 2000
// debounce delay between keystroke / index update and filter rebuild
private let kSearchDebounceSeconds: Double = 0.5
init(model inModel: AppModel) {
self.model = inModel
self.sortDescriptor = Preferences.shared.lastSort
// Re-filter when the shared index changes. @Published fires the
// current value on subscribe; dropFirst skips that replay and we
// run one explicit scheduleFilter() below so the initial debounce
// delay applies consistently.
inModel.$allRecords
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.scheduleFilter()
}
.store(in: &cancellables)
scheduleFilter()
}
// debounces filter rebuilds so we don't refilter on every keystroke
// or every FSEvents batch mutation of AppModel.allRecords. The same
// debounce window covers all input changes, which is what the old
// single-model code did before this split.
private func scheduleFilter() {
filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained
let vQuery = query
let vSort = sortDescriptor
let vRecords = model.allRecords
let vMax = kMaxVisibleRows
let vDelayNanos = UInt64(kSearchDebounceSeconds * 1_000_000_000)
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
try? await Task.sleep(nanoseconds: vDelayNanos)
if Task.isCancelled { return }
let vEngine = SearchEngine(inQuery: vQuery)
var vFiltered: [FileRecord]
if vEngine.isActive {
vFiltered = vRecords.filter { vEngine.match(inRecord: $0) }
} else {
vFiltered = vRecords
}
if Task.isCancelled { return }
AppModel.sortInPlace(inRecords: &vFiltered, inDescriptor: vSort)
if Task.isCancelled { return }
let vCapped: [FileRecord]
if vFiltered.count > vMax {
vCapped = Array(vFiltered.prefix(vMax))
} else {
vCapped = vFiltered
}
if Task.isCancelled { return }
// hop back to main with DispatchQueue.main.async (rather than
// await MainActor.run) so the assignment is guaranteed to land
// on the next runloop tick, avoiding NSTableView reentrance when
// the search field is mid-edit
DispatchQueue.main.async {
guard let vSelf = self else { return }
// Skip the @Published fire when the resulting list is byte-
// for-byte identical to what the Table is already showing.
// Full FileRecord equality catches mtime / size updates, so
// we only skip true no-op reassignments.
if vSelf.visibleRecords == vCapped { return }
vSelf.visibleRecords = vCapped
}
}
}
}
+16 -9
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env bash
# Builds Allofit.app from the SwiftPM executable so the binary can be
# launched like any other macOS app (double-click in Finder, dragged into
# /Applications, etc.). Run from the project root:
# ./build-app.sh
# ./build-app.sh --version 1.0.0 # explicit version stamp
# ./build-app.sh -v 1.0.0 -b 42 # version + build number
# ALLOFIT_VERSION=1.0.0 ./build-app.sh # env var still works
# /Applications, etc.). Can be called from anywhere - paths are resolved
# relative to the script's own location:
# ./scripts/build-app.sh # from project root
# scripts/build-app.sh --version 1.0.0
# ./build-app.sh -v 1.0.0 -b 42 # from scripts/
# ALLOFIT_VERSION=1.0.0 ./scripts/build-app.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# The produced bundle ends up at ./Allofit.app.
# The produced bundle ends up at <project root>/outputs/Allofit-<version>.app
# (the version is appended so multiple builds can coexist in outputs/).
#
# Icon support (optional, first match wins):
# icons/Allofit.icns - pre-built .icns, copied straight in
@@ -69,13 +71,18 @@ else
kBuildNumber="${ALLOFIT_BUILD:-0}"
fi
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
# Resolve the project root from the script's own location (scripts/..),
# so this script works regardless of the caller's CWD.
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
vOutputsDir="${vProjectRoot}/outputs"
vAppBundle="${vOutputsDir}/${kAppName}-${kVersion}.app"
vMacOSDir="${vAppBundle}/Contents/MacOS"
vResourcesDir="${vAppBundle}/Contents/Resources"
vBinarySrc="${vProjectRoot}/.build/release/${kAppName}"
vIconStagingDir="${vProjectRoot}/.build/icon-staging"
mkdir -p "${vOutputsDir}"
# ==================
# MARK: Build binary
# ==================
@@ -190,7 +197,7 @@ cat > "${vAppBundle}/Contents/Info.plist" <<EOF
<key>CFBundleVersion</key>
<string>${kBuildNumber}</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 bitsycore — MIT Licensed</string>
<string>© 2026 bitsycore - MIT Licensed</string>
${vIconPlistEntry}
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
+21 -11
View File
@@ -3,22 +3,31 @@
# distribution from the GitHub Releases page. The DMG opens to a window
# with the .app and an /Applications symlink so the user can drag-install.
#
# Usage:
# ./build-dmg.sh # default version (0.0.0)
# ./build-dmg.sh --version 1.0.0 # explicit version
# ./build-dmg.sh -v 1.0.0 --skip-build # version + skip rebuild
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh # env var still works
# Can be called from anywhere - paths are resolved relative to the
# script's own location:
# ./scripts/build-dmg.sh # from project root
# scripts/build-dmg.sh --version 1.0.0
# ./build-dmg.sh -v 1.0.0 --skip-build # from scripts/
# ALLOFIT_VERSION=1.0.0 ./scripts/build-dmg.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# Output: Allofit-<version>.dmg in the project root.
# Output: <project root>/outputs/Allofit-<version>.dmg.
set -euo pipefail
kAppName="Allofit"
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
# Resolve the project root from the script's own location (scripts/..),
# so this script works regardless of the caller's CWD.
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
vOutputsDir="${vProjectRoot}/outputs"
vStagingDir="${vProjectRoot}/.build/dmg-staging"
mkdir -p "${vOutputsDir}"
# vAppBundle is set after kVersion is resolved below - the .app bundle's
# filename includes the version, so we can't construct the path until we
# know which version we're targeting.
# ==================
# MARK: Args
# ==================
@@ -56,7 +65,8 @@ if [[ -n "$vVersionArg" ]]; then
else
kVersion="${ALLOFIT_VERSION:-0.0.0}"
fi
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
vDmgPath="${vOutputsDir}/${kAppName}-${kVersion}.dmg"
vAppBundle="${vOutputsDir}/${kAppName}-${kVersion}.app"
# ==================
# MARK: Build .app
@@ -66,11 +76,11 @@ if [[ "$vSkipBuild" -eq 0 ]]; then
echo "==> Rebuilding ${kAppName}.app first"
# Propagate the resolved version into build-app.sh so the bundled
# Info.plist matches what we're naming the DMG.
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/build-app.sh"
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/scripts/build-app.sh"
fi
if [[ ! -d "${vAppBundle}" ]]; then
echo "Run ./build-app.sh first - ${kAppName}.app is missing" >&2
echo "Run ./scripts/build-app.sh first - ${kAppName}.app is missing" >&2
exit 1
fi
+15 -12
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env bash
# Removes every trace of Allofit from the system: launchd services, cache
# files, user preferences, build artifacts and log files. Useful for a
# clean reinstall test or to fully uninstall.
# clean reinstall test or to fully uninstall. Can be called from anywhere -
# paths are resolved relative to the script's own location.
#
# Usage:
# ./clean.sh # clean everything
# ./clean.sh --keep-build # leave .build/ and Allofit.app/ in place
# ./clean.sh --keep-prefs # leave UserDefaults / preferences alone
# ./clean.sh --dry-run # print what would happen, change nothing
# ./scripts/clean.sh # from project root
# scripts/clean.sh --keep-build # leave .build/ and outputs/ in place
# ./clean.sh --keep-prefs # from scripts/, keep user prefs
# ./clean.sh --dry-run # print what would happen, change nothing
set -uo pipefail
# ==================
@@ -29,7 +30,9 @@ kServiceLogStderr="/tmp/allofit-service.err"
# MARK: Args
# ==================
vScriptDir="$(cd "$(dirname "$0")" && pwd)"
# Resolve the project root from the script's own location (scripts/..),
# so this script works regardless of the caller's CWD.
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
vKeepBuild=0
vKeepPrefs=0
vDryRun=0
@@ -105,7 +108,7 @@ fi
echo "==> Killing any leftover daemon processes"
# `pkill -f` matches against the full command line; the daemon binary is
# now installed as ".../Allofit Service" (renamed copy), so the literal
# now installed as ".../Allofit service" (renamed copy), so the literal
# "Allofit --service" no longer appears - use a regex that handles both
# the legacy and the renamed binary by anchoring on "Allofit...--service"
vRun pkill -f "Allofit.*--service" >/dev/null 2>&1 || true
@@ -172,13 +175,13 @@ fi
# ==================
if [[ "$vKeepBuild" -eq 0 ]]; then
if [[ -d "${vScriptDir}/.build" ]]; then
if [[ -d "${vProjectRoot}/.build" ]]; then
echo "==> Removing SwiftPM .build directory"
vRun rm -rf "${vScriptDir}/.build"
vRun rm -rf "${vProjectRoot}/.build"
fi
if [[ -d "${vScriptDir}/Allofit.app" ]]; then
echo "==> Removing Allofit.app bundle"
vRun rm -rf "${vScriptDir}/Allofit.app"
if [[ -d "${vProjectRoot}/outputs" ]]; then
echo "==> Removing outputs/ directory (built .app and .dmg)"
vRun rm -rf "${vProjectRoot}/outputs"
fi
fi
+30
View File
@@ -0,0 +1,30 @@
import Foundation
// Main is the binary's entry point. Routes by command-line arguments:
// --service run the headless indexer daemon
// install / uninstall / ... CLI service-control commands (see CLI.swift)
// anything else / no args launch the SwiftUI GUI
@main
struct Main {
static func main() {
let vArgs = Array(CommandLine.arguments.dropFirst())
// No args: GUI mode. The Dock and Finder launch us with no args.
if vArgs.isEmpty {
AllofitApp.main()
return
}
// Headless indexer daemon, invoked by launchd's plist.
if vArgs.contains("--service") {
AllofitService.run()
return
}
// Anything else is a CLI command. Unknown commands print a hint
// to stderr and exit 1 rather than silently launching the GUI.
if !CLI.handle(arguments: vArgs) {
FileHandle.standardError.write(Data(
"Unknown command: \(vArgs.joined(separator: " "))\nUse --help for usage.\n".utf8
))
exit(1)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
// ExclusionMatcher tests filesystem paths against a configured exclusion list.
// A path is excluded when it matches an exclusion exactly or sits beneath one
// of the configured prefix directories.
//
// Called for every entry during a walk and for every record during a prune,
// so the check is a plain UTF-8 prefix compare: the paths it receives come
// from URL.path / FSEvents and are already standardized.
struct ExclusionMatcher: Sendable {
// normalized exclusions (no trailing slash, tilde expanded)
private let paths: [String]
// the same exclusions with a trailing slash, as UTF-8 bytes
private let prefixes: [[UInt8]]
// builds a matcher from raw user-entered exclusion strings
init(inExclusions: [String]) {
paths = inExclusions.map { vRaw in
let vExpanded = (vRaw as NSString).expandingTildeInPath
let vStandard = (vExpanded as NSString).standardizingPath
if vStandard.hasSuffix("/") && vStandard.count > 1 {
return String(vStandard.dropLast())
}
return vStandard
}
prefixes = paths.map { Array(($0 == "/" ? "/" : $0 + "/").utf8) }
}
// returns true when inPath matches an exclusion exactly or is below one
func isExcluded(inPath: String) -> Bool {
if paths.isEmpty { return false }
for (vI, vEx) in paths.enumerated() {
if inPath == vEx { return true }
if inPath.utf8.starts(with: prefixes[vI]) { return true }
}
return false
}
}
@@ -10,19 +10,20 @@ enum FileIndexer {
// the set of resource keys we ask for up front so they are pre-fetched in bulk
static let kPrefetchKeys: [URLResourceKey] = [
.nameKey,
.isDirectoryKey,
.fileSizeKey,
.creationDateKey,
.contentModificationDateKey,
.fileResourceIdentifierKey
.isHiddenKey
]
// same keys as a Set, built once instead of per record
private static let kPrefetchKeySet = Set(kPrefetchKeys)
// recursively walks inRoot, yielding records in batches via inBatch.
// The batched form lets long scans publish partial progress: the daemon
// can have its autosave write what it has so far while the rest of the
// filesystem is still being walked. Excluded paths and their descendants
// are pruned from the output.
// are pruned from the output, as are hidden entries and package contents.
static func walkRoot(inRoot: URL,
inExclusions: ExclusionMatcher? = nil,
inBatchSize: Int = 2000,
@@ -43,9 +44,11 @@ enum FileIndexer {
var vBatch: [FileRecord] = []
vBatch.reserveCapacity(inBatchSize)
// one String per directory instead of one per entry
let vInterner = PathInterner()
// include the root directory itself in the first batch
if let vRootRecord = makeRecord(inURL: inRoot) {
if let vRootRecord = makeRecord(inURL: inRoot, inInterner: vInterner) {
vBatch.append(vRootRecord)
}
@@ -66,7 +69,7 @@ enum FileIndexer {
return
}
if let vRecord = makeRecord(inURL: vURL) {
if let vRecord = makeRecord(inURL: vURL, inInterner: vInterner, inFresh: false) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
@@ -80,16 +83,23 @@ enum FileIndexer {
}
}
// true when the item at inPath has the hidden flag (UF_HIDDEN or a
// dot-name) - the same test the walker's .skipsHiddenFiles applies
static func isHidden(inPath: String) -> Bool {
return autoreleasepool {
let vValues = try? URL(fileURLWithPath: inPath).resourceValues(forKeys: [.isHiddenKey])
return vValues?.isHidden ?? false
}
}
// convenience wrapper that materializes the full record list. Used by the
// GUI's in-process reindex (which builds everything before swapping the
// index in atomically). For the daemon, prefer walkRoot directly so the
// in-memory index grows incrementally and the autosave can persist partial
// progress while the scan continues.
// GUI's in-process reindex and subtree rescans. For the daemon, prefer
// walkRoot directly so the in-memory index grows incrementally and the
// autosave can persist partial progress while the scan continues.
static func indexRoot(inRoot: URL,
inExclusions: ExclusionMatcher? = nil,
inProgress: ((Int) -> Void)? = nil) -> [FileRecord] {
var vRecords: [FileRecord] = []
vRecords.reserveCapacity(100_000)
var vLastReport = Date.distantPast
let kReportInterval: TimeInterval = 0.25
walkRoot(inRoot: inRoot, inExclusions: inExclusions) { vBatch in
@@ -104,38 +114,66 @@ enum FileIndexer {
return vRecords
}
// builds a FileRecord from a URL's pre-fetched resource values.
// Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise
// silently return the cached pre-edit mtime. Callers (walkRoot's
// enumerator loop, applyFileSystemChanges, AllofitService) already
// wrap each invocation in an autoreleasepool, so the autoreleased
// NSDate / NSNumber / NSURL objects from resourceValues() drain at
// the caller's pool boundary.
static func makeRecord(inURL: URL) -> FileRecord? {
// builds a FileRecord from a URL's resource values.
// inFresh clears the URL's resource-value cache first so we always
// re-stat the file - a file modified between two FSEvents batches would
// otherwise silently return the cached pre-edit mtime. The walker passes
// false because the enumerator's values were just bulk-fetched.
// inSkipHidden returns nil for hidden entries (dot-files, UF_HIDDEN),
// matching what the walker's .skipsHiddenFiles does. Callers wrap each
// invocation in an autoreleasepool.
static func makeRecord(inURL: URL,
inInterner: PathInterner? = nil,
inFresh: Bool = true,
inSkipHidden: Bool = false) -> FileRecord? {
var vUrl = inURL
vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
if inFresh {
vUrl.removeAllCachedResourceValues()
}
guard let vValues = try? vUrl.resourceValues(forKeys: kPrefetchKeySet) else {
return nil
}
let vName = vValues.name ?? inURL.lastPathComponent
let vParent = inURL.deletingLastPathComponent().path
let vIsDir = vValues.isDirectory ?? false
let vSize = Int64(vValues.fileSize ?? 0)
let vCreated = vValues.creationDate ?? .distantPast
let vModified = vValues.contentModificationDate ?? .distantPast
// derive a stable identifier from the absolute path
var vHasher = Hasher()
vHasher.combine(inURL.path)
let vId = UInt64(bitPattern: Int64(vHasher.finalize()))
if inSkipHidden && (vValues.isHidden ?? false) {
return nil
}
// name/parent come from the path (not .nameKey) so that
// parentPath + "/" + name always round-trips to the watched path,
// which is what the id and the parent-is-indexed rule rely on
let vPath = inURL.path
let vName: String
let vParentRaw: String
if vPath == "/" {
vName = "/"
vParentRaw = ""
} else {
vName = inURL.lastPathComponent
vParentRaw = inURL.deletingLastPathComponent().path
}
let vParent = inInterner?.intern(vParentRaw) ?? vParentRaw
return FileRecord(
id: vId,
name: vName,
parentPath: vParent,
size: vSize,
dateCreated: vCreated,
dateModified: vModified,
isDirectory: vIsDir
size: Int64(vValues.fileSize ?? 0),
dateCreated: vValues.creationDate ?? .distantPast,
dateModified: vValues.contentModificationDate ?? .distantPast,
isDirectory: vValues.isDirectory ?? false
)
}
}
// PathInterner hands back one shared String instance per distinct value,
// so the thousands of records of a directory share a single parentPath
// buffer instead of each holding its own copy. Not thread-safe: use one
// per walk / load.
final class PathInterner {
// distinct value -> shared instance
private var table: [String: String] = [:]
// returns the shared instance equal to inString
func intern(_ inString: String) -> String {
if let vShared = table[inString] { return vShared }
table[inString] = inString
return inString
}
}
+125
View File
@@ -0,0 +1,125 @@
import Foundation
// FileRecord stores the minimum metadata needed to display and search a single
// filesystem entry. Keeping only names and small fixed-size fields lets the
// in-memory index scale to hundreds of thousands of entries.
struct FileRecord: Identifiable, Hashable, Sendable {
// stable identifier derived from the absolute path (see pathHash). Also
// the key of IndexState's lookup table, and the SwiftUI row identity -
// being deterministic, it survives updates and relaunches so the
// selection doesn't jump when a file changes under it.
let id: UInt64
// last path component (file or directory name)
let name: String
// parent directory absolute path (interned: siblings share one buffer)
let parentPath: String
// size in bytes (0 for directories)
let size: Int64
// creation timestamp
let dateCreated: Date
// last modification timestamp
let dateModified: Date
// true if this entry is a directory
let isDirectory: Bool
// lowercased, NFC-normalized name used for case-insensitive matching.
// Shares the name's storage when the name is already in that form.
let nameLower: String
init(name: String,
parentPath: String,
size: Int64,
dateCreated: Date,
dateModified: Date,
isDirectory: Bool) {
self.id = FileRecord.pathHash(inParent: parentPath, inName: name)
self.name = name
self.parentPath = parentPath
self.size = size
self.dateCreated = dateCreated
self.dateModified = dateModified
self.isDirectory = isDirectory
let vLower = FileRecord.searchForm(of: name)
self.nameLower = (vLower == name) ? name : vLower
}
// returns the absolute full path computed from parent and name
var fullPath: String {
if parentPath.isEmpty { return name }
if parentPath == "/" { return "/" + name }
return parentPath + "/" + name
}
// ===========================
// MARK: Normalization / hashing
// ===========================
// lowercases and canonically composes a string so byte-level matching
// behaves like a case-insensitive, normalization-insensitive compare.
// Pure-ASCII strings (the vast majority) skip the NFC pass.
static func searchForm(of inString: String) -> String {
let vLower = inString.lowercased()
if vLower.utf8.allSatisfy({ $0 < 0x80 }) { return vLower }
return vLower.precomposedStringWithCanonicalMapping
}
// 64-bit FNV-1a offset basis and prime
private static let kFnvOffset: UInt64 = 0xcbf2_9ce4_8422_2325
private static let kFnvPrime: UInt64 = 0x0000_0100_0000_01b3
// FNV-1a hash of an absolute path's UTF-8 bytes. Deterministic across
// processes (unlike Hasher, which is randomly seeded per launch).
// Non-ASCII paths are hashed in composed (NFC) form: APFS accepts both
// "é" and "e + combining accent" for the same file, and FSEvents and the
// directory enumerator don't always report the same form.
static func pathHash(_ inPath: String) -> UInt64 {
var vHash = kFnvOffset
if !isASCII(inPath) {
for vByte in inPath.precomposedStringWithCanonicalMapping.utf8 {
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
}
return vHash
}
for vByte in inPath.utf8 {
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
}
return vHash
}
// true when the string is plain ASCII (no normalization ambiguity)
private static func isASCII(_ inString: String) -> Bool {
return inString.utf8.allSatisfy { $0 < 0x80 }
}
// same value as pathHash(fullPath) but computed from the parent + name
// without allocating the joined string (ASCII fast path)
static func pathHash(inParent: String, inName: String) -> UInt64 {
if inParent.isEmpty { return pathHash(inName) }
if !isASCII(inParent) || !isASCII(inName) {
return pathHash(inParent == "/" ? "/" + inName : inParent + "/" + inName)
}
var vHash = kFnvOffset
for vByte in inParent.utf8 {
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
}
if inParent != "/" {
vHash = (vHash ^ UInt64(UInt8(ascii: "/"))) &* kFnvPrime
}
for vByte in inName.utf8 {
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
}
return vHash
}
}
// Sort modes for FileRecord lists. Defined at file scope so both Preferences
// (which persists the last choice) and AppModel can reference it without a
// circular dependency.
enum FileSortDescriptor: String, CaseIterable, Identifiable {
case nameAscending, nameDescending
case sizeAscending, sizeDescending
case createdAscending, createdDescending
case modifiedAscending, modifiedDescending
case pathAscending, pathDescending
var id: String { rawValue }
}
@@ -10,7 +10,14 @@ struct FSChange {
let path: String
// raw FSEvents flag bits for this change
let flags: FSEventStreamEventFlags
// FSEvents id of this change, persisted so the next launch resumes after it
let eventId: FSEventStreamEventId
// true for the synthetic marker FSEvents sends once the replay of
// historical events (sinceWhen) is finished - not a real file change
var isHistoryDone: Bool {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagHistoryDone) != 0
}
// true when the kernel asks the client to rescan inside this path
// because events were dropped (history too old or buffer overflow)
var mustScanSubDirs: Bool {
@@ -75,7 +82,7 @@ final class FileWatcher: @unchecked Sendable {
)
// C callback: forwards paths + flags back to the Swift handler
let vCallback: FSEventStreamCallback = { (_, info, numEvents, eventPaths, eventFlags, _) in
let vCallback: FSEventStreamCallback = { (_, info, numEvents, eventPaths, eventFlags, eventIds) in
guard let vInfo = info else { return }
let vWatcher = Unmanaged<FileWatcher>.fromOpaque(vInfo).takeUnretainedValue()
let vPathsArray = unsafeBitCast(eventPaths, to: NSArray.self)
@@ -83,7 +90,7 @@ final class FileWatcher: @unchecked Sendable {
vChanges.reserveCapacity(numEvents)
for vI in 0..<numEvents {
guard let vPath = vPathsArray[vI] as? String else { continue }
vChanges.append(FSChange(path: vPath, flags: eventFlags[vI]))
vChanges.append(FSChange(path: vPath, flags: eventFlags[vI], eventId: eventIds[vI]))
}
vWatcher.lock.lock()
let vHandler = vWatcher.handler
+337
View File
@@ -0,0 +1,337 @@
import Foundation
// ResolvedChanges is a coalesced set of FSEvents changes whose filesystem
// lookups (stat) are already done, so merging it into an IndexState is pure
// in-memory bookkeeping. Built off the main thread by IndexState.resolve.
struct ResolvedChanges: Sendable {
// fresh records for reported paths that exist and are indexable
var upserts: [FileRecord] = []
// reported paths that are gone (or no longer indexable: hidden, excluded)
var removals: [String] = []
// subtrees the kernel asked us to rescan (history lost / coalesced)
var rescans: [String] = []
// highest FSEvents id covered by this set of changes
var maxEventId: UInt64 = 0
// true when the end-of-replay marker was part of this set
var historyDone = false
}
// IndexState is the in-memory index: the record array plus an id -> position
// lookup. Records are kept in no particular order (search results are sorted
// on demand), which lets removals swap the last element into the hole in O(1)
// instead of shifting the array and rebuilding the whole lookup.
//
// Indexing rules shared by the initial walk and live updates:
// - a record is only kept if its parent directory is itself indexed (or it
// is a root). That mirrors the walker skipping hidden directories,
// package contents and exclusions without re-checking every ancestor.
// - removing a directory removes everything below it (a folder moved to the
// Trash only produces one FSEvents entry for the folder itself).
// - a directory that appears (created, moved or renamed in) is rescanned,
// since FSEvents does not report the entries moved along with it.
//
// Value type: callers own it on one thread (AppModel on main, the service
// under its lock).
struct IndexState {
// all indexed entries, unordered
private(set) var records: [FileRecord] = []
// FileRecord.id -> position in records
private(set) var positions: [UInt64: Int] = [:]
// builds the state from a list of records, dropping duplicate paths
init(inRecords: [FileRecord] = []) {
records.reserveCapacity(inRecords.count)
positions.reserveCapacity(inRecords.count)
for vRecord in inRecords where positions[vRecord.id] == nil {
positions[vRecord.id] = records.count
records.append(vRecord)
}
}
// number of indexed entries
var count: Int { records.count }
// true when the given absolute path is indexed
func contains(inPath: String) -> Bool {
return positions[FileRecord.pathHash(inPath)] != nil
}
// returns the record at an absolute path, if indexed
func record(atPath inPath: String) -> FileRecord? {
guard let vPos = positions[FileRecord.pathHash(inPath)] else { return nil }
return records[vPos]
}
// ===========================
// MARK: Mutations
// ===========================
// inserts or replaces a record; returns true if the index changed
@discardableResult
mutating func upsert(_ inRecord: FileRecord) -> Bool {
if let vPos = positions[inRecord.id] {
if records[vPos] == inRecord { return false }
records[vPos] = inRecord
return true
}
positions[inRecord.id] = records.count
records.append(inRecord)
return true
}
// removes the records at the given positions (swap-with-last, O(k))
private mutating func remove(inPositions: [Int]) {
for vPos in inPositions.sorted(by: >) {
let vRemovedId = records[vPos].id
let vLast = records.count - 1
if vPos != vLast {
let vMoved = records[vLast]
records[vPos] = vMoved
positions[vMoved.id] = vPos
}
records.removeLast()
positions.removeValue(forKey: vRemovedId)
}
}
// removes each path and, for directories, everything below it.
// Returns the number of records removed.
@discardableResult
mutating func removeSubtrees(inPaths: [String]) -> Int {
var vPositions = Set<Int>()
var vDirPrefixes: [String] = []
for vPath in inPaths {
guard let vPos = positions[FileRecord.pathHash(vPath)] else { continue }
vPositions.insert(vPos)
if records[vPos].isDirectory {
vDirPrefixes.append(vPath)
}
}
if !vDirPrefixes.isEmpty {
let vMatcher = SubtreeMatcher(inRoots: vDirPrefixes)
for (vPos, vRecord) in records.enumerated() where vMatcher.containsChild(inParentPath: vRecord.parentPath) {
vPositions.insert(vPos)
}
}
remove(inPositions: Array(vPositions))
return vPositions.count
}
// replaces everything at and below each root with the given records
// (the result of re-walking those subtrees)
mutating func replaceSubtrees(inRoots: [String], inRecords: [FileRecord]) {
let vMatcher = SubtreeMatcher(inRoots: inRoots)
var vPositions: [Int] = []
for (vPos, vRecord) in records.enumerated() {
if vMatcher.containsChild(inParentPath: vRecord.parentPath) || vMatcher.isRoot(inRecord: vRecord) {
vPositions.append(vPos)
}
}
remove(inPositions: vPositions)
for vRecord in inRecords {
upsert(vRecord)
}
}
// merges resolved FSEvents changes. Returns whether anything changed and
// the subtrees that must be (re)walked: kernel-requested rescans plus
// directories that newly appeared. inRoots are the configured roots,
// whose own records are allowed without an indexed parent.
mutating func apply(inChanges: ResolvedChanges,
inRoots: Set<String>) -> (changed: Bool, rescans: [String]) {
var vChanged = false
var vRescans: [String] = []
// removals first: a rename shows up as old path gone + new path present
if !inChanges.removals.isEmpty {
vChanged = removeSubtrees(inPaths: inChanges.removals) > 0 || vChanged
}
// parents before children, so a new folder and its new files arriving
// in the same batch are accepted in one pass
let vUpserts = inChanges.upserts.sorted { $0.parentPath.utf8.count < $1.parentPath.utf8.count }
for vRecord in vUpserts {
let vIsRoot = inRoots.contains(vRecord.fullPath)
guard vIsRoot || contains(inPath: vRecord.parentPath) else { continue }
let vIsNew = positions[vRecord.id] == nil
if upsert(vRecord) { vChanged = true }
if vIsNew && vRecord.isDirectory {
vRescans.append(vRecord.fullPath)
}
}
// kernel rescans only for subtrees that belong to the index
for vPath in inChanges.rescans where inRoots.contains(vPath) || contains(inPath: vPath) {
vRescans.append(vPath)
}
return (vChanged, SubtreeMatcher.minimalRoots(inPaths: vRescans))
}
// drops records that fall outside the current configuration: excluded
// paths, entries under removed roots, entries whose parent isn't indexed,
// and hidden entries the walker would have skipped (content added by
// older versions' live updates). Hidden means a dot-name anywhere, or a
// hidden flag (inIsHidden, e.g. ~/Library) on a folder directly under a
// root - deeper folders aren't stat'ed so a prune stays cheap even on
// network roots. Run on load and after the roots/exclusions change.
// Returns the number removed.
@discardableResult
mutating func prune(inRoots: Set<String>,
inExclusions: ExclusionMatcher,
inIsHidden: ((String) -> Bool)? = nil) -> Int {
// visit shallow entries first so a dropped directory takes its
// descendants with it in the same pass
let vOrder = records.indices.sorted {
records[$0].parentPath.utf8.count < records[$1].parentPath.utf8.count
}
var vKept = Set<UInt64>()
vKept.reserveCapacity(records.count)
var vDrop: [Int] = []
for vPos in vOrder {
let vRecord = records[vPos]
let vFull = vRecord.fullPath
let vIsRoot = inRoots.contains(vFull)
var vKeep = vIsRoot || vKept.contains(FileRecord.pathHash(vRecord.parentPath))
if vKeep && !vIsRoot {
if vRecord.name.hasPrefix(".") {
vKeep = false
} else if vRecord.isDirectory, inRoots.contains(vRecord.parentPath), let vCheck = inIsHidden {
vKeep = !vCheck(vFull)
}
}
if vKeep && !inExclusions.isExcluded(inPath: vFull) {
vKept.insert(vRecord.id)
} else {
vDrop.append(vPos)
}
}
remove(inPositions: vDrop)
return vDrop.count
}
// returns a copy with array/dictionary capacity trimmed to the content.
// Swift collections never shrink on their own, so after heavy churn
// the slack can amount to hundreds of MB.
func compacted() -> IndexState {
var vResult = IndexState()
vResult.records = Array(unsafeUninitializedCapacity: records.count) { vBuffer, vCount in
_ = vBuffer.initialize(from: records)
vCount = records.count
}
vResult.positions = Dictionary(minimumCapacity: positions.count)
for (vKey, vValue) in positions {
vResult.positions[vKey] = vValue
}
return vResult
}
// ===========================
// MARK: Change resolution
// ===========================
// resolves raw FSEvents changes against the filesystem. Each unique path
// is stat'ed once however many times it was reported (a multi-day replay
// repeats hot files thousands of times). Safe to call on any thread.
static func resolve(inChanges: [FSChange], inExclusions: ExclusionMatcher) -> ResolvedChanges {
var vResult = ResolvedChanges()
var vSeen = Set<String>()
var vRescanSeen = Set<String>()
var vPaths: [String] = []
for vChange in inChanges {
vResult.maxEventId = max(vResult.maxEventId, UInt64(vChange.eventId))
if vChange.isHistoryDone {
vResult.historyDone = true
continue
}
if vChange.mustScanSubDirs {
if !inExclusions.isExcluded(inPath: vChange.path), vRescanSeen.insert(vChange.path).inserted {
vResult.rescans.append(vChange.path)
}
continue
}
if vSeen.insert(vChange.path).inserted {
vPaths.append(vChange.path)
}
}
// package check cache: the walker doesn't descend into bundles, so
// changes inside a .app / .photoslibrary / ... are dropped as well
var vPackageCache: [String: Bool] = [:]
for vPath in vPaths {
// per-path pool: the URL + resourceValues objects autorelease
autoreleasepool {
if inExclusions.isExcluded(inPath: vPath) {
vResult.removals.append(vPath)
return
}
let vUrl = URL(fileURLWithPath: vPath)
let vParent = vUrl.deletingLastPathComponent().path
let vInPackage: Bool
if let vCached = vPackageCache[vParent] {
vInPackage = vCached
} else {
let vValues = try? URL(fileURLWithPath: vParent, isDirectory: true)
.resourceValues(forKeys: [.isPackageKey])
vInPackage = vValues?.isPackage ?? false
vPackageCache[vParent] = vInPackage
}
if !vInPackage, let vRecord = FileIndexer.makeRecord(inURL: vUrl, inSkipHidden: true) {
vResult.upserts.append(vRecord)
} else {
// normalized form (no trailing slash) so it hashes like the record
vResult.removals.append(vUrl.path)
}
}
}
return vResult
}
}
// SubtreeMatcher answers "is this entry at or below one of these
// directories?" using byte-level prefix checks on the parent path, so the
// O(n) passes over the index don't allocate a full path per record.
struct SubtreeMatcher {
// root directories without trailing slash ("/" kept as is)
private let roots: [String]
// roots with a trailing slash, for descendant checks
private let prefixes: [[UInt8]]
// record ids of the roots themselves
private let rootIds: Set<UInt64>
// builds a matcher for the given directory paths
init(inRoots: [String]) {
roots = inRoots.map { ($0.count > 1 && $0.hasSuffix("/")) ? String($0.dropLast()) : $0 }
prefixes = roots.map { Array(($0 == "/" ? "/" : $0 + "/").utf8) }
rootIds = Set(roots.map { FileRecord.pathHash($0) })
}
// true if a record whose parent is inParentPath lies below a root
// (the root directory's own record is matched by isRoot instead)
func containsChild(inParentPath: String) -> Bool {
for (vI, vRoot) in roots.enumerated() {
if inParentPath == vRoot { return true }
if inParentPath.utf8.starts(with: prefixes[vI]) { return true }
}
return false
}
// true if the record is one of the roots itself
func isRoot(inRecord: FileRecord) -> Bool {
return rootIds.contains(inRecord.id)
}
// removes duplicates and paths nested under another path of the list,
// so overlapping subtrees are only walked once
static func minimalRoots(inPaths: [String]) -> [String] {
var vResult: [String] = []
for vPath in Set(inPaths).sorted(by: { $0.utf8.count < $1.utf8.count }) {
let vCovered = vResult.contains { vRoot in
vRoot == "/" || vPath == vRoot || vPath.hasPrefix(vRoot + "/")
}
if !vCovered { vResult.append(vPath) }
}
return vResult
}
}
@@ -189,10 +189,17 @@ enum IndexStore {
var vP = 0
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
guard let vCount = readU64(from: vPayload, offset: &vP) else { return nil }
// each record takes at least 41 bytes - rejects a corrupt count
// before it turns into a huge reserveCapacity
guard vCount <= UInt64(vPayload.count / 41) else { return nil }
var vRecords: [FileRecord] = []
vRecords.reserveCapacity(Int(vCount))
// siblings share one parentPath buffer instead of one copy each
let vInterner = PathInterner()
for _ in 0..<Int(vCount) {
guard let vId = readU64(from: vPayload, offset: &vP),
// the stored id is ignored: ids are recomputed from the path so
// caches written with the old per-launch random hash still load
guard let _ = readU64(from: vPayload, offset: &vP),
let vSize = readI64(from: vPayload, offset: &vP),
let vCreated = readF64(from: vPayload, offset: &vP),
let vModified = readF64(from: vPayload, offset: &vP),
@@ -203,9 +210,8 @@ enum IndexStore {
return nil
}
vRecords.append(FileRecord(
id: vId,
name: vName,
parentPath: vParent,
parentPath: vInterner.intern(vParent),
size: vSize,
dateCreated: Date(timeIntervalSince1970: vCreated),
dateModified: Date(timeIntervalSince1970: vModified),
@@ -280,8 +286,10 @@ enum IndexStore {
guard offset + vLength <= inData.count else { return nil }
let vStart = inData.startIndex + offset
let vEnd = vStart + vLength
let vSlice = inData[vStart..<vEnd]
offset += vLength
return String(data: vSlice, encoding: .utf8)
// native decode (no Foundation bridging); invalid bytes are repaired
return inData.withUnsafeBytes { vPtr in
String(decoding: vPtr[(vStart - inData.startIndex)..<(vEnd - inData.startIndex)], as: UTF8.self)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
// ResultSorter turns the matching positions of a search into the first N
// records in the requested order. Only N rows are ever displayed, so instead
// of sorting every match (seconds for an unfiltered million-entry index) it
// keeps a bounded max-heap of the best N candidates: O(m log N), and in
// practice about one comparison per match once the heap is warm.
enum ResultSorter {
// returns the first inLimit records of inPositions, ordered by inDescriptor
static func top(inRecords: [FileRecord],
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [FileRecord] {
let vLess = comparator(for: inDescriptor)
// strict weak order on positions, ties broken by id for stability
let vBefore: (Int32, Int32) -> Bool = { vA, vB in
let vRa = inRecords[Int(vA)]
let vRb = inRecords[Int(vB)]
if vLess(vRa, vRb) { return true }
if vLess(vRb, vRa) { return false }
return vRa.id < vRb.id
}
if inPositions.count <= inLimit {
return inPositions.sorted(by: vBefore).map { inRecords[Int($0)] }
}
// max-heap (worst candidate on top) of the best inLimit seen so far
var vHeap = Array(inPositions.prefix(inLimit))
// restores the heap property downward from inStart
func siftDown(_ inStart: Int) {
var vI = inStart
while true {
let vLeft = 2 * vI + 1
if vLeft >= vHeap.count { return }
var vWorst = vLeft
let vRight = vLeft + 1
if vRight < vHeap.count && vBefore(vHeap[vLeft], vHeap[vRight]) { vWorst = vRight }
if !vBefore(vHeap[vI], vHeap[vWorst]) { return }
vHeap.swapAt(vI, vWorst)
vI = vWorst
}
}
for vI in stride(from: vHeap.count / 2 - 1, through: 0, by: -1) {
siftDown(vI)
}
for vPos in inPositions[inLimit...] where vBefore(vPos, vHeap[0]) {
vHeap[0] = vPos
siftDown(0)
}
return vHeap.sorted(by: vBefore).map { inRecords[Int($0)] }
}
// primary ordering for each sort mode. Names compare on the folded form
// (plain code-point order: fast, case-insensitive), path sorts by folder
// then name.
private static func comparator(for inDescriptor: FileSortDescriptor) -> (FileRecord, FileRecord) -> Bool {
switch inDescriptor {
case .nameAscending:
return { $0.nameLower < $1.nameLower }
case .nameDescending:
return { $0.nameLower > $1.nameLower }
case .sizeAscending:
return { $0.size < $1.size }
case .sizeDescending:
return { $0.size > $1.size }
case .createdAscending:
return { $0.dateCreated < $1.dateCreated }
case .createdDescending:
return { $0.dateCreated > $1.dateCreated }
case .modifiedAscending:
return { $0.dateModified < $1.dateModified }
case .modifiedDescending:
return { $0.dateModified > $1.dateModified }
case .pathAscending:
return { $0.parentPath != $1.parentPath ? $0.parentPath < $1.parentPath : $0.nameLower < $1.nameLower }
case .pathDescending:
return { $0.parentPath != $1.parentPath ? $0.parentPath > $1.parentPath : $0.nameLower > $1.nameLower }
}
}
}
+345
View File
@@ -0,0 +1,345 @@
import Foundation
// SearchEngine matches a user query against FileRecords using voidtools
// Everything's query syntax:
//
// foo bar AND: both "foo" and "bar" appear in the name
// *.png | *.jpg OR: binds tighter than AND (Everything precedence)
// !tmp NOT: names that don't contain "tmp"
// "my file" quotes keep spaces inside a single term
// Start*.pdf wildcards: * any run, ? one character; a wildcard
// term must match the whole name (anchored)
// src/main a term containing "/" is matched against the full path
// some/**/path with wildcards, a path term matches whole folder
// names anywhere in the path: * stays inside one
// name, ** spans any number of folders (zero too).
// "photos/**/IMG_????.heic" or "src/**/build/" (a
// trailing / means "anything inside that folder")
// ext:pdf;docx extension list
// file: folder: restrict to files / folders (alone or as a prefix,
// e.g. folder:build)
//
// Matching is case- and normalization-insensitive and runs on raw UTF-8
// bytes (memmem / a byte-level glob) against the record's pre-lowercased
// name, so a full pass over a million records takes milliseconds.
struct SearchEngine: Sendable {
// how a single term's text is matched
private enum Pattern: Sendable {
// case-folded UTF-8 needle searched anywhere in the target
case substring([UInt8])
// case-folded UTF-8 glob that must match the whole target
case glob([UInt8])
// case-folded path glob, aligned on folder boundaries
case pathGlob([UInt8])
// allowed extensions (case-folded, without the dot)
case extensions([[UInt8]])
}
// one term of the query, e.g. `!folder:*.app`
private struct Term: Sendable {
// text pattern, nil for a bare file: / folder: modifier
let pattern: Pattern?
// true to match the full path instead of the name
let matchesPath: Bool
// required entry kind: true = folders only, false = files only
let requiresDirectory: Bool?
// true when the term was prefixed with "!"
let negated: Bool
}
// AND of OR-groups: every group must have at least one matching term
private let groups: [[Term]]
// builds an engine for the provided query string
init(inQuery: String) {
var vGroups: [[Term]] = []
var vJoinNext = false
for vToken in SearchEngine.tokenize(inQuery: inQuery) {
if vToken == "|" {
vJoinNext = !vGroups.isEmpty
continue
}
guard let vTerm = SearchEngine.parseTerm(inToken: vToken) else { continue }
if vJoinNext {
vGroups[vGroups.count - 1].append(vTerm)
} else {
vGroups.append([vTerm])
}
vJoinNext = false
}
// cheap name-only groups first: path terms build a lowercased
// full path per record, so they only run on records that survived
self.groups = vGroups.sorted { vA, vB in
!vA.contains(where: { $0.matchesPath }) && vB.contains(where: { $0.matchesPath })
}
}
// true when this engine has any pattern that will filter results
var isActive: Bool { !groups.isEmpty }
// returns true if the record satisfies every AND-group of the query
func match(inRecord: FileRecord) -> Bool {
for vGroup in groups {
var vAny = false
for vTerm in vGroup where SearchEngine.matches(inTerm: vTerm, inRecord: inRecord) {
vAny = true
break
}
if !vAny { return false }
}
return true
}
// ===========================
// MARK: Parsing
// ===========================
// splits the query on whitespace (outside quotes) and isolates "|"
// separators. Quotes are removed; their content stays one token.
private static func tokenize(inQuery: String) -> [String] {
var vTokens: [String] = []
var vCurrent = ""
var vInQuotes = false
// flushes the token being built, if any
func flush() {
if !vCurrent.isEmpty { vTokens.append(vCurrent) }
vCurrent = ""
}
for vChar in inQuery {
if vChar == "\"" {
vInQuotes.toggle()
} else if vInQuotes {
vCurrent.append(vChar)
} else if vChar.isWhitespace {
flush()
} else if vChar == "|" {
flush()
vTokens.append("|")
} else {
vCurrent.append(vChar)
}
}
flush()
return vTokens
}
// parses one token into a term; nil when the token carries nothing
private static func parseTerm(inToken: String) -> Term? {
var vText = Substring(inToken)
var vNegated = false
while vText.hasPrefix("!") {
vNegated.toggle()
vText = vText.dropFirst()
}
var vRequiresDir: Bool?
let vLower = vText.lowercased()
if vLower.hasPrefix("folder:") {
vRequiresDir = true
vText = vText.dropFirst("folder:".count)
} else if vLower.hasPrefix("file:") {
vRequiresDir = false
vText = vText.dropFirst("file:".count)
}
if vText.lowercased().hasPrefix("ext:") {
let vExts = vText.dropFirst("ext:".count)
.split(whereSeparator: { $0 == ";" || $0 == "," })
.map { vExt -> [UInt8] in
let vClean = vExt.hasPrefix(".") ? vExt.dropFirst() : vExt
return Array(FileRecord.searchForm(of: String(vClean)).utf8)
}
.filter { !$0.isEmpty }
if vExts.isEmpty && vRequiresDir == nil { return nil }
return Term(
pattern: vExts.isEmpty ? nil : .extensions(vExts),
matchesPath: false,
requiresDirectory: vRequiresDir,
negated: vNegated
)
}
if vText.isEmpty {
// a bare modifier ("file:", "!folder:") still filters
guard vRequiresDir != nil else { return nil }
return Term(pattern: nil, matchesPath: false, requiresDirectory: vRequiresDir, negated: vNegated)
}
let vNeedle = Array(FileRecord.searchForm(of: String(vText)).utf8)
let vHasWildcards = vText.contains("*") || vText.contains("?")
let vIsPath = vText.contains("/")
let vPattern: Pattern
if vHasWildcards {
vPattern = vIsPath ? .pathGlob(vNeedle) : .glob(vNeedle)
} else {
vPattern = .substring(vNeedle)
}
return Term(
pattern: vPattern,
matchesPath: vIsPath,
requiresDirectory: vRequiresDir,
negated: vNegated
)
}
// ===========================
// MARK: Matching
// ===========================
// evaluates one term (including its negation) against a record
private static func matches(inTerm: Term, inRecord: FileRecord) -> Bool {
var vResult = true
if let vDir = inTerm.requiresDirectory, vDir != inRecord.isDirectory {
vResult = false
} else if let vPattern = inTerm.pattern {
if inTerm.matchesPath {
let vPath = FileRecord.searchForm(of: inRecord.fullPath)
vResult = matches(inPattern: vPattern, inTarget: vPath)
} else {
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
}
}
return inTerm.negated ? !vResult : vResult
}
// runs a pattern against an already case-folded target string
private static func matches(inPattern: Pattern, inTarget: String) -> Bool {
var vTarget = inTarget
return vTarget.withUTF8 { vBytes in
switch inPattern {
case .substring(let vNeedle):
return containsBytes(inHaystack: vBytes, inNeedle: vNeedle)
case .glob(let vGlob):
return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) }
case .pathGlob(let vGlob):
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vBytes) }
case .extensions(let vExts):
return hasExtension(inName: vBytes, inExtensions: vExts)
}
}
}
// substring search over raw bytes
private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool {
if inNeedle.isEmpty { return true }
if inNeedle.count > inHaystack.count { return false }
return inNeedle.withUnsafeBufferPointer { vNeedle in
memmem(inHaystack.baseAddress, inHaystack.count, vNeedle.baseAddress, vNeedle.count) != nil
}
}
// true when the name's extension (after the last dot) is in the list
private static func hasExtension(inName: UnsafeBufferPointer<UInt8>, inExtensions: [[UInt8]]) -> Bool {
guard let vDot = inName.lastIndex(of: UInt8(ascii: ".")), vDot > 0 else { return false }
let vExt = UnsafeBufferPointer(rebasing: inName[(vDot + 1)...])
for vCandidate in inExtensions where vCandidate.count == vExt.count {
if vCandidate.elementsEqual(vExt) { return true }
}
return false
}
// index of the next UTF-8 scalar start after inIndex
private static func nextScalar(in inText: UnsafeBufferPointer<UInt8>, after inIndex: Int) -> Int {
var vI = inIndex + 1
while vI < inText.count && (inText[vI] & 0xC0) == 0x80 { vI += 1 }
return vI
}
// anchored wildcard match: * = any run, ? = exactly one character.
// Iterative with single-star backtracking (linear in practice).
private static func globMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
let kStar = UInt8(ascii: "*")
let kQuestion = UInt8(ascii: "?")
var vP = 0
var vT = 0
var vStarP = -1
var vStarT = 0
while vT < inText.count {
if vP < inPattern.count && inPattern[vP] == kQuestion {
vP += 1
vT = nextScalar(in: inText, after: vT)
} else if vP < inPattern.count && inPattern[vP] == kStar {
vStarP = vP
vStarT = vT
vP += 1
} else if vP < inPattern.count && inPattern[vP] == inText[vT] {
vP += 1
vT += 1
} else if vStarP >= 0 {
vStarT = nextScalar(in: inText, after: vStarT)
vT = vStarT
vP = vStarP + 1
} else {
return false
}
}
while vP < inPattern.count && inPattern[vP] == kStar { vP += 1 }
return vP == inPattern.count
}
// path glob: the pattern may start at any folder boundary of the path
// (or only at the root when it starts with "/") and must end on one
// (end of path, or a "/" in the path). A pattern ending with "/" matches
// everything inside that folder.
private static func pathGlobMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
let kSlash = UInt8(ascii: "/")
if inPattern.first == kSlash {
return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0)
}
for vStart in 0..<inText.count where vStart == 0 || inText[vStart - 1] == kSlash {
if pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: vStart) { return true }
}
return false
}
// recursive matcher behind pathGlobMatch, starting at pattern index
// inP and text index inT
private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>,
inP: Int,
inText: UnsafeBufferPointer<UInt8>,
inT: Int) -> Bool {
let kSlash = UInt8(ascii: "/")
let kStar = UInt8(ascii: "*")
let kQuestion = UInt8(ascii: "?")
var vP = inP
var vT = inT
while vP < inPattern.count {
let vChar = inPattern[vP]
if vChar == kStar {
if vP + 1 < inPattern.count && inPattern[vP + 1] == kStar {
// "**": any run of characters, folder separators included
let vNext = vP + 2
// "**/" also matches zero folders ("a/**/b" matches "a/b")
if vNext < inPattern.count && inPattern[vNext] == kSlash,
pathGlobFrom(inPattern: inPattern, inP: vNext + 1, inText: inText, inT: vT) {
return true
}
var vTry = vT
while true {
if pathGlobFrom(inPattern: inPattern, inP: vNext, inText: inText, inT: vTry) { return true }
if vTry >= inText.count { return false }
vTry += 1
}
}
// "*": any run of characters inside one folder name
var vTry = vT
while true {
if pathGlobFrom(inPattern: inPattern, inP: vP + 1, inText: inText, inT: vTry) { return true }
if vTry >= inText.count || inText[vTry] == kSlash { return false }
vTry = nextScalar(in: inText, after: vTry)
}
} else if vChar == kQuestion {
if vT >= inText.count || inText[vT] == kSlash { return false }
vT = nextScalar(in: inText, after: vT)
vP += 1
} else {
if vT >= inText.count || inText[vT] != vChar { return false }
vT += 1
vP += 1
}
}
// must stop on a folder boundary, unless the pattern ended with "/"
return vT == inText.count || inText[vT] == kSlash || (vP > 0 && inPattern[vP - 1] == kSlash)
}
}
@@ -48,16 +48,23 @@ enum VolumeManager {
}
// returns the effective root URLs combining configured paths and mounted
// volumes that match the include-mounted / include-network preferences
// volumes that match the include-mounted / include-network preferences.
// Paths are standardized and nested roots dropped (a root inside another
// root would otherwise be walked, watched and indexed twice).
static func effectiveRoots(inPreferences: Preferences) -> [URL] {
var vRoots: [URL] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0) }
var vPaths: [String] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
if inPreferences.includeMountedVolumes || inPreferences.includeNetworkVolumes {
for vVol in mountedVolumes() {
if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue }
if !vVol.isNetwork && !inPreferences.includeMountedVolumes { continue }
vRoots.append(vVol.url)
vPaths.append(vVol.url.standardizedFileURL.path)
}
}
return vRoots
let vMinimal = Set(SubtreeMatcher.minimalRoots(inPaths: vPaths))
// keep the user's order, minus duplicates and nested entries
var vSeen = Set<String>()
return vPaths
.filter { vMinimal.contains($0) && vSeen.insert($0).inserted }
.map { URL(fileURLWithPath: $0) }
}
}
+233
View File
@@ -0,0 +1,233 @@
import Foundation
import CoreServices
import Darwin
// AllofitService is the headless runtime invoked by launchd when the binary
// is launched with the --service argument. It resumes from its cache (or
// builds an initial index), then listens to FSEvents and writes the cache to
// disk every few seconds.
//
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
// (a leftover launchd job, or someone running --service manually) exits
// quietly instead of fighting for the cache file.
enum AllofitService {
// Shared mutable state across the FSEvents callback queue and the
// autosave loop. Wrapped in a class so closures capture by reference
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
// Sendable because every access goes through the NSLock below.
private final class State: @unchecked Sendable {
// the in-memory index (same rules as the GUI's built-in indexer)
var index = IndexState()
// true when the index changed since the last save
var dirty: Bool = false
// FSEvents id the index is complete up to. 0 while an initial walk
// is in progress, so a partial cache saved mid-scan is never taken
// for a complete one on the next start.
var lastEventId: UInt64 = 0
// guards every field above
let lock = NSLock()
}
// runs the indexer/watcher loop forever (never returns)
static func run() -> Never {
// ensure files we create are world-readable (root daemon writes the
// cache; the GUI runs as the user and must be able to read it)
umask(0o022)
NSLog("[Allofit] service starting (uid=\(getuid()))")
// figure out whether we are the root daemon writing to /Library or
// the user agent writing to ~/Library, then acquire the lock
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
if !vLock.tryLock() {
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
exit(0)
}
let vPrefs = Preferences.shared
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs).map { $0.path }
let vRootSet = Set(vRoots)
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
// log the actual configuration so the user can verify owner-prefs sync
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
NSLog("[Allofit] roots: %@", vRoots.joined(separator: ", "))
NSLog("[Allofit] excluded paths (%d): %@",
vPrefs.excludedPaths.count,
vPrefs.excludedPaths.joined(separator: ", "))
let vState = State()
// fast resume: load the cache, drop what no longer matches the
// configuration and replay FSEvents from the saved id. Only roots
// missing from the cache are walked. Without a usable cache (none,
// incomplete, or an id from another FSEvents database) every root
// is walked from scratch.
let vCurrentId = UInt64(FSEventsGetCurrentEventId())
var vSinceId = vCurrentId
var vToScan = vRoots
if let vCache = IndexStore.load(), vCache.lastEventId > 0, vCache.lastEventId <= vCurrentId {
var vIndex = IndexState(inRecords: vCache.records)
let vPruned = vIndex.prune(inRoots: vRootSet, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden)
vToScan = vRoots.filter { !vIndex.contains(inPath: $0) }
vSinceId = vCache.lastEventId
vState.index = vIndex
vState.dirty = vPruned > 0
NSLog("[Allofit] resumed %d records from cache (%d pruned), replaying from event %llu",
vIndex.count, vPruned, vSinceId)
}
// periodic save loop, started before any walk so that a long initial
// scan publishes partial progress for the GUI
startAutosaveLoop(inState: vState)
// walk whatever the cache doesn't cover. The batch callback runs in
// its own autoreleasepool so the autoreleased NSURL / NSDate /
// NSNumber from the enumeration don't pile up until the whole scan
// finishes - on a million-file scan that peaked well over a gig.
if !vToScan.isEmpty {
for vRoot in vToScan {
autoreleasepool {
NSLog("[Allofit] scanning %@", vRoot)
FileIndexer.walkRoot(inRoot: URL(fileURLWithPath: vRoot), inExclusions: vMatcher) { vBatch in
autoreleasepool {
vState.lock.lock()
for vRecord in vBatch {
vState.index.upsert(vRecord)
}
vState.dirty = true
vState.lock.unlock()
}
}
}
}
vState.lock.lock()
let vCount = vState.index.count
vState.lock.unlock()
NSLog("[Allofit] scan complete (%d entries)", vCount)
}
// the index is now complete up to vSinceId: replaying from there
// fills in whatever happened during the walk
vState.lock.lock()
vState.lastEventId = vSinceId
vState.dirty = true
vState.lock.unlock()
// FSEvents watcher. The callbacks of one stream run serially on
// its dispatch queue, so resolve / apply / rescan never overlap.
let vWatcher = FileWatcher()
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
vWatcher.start(
inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(vSinceId)
) { vChanges in
autoreleasepool {
// stat() outside the lock so the autosave isn't blocked
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
vState.lock.lock()
let vResult = vState.index.apply(inChanges: vResolved, inRoots: vRootSet)
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
if vResult.changed { vState.dirty = true }
vState.lock.unlock()
if vResolved.historyDone {
NSLog("[Allofit] FSEvents history replay done")
vState.lock.lock()
vState.dirty = true
vState.lock.unlock()
}
// folders moved in, or history lost: walk them again
if !vResult.rescans.isEmpty {
NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count)
var vFresh: [FileRecord] = []
for vPath in vResult.rescans {
vFresh.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: vMatcher
))
}
vState.lock.lock()
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
vState.dirty = true
vState.lock.unlock()
}
}
}
// block forever on the runloop so launchd keeps us alive
RunLoop.current.run()
exit(0)
}
// periodic save loop (background thread). 3-second check interval so
// new files appear in the GUI within a few seconds of being created.
// Every N saves we also compact the in-memory containers so Swift's
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file
// activity. RSS is logged each save so the trajectory is visible.
//
// CRITICAL: each iteration runs inside its own autoreleasepool. The
// outer GCD block has an autorelease pool that drains when the block
// returns - which for our `while true` never happens. Without an
// inner pool, every save's autoreleased NSData (returned by
// .compressed(using: .lz4) and friends) accumulates forever and the
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
private static func startAutosaveLoop(inState: State) {
DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20
var vSavesSinceCompact = 0
while true {
sleep(3)
autoreleasepool {
inState.lock.lock()
let vShouldSave = inState.dirty
let vSnapshot = inState.index.records
let vEventId = inState.lastEventId
inState.dirty = false
inState.lock.unlock()
if !vShouldSave { return }
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB())
IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId)
vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0
compact(inState: inState)
}
}
}
}
}
// replaces the index with a copy whose capacity matches its content,
// releasing the slack accumulated by days of churn
private static func compact(inState: State) {
let vBefore = processFootprintMB()
inState.lock.lock()
inState.index = inState.index.compacted()
let vCount = inState.index.count
inState.lock.unlock()
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
vCount, vBefore, processFootprintMB())
}
// resident-memory size in MB matching Activity Monitor's "Memory" column
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
// of pages owned by the task minus shared/clean pages.
private static func processFootprintMB() -> Double {
var vInfo = task_vm_info_data_t()
var vCount = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
}
}
if vResult == KERN_SUCCESS {
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
}
return -1
}
}
+254
View File
@@ -0,0 +1,254 @@
import Foundation
// CLI dispatches sub-commands invoked from Main.swift before the GUI app
// boots. Lets the user install / uninstall / start / stop the background
// service and flip the GUI's service-mode preference without opening the
// Settings window.
//
// Usage shape:
// Allofit launches the GUI (default)
// Allofit --service runs as the headless indexer (existing)
// Allofit install <user|root> installs the LaunchAgent or LaunchDaemon
// Allofit uninstall [user|root]
// Allofit start [user|root]
// Allofit stop [user|root]
// Allofit mode <none|user|root> sets GUI Preferences.serviceMode
// Allofit status prints install + run state
// Allofit --help
enum CLI {
// returns true when a CLI command was recognised and handled, in which
// case Main.swift returns without booting the GUI. Returns false to
// fall through to the SwiftUI app.
static func handle(arguments inArgs: [String]) -> Bool {
guard let vCmd = inArgs.first else { return false }
let vRest = Array(inArgs.dropFirst())
switch vCmd {
case "-h", "--help":
printHelp()
return true
case "install":
runInstall(inArgs: vRest)
return true
case "uninstall":
runUninstall(inArgs: vRest)
return true
case "start":
runStart(inArgs: vRest)
return true
case "stop":
runStop(inArgs: vRest)
return true
case "mode":
runSetMode(inArgs: vRest)
return true
case "status":
runStatus()
return true
default:
return false
}
}
// ===========================
// MARK: Command implementations
// ===========================
// install <user|root> - copy the running binary to the daemon path and
// register the launchd plist. Root scope prompts once for an admin
// password via NSAppleScript inside ServiceInstaller.
private static func runInstall(inArgs: [String]) {
guard let vScope = parseScope(inArgs.first) else {
fail("Usage: allofit install <user|root>")
}
print("==> Installing \(label(for: vScope))…")
do {
try ServiceInstaller.install(inScope: vScope)
print("Installed.")
} catch {
fail("Install failed: \(error.localizedDescription)")
}
}
// uninstall [user|root] - explicit scope, or every installed scope when
// none is given (the typical "tear it all down" intent).
private static func runUninstall(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .everyInstalled)
guard !vScopes.isEmpty else {
print("Nothing to uninstall (no service installed).")
return
}
for vScope in vScopes {
print("==> Uninstalling \(label(for: vScope))…")
do {
try ServiceInstaller.uninstall(inScope: vScope)
print("Uninstalled \(label(for: vScope)).")
} catch {
print("Uninstall failed for \(label(for: vScope)): \(error.localizedDescription)")
}
}
}
private static func runStart(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else {
fail("No service installed. Use: allofit install <user|root>")
}
for vScope in vScopes {
print("==> Starting \(label(for: vScope))…")
do {
try ServiceInstaller.start(inScope: vScope)
print("Started.")
} catch {
fail("Start failed: \(error.localizedDescription)")
}
}
}
private static func runStop(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else {
fail("No service installed.")
}
for vScope in vScopes {
print("==> Stopping \(label(for: vScope))…")
do {
try ServiceInstaller.stop(inScope: vScope)
print("Stopped.")
} catch {
fail("Stop failed: \(error.localizedDescription)")
}
}
}
// mode <none|user|root> - writes Preferences.serviceMode so the next GUI
// launch boots in the chosen role. Doesn't install or start anything
// itself; pair with `install` + `start` for a one-shot setup.
private static func runSetMode(inArgs: [String]) {
guard let vArg = inArgs.first else {
fail("Usage: allofit mode <none|user|root>")
}
let vMode: Preferences.ServiceMode
switch vArg {
case "none", "off":
vMode = .none
case "user", "useragent":
vMode = .userAgent
case "root", "rootdaemon", "system", "admin":
vMode = .rootDaemon
default:
fail("Unknown mode: \(vArg). Use one of: none, user, root")
}
// Write into both the bundle id and the SwiftPM/process domain so
// the setting takes regardless of how the GUI is launched (drag
// from /Applications uses the bundle id; `swift run Allofit` uses
// the executable name).
writeMode(vMode, toDomain: kBundleId)
writeMode(vMode, toDomain: kProcessName)
print("Service mode set to \(vMode.rawValue).")
}
private static func writeMode(_ inMode: Preferences.ServiceMode, toDomain inDomain: String) {
guard let vDefaults = UserDefaults(suiteName: inDomain) else { return }
vDefaults.set(inMode.rawValue, forKey: kServiceModeKey)
vDefaults.synchronize()
}
private static func runStatus() {
print("Bundle version: \(ServiceInstaller.bundleVersion())")
for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] {
let vInstalled = ServiceInstaller.isInstalled(inScope: vScope)
let vRunning = vInstalled && ServiceInstaller.isRunning(inScope: vScope)
let vVersion = ServiceInstaller.installedVersion(inScope: vScope) ?? "-"
let vState: String
if vRunning {
vState = "running"
} else if vInstalled {
vState = "installed (stopped)"
} else {
vState = "not installed"
}
print("\(label(for: vScope).padding(toLength: 14, withPad: " ", startingAt: 0)) \(vState) v\(vVersion)")
}
}
// ===========================
// MARK: Helpers
// ===========================
private static let kBundleId = "com.bitsycore.allofit"
private static let kProcessName = "Allofit"
private static let kServiceModeKey = "Allofit.serviceMode"
private enum ScopeResolution {
case firstInstalled
case everyInstalled
}
// Returns the scopes a command should operate on:
// - explicit `user`/`root` arg: just that one
// - no arg, firstInstalled mode: the single installed scope, if any
// - no arg, everyInstalled mode: all installed scopes
private static func scopesToActOn(inArgs: [String], defaultMode: ScopeResolution) -> [ServiceInstaller.Scope] {
if let vScope = parseScope(inArgs.first) {
return [vScope]
}
let vAll: [ServiceInstaller.Scope] = [.userAgent, .rootDaemon]
let vInstalled = vAll.filter { ServiceInstaller.isInstalled(inScope: $0) }
switch defaultMode {
case .firstInstalled:
return vInstalled.first.map { [$0] } ?? []
case .everyInstalled:
return vInstalled
}
}
private static func parseScope(_ inArg: String?) -> ServiceInstaller.Scope? {
guard let vArg = inArg else { return nil }
switch vArg {
case "user", "useragent":
return .userAgent
case "root", "rootdaemon", "daemon", "system", "admin":
return .rootDaemon
default:
return nil
}
}
private static func label(for inScope: ServiceInstaller.Scope) -> String {
switch inScope {
case .userAgent: return "user agent"
case .rootDaemon: return "root daemon"
}
}
// prints a message to stderr and exits with status 1
private static func fail(_ inMessage: String) -> Never {
FileHandle.standardError.write(Data((inMessage + "\n").utf8))
exit(1)
}
private static func printHelp() {
print("""
Usage: allofit [command [args]]
Service control:
install <user|root> Copy binary, register launchd plist, start the service.
'root' prompts once for an admin password.
uninstall [user|root] Stop and remove. Without an arg, uninstalls every
installed scope.
start [user|root] Start an installed service. Picks the only installed
one when no arg is given.
stop [user|root] Stop a running service.
mode <none|user|root> Set the GUI's service-mode preference. Does NOT
install anything itself.
status Show install + run state for both scopes.
Other:
--service Run as the headless indexer (used by launchd).
--help, -h This message.
With no arguments, launches the GUI.
""")
}
}
@@ -18,7 +18,7 @@ enum ServiceInstaller {
case rootDaemon
}
// errors surfaced to the UI
// errors surfaced to the ui
enum InstallError: Error, LocalizedError {
case binaryNotFound
case authorizationFailed(String)
@@ -33,7 +33,7 @@ enum ServiceInstaller {
}
// installs (or replaces) the launchd plist for the given scope. Also
// copies the binary to a renamed location ("Allofit Service") so the
// copies the binary to a renamed location ("Allofit service") so the
// daemon process shows up distinctly from the GUI in Activity Monitor /
// `ps` - both used to be called "Allofit" because they share a binary.
static func install(inScope: Scope) throws {
@@ -236,7 +236,7 @@ enum ServiceInstaller {
}
// Filesystem path where the daemon's binary is installed. We use a
// renamed copy ("Allofit Service") so the daemon process is named
// renamed copy ("Allofit service") so the daemon process is named
// differently from the GUI in Activity Monitor / ps - both used to
// be just "Allofit" because they shared the same on-disk binary.
// The filename embedded in argv[0] is what those tools display.
@@ -315,7 +315,7 @@ enum ServiceInstaller {
// runs a shell script with administrator privileges. Bridges
// AdminShell.Error into ServiceInstaller.InstallError so the calling
// SettingsView UI gets a single error type to surface.
// SettingsView ui gets a single error type to surface.
private static func runWithAdminPrivileges(inScript: String) throws {
do {
_ = try AdminShell.run(inScript)
@@ -8,8 +8,11 @@ struct AllofitApp: App {
// AppKit delegate that wrestles focus from whichever app was frontmost,
// needed because SwiftPM-launched binaries default to .accessory policy
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
// shared application state injected into the view tree
@StateObject private var model = AppModel()
// shared application state injected into the view tree. Held with
// @State (not @StateObject) so the App does not subscribe to it: the
// model's counters change on every index update, and observing them
// here would re-evaluate every scene each time.
@State private var model = AppModel()
// session-scoped store of sudo-staged user-readable copies. Sits
// alongside AppModel so both the Table (lock badge on rows) and
// the PreviewPane observe the same authorization state.
@@ -86,6 +89,12 @@ struct AllofitWindowContent: View {
.environmentObject(searchModel)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
.onAppear {
model.start()
}
.onDisappear {
model.saveCache()
}
}
}
@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import Combine
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
@@ -23,23 +24,71 @@ private final class BackgroundMtime: @unchecked Sendable {
}
}
// 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
// guards changes and flushScheduled
private let lock = NSLock()
// 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 Published properties
// are written from main. Heavy work (LZ4 (de)compression, filtering, sorting,
// path-lookup rebuilds, filesystem walks) happens on background queues; the
// result is then assigned back on main with DispatchQueue.main.async or
// MainActor.run. Snapshots of value types (Array, String) cross thread
// boundaries via Swift COW semantics, so no defensive copies are needed.
// 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 full index of every entry observed so far
@Published private(set) var allRecords: [FileRecord] = []
// the canonical index (records + id lookup), main-thread only
private var index = IndexState()
// every entry observed so far, in no particular order
var allRecords: [FileRecord] { index.records }
// fires on main after allRecords changed (no payload by design)
let recordsChanged = PassthroughSubject<Void, Never>()
// 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
@@ -51,6 +100,10 @@ final class AppModel: ObservableObject {
// how often the index is written to disk while running (seconds)
private let kAutosaveSeconds: TimeInterval = 30
// delay used to coalesce FSEvents batches before resolving them
private let kChangeCoalesceSeconds: TimeInterval = 0.5
// 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
@@ -60,18 +113,25 @@ final class AppModel: ObservableObject {
private let watcher = FileWatcher()
// watcher used in reader mode to detect cache file refreshes
private let cacheWatcher = FileWatcher()
// path -> array index, kept in sync with allRecords (main-thread only)
private var pathIndex: [String: Int] = [:]
// 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> = []
// 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 allRecords has changed since the last save
// true when the index has changed since the last save
private var dirty = false
// last FSEvents event id known to be reflected in allRecords
// 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
@@ -91,8 +151,30 @@ final class AppModel: ObservableObject {
private var lastReloadAt: Date?
init() {
// no synchronous UI-state restoration needed; the cache file is
// loaded off-main in start() so the window appears instantly
// 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)
// 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)
}
// kicks off background activity for the first time. Subsequent calls are
@@ -118,6 +200,7 @@ final class AppModel: ObservableObject {
private func tearDownActiveMode() {
watcher.stop()
cacheWatcher.stop()
pendingChanges.reset()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollSource?.cancel()
@@ -146,32 +229,33 @@ final class AppModel: ObservableObject {
isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
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=%@",
prefs.serviceMode.rawValue,
isIndexer ? "true" : "false",
vIsIndexer ? "true" : "false",
vCacheURL.path)
ioQueue.async { [weak self] in
// decompress + parse off-main (this is the expensive part)
// decompress + parse + reconcile off-main (the expensive part)
let vCache = IndexStore.load(from: vCacheURL)
// build the path lookup while we are already off-main
let vLookup = AppModel.buildPathLookup(inRecords: vCache?.records ?? [])
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", vCache.records.count)
vSelf.allRecords = vCache.records
vSelf.pathIndex = vLookup
vSelf.indexedCount = vCache.records.count
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.allRecords = []
vSelf.pathIndex = [:]
vSelf.indexedCount = 0
vSelf.lastEventId = 0
}
vSelf.replaceIndex(inState: vState)
vSelf.dirty = vPruned > 0
vSelf.isLoadingCache = false
// WindowSearchModel(s) observe allRecords and will refilter;
// no need to kick a filter here ourselves
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
@@ -202,7 +286,6 @@ final class AppModel: ObservableObject {
let vStartId = UInt64(FSEventsGetCurrentEventId())
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
@@ -217,13 +300,12 @@ final class AppModel: ObservableObject {
vAccumulated.append(contentsOf: vList)
}
}
let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
let vState = IndexState(inRecords: vAccumulated)
// persist off-main before bouncing back so main never sees the
// LZ4 compression cost
IndexStore.save(inRecords: vFinal, inLastEventId: vStartId)
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
DispatchQueue.main.async {
self?.applyFreshIndex(inRecords: vFinal, inLookup: vLookup, inEventId: vStartId)
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
}
}
}
@@ -233,7 +315,7 @@ final class AppModel: ObservableObject {
// 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.
// dialog from NSAppleScript) doesn't freeze the ui.
func performReindex() async {
if isIndexer {
reindex()
@@ -314,7 +396,7 @@ final class AppModel: ObservableObject {
}
// shared helper: runs a service-mode operation on a detached queue while
// publishing isWorking/workMessage so the UI can show progress. Returns
// publishing isWorking/workMessage so the ui can show progress. Returns
// true if the action ran without throwing.
@discardableResult
private func runPrivilegedAction(inLabel: String,
@@ -343,39 +425,93 @@ final class AppModel: ObservableObject {
}
}
// writes the current index to disk (called on quit and by autosave)
// writes the current index to disk (autosave, window close, replay end).
// Snapshots on main (cheap COW), compresses off-main.
func saveCache() {
guard isIndexer else { return }
// snapshot on main (cheap COW), then dispatch compression off-main
let vRecords = allRecords
let vEventId = max(lastEventId, watcher.latestEventId)
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
let vEventId = 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 = 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() {
if allRecords.isEmpty {
reindex()
} else {
startWatching(inSinceWhen: lastEventId)
}
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)
kickRescanSubtrees(inPaths: vRoots)
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 {
kickRescanSubtrees(inPaths: vMissing)
}
}
// 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)
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
watchedRoots = Set(vRoots)
pendingChanges.reset()
let vPending = pendingChanges
let vQueue = indexQueue
let vDelay = kChangeCoalesceSeconds
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
watcher.start(
inRoots: vRoots.map { $0.path },
inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { vChanges in
DispatchQueue.main.async { [weak self] in
self?.applyFileSystemChanges(inChanges: vChanges)
) { [weak self] vChanges in
guard vPending.append(inChanges: vChanges) else { return }
vQueue.asyncAfter(deadline: .now() + vDelay) { [weak self] in
let vResolved = IndexState.resolve(
inChanges: vPending.drain(),
inExclusions: vMatcher
)
DispatchQueue.main.async {
self?.applyResolvedChanges(inResolved: vResolved)
}
}
}
}
@@ -393,103 +529,84 @@ final class AppModel: ObservableObject {
autosaveTimer = vTimer
}
// applies a batch of FSEvents-reported changes. Updates and removals are
// batched so we rebuild the path lookup once per batch (the per-event
// rebuild used previously was O(n × removals) on main and also left
// pathIndex with stale indices between iterations). MustScanSubDirs is
// handled off-main so a kernel history loss doesn't freeze the UI.
private func applyFileSystemChanges(inChanges: [FSChange]) {
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
var vChanged = false
var vRescan: [String] = []
var vRemoved: Set<String> = []
for vChange in inChanges {
if vMatcher.isExcluded(inPath: vChange.path) { continue }
if vChange.mustScanSubDirs {
vRescan.append(vChange.path)
continue
}
// skip paths we've already queued for removal in this batch
if vRemoved.contains(vChange.path) { continue }
let vURL = URL(fileURLWithPath: vChange.path)
let vExists = (try? vURL.checkResourceIsReachable()) ?? false
if vExists, let vRecord = FileIndexer.makeRecord(inURL: vURL) {
if let vIdx = pathIndex[vRecord.fullPath] {
allRecords[vIdx] = vRecord
} else {
allRecords.append(vRecord)
pathIndex[vRecord.fullPath] = allRecords.count - 1
}
vChanged = true
} else if pathIndex[vChange.path] != nil {
// defer the actual removal so we can bulk-remove all at once
// at the end of the batch, then rebuild the lookup once
vRemoved.insert(vChange.path)
vChanged = true
}
}
if !vRemoved.isEmpty {
// one bulk pass over allRecords + one lookup rebuild per batch
allRecords.removeAll { vRemoved.contains($0.fullPath) }
rebuildPathLookup()
}
if !vRescan.isEmpty {
kickRescanSubtrees(inPaths: vRescan, inExclusions: vMatcher)
}
if vChanged {
indexedCount = allRecords.count
lastEventId = watcher.latestEventId
// 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 vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
lastEventId = max(lastEventId, inResolved.maxEventId)
if vResult.changed {
dirty = true
// WindowSearchModel(s) observe @Published allRecords and refilter
// on debounce; no need to fire a manual filter pass here
publishRecords()
}
if !vResult.rescans.isEmpty {
kickRescanSubtrees(inPaths: vResult.rescans)
}
// 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()
}
}
// rebuilds the entries below the given subtrees from the filesystem in
// the background, then hands the new arrays back to main in one shot
private func kickRescanSubtrees(inPaths: [String], inExclusions: ExclusionMatcher) {
let vCurrent = allRecords
let vPrefixes = inPaths.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
// 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.
private func kickRescanSubtrees(inPaths: [String]) {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration
indexQueue.async { [weak self] in
var vKept = vCurrent.filter { vRec in
let vP = vRec.fullPath
for vPre in vPrefixes where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
return false
}
return true
}
for vPath in inPaths {
// per-subtree autoreleasepool keeps the rescan's
// autoreleased URL/stat objects from accumulating across
// subtrees inside this long-running async block
var vRecords: [FileRecord] = []
for vPath in vRoots {
// per-subtree autoreleasepool keeps the walk's autoreleased
// URL/stat objects from accumulating across subtrees
autoreleasepool {
let vList = FileIndexer.indexRoot(
vRecords.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
inExclusions: vMatcher
))
}
}
let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
let vFinal = vRecords
DispatchQueue.main.async {
self?.allRecords = vFinal
self?.pathIndex = vLookup
self?.indexedCount = vFinal.count
self?.dirty = true
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
vSelf.dirty = true
vSelf.publishRecords()
}
}
}
// rebuilds the path -> array-index dictionary from allRecords. Called
// synchronously on main after batched removals so the subsequent code
// sees a consistent pathIndex. One-shot O(n) per FSEvents batch.
private func rebuildPathLookup() {
pathIndex = AppModel.buildPathLookup(inRecords: allRecords)
// 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
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.startWatching(inSinceWhen: vSelf.lastEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty {
vSelf.kickRescanSubtrees(inPaths: vMissing)
}
}
}
}
// ===========================
@@ -506,7 +623,7 @@ final class AppModel: ObservableObject {
// 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]) { vChanges in
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")
@@ -544,11 +661,7 @@ final class AppModel: ObservableObject {
// 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. This is what was eating clicks:
// the daemon resaving an unchanged cache still bumped its mtime,
// the poller triggered a reload, and the resulting @Published
// cascade re-rendered the Table at the exact moment the user
// was clicking a row.
// 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.
@@ -560,21 +673,20 @@ final class AppModel: ObservableObject {
lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vCurrentEventId = lastEventId
let vCurrentCount = index.count
ioQueue.async { [weak self] in
let vCache = IndexStore.load(from: vUrl)
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)
if let vC = vCache, vC.lastEventId == vCurrentEventId {
return
}
let vLookup = AppModel.buildPathLookup(inRecords: vCache?.records ?? [])
// 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 vCache = vCache else { return }
self?.allRecords = vCache.records
self?.pathIndex = vLookup
self?.indexedCount = vCache.records.count
self?.lastEventId = vCache.lastEventId
guard let vSelf = self, !vSelf.isIndexer else { return }
vSelf.lastEventId = vCache.lastEventId
vSelf.replaceIndex(inState: vState)
}
}
}
@@ -583,15 +695,24 @@ final class AppModel: ObservableObject {
// MARK: Internals
// ===========================
// installs a freshly-built index and starts watching for changes. The
// per-window WindowSearchModel(s) observe allRecords and will refilter
// themselves; no need to fire a filter pass from here.
private func applyFreshIndex(inRecords: [FileRecord],
inLookup: [String: Int],
inEventId: UInt64) {
allRecords = inRecords
pathIndex = inLookup
indexedCount = inRecords.count
// 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() {
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
@@ -599,43 +720,4 @@ final class AppModel: ObservableObject {
startWatching(inSinceWhen: inEventId)
}
}
// builds a fresh path -> array-index dictionary for the given records.
// nonisolated so any background queue can call it without an actor hop.
private nonisolated static func buildPathLookup(inRecords: [FileRecord]) -> [String: Int] {
var vMap: [String: Int] = [:]
vMap.reserveCapacity(inRecords.count)
for (vI, vRecord) in inRecords.enumerated() {
vMap[vRecord.fullPath] = vI
}
return vMap
}
// sorts the provided slice in place. nonisolated so the detached filter
// task can call it without an actor hop. Called by WindowSearchModel.
nonisolated static func sortInPlace(inRecords: inout [FileRecord],
inDescriptor: FileSortDescriptor) {
switch inDescriptor {
case .nameAscending:
inRecords.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
case .nameDescending:
inRecords.sort { $0.name.localizedStandardCompare($1.name) == .orderedDescending }
case .sizeAscending:
inRecords.sort { $0.size < $1.size }
case .sizeDescending:
inRecords.sort { $0.size > $1.size }
case .createdAscending:
inRecords.sort { $0.dateCreated < $1.dateCreated }
case .createdDescending:
inRecords.sort { $0.dateCreated > $1.dateCreated }
case .modifiedAscending:
inRecords.sort { $0.dateModified < $1.dateModified }
case .modifiedDescending:
inRecords.sort { $0.dateModified > $1.dateModified }
case .pathAscending:
inRecords.sort { $0.parentPath < $1.parentPath }
case .pathDescending:
inRecords.sort { $0.parentPath > $1.parentPath }
}
}
}
@@ -7,7 +7,9 @@ import AppKit
// the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View {
@EnvironmentObject var model: AppModel
// AppModel is intentionally not observed here: its counters change on
// every index update and would re-evaluate this body (and the Table)
// each time. StatusBarView observes it instead.
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
// per-window search model: owns this window's query + filtered slice so
@@ -81,12 +83,6 @@ struct ContentView: View {
.help("Preferences (⌘,)")
}
}
.onAppear {
model.start()
}
.onDisappear {
model.saveCache()
}
.onChange(of: columnCustomization) { _, vNew in
// Debounced + off-main save. SwiftUI fires onChange on every
// micro-update during a column drag - encoding synchronously
@@ -208,7 +204,7 @@ struct ContentView: View {
.customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Formatters.size(bytes: vRecord.size))
Text(vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size))
.foregroundColor(.secondary)
.monospacedDigit()
}
@@ -342,12 +338,16 @@ private struct StatusBarView: View {
var body: some View {
HStack(spacing: 8) {
if model.isIndexing {
if model.isLoadingCache {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount) entries")
Text("Loading index…")
} else if model.isIndexing {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount.formatted()) entries")
} else {
Text("\(searchModel.visibleRecords.count) shown · \(model.indexedCount) indexed")
Text(resultsText)
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
@@ -366,4 +366,15 @@ private struct StatusBarView: View {
.font(.caption)
.foregroundColor(.secondary)
}
// "N results" plus how many are listed when the list is capped
private var resultsText: String {
let vMatches = searchModel.matchCount
let vShown = searchModel.visibleRecords.count
let vIndexed = model.indexedCount.formatted()
if vShown < vMatches {
return "\(vMatches.formatted()) results (first \(vShown.formatted()) listed) · \(vIndexed) indexed"
}
return "\(vMatches.formatted()) results · \(vIndexed) indexed"
}
}
@@ -26,7 +26,7 @@ enum Formatters {
// short date+time, with em-dash for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
if inDate.timeIntervalSince1970 < 1 { return "-" }
return kDateFormatter.string(from: inDate)
}
}
@@ -75,7 +75,6 @@ struct AuthorizeBadge: View {
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var access: AccessManager
@EnvironmentObject var searchModel: WindowSearchModel
// passed in from ContentView (its @State) so this view re-renders
@@ -64,7 +64,7 @@ private struct RootsTab: View {
.font(.caption)
.foregroundColor(.secondary)
}
Text("Changes take effect on the next reindex. In service mode this stops the daemon, deletes its cache, and restarts it so the fresh process re-scans from scratch.")
Text("In built-in mode, changes apply automatically: new folders are scanned and removed ones dropped from the index. With a service, they apply when the service restarts; Reindex now stops the daemon, deletes its cache, and restarts it so the fresh process re-scans from scratch.")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -131,7 +131,7 @@ private struct ExclusionsTab: View {
.disabled(selection == nil)
Spacer()
}
Text("Entries match exact paths and any descendants. Changes apply on next reindex.")
Text("Entries match exact paths and any descendants. In built-in mode, matching entries are removed from the index right away.")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -210,7 +210,7 @@ private struct VolumesTab: View {
}
// ===========================
// MARK: Service tab
// MARK: service tab
// ===========================
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
@@ -374,7 +374,7 @@ private struct ServiceTab: View {
.font(.callout)
}
LabeledContent("Installed version") {
Text(vInstalledVer ?? "—")
Text(vInstalledVer ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -451,7 +451,7 @@ private struct CacheTab: View {
.monospacedDigit()
}
LabeledContent("Last modified") {
Text(cacheFileMtime() ?? "—")
Text(cacheFileMtime() ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -528,7 +528,7 @@ private struct CacheTab: View {
// formats a byte count as a human-friendly short string
private func formatSize(inBytes: Int64) -> String {
if inBytes <= 0 { return "—" }
if inBytes <= 0 { return "-" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
@@ -548,8 +548,8 @@ private struct DiagnosticsTab: View {
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var model: AppModel
@State private var daemonStatus: String = "—"
@State private var serviceLogTail: String = "—"
@State private var daemonStatus: String = "-"
@State private var serviceLogTail: String = "-"
@State private var lastRefresh: Date = Date()
var body: some View {
@@ -577,7 +577,7 @@ private struct DiagnosticsTab: View {
.monospacedDigit()
}
LabeledContent("Cache mtime") {
Text(cacheMtimeString() ?? "—")
Text(cacheMtimeString() ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -699,7 +699,7 @@ private struct DiagnosticsTab: View {
// formats a byte count as a human-friendly short string
private func formatSize(_ inBytes: Int64) -> String {
if inBytes <= 0 { return "—" }
if inBytes <= 0 { return "-" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
+174
View File
@@ -0,0 +1,174 @@
import Foundation
import SwiftUI
import Combine
// WindowSearchModel owns the per-window slice of the search/index pipeline:
// the query the user types, the sort descriptor chosen by clicking a column
// header in THIS window, and the filtered+sorted+capped slice of
// AppModel.allRecords currently shown here. Each new window gets its own
// instance so two windows can run independent searches and independent
// sorts against the same shared index.
//
// AppModel remains the single source of truth for allRecords. We subscribe
// to its recordsChanged signal and rebuild the visible slice on a debounced
// background task whenever any input changes.
@MainActor
final class WindowSearchModel: ObservableObject {
// the user-entered search query; refilter is debounced via scheduleFilter
@Published var query: String = "" {
didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
}
// the sort descriptor chosen by clicking a column header. Per-window:
// clicking the Size header in window A no longer re-sorts window B.
// Persisted to prefs (last-write-wins) so a fresh window opens with
// the most recently chosen sort.
@Published var sortDescriptor: FileSortDescriptor {
didSet {
Preferences.shared.lastSort = sortDescriptor
scheduleFilter(inDelay: kTypingDebounceSeconds)
}
}
// the filtered, sorted and capped records currently shown in the table
@Published private(set) var visibleRecords: [FileRecord] = []
// total number of records matching the query (may exceed visibleRecords)
@Published private(set) var matchCount: Int = 0
// strong ref to the shared index; the per-window WindowSearchModel does
// not outlive its window, so the shared model has a longer lifetime
private let model: AppModel
// Combine subscription to AppModel's recordsChanged signal
private var cancellables: Set<AnyCancellable> = []
// pending filter+sort task, cancelled if a newer one supersedes it
private var filterTask: Task<Void, Never>?
// maximum rows handed to SwiftUI Table for snappy scrolling
private let kMaxVisibleRows = 2000
// delay after a keystroke / sort click: just enough to merge key repeats
private let kTypingDebounceSeconds: Double = 0.04
// delay after an index change: merges bursts of FSEvents updates
private let kIndexDebounceSeconds: Double = 0.3
// records per parallel filter chunk
private nonisolated static let kChunkSize = 16_384
init(model inModel: AppModel) {
self.model = inModel
self.sortDescriptor = Preferences.shared.lastSort
// Re-filter when the shared index changes. The signal carries no
// payload (the array itself is read when the filter runs), so a
// burst of updates never queues up copies of the index.
inModel.recordsChanged
.sink { [weak self] in
self?.scheduleFilter(inDelay: self?.kIndexDebounceSeconds ?? 0.3)
}
.store(in: &cancellables)
scheduleFilter(inDelay: 0)
}
// debounces filter rebuilds so we don't refilter on every keystroke
// or every FSEvents batch. The latest call wins: earlier pending
// tasks are cancelled before they start (or between chunks).
private func scheduleFilter(inDelay: Double) {
filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained
let vQuery = query
let vSort = sortDescriptor
let vRecords = model.allRecords
let vMax = kMaxVisibleRows
let vDelayNanos = UInt64(inDelay * 1_000_000_000)
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
if vDelayNanos > 0 {
try? await Task.sleep(nanoseconds: vDelayNanos)
}
if Task.isCancelled { return }
let vEngine = SearchEngine(inQuery: vQuery)
// the chunks run on GCD worker threads, which can't see this
// task's cancellation - relay it through a shared flag
let vCancelled = ManagedAtomicFlag()
let vResult = await withTaskCancellationHandler {
WindowSearchModel.matchingPositions(
inRecords: vRecords,
inEngine: vEngine,
inCancelled: vCancelled
)
} onCancel: {
vCancelled.set()
}
guard let vPositions = vResult else { return }
if Task.isCancelled { return }
let vTop = ResultSorter.top(
inRecords: vRecords,
inPositions: vPositions,
inLimit: vMax,
inDescriptor: vSort
)
if Task.isCancelled { return }
let vCount = vPositions.count
// hop back to main with DispatchQueue.main.async (rather than
// await MainActor.run) so the assignment is guaranteed to land
// on the next runloop tick, avoiding NSTableView reentrance when
// the search field is mid-edit
DispatchQueue.main.async {
guard let vSelf = self else { return }
if vSelf.matchCount != vCount { vSelf.matchCount = vCount }
// Skip the @Published fire when the resulting list is
// identical to what the Table is already showing. Full
// FileRecord equality catches mtime / size updates, so we
// only skip true no-op reassignments.
if vSelf.visibleRecords == vTop { return }
vSelf.visibleRecords = vTop
}
}
}
// positions of the records matching the engine, computed in parallel
// chunks. Returns nil if the task was cancelled midway.
private nonisolated static func matchingPositions(inRecords: [FileRecord],
inEngine: SearchEngine,
inCancelled: ManagedAtomicFlag) -> [Int32]? {
if !inEngine.isActive {
return (0..<Int32(inRecords.count)).map { $0 }
}
let vChunkCount = (inRecords.count + kChunkSize - 1) / kChunkSize
if vChunkCount == 0 { return [] }
var vChunks = [[Int32]](repeating: [], count: vChunkCount)
vChunks.withUnsafeMutableBufferPointer { vOut in
// each chunk writes only its own slot, so sharing is safe
nonisolated(unsafe) let vOutBase = vOut.baseAddress!
DispatchQueue.concurrentPerform(iterations: vChunkCount) { vChunk in
if inCancelled.isSet { return }
let vStart = vChunk * kChunkSize
let vEnd = min(vStart + kChunkSize, inRecords.count)
var vLocal: [Int32] = []
for vI in vStart..<vEnd where inEngine.match(inRecord: inRecords[vI]) {
vLocal.append(Int32(vI))
}
(vOutBase + vChunk).pointee = vLocal
}
}
if inCancelled.isSet { return nil }
return Array(vChunks.joined())
}
}
// Minimal thread-safe boolean used to stop parallel filter chunks early.
private final class ManagedAtomicFlag: @unchecked Sendable {
// current value
private var value = false
// guards value
private let lock = NSLock()
// true once set() has been called
var isSet: Bool {
lock.lock()
defer { lock.unlock() }
return value
}
// raises the flag
func set() {
lock.lock()
value = true
lock.unlock()
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
import Testing
@testable import Allofit
// Tests for the index bookkeeping rules (IndexState), stable ids, the
// top-N result selection and the cache file round trip.
@Suite("IndexState")
struct IndexStateTests {
// shorthand for a file record at an absolute path
static func file(_ inPath: String, size inSize: Int64 = 0) -> FileRecord {
let vUrl = URL(fileURLWithPath: inPath)
return FileRecord(
name: vUrl.lastPathComponent,
parentPath: vUrl.deletingLastPathComponent().path,
size: inSize,
dateCreated: Date(timeIntervalSince1970: 1),
dateModified: Date(timeIntervalSince1970: 1),
isDirectory: false
)
}
// shorthand for a directory record at an absolute path
static func dir(_ inPath: String) -> FileRecord {
let vUrl = URL(fileURLWithPath: inPath)
return FileRecord(
name: vUrl.lastPathComponent,
parentPath: vUrl.deletingLastPathComponent().path,
size: 0,
dateCreated: Date(timeIntervalSince1970: 1),
dateModified: Date(timeIntervalSince1970: 1),
isDirectory: true
)
}
// sorted full paths of the state, for readable comparisons
static func paths(_ inState: IndexState) -> [String] {
return inState.records.map { $0.fullPath }.sorted()
}
// a small tree rooted at /r
static func sampleState() -> IndexState {
return IndexState(inRecords: [
dir("/r"),
dir("/r/a"),
file("/r/a/1.txt"),
dir("/r/a/b"),
file("/r/a/b/2.txt"),
file("/r/c.txt")
])
}
@Test func idIsStableAndMatchesPathHash() {
let vRecord = Self.file("/Users/me/x.txt")
#expect(vRecord.id == FileRecord.pathHash("/Users/me/x.txt"))
#expect(FileRecord.pathHash(inParent: "/", inName: "etc") == FileRecord.pathHash("/etc"))
}
@Test func duplicatesAreDropped() {
let vState = IndexState(inRecords: [Self.file("/r/x"), Self.file("/r/x")])
#expect(vState.count == 1)
}
@Test func removingADirectoryRemovesItsSubtree() {
var vState = Self.sampleState()
let vRemoved = vState.removeSubtrees(inPaths: ["/r/a"])
#expect(vRemoved == 4)
#expect(Self.paths(vState) == ["/r", "/r/c.txt"])
// lookup stays consistent after swap-removal
for (vPos, vRecord) in vState.records.enumerated() {
#expect(vState.positions[vRecord.id] == vPos)
}
}
@Test func siblingWithSharedPrefixIsKept() {
var vState = IndexState(inRecords: [Self.dir("/r"), Self.dir("/r/a"), Self.file("/r/a/x"), Self.file("/r/ab")])
vState.removeSubtrees(inPaths: ["/r/a"])
#expect(Self.paths(vState) == ["/r", "/r/ab"])
}
@Test func applyRequiresAnIndexedParentAndRescansNewFolders() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [
Self.file("/r/a/new.txt"), // parent indexed: accepted
Self.file("/r/hidden/x.txt"), // parent not indexed: dropped
Self.dir("/r/moved"), // new folder: accepted + rescanned
Self.file("/r/moved/inside.txt") // its parent arrives in the same batch
]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.changed)
#expect(vResult.rescans == ["/r/moved"])
#expect(vState.contains(inPath: "/r/a/new.txt"))
#expect(!vState.contains(inPath: "/r/hidden/x.txt"))
#expect(vState.contains(inPath: "/r/moved/inside.txt"))
}
@Test func renameRemovesOldSubtree() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.removals = ["/r/a"]
vChanges.upserts = [Self.dir("/r/renamed")]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(!vState.contains(inPath: "/r/a/b/2.txt"))
#expect(vResult.rescans == ["/r/renamed"])
}
@Test func unchangedUpsertIsNotAChange() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [Self.file("/r/c.txt")]
#expect(!vState.apply(inChanges: vChanges, inRoots: ["/r"]).changed)
}
@Test func pruneDropsOrphansExclusionsAndOldRoots() {
var vState = Self.sampleState()
// orphan: parent /r/hidden is not indexed
vState.upsert(Self.file("/r/hidden/x.txt"))
// leftover of a root that is no longer configured
vState.upsert(Self.dir("/old"))
vState.upsert(Self.file("/old/y.txt"))
let vRemoved = vState.prune(inRoots: ["/r"], inExclusions: ExclusionMatcher(inExclusions: ["/r/a/b"]))
#expect(vRemoved == 5)
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/1.txt", "/r/c.txt"])
}
@Test func pruneDropsHiddenEntries() {
var vState = Self.sampleState()
vState.upsert(Self.dir("/r/Library"))
vState.upsert(Self.file("/r/Library/deep.txt"))
vState.upsert(Self.dir("/r/a/.git"))
vState.upsert(Self.file("/r/a/.git/HEAD"))
let vRemoved = vState.prune(
inRoots: ["/r"],
inExclusions: ExclusionMatcher(inExclusions: []),
inIsHidden: { $0 == "/r/Library" }
)
#expect(vRemoved == 4)
#expect(Self.paths(vState) == Self.paths(Self.sampleState()))
}
@Test func composedAndDecomposedNamesShareAnId() {
let vComposed = Self.file("/r/Envoy\u{00E9}s.msf")
let vDecomposed = Self.file("/r/Envoye\u{0301}s.msf")
#expect(vComposed.id == vDecomposed.id)
#expect(IndexState(inRecords: [vComposed, vDecomposed]).count == 1)
#expect(FileRecord.pathHash("/r/Envoye\u{0301}s.msf") == vComposed.id)
}
@Test func replaceSubtreesSwapsContent() {
var vState = Self.sampleState()
vState.replaceSubtrees(inRoots: ["/r/a"], inRecords: [Self.dir("/r/a"), Self.file("/r/a/fresh.txt")])
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"])
}
@Test func minimalRootsDropsNestedPaths() {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
}
@Test func topSelectionMatchesFullSort() {
let vRecords = (0..<5000).map { Self.file("/r/f\($0 % 977)-\($0).txt", size: Int64(($0 * 7919) % 1013)) }
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
for vSort in FileSortDescriptor.allCases {
let vTop = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
let vFull = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
#expect(vTop.map(\.id) == Array(vFull.prefix(100)).map(\.id), "sort \(vSort.rawValue)")
}
}
@Test func cacheRoundTrip() throws {
let vUrl = FileManager.default.temporaryDirectory
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
defer { try? FileManager.default.removeItem(at: vUrl) }
let vRecords = Self.sampleState().records
IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
let vLoaded = try #require(IndexStore.load(from: vUrl))
#expect(vLoaded.lastEventId == 42)
#expect(vLoaded.records == vRecords)
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
import Testing
@testable import Allofit
// Tests for the Everything-style query syntax implemented by SearchEngine.
@Suite("SearchEngine")
struct SearchEngineTests {
// builds a record from an absolute path
static func record(_ inPath: String, isDirectory inIsDir: Bool = false) -> FileRecord {
let vUrl = URL(fileURLWithPath: inPath)
return FileRecord(
name: vUrl.lastPathComponent,
parentPath: vUrl.deletingLastPathComponent().path,
size: 0,
dateCreated: .distantPast,
dateModified: .distantPast,
isDirectory: inIsDir
)
}
// true when the query matches the record at inPath
static func matches(_ inQuery: String, _ inPath: String, isDirectory inIsDir: Bool = false) -> Bool {
return SearchEngine(inQuery: inQuery).match(inRecord: record(inPath, isDirectory: inIsDir))
}
@Test func emptyQueryIsInactive() {
#expect(!SearchEngine(inQuery: " ").isActive)
#expect(SearchEngine(inQuery: "a").isActive)
}
@Test func substringIsCaseInsensitive() {
#expect(Self.matches("report", "/d/Annual REPORT 2024.pdf"))
#expect(!Self.matches("report", "/d/summary.pdf"))
}
@Test func readmeExamples() {
#expect(Self.matches("Start*.pdf", "/d/StartHere.pdf"))
#expect(!Self.matches("Start*.pdf", "/d/Restart.pdf"))
#expect(Self.matches("IMG_????.heic", "/d/IMG_1234.heic"))
#expect(!Self.matches("IMG_????.heic", "/d/IMG_12345.heic"))
#expect(Self.matches("*.png | *.jpg", "/d/a.jpg"))
#expect(Self.matches("*.png|*.jpg", "/d/a.png"))
#expect(!Self.matches("*.png | *.jpg", "/d/a.gif"))
}
@Test func spaceIsAnd() {
#expect(Self.matches("app apk", "/d/myapp-release.apk"))
#expect(!Self.matches("app apk", "/d/myapp-release.zip"))
}
@Test func orBindsTighterThanAnd() {
// a AND (b OR c)
#expect(Self.matches("osm *.apk|*.aab", "/d/osm-1.aab"))
#expect(!Self.matches("osm *.apk|*.aab", "/d/other.aab"))
}
@Test func notAndQuotes() {
#expect(Self.matches("report !draft", "/d/report-final.pdf"))
#expect(!Self.matches("report !draft", "/d/report-draft.pdf"))
#expect(Self.matches("\"my file\"", "/d/this is my file.txt"))
#expect(!Self.matches("\"my file\"", "/d/my other file.txt"))
// wildcards still apply inside quotes
#expect(Self.matches("\"*app*\" *.apk", "/d/osmapp.apk"))
}
@Test func extensionAndKindModifiers() {
#expect(Self.matches("ext:pdf;docx", "/d/a.DOCX"))
#expect(!Self.matches("ext:pdf;docx", "/d/a.doc"))
#expect(Self.matches("folder:build", "/d/build", isDirectory: true))
#expect(!Self.matches("folder:build", "/d/build.gradle"))
#expect(Self.matches("file: build", "/d/build.gradle"))
#expect(!Self.matches("file:", "/d/src", isDirectory: true))
}
@Test func pathSubstring() {
#expect(Self.matches("sources/main", "/u/sources/main/app.swift"))
#expect(!Self.matches("sources/main", "/u/sources/test/app.swift"))
}
@Test func pathGlobWithDoubleStar() {
let vQuery = "\"some/folder/**/path\" IMG_????.heic"
#expect(Self.matches(vQuery, "/Users/me/some/folder/a/b/path/IMG_0001.heic"))
// ** also matches zero folders
#expect(Self.matches(vQuery, "/Users/me/some/folder/path/IMG_0001.heic"))
// the name term still applies
#expect(!Self.matches(vQuery, "/Users/me/some/folder/a/path/IMG_01.heic"))
// folder names must match whole: "paths" is not "path"
#expect(!Self.matches(vQuery, "/Users/me/some/folder/a/paths/IMG_0001.heic"))
// must start on a folder boundary: "awesome" is not "some"
#expect(!Self.matches(vQuery, "/Users/me/awesome/folder/a/path/IMG_0001.heic"))
}
@Test func pathGlobSingleStarStaysInOneFolder() {
#expect(Self.matches("src/*/build/", "/p/src/app/build/out.o"))
#expect(!Self.matches("src/*/build/", "/p/src/app/sub/build/out.o"))
#expect(Self.matches("/p/src/**/*.o", "/p/src/a/b/out.o"))
#expect(!Self.matches("/src/**/*.o", "/p/src/a/b/out.o"))
}
@Test func unicodeNormalizationAndCase() {
// name stored decomposed (NFD), query typed composed (NFC)
let vDecomposed = "Re\u{0301}sume\u{0301}.pdf"
#expect(Self.matches("RÉSUMÉ", "/d/" + vDecomposed))
#expect(Self.matches("r?sum?.pdf", "/d/" + vDecomposed))
}
}