11 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
Bitsy c68b05ab9a Refactor file structure and implement WindowSearchModel for independent window searches 2026-06-18 00:06:06 +02:00
Bitsy 3e67da4634 Optimize memory management: add autorelease pools to reduce memory accumulation during file scanning and indexing 2026-06-16 21:15:48 +02:00
Bitsy bac2ca0287 Cleanup and remove make placeholder icon 2026-06-16 21:00:39 +02:00
Bitsy 9292c3ee29 Enhance service management: add start/stop functionality, improve daemon binary handling, and optimize memory usage 2026-06-16 14:41:58 +02:00
Bitsy cc386bef67 Add inline preview pane, spacebar Quick Look, sudo-elevated access
Inline preview pane

  PreviewPane wraps Quartz's QLPreviewView via NSViewRepresentable so
  the same renderer that powers the floating QLPreviewPanel also
  renders inline. ContentView layout switched to HSplitView (table
  on the left, preview pane on the right) with a metadata footer
  under the preview showing name / parent path / size / mtime.

  Toolbar gains a sidebar.right toggle to hide/show the pane; state
  persists across launches via @AppStorage("Allofit.showPreviewPane").
  Default window size bumped to 1100x640 so both panes fit on first
  launch.

Spacebar -> Quick Look

  .onKeyPress(.space) attached to the Table opens the floating
  QLPreviewPanel for the current selection. .onKeyPress is focus-
  scoped, so typing a literal space into the search field continues
  to work.

Elevated access for root-only files

  When the index includes files the GUI user can't read (root daemon
  with FDA indexed another user's home, /var/db, etc.), the preview
  pane becomes a single big tap-target showing a lock icon + "Click
  to authorize preview", and a small AuthorizeBadge appears at the
  right of the selected row when the pane is closed.

  Clicking either runs ElevatedAccess.stage() which sudo cp's the
  file into ~/Library/Caches/Allofit/elevated/, chowns it to the
  current user and chmod 0644. AdminShell wraps the
  NSAppleScript-based admin invocation that we previously had only
  inlined in ServiceInstaller; the system caches the password
  prompt for ~5 minutes so successive authorizations are silent.
  AccessManager holds the staged-URL mapping as a @MainActor
  ObservableObject shared between ContentView and PreviewPane, so
  the badge disappears and the preview switches to the staged copy
  as soon as the cp lands. Quick Look and Open now route through
  AccessManager.effectiveURL(for:) so the user-readable staged copy
  is used when available; Reveal in Finder and Copy Path keep using
  the original path. ElevatedAccess.cleanup() wipes the staging dir
  on app launch and on applicationWillTerminate so privileged
  copies don't accumulate across sessions.

Other tidies

  * Formatters.swift consolidates byte-count + date helpers used by
    both Table columns and the preview pane footer.
  * StatusBarView extracted from ContentView so @Published refreshes
    update only the leaf view rather than the Table closure scope.
  * ServiceInstaller's runWithAdminPrivileges and shellQuote now
    delegate to AdminShell so admin escalation has one definition.
2026-06-15 17:02:53 +02:00
47 changed files with 3570 additions and 1163 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
-177
View File
@@ -1,177 +0,0 @@
import Foundation
import CoreServices
// 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.
let vStartId = UInt64(FSEventsGetCurrentEventId())
for vRoot in vRoots {
NSLog("[Allofit] scanning %@", vRoot.path)
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
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
var vRemovedCount = 0
for vChange in vChanges {
if vMatcher.isExcluded(inPath: vChange.path) { continue }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
continue
}
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 let vIdx = vState.pathIndex[vChange.path] {
vState.records.remove(at: vIdx)
vState.pathIndex.removeAll(keepingCapacity: true)
for (vI, vR) in vState.records.enumerated() {
vState.pathIndex[vR.fullPath] = vI
}
vRemovedCount += 1
}
}
if vAdded + vUpdated + vRemovedCount > 0 {
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
vAdded, vUpdated, vRemovedCount, 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)
}
vState.pathIndex.removeAll(keepingCapacity: true)
for (vI, vR) in vState.records.enumerated() {
vState.pathIndex[vR.fullPath] = vI
}
}
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.
DispatchQueue.global(qos: .utility).async {
while true {
sleep(3)
vState.lock.lock()
let vShouldSave = vState.dirty
let vSnapshot = vState.records
vState.dirty = false
vState.lock.unlock()
if vShouldSave {
NSLog("[Allofit] autosaving %d records", vSnapshot.count)
IndexStore.save(
inRecords: vSnapshot,
inLastEventId: vWatcher.latestEventId
)
}
}
}
// block forever on the runloop so launchd keeps us alive
RunLoop.current.run()
exit(0)
}
}
-33
View File
@@ -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
}
}
-129
View File
@@ -1,129 +0,0 @@
import Foundation
// FileIndexer walks a filesystem root and emits lightweight FileRecord entries.
// It is a stateless utility so it can be invoked safely from any thread.
//
// Pre-fetching a fixed set of URLResourceKey values lets FileManager batch the
// attribute reads through getattrlistbulk under the hood, which is the macOS
// equivalent of the bulk MFT scan that makes voidtools Everything so fast.
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
]
// 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.
static func walkRoot(inRoot: URL,
inExclusions: ExclusionMatcher? = nil,
inBatchSize: Int = 2000,
inBatch: (_ inRecords: [FileRecord]) -> Void) {
// exclude the root itself
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: inRoot.path) {
return
}
guard let vEnumerator = FileManager.default.enumerator(
at: inRoot,
includingPropertiesForKeys: kPrefetchKeys,
options: [.skipsHiddenFiles, .skipsPackageDescendants],
errorHandler: { (_, _) in true }
) else {
return
}
var vBatch: [FileRecord] = []
vBatch.reserveCapacity(inBatchSize)
// include the root directory itself in the first batch
if let vRootRecord = makeRecord(inURL: inRoot) {
vBatch.append(vRootRecord)
}
for vCase in vEnumerator {
guard let vURL = vCase as? URL else { continue }
// short-circuit excluded entries (and don't descend into them)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() }
continue
}
if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
}
}
}
if !vBatch.isEmpty {
inBatch(vBatch)
}
}
// 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.
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
vRecords.append(contentsOf: vBatch)
let vNow = Date()
if vNow.timeIntervalSince(vLastReport) >= kReportInterval {
inProgress?(vRecords.count)
vLastReport = vNow
}
}
inProgress?(vRecords.count)
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.
static func makeRecord(inURL: URL) -> FileRecord? {
var vUrl = inURL
vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) 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()))
return FileRecord(
id: vId,
name: vName,
parentPath: vParent,
size: vSize,
dateCreated: vCreated,
dateModified: vModified,
isDirectory: vIsDir
)
}
}
-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 }
}
-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()
}
}
}
-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
}
}
View File
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env swift
//
// Generates a 1024x1024 placeholder app icon at icons/icon.png so build-app.sh
// has something to bake into Allofit.app. Designed to be "Icon Composer ready"
// - the output is a single flat 1024x1024 PNG, which is exactly what Apple's
// Icon Composer (macOS 26+) takes as a base layer when you want to refine
// the icon with Liquid Glass effects.
//
// Run from the project root:
// swift make-placeholder-icon.swift
// ./build-app.sh # picks up icons/icon.png automatically
//
// Tweak the colors / shape constants below and re-run to iterate.
import AppKit
import CoreGraphics
import ImageIO
import Foundation
// ==================
// MARK: Tunables
// ==================
// Final image side length (px). 1024 is the largest slot the macOS iconset
// asks for so it works for every output size after sips downscales.
let kSizePixels: Int = 1024
// Apple's rounded-square corner radius is ~22.37% of side (classic) or
// ~25% (macOS 26 "Tahoe"). 22.37% is a safe middle ground.
let kCornerFactor: CGFloat = 0.2237
// Gradient stops (top-left to bottom-right): blue → teal
let kGradientStart = CGColor(srgbRed: 0.00, green: 0.48, blue: 1.00, alpha: 1)
let kGradientEnd = CGColor(srgbRed: 0.13, green: 0.71, blue: 0.92, alpha: 1)
// Magnifying glass styling (in normalised 0..1 of the canvas)
let kGlassCenterX: CGFloat = 0.42
let kGlassCenterY: CGFloat = 0.58
let kGlassRadius: CGFloat = 0.20
let kHandleLength: CGFloat = 0.22
let kStrokeWidth: CGFloat = 0.06
// Output relative path
let kOutputPath = "icons/icon.png"
// ==================
// MARK: Draw
// ==================
let vSize = CGFloat(kSizePixels)
guard let vColorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
fputs("could not create sRGB color space\n", stderr); exit(1)
}
guard let ctx = CGContext(
data: nil,
width: kSizePixels,
height: kSizePixels,
bitsPerComponent: 8,
bytesPerRow: 0,
space: vColorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
fputs("could not create CG context\n", stderr); exit(2)
}
// Rounded-square mask matching Apple's app icon silhouette
let vRect = CGRect(x: 0, y: 0, width: vSize, height: vSize)
let vCornerRadius = vSize * kCornerFactor
let vMaskPath = CGPath(roundedRect: vRect,
cornerWidth: vCornerRadius,
cornerHeight: vCornerRadius,
transform: nil)
// Gradient background, clipped to the rounded square
ctx.saveGState()
ctx.addPath(vMaskPath)
ctx.clip()
let vGradient = CGGradient(
colorsSpace: vColorSpace,
colors: [kGradientStart, kGradientEnd] as CFArray,
locations: [0.0, 1.0]
)!
ctx.drawLinearGradient(vGradient,
start: CGPoint(x: 0, y: vSize),
end: CGPoint(x: vSize, y: 0),
options: [])
ctx.restoreGState()
// White magnifying glass on top
ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
ctx.setLineWidth(vSize * kStrokeWidth)
ctx.setLineCap(.round)
let vGlassCenter = CGPoint(x: vSize * kGlassCenterX, y: vSize * kGlassCenterY)
let vGlassR = vSize * kGlassRadius
ctx.strokeEllipse(in: CGRect(
x: vGlassCenter.x - vGlassR,
y: vGlassCenter.y - vGlassR,
width: vGlassR * 2,
height: vGlassR * 2
))
// Handle: from the lower-right edge of the glass, going down-right at -45°
let vAngle: CGFloat = -.pi / 4
let vHandleStart = CGPoint(
x: vGlassCenter.x + vGlassR * cos(vAngle),
y: vGlassCenter.y + vGlassR * sin(vAngle)
)
let vHandleLen = vSize * kHandleLength
let vHandleEnd = CGPoint(
x: vHandleStart.x + vHandleLen * cos(vAngle),
y: vHandleStart.y + vHandleLen * sin(vAngle)
)
ctx.move(to: vHandleStart)
ctx.addLine(to: vHandleEnd)
ctx.strokePath()
// ==================
// MARK: Write PNG
// ==================
guard let vCgImage = ctx.makeImage() else {
fputs("could not create CGImage from context\n", stderr); exit(3)
}
try? FileManager.default.createDirectory(
atPath: (kOutputPath as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
let vOutputURL = URL(fileURLWithPath: kOutputPath)
guard let vDest = CGImageDestinationCreateWithURL(
vOutputURL as CFURL,
"public.png" as CFString,
1,
nil
) else {
fputs("could not create image destination\n", stderr); exit(4)
}
CGImageDestinationAddImage(vDest, vCgImage, nil)
guard CGImageDestinationFinalize(vDest) else {
fputs("PNG finalize failed\n", stderr); exit(5)
}
print("Wrote \(kOutputPath) (\(kSizePixels)x\(kSizePixels))")
+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
+19 -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
@@ -104,7 +107,11 @@ if [[ -f "$kSystemDaemonPlist" ]]; then
fi
echo "==> Killing any leftover daemon processes"
vRun pkill -f "Allofit --service" >/dev/null 2>&1 || true
# `pkill -f` matches against the full command line; the daemon binary is
# 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
# ==================
# MARK: Cache files
@@ -168,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
}
}
+179
View File
@@ -0,0 +1,179 @@
import Foundation
// FileIndexer walks a filesystem root and emits lightweight FileRecord entries.
// It is a stateless utility so it can be invoked safely from any thread.
//
// Pre-fetching a fixed set of URLResourceKey values lets FileManager batch the
// attribute reads through getattrlistbulk under the hood, which is the macOS
// equivalent of the bulk MFT scan that makes voidtools Everything so fast.
enum FileIndexer {
// the set of resource keys we ask for up front so they are pre-fetched in bulk
static let kPrefetchKeys: [URLResourceKey] = [
.isDirectoryKey,
.fileSizeKey,
.creationDateKey,
.contentModificationDateKey,
.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, as are hidden entries and package contents.
static func walkRoot(inRoot: URL,
inExclusions: ExclusionMatcher? = nil,
inBatchSize: Int = 2000,
inBatch: (_ inRecords: [FileRecord]) -> Void) {
// exclude the root itself
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: inRoot.path) {
return
}
guard let vEnumerator = FileManager.default.enumerator(
at: inRoot,
includingPropertiesForKeys: kPrefetchKeys,
options: [.skipsHiddenFiles, .skipsPackageDescendants],
errorHandler: { (_, _) in true }
) else {
return
}
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, inInterner: vInterner) {
vBatch.append(vRootRecord)
}
// Per-iteration autoreleasepool: every URL pulled from the
// enumerator + every resourceValues() read autoreleases an
// NSURL / NSDate / NSNumber. Without this drain a million-file
// walk would let those accumulate into hundreds of MB of dead
// allocations until the enclosing async block exited - and the
// daemon's enclosing block never exits.
for vCase in vEnumerator {
autoreleasepool {
guard let vURL = vCase as? URL else { return }
// short-circuit excluded entries (and don't descend into them)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() }
return
}
if let vRecord = makeRecord(inURL: vURL, inInterner: vInterner, inFresh: false) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
}
}
}
}
if !vBatch.isEmpty {
inBatch(vBatch)
}
}
// 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 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] = []
var vLastReport = Date.distantPast
let kReportInterval: TimeInterval = 0.25
walkRoot(inRoot: inRoot, inExclusions: inExclusions) { vBatch in
vRecords.append(contentsOf: vBatch)
let vNow = Date()
if vNow.timeIntervalSince(vLastReport) >= kReportInterval {
inProgress?(vRecords.count)
vLastReport = vNow
}
}
inProgress?(vRecords.count)
return vRecords
}
// 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
if inFresh {
vUrl.removeAllCachedResourceValues()
}
guard let vValues = try? vUrl.resourceValues(forKeys: kPrefetchKeySet) else {
return nil
}
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(
name: vName,
parentPath: vParent,
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
}
}
@@ -91,8 +91,18 @@ enum IndexStore {
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
}
// writes the records and event id atomically to a specific URL
// writes the records and event id atomically to a specific URL.
// Wrapped in autoreleasepool because every call autoreleases a number
// of Foundation objects (the compressed NSData, NSURLs created by
// FileManager.replaceItem, etc.) - if the caller is a long-running
// block whose own pool never drains, those would accumulate forever.
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
autoreleasepool {
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
}
}
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId)
@@ -151,8 +161,16 @@ enum IndexStore {
return load(from: cacheURL)
}
// reads the persisted index back from a specific URL
// reads the persisted index back from a specific URL. Wrapped in
// autoreleasepool so the decompressed NSData and the per-record
// String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool {
load_impl(from: inUrl)
}
}
private static func load_impl(from inUrl: URL) -> LoadResult? {
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
var vOffset = 0
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
@@ -171,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),
@@ -185,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),
@@ -262,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) }
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
import AppKit
// AdminShell runs short shell commands with administrator privileges by
// wrapping them in `do shell script ... with administrator privileges`
// via NSAppleScript. The system shows its native password prompt the
// first time within a session; subsequent calls inside the auth-cache
// window (about 5 minutes) re-use the credential without re-prompting.
//
// Used by both ServiceInstaller (LaunchDaemon install/uninstall and
// cache-clear-and-restart) and ElevatedAccess (sudo cp of a single
// unreadable file into the per-user staging cache).
enum AdminShell {
// surfaces an AppleScript failure - typically the user clicked
// Cancel on the password prompt, or the embedded shell command
// returned a non-zero exit code
enum Error: Swift.Error, LocalizedError {
case scriptFailed(String)
var errorDescription: String? {
switch self {
case .scriptFailed(let vMsg): return vMsg
}
}
}
// runs inScript as root via NSAppleScript. Returns the script's
// stdout. Throws Error.scriptFailed if NSAppleScript reports an
// error (cancelled prompt, non-zero shell exit, etc).
@discardableResult
static func run(_ inScript: String) throws -> String {
// AppleScript string literal needs backslashes and double quotes
// escaped before we embed the shell command
let vEscaped = inScript
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let vSource = "do shell script \"\(vEscaped)\" with administrator privileges"
let vAppleScript = NSAppleScript(source: vSource)
var vErr: NSDictionary?
let vResult = vAppleScript?.executeAndReturnError(&vErr)
guard let vDescriptor = vResult else {
let vMessage = vErr?[NSAppleScript.errorMessage] as? String
?? "Authorization cancelled or failed"
throw Error.scriptFailed(vMessage)
}
return vDescriptor.stringValue ?? ""
}
// POSIX-style single-quote escape so a string can be safely embedded
// inside the inScript argument of run(_:). Each embedded single
// quote becomes the escape sequence '\''. Use for any user-supplied
// path or argument; literal command names should not be quoted.
static func quote(_ inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
+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.
""")
}
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
// ElevatedAccess provides on-demand sudo-backed access to files the GUI
// user can't read directly. Common case: the root LaunchDaemon indexed
// `/Users/<otheruser>/...` (it has Full Disk Access), the GUI runs as
// the current user, and trying to render an inline preview hits a
// permission denial. The user clicks "Authorize" in the preview pane,
// AdminShell prompts for the password once, sudo copies the file to
// the per-user staging directory and chowns it to the GUI user.
//
// The staged copy is owned by the GUI user, lives in
// ~/Library/Caches/Allofit/elevated/
// and is wiped at app launch and at app quit so privileged copies don't
// linger on disk across sessions.
enum ElevatedAccess {
// per-user staging directory; lives under Library/Caches so macOS
// itself may purge it under disk-pressure, and our own cleanup() at
// launch + terminate keeps it from accumulating
static var stagingDirectory: URL {
let vCaches = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!
return vCaches.appendingPathComponent("Allofit/elevated", isDirectory: true)
}
// true if the current user can read the file at inPath without elevation
static func canRead(path inPath: String) -> Bool {
return FileManager.default.isReadableFile(atPath: inPath)
}
// wipes anything in the staging directory. Called on app launch (so a
// previous session's elevated copies don't survive a relaunch) and on
// app terminate (so they don't survive a clean quit either). Failure
// is silent - if cleanup fails the next launch's cleanup will retry.
static func cleanup() {
try? FileManager.default.removeItem(at: stagingDirectory)
}
// copies inUrl into the staging directory via sudo, chowns it to the
// current user, and returns the staged URL. Caller is responsible for
// catching AdminShell.Error.scriptFailed (cancelled prompt etc).
//
// Throws if the parent staging directory can't be created or the
// admin script fails. Side effect: the system prompts for password
// the first time within the auth-cache window.
static func stage(_ inUrl: URL) throws -> URL {
let vDir = stagingDirectory
// create as the current user so the dir is owned by us; sudo
// only handles the file copy itself
try FileManager.default.createDirectory(
at: vDir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
var vDst = vDir.appendingPathComponent(UUID().uuidString)
let vExt = inUrl.pathExtension
if !vExt.isEmpty {
vDst.appendPathExtension(vExt)
}
// cp + chown to the current user. The chmod restores plain user
// rw / group+other r so the file is treated normally by QL etc.
let vScript = """
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \
chmod 0644 \(AdminShell.quote(vDst.path))
"""
_ = try AdminShell.run(vScript)
return vDst
}
}
@@ -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)
@@ -32,13 +32,36 @@ enum ServiceInstaller {
}
}
// installs (or replaces) the launchd plist for the given scope
// installs (or replaces) the launchd plist for the given scope. Also
// 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 {
let vBinary = try resolveBinaryPath()
let vPlistData = try makePlistData(inBinary: vBinary, inScope: inScope)
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
let vVersionFile = vDaemonBinary + ".version"
let vVersionString = bundleVersion()
let vPlistData = try makePlistData(inBinary: vDaemonBinary, inScope: inScope)
switch inScope {
case .userAgent:
// user agent install: filesystem ops as the current user,
// no sudo needed for either the binary copy or the plist
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(
atPath: vDaemonDir,
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(atPath: vDaemonBinary)
try FileManager.default.copyItem(atPath: vBinary, toPath: vDaemonBinary)
// sidecar .version file - lets the GUI show which build is
// installed without having to run the copied binary
try? vVersionString.write(
toFile: vVersionFile,
atomically: true,
encoding: .utf8
)
let vTarget = userAgentPath()
try? FileManager.default.createDirectory(
at: vTarget.deletingLastPathComponent(),
@@ -52,12 +75,23 @@ enum ServiceInstaller {
}
case .rootDaemon:
// root daemon install: binary copy + plist install + boot
// happen inside a single admin-priv script so the user
// gets ONE password prompt covering all of it
let vTmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(kLabel).plist")
try vPlistData.write(to: vTmp, options: .atomic)
let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
let vScript = """
cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
mkdir -p \(shellQuote(inString: vDaemonDir)) \
&& rm -f \(shellQuote(inString: vDaemonBinary)) \
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \
&& chmod 644 \(shellQuote(inString: vVersionFile)) \
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
@@ -67,17 +101,25 @@ enum ServiceInstaller {
}
}
// removes the launchd plist for the given scope
// removes the launchd plist AND the renamed binary copy AND the
// sidecar .version file for the given scope
static func uninstall(inScope: Scope) throws {
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
let vVersionFile = vDaemonBinary + ".version"
switch inScope {
case .userAgent:
let vTarget = userAgentPath()
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
try? FileManager.default.removeItem(at: vTarget)
try? FileManager.default.removeItem(atPath: vDaemonBinary)
try? FileManager.default.removeItem(atPath: vVersionFile)
case .rootDaemon:
let vTarget = rootDaemonPath()
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null ; rm -f \(shellQuote(inString: vTarget.path))"
let vScript = """
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile))
"""
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -90,6 +132,74 @@ enum ServiceInstaller {
}
}
// stops the running daemon for the given scope without uninstalling.
// The plist file stays on disk so a later start() (or app relaunch
// since RunAtLoad=true) brings it back. Use cases: temporary disable,
// freeing FSEvents resources, or pre-flight before a manual reindex.
static func stop(inScope: Scope) throws {
switch inScope {
case .userAgent:
let vPlist = userAgentPath().path
let vResult = runLaunchctl(inArgs: ["unload", vPlist])
if vResult.exitCode != 0 {
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
// starts a previously-installed but currently-stopped daemon.
static func start(inScope: Scope) throws {
switch inScope {
case .userAgent:
let vPlist = userAgentPath().path
let vResult = runLaunchctl(inArgs: ["load", "-w", vPlist])
if vResult.exitCode != 0 {
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
// true if the daemon process for the given scope is currently alive.
// We check by reading the indexer lock file's PID and probing with
// kill(pid, 0) - cheaper and non-privileged compared to launchctl.
static func isRunning(inScope: Scope) -> Bool {
let vSystem = (inScope == .rootDaemon)
let vLockPath = IndexStore.lockURL(forSystem: vSystem).path
guard let vPid = IndexerLock.readHolderPid(path: vLockPath) else { return false }
// signal 0 = check-only; EPERM means "process exists but we lack
// permission to signal it", which is still "alive"
let vRc = kill(vPid, 0)
if vRc == 0 { return true }
if vRc == -1 && errno == EPERM { return true }
return false
}
// returns the version stamped into the installed daemon binary's
// sidecar .version file (written at install time). nil if the
// service isn't installed or the sidecar is missing/corrupt.
static func installedVersion(inScope: Scope) -> String? {
let vPath = daemonBinaryPath(inScope: inScope) + ".version"
guard let vText = try? String(contentsOfFile: vPath, encoding: .utf8) else {
return nil
}
let vTrimmed = vText.trimmingCharacters(in: .whitespacesAndNewlines)
return vTrimmed.isEmpty ? nil : vTrimmed
}
// returns the version of the running .app bundle (what would be
// installed if the user clicks Install right now)
static func bundleVersion() -> String {
return (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?"
}
// stops the service, removes the cache file, and starts the service again
// as a single privileged operation. Necessary for the root daemon because
// the cache file is owned by root and the daemon would otherwise just
@@ -125,6 +235,21 @@ enum ServiceInstaller {
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
}
// Filesystem path where the daemon's binary is installed. We use a
// 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.
static func daemonBinaryPath(inScope: Scope) -> String {
switch inScope {
case .userAgent:
return NSHomeDirectory()
+ "/Library/Application Support/Allofit/Allofit Service"
case .rootDaemon:
return "/Library/Application Support/Allofit/Allofit Service"
}
}
// resolves an absolute path to the currently running binary
private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0]
@@ -188,24 +313,20 @@ enum ServiceInstaller {
return (vProcess.terminationStatus, vOutStr, vErrStr)
}
// runs a shell script with administrator privileges through AppleScript;
// the system shows the standard password prompt the first time
// 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.
private static func runWithAdminPrivileges(inScript: String) throws {
let vEscaped = inScript
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let vAppleScriptSource = "do shell script \"\(vEscaped)\" with administrator privileges"
let vScript = NSAppleScript(source: vAppleScriptSource)
var vErr: NSDictionary?
let vResult = vScript?.executeAndReturnError(&vErr)
if vResult == nil {
let vMessage = vErr?[NSAppleScript.errorMessage] as? String ?? "unknown AppleScript error"
throw InstallError.authorizationFailed(vMessage)
do {
_ = try AdminShell.run(inScript)
} catch let vErr as AdminShell.Error {
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
}
}
// minimal POSIX-style single-quote escape
// shell-quote helper, delegating to the shared AdminShell quoter so
// both call sites use the same escaping rules
private static func shellQuote(inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'"
return AdminShell.quote(inString)
}
}
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import SwiftUI
// AccessManager holds the in-memory mapping from FileRecord.ID to the
// staged user-readable copy produced by an "Authorize" tap. Both the
// Table (which shows a lock badge on the selected row when the preview
// pane is closed) and the PreviewPane (which gates the QLPreviewView
// behind the same badge) observe this so the badge disappears and the
// preview switches to the staged URL as soon as the sudo copy lands.
//
// All published mutations happen on the main actor; the actual sudo cp
// runs inside a Task.detached spawned by `authorize(_:)` so the AppleScript
// password prompt doesn't block the main runloop.
@MainActor
final class AccessManager: ObservableObject {
// id -> URL of the user-readable copy in ~/Library/Caches/Allofit/elevated
@Published private(set) var stagedURLs: [FileRecord.ID: URL] = [:]
// ids currently being authorized (admin script in flight); used to
// render a small spinner inside the lock badge
@Published private(set) var authorizingIds: Set<FileRecord.ID> = []
// most recent authorization error (cancelled prompt, sudo failure,
// etc); surfaced as the badge's accessibility / tooltip text
@Published private(set) var lastError: String?
// returns the URL to use when previewing / opening the file: the
// staged copy if we have one, otherwise the original path
func effectiveURL(for inRecord: FileRecord) -> URL {
if let vStaged = stagedURLs[inRecord.id] {
return vStaged
}
return URL(fileURLWithPath: inRecord.fullPath)
}
// true if the effective URL (staged or original) isn't readable by
// the current user - this is what drives the lock-badge visibility
func needsAuthorization(for inRecord: FileRecord) -> Bool {
let vUrl = effectiveURL(for: inRecord)
return !ElevatedAccess.canRead(path: vUrl.path)
}
// true while an authorize task is in flight for the given record id
func isAuthorizing(_ inId: FileRecord.ID) -> Bool {
return authorizingIds.contains(inId)
}
// runs the sudo cp + chown flow for one record. The first call inside
// the system's admin-auth-cache window (~5 min) prompts for the
// password; subsequent calls within that window are silent.
func authorize(_ inRecord: FileRecord) async {
let vId = inRecord.id
// idempotency: a double-tap on the badge shouldn't kick off two
// concurrent admin scripts for the same file
guard !authorizingIds.contains(vId) else { return }
authorizingIds.insert(vId)
lastError = nil
let vOriginalUrl = URL(fileURLWithPath: inRecord.fullPath)
do {
// Task.detached so the synchronous NSAppleScript admin prompt
// runs on a background thread; the prompt itself is shown on
// main by AppKit regardless of where we invoke it from
let vStaged = try await Task.detached(priority: .userInitiated) {
try ElevatedAccess.stage(vOriginalUrl)
}.value
stagedURLs[vId] = vStaged
} catch {
lastError = error.localizedDescription
}
authorizingIds.remove(vId)
}
// wipes the in-memory mapping. Called after ElevatedAccess.cleanup()
// removes the on-disk files so the two stay consistent.
func reset() {
stagedURLs.removeAll()
authorizingIds.removeAll()
lastError = nil
}
}
@@ -8,18 +8,22 @@ 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.
@StateObject private var access = AccessManager()
var body: some Scene {
WindowGroup("Allofit") {
ContentView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
AllofitWindowContent(model: model, access: access)
}
.windowToolbarStyle(.unified)
.defaultSize(width: 1100, height: 640)
.commands {
// custom About panel with a clickable repo link in the credits
CommandGroup(replacing: .appInfo) {
@@ -31,7 +35,11 @@ struct AllofitApp: App {
}
.keyboardShortcut("r", modifiers: [.command])
}
CommandGroup(replacing: .newItem) { }
// SwiftUI provides File > New Window (⌘N) automatically for a
// WindowGroup; nothing to add here. Additional windows share the
// AppModel/AccessManager StateObjects defined above, so the index
// (and current search/sort) stays in sync across them - only the
// per-window selection / column-customization differ.
// ⌘F focuses the search field. Standard Find-style shortcut.
// SearchField's Coordinator observes the notification and calls
// makeFirstResponder on its underlying NSSearchField.
@@ -46,10 +54,50 @@ struct AllofitApp: App {
SettingsView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.environmentObject(access)
}
}
}
// AllofitWindowContent is the per-window root: it creates a fresh
// WindowSearchModel for each window so the query / visible slice are
// independent, while the shared AppModel + AccessManager + Preferences
// are injected from the App level.
//
// The model/access StateObjects must be passed in via init so the
// @StateObject autoclosure for WindowSearchModel can capture the shared
// AppModel instance - @EnvironmentObject isn't available at init time.
struct AllofitWindowContent: View {
let model: AppModel
let access: AccessManager
@StateObject private var searchModel: WindowSearchModel
init(model inModel: AppModel, access inAccess: AccessManager) {
self.model = inModel
self.access = inAccess
// @autoclosure: SwiftUI evaluates this exactly once when the view
// first appears, so re-renders won't keep allocating new search models
_searchModel = StateObject(wrappedValue: WindowSearchModel(model: inModel))
}
var body: some View {
ContentView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.environmentObject(access)
.environmentObject(searchModel)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
.onAppear {
model.start()
}
.onDisappear {
model.saveCache()
}
}
}
// MainWindowMarker captures the main WindowGroup's NSWindow into
// AppDelegate.mainWindow so applicationShouldHandleReopen can re-show that
// specific window without triggering AppKit's default behavior of unhiding
@@ -126,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay.
// The system default is ~2 s; that makes truncated Name/Path cells
// feel unreadable. Registered as a default so a user-set value in
// the global domain still wins. Seconds.
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 0.3])
// wipe any elevated-access staging files left over from a previous
// run so a crash or hard-kill doesn't accumulate privileged copies
// in ~/Library/Caches across sessions
ElevatedAccess.cleanup()
// bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey {
@@ -136,6 +193,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// called on clean Cmd+Q quit; wipes the elevated-access staging dir
// so the user-readable copies of privileged files don't linger
func applicationWillTerminate(_ notification: Notification) {
ElevatedAccess.cleanup()
}
// keep the process alive when the user closes the last window: the index
// stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item.
@@ -143,6 +206,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return false
}
// dock-icon right-click contextual menu: surface a "New Window" entry so
// the user can spawn an additional window without bringing the app to the
// front first. The action defers to whatever the File > New Window menu
// item does (SwiftUI auto-generates that item for WindowGroup) so we stay
// compatible with whichever underlying selector SwiftUI uses.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let vMenu = NSMenu()
let vItem = NSMenuItem(title: "New Window",
action: #selector(newWindowFromDock(_:)),
keyEquivalent: "")
vItem.target = self
vMenu.addItem(vItem)
return vMenu
}
// finds the ⌘N main-menu item (File > New Window) and re-invokes its
// action. We match on the keyboard shortcut rather than the title so the
// lookup survives localized menus.
@objc func newWindowFromDock(_ sender: Any?) {
guard let vMain = NSApp.mainMenu else { return }
for vTop in vMain.items {
guard let vSub = vTop.submenu else { continue }
for vItem in vSub.items {
if vItem.keyEquivalent == "n",
vItem.keyEquivalentModifierMask == [.command],
let vAction = vItem.action {
NSApp.sendAction(vAction, to: vItem.target, from: nil)
return
}
}
}
}
// dock-icon click while no windows are visible: re-show only the main
// window and return false so AppKit doesn't run its default "unhide every
// hidden window" action (which would also resurrect the Settings window).
@@ -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,24 +24,71 @@ private final class BackgroundMtime: @unchecked Sendable {
}
}
// AppModel is the central observable state for the application.
// 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, debounces the search query and exposes a filtered/sorted
// slice of the index to SwiftUI.
// 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 filtered, sorted and capped records currently shown in the table
@Published private(set) var visibleRecords: [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
@@ -49,53 +97,41 @@ final class AppModel: ObservableObject {
@Published private(set) var isLoadingCache: Bool = false
// true when this process owns the index (got the lock or built-in mode)
@Published private(set) var isIndexer: Bool = false
// the user-entered search query, debounced before filtering
@Published var query: String = "" {
didSet {
scheduleSearch()
prefs.lastQuery = query
}
}
// the active sort descriptor chosen by the user via column headers
@Published var sortDescriptor: FileSortDescriptor = .nameAscending {
didSet {
applyFilterAndSort()
prefs.lastSort = sortDescriptor
}
}
// maximum number of rows handed to SwiftUI Table for snappy scrolling
private let kMaxVisibleRows = 5000
// debounce delay between keystroke and filter rebuild
private let kSearchDebounceSeconds = 0.05
// 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
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
private let searchQueue = DispatchQueue(label: "allofit.search", qos: .userInitiated)
private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility)
// pending debounced search work, cancelled on each keystroke
private var searchWorkItem: DispatchWorkItem?
// pending filter+sort task, cancelled if a newer one supersedes it
private var filterTask: Task<Void, Never>?
// watcher used in indexer mode for live updates
private let watcher = FileWatcher()
// watcher used in reader mode to detect cache file refreshes
private let cacheWatcher = FileWatcher()
// 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
@@ -106,8 +142,6 @@ final class AppModel: ObservableObject {
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// last mtime we observed on the cache file - skips needless reloads
private var lastSeenCacheMtime: Date?
// minimum gap between two reader-mode reloads. Prevents the Table from
// being re-rendered while the user is mid-click. 1 second is well below
// any human-perceptible "stale" threshold but caps the worst-case churn
@@ -117,10 +151,30 @@ final class AppModel: ObservableObject {
private var lastReloadAt: Date?
init() {
// only the cheap UI state is restored synchronously - the cache file
// is loaded off-main in start() so the window appears instantly
query = prefs.lastQuery
sortDescriptor = prefs.lastSort
// 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
@@ -146,14 +200,13 @@ final class AppModel: ObservableObject {
private func tearDownActiveMode() {
watcher.stop()
cacheWatcher.stop()
pendingChanges.reset()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollSource?.cancel()
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
filterTask?.cancel()
filterTask = nil
}
// determines indexer vs reader role, loads the cache from the appropriate
@@ -176,39 +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
}
// capture the initial mtime so the reader-mode poller doesn't
// immediately re-trigger on its first tick
if let vAttrs = try? FileManager.default.attributesOfItem(atPath: vCacheURL.path),
let vMtime = vAttrs[.modificationDate] as? Date {
vSelf.lastSeenCacheMtime = vMtime
} else {
vSelf.lastSeenCacheMtime = nil
vSelf.lastEventId = 0
}
vSelf.replaceIndex(inState: vState)
vSelf.dirty = vPruned > 0
vSelf.isLoadingCache = false
vSelf.applyFilterAndSort()
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
@@ -239,23 +286,26 @@ final class AppModel: ObservableObject {
let vStartId = UInt64(FSEventsGetCurrentEventId())
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block
autoreleasepool {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
}
}
vAccumulated.append(contentsOf: vList)
}
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)
}
}
}
@@ -265,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()
@@ -309,6 +359,28 @@ final class AppModel: ObservableObject {
}
}
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService() async {
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in
try ServiceInstaller.stop(inScope: vScope)
}
}
// starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write.
func performStartService() async {
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in
try ServiceInstaller.start(inScope: vScope)
}
if vOk {
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService() async {
@@ -324,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,
@@ -353,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)
}
}
}
}
@@ -403,98 +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
scheduleSearch()
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
var vRecords: [FileRecord] = []
for vPath in vRoots {
// per-subtree autoreleasepool keeps the walk's autoreleased
// URL/stat objects from accumulating across subtrees
autoreleasepool {
vRecords.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: vMatcher
))
}
return true
}
for vPath in inPaths {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
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
self?.scheduleSearch()
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)
}
}
}
}
// ===========================
@@ -511,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")
@@ -543,30 +655,13 @@ final class AppModel: ObservableObject {
cachePollSource = vSource
}
// stat()'s the cache file and triggers a reload when mtime changes
private func pollCacheForChanges() {
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vUrl.path),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
if lastSeenCacheMtime != vMtime {
NSLog("[Allofit GUI] cache mtime changed (poll), reloading")
lastSeenCacheMtime = vMtime
reloadFromCache()
}
}
// reloads the on-disk cache off-main and swaps it in atomically.
//
// Two guards keep this from disrupting Table interactions:
// 1. lastEventId comparison - the daemon advances its event id every
// time it actually persists new state, so a matching id means the
// records on disk are identical to what we have and we can bail
// before touching any @Published. 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.
@@ -578,22 +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
self?.applyFilterAndSort()
guard let vSelf = self, !vSelf.isIndexer else { return }
vSelf.lastEventId = vCache.lastEventId
vSelf.replaceIndex(inState: vState)
}
}
}
@@ -602,114 +695,29 @@ final class AppModel: ObservableObject {
// MARK: Internals
// ===========================
// installs a freshly-built index and refreshes the visible slice
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
applyFilterAndSort()
if isIndexer {
startWatching(inSinceWhen: inEventId)
}
}
// debounces filter rebuilds so we don't refilter on every keystroke
private func scheduleSearch() {
searchWorkItem?.cancel()
let vItem = DispatchWorkItem { [weak self] in
DispatchQueue.main.async {
self?.applyFilterAndSort()
}
}
searchWorkItem = vItem
searchQueue.asyncAfter(deadline: .now() + kSearchDebounceSeconds, execute: vItem)
}
// runs the actual filter and sort on a detached task so the UI never
// stalls on keystrokes. The previous task is cancelled to avoid races.
private func applyFilterAndSort() {
filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained
let vQuery = query
let vSort = sortDescriptor
let vRecords = allRecords
let vMax = kMaxVisibleRows
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
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. Clicks landing on
// the Table during a no-op reload no longer get dropped.
if vSelf.visibleRecords == vCapped { return }
vSelf.visibleRecords = vCapped
}
}
}
// 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.
private 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 }
}
}
}
@@ -3,12 +3,18 @@ import AppKit
// ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table that fills the body, and a status bar at
// the bottom. The Settings gear sits permanently in the window toolbar.
// on macOS 15), a results table on the left, a Quick Look preview pane on
// 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
// two windows can run independent searches against the shared AppModel
@EnvironmentObject var searchModel: WindowSearchModel
@State private var selection: Set<FileRecord.ID> = []
// drives the Table's drag-to-reorder and column-visibility customization.
// Initial value is hydrated from UserDefaults so the user's column order
@@ -18,18 +24,20 @@ struct ContentView: View {
// Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>?
// whether the right-hand preview pane is currently visible. Persisted
// across launches so the user's pane-visibility preference sticks.
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
// Computed binding for the Table's sortOrder: reads/writes
// model.sortDescriptor directly so the sort state survives any number
// of window closes / reopens (the previous `@State sortOrder` got
// reset whenever the view was recreated, and the onChange-syncing
// dance occasionally didn't re-wire properly after a window reopen).
// Computed binding for the Table's sortOrder: reads/writes the
// per-window searchModel.sortDescriptor so clicking a column header
// only re-sorts this window. The last-clicked sort is mirrored into
// Preferences so a fresh window opens with the most recent choice.
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
get: { [Self.comparatorFor(inDescriptor: searchModel.sortDescriptor)] },
set: { vNewOrder in
guard let vFirst = vNewOrder.first else { return }
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
@@ -37,21 +45,37 @@ struct ContentView: View {
// model while NSTableView is still in its sort delegate
// callback (avoids the reentrant-operation AppKit warning)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
searchModel.sortDescriptor = vDescriptor
}
}
)
}
var body: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
Group {
if showPreviewPane {
HSplitView {
mainColumn
.layoutPriority(1)
.frame(minWidth: 460)
PreviewPane(selection: selection)
.frame(minWidth: 200, idealWidth: 360)
}
} else {
mainColumn
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showPreviewPane.toggle()
} label: {
Image(systemName: showPreviewPane
? "sidebar.right"
: "sidebar.squares.right")
}
.help(showPreviewPane ? "Hide preview" : "Show preview")
}
ToolbarItem(placement: .primaryAction) {
SettingsLink {
Image(systemName: "gearshape")
@@ -59,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
@@ -80,12 +98,21 @@ struct ContentView: View {
}
}
// search bar + table + status bar - everything except the preview pane
private var mainColumn: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
}
}
// ===========================
// MARK: Column customization persistence
// ===========================
// loads the previously-saved column order/visibility from UserDefaults,
// or returns a fresh default if nothing was saved or decoding fails
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode(
@@ -98,10 +125,6 @@ struct ContentView: View {
return vCustom
}
// persists the current column order/visibility to UserDefaults.
// nonisolated so the debounced background task can call it without an
// actor hop - the encode is the only non-trivial step and we want it
// genuinely off-main during column drags.
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
@@ -111,13 +134,9 @@ struct ContentView: View {
// MARK: Search bar
// ===========================
// Always-visible row at the top. `.background(.bar)` uses the system
// "bar" material, which sits right below the toolbar with the same
// vibrancy treatment - on macOS 26 this is the Liquid Glass surface,
// on macOS 15 it's the standard chrome material.
private var searchBar: some View {
SearchField(
text: $model.query,
text: $searchModel.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
@@ -132,16 +151,11 @@ struct ContentView: View {
// ===========================
private var resultsTable: some View {
// Uses the explicit `rows:` form of Table so we can attach `.draggable`
// to TableRow rather than to cell content. Putting `.draggable` on
// cell content installs a SwiftUI drag-gesture recognizer that
// competes with NSTableView's mouseDown → selection event on
// macOS 26 - the recognizer's "should this be a drag?" decision
// delays and occasionally eats the click, leaving the row never
// selected even though right-click (which bypasses the drag gesture
// entirely) still works. Row-level `.draggable` puts the drag at
// the same scope as NSTableView's own row-drag machinery and leaves
// the click path clean.
// Uses the explicit `rows:` form of Table so `.draggable` lives on
// TableRow rather than embedded in cell content. Cell-content
// draggable installs a SwiftUI drag-gesture recognizer that races
// with NSTableView's mouseDown→selection event on macOS 26 and
// occasionally eats left-clicks; row-level draggable doesn't.
Table(of: FileRecord.self,
selection: $selection,
sortOrder: sortOrderBinding,
@@ -156,6 +170,24 @@ struct ContentView: View {
.frame(width: 16, height: 16)
Text(vRecord.name)
.lineLimit(1)
.help(vRecord.name)
// When the preview pane is closed, surface the
// elevate-permission affordance on the selected row
// itself so the user has a way to authorize without
// having to open the pane first. needsAuthorization
// is a stat() call so we only invoke it for the row
// that's actually selected.
if !showPreviewPane,
selection.count == 1,
selection.contains(vRecord.id),
access.needsAuthorization(for: vRecord) {
Spacer(minLength: 4)
// pass access explicitly: Table cells live in
// detached NSHostingViews that don't reliably
// inherit @EnvironmentObject, which was the cause
// of repeated EnvironmentObject.error() crashes
AuthorizeBadge(access: access, record: vRecord)
}
}
}
.width(min: 200, ideal: 320)
@@ -166,12 +198,13 @@ struct ContentView: View {
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.help(vRecord.parentPath)
}
.width(min: 200, ideal: 380)
.customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Self.formatSize(inBytes: vRecord.size))
Text(vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size))
.foregroundColor(.secondary)
.monospacedDigit()
}
@@ -179,7 +212,7 @@ struct ContentView: View {
.customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateCreated))
Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary)
.monospacedDigit()
}
@@ -187,14 +220,14 @@ struct ContentView: View {
.customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateModified))
Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("modified")
} rows: {
ForEach(model.visibleRecords) { vRecord in
ForEach(searchModel.visibleRecords) { vRecord in
TableRow(vRecord)
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
@@ -208,6 +241,16 @@ struct ContentView: View {
} primaryAction: { vIds in
openSelection(inIds: vIds)
}
// Finder-style spacebar Quick Look. .onKeyPress only fires when the
// view (Table) has keyboard focus, so spaces typed into the search
// field still produce literal spaces in the query.
.onKeyPress(.space) {
guard !selection.isEmpty else { return .ignored }
let vUrls = recordsFor(inIds: selection)
.map { URL(fileURLWithPath: $0.fullPath) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
return .handled
}
}
// ===========================
@@ -215,36 +258,43 @@ struct ContentView: View {
// ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) {
// reveal in Finder shows the *original* file (not the staged copy),
// since the user wants to navigate to the real location on disk
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls)
}
private func quickLookSelection(inIds: Set<FileRecord.ID>) {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
// prefer the staged URL when one exists - QLPreviewPanel renders
// it without permission issues, whereas the original would fail
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
}
private func copyPaths(inIds: Set<FileRecord.ID>) {
// always copy the original path - the staged tmp path is an
// implementation detail that has no meaning outside this session
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
}
private func openSelection(inIds: Set<FileRecord.ID>) {
// open the staged copy when available so the default app can read
// it; falls back to the original path for files we can read directly
for vRecord in recordsFor(inIds: inIds) {
NSWorkspace.shared.open(URL(fileURLWithPath: vRecord.fullPath))
NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
}
}
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
return model.visibleRecords.filter { inIds.contains($0.id) }
return searchModel.visibleRecords.filter { inIds.contains($0.id) }
}
// ===========================
// MARK: Sort mapping
// ===========================
// converts a Table sort comparator into the model's FileSortDescriptor
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath
@@ -256,7 +306,6 @@ struct ContentView: View {
return .nameAscending
}
// returns the matching comparator for a given persisted sort descriptor
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
@@ -271,32 +320,6 @@ struct ContentView: View {
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
}
}
// ===========================
// MARK: Formatting helpers
// ===========================
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
fileprivate static func formatSize(inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
fileprivate static func formatDate(inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
}
// ===========================
@@ -305,22 +328,26 @@ struct ContentView: View {
// Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small view rather than the ContentView body that
// contains the Table. SwiftUI's dependency tracking is per-View, so an
// isolated leaf observer doesn't churn the Table's closure scope.
// only re-evaluate this small leaf view rather than the ContentView body
// that contains the Table.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var searchModel: WindowSearchModel
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("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
Text(resultsText)
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
@@ -330,7 +357,7 @@ private struct StatusBarView: View {
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
}
if !model.query.isEmpty {
if !searchModel.query.isEmpty {
Text("· Filtered").foregroundColor(.secondary)
}
}
@@ -339,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"
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
// Formatters bundles the byte-count and date formatting used by both
// the Table's columns and the right-hand preview pane footer. Keeping
// the formatter instances cached at file scope avoids reconstructing
// them per row render, which would be expensive at 5 000 rows.
enum Formatters {
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
// human-friendly byte count, e.g. "1.2 MB"
static func size(bytes inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
// short date+time, with em-dash for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "-" }
return kDateFormatter.string(from: inDate)
}
}
+206
View File
@@ -0,0 +1,206 @@
import SwiftUI
import AppKit
import Quartz
// QuickLookPreviewView wraps Quartz's QLPreviewView so an inline Quick
// Look preview can be embedded inside a SwiftUI hierarchy. The same
// renderer powers the floating QLPreviewPanel (spacebar), so file-type
// coverage (PDFs, images, video, source files, plists, etc.) is
// identical between the inline pane and the floating panel.
struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
func makeNSView(context: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
// keep the view alive when the parent window closes - we own its
// lifetime via SwiftUI, not via QLPreviewPanel's modal behaviour
vView.shouldCloseWithWindow = false
vView.autostarts = true
return vView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let vQlView = nsView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// AuthorizeBadge is the small lock icon that appears at the right of the
// selected row when the preview pane is closed. Clicking it kicks off the
// sudo cp + chown via AdminShell - the system prompts for the password
// the first time inside the admin-auth-cache window.
//
// access is taken as an @ObservedObject property (not @EnvironmentObject)
// because this view is hosted inside a SwiftUI Table cell, and Table cell
// content is rendered in its own NSHostingView. That hosting view does
// not reliably inherit the parent's environment objects; a missing
// access lookup triggers EnvironmentObject.error() → SIGTRAP. Passing
// the AccessManager explicitly sidesteps that entire failure mode.
struct AuthorizeBadge: View {
@ObservedObject var access: AccessManager
let record: FileRecord
var body: some View {
Button {
Task { await access.authorize(record) }
} label: {
if access.isAuthorizing(record.id) {
ProgressView()
.controlSize(.small)
.frame(width: 16, height: 16)
} else {
Image(systemName: "lock.shield.fill")
.foregroundStyle(.orange)
.font(.system(size: 14, weight: .semibold))
}
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(record.id))
.help(access.isAuthorizing(record.id)
? "Authorizing…"
: "Authorize to read this file")
}
}
// PreviewPane is the right-hand side panel in the main window. When
// exactly one row is selected it renders a Quick Look preview plus a
// small metadata footer. If the file isn't user-readable the preview
// area becomes a single big tap-target showing a lock icon - clicking
// it (or the badge that appears on the selected row when the pane is
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var access: AccessManager
@EnvironmentObject var searchModel: WindowSearchModel
// passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes
let selection: Set<FileRecord.ID>
private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil }
return searchModel.visibleRecords.first(where: { $0.id == vId })
}
var body: some View {
VStack(spacing: 0) {
if let vRecord = selectedRecord {
content(for: vRecord)
} else {
emptyState
}
}
.background(Color(NSColor.controlBackgroundColor))
}
// preview + metadata footer for one selected record
private func content(for inRecord: FileRecord) -> some View {
let vEffectiveUrl = access.effectiveURL(for: inRecord)
let vReadable = ElevatedAccess.canRead(path: vEffectiveUrl.path)
return VStack(spacing: 0) {
Group {
if vReadable {
QuickLookPreviewView(url: vEffectiveUrl)
} else {
authorizePrompt(for: inRecord)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
metadata(for: inRecord)
}
}
// full-area authorize hint shown when the selected file isn't
// user-readable. The whole area is the button target so users
// can click anywhere over the locked preview to authorize.
private func authorizePrompt(for inRecord: FileRecord) -> some View {
Button {
Task { await access.authorize(inRecord) }
} label: {
VStack(spacing: 10) {
if access.isAuthorizing(inRecord.id) {
ProgressView()
.controlSize(.regular)
} else {
Image(systemName: "lock.shield.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
}
Text(access.isAuthorizing(inRecord.id)
? "Authorizing…"
: "Click to authorize preview")
.font(.callout)
.foregroundColor(.secondary)
if let vErr = access.lastError, !access.isAuthorizing(inRecord.id) {
Text(vErr)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(inRecord.id))
}
// thin metadata bar at the bottom of the preview pane
private func metadata(for inRecord: FileRecord) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(inRecord.name)
.font(.headline)
.lineLimit(2)
.truncationMode(.middle)
Text(inRecord.parentPath)
.font(.caption)
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.textSelection(.enabled)
HStack(spacing: 6) {
if !inRecord.isDirectory {
Text(Formatters.size(bytes: inRecord.size))
.monospacedDigit()
Text("·")
}
Text("Modified \(Formatters.date(inRecord.dateModified))")
.monospacedDigit()
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
// placeholder shown when nothing or multiple rows are selected
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "eye.slash")
.font(.system(size: 32))
.foregroundColor(.secondary.opacity(0.6))
Text(placeholderText)
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var placeholderText: String {
if selection.isEmpty {
return "Select a file to preview"
}
return "\(selection.count) items selected"
}
}
@@ -90,6 +90,12 @@ struct SearchField: NSViewRepresentable {
private var navigationIndex: Int?
// text the user typed before starting to navigate; restored on rollback
private var savedQuery: String = ""
// debounced auto-save to recents: if the user stops typing for this
// many nanoseconds, the current query is added to recents as though
// they had pressed Return
private static let kAutoSaveDelayNanos: UInt64 = 4_000_000_000
// pending auto-save task; cancelled+rescheduled on every keystroke
private var autoSaveTask: Task<Void, Never>?
init(_ inParent: SearchField) {
parent = inParent
@@ -109,9 +115,12 @@ struct SearchField: NSViewRepresentable {
}
// fired by the Find menu item; brings the search field to first
// responder and selects existing text so typing replaces it
// responder and selects existing text so typing replaces it.
// With multi-window, every Coordinator gets the notification - so
// we only act when our field belongs to the currently key window;
// otherwise inactive windows would all race to steal focus.
@objc private func handleFocusRequest() {
guard let vField = field else { return }
guard let vField = field, vField.window?.isKeyWindow == true else { return }
vField.window?.makeFirstResponder(vField)
vField.selectText(nil)
}
@@ -128,6 +137,28 @@ struct SearchField: NSViewRepresentable {
self?.parent.text = vValue
}
}
scheduleAutoSave(field: vField)
}
// reschedules the deferred "treat the current query as submitted"
// task. Called on every keystroke; if the user stops typing for the
// configured dwell time, the query lands in recents without Enter.
private func scheduleAutoSave(field inField: NSSearchField) {
autoSaveTask?.cancel()
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
guard !vQuery.isEmpty else { return }
autoSaveTask = Task { [weak self, weak inField] in
try? await Task.sleep(nanoseconds: Coordinator.kAutoSaveDelayNanos)
if Task.isCancelled { return }
await MainActor.run {
guard let vField = inField else { return }
// the user may have edited or cleared the field while we
// slept; only commit if the snapshot still matches
let vNow = vField.stringValue.trimmingCharacters(in: .whitespaces)
guard vNow == vQuery else { return }
self?.addCurrentToRecents(field: vField)
}
}
}
// NSSearchField's action target: fires on Return and on recent-pick
@@ -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,15 +210,29 @@ private struct VolumesTab: View {
}
// ===========================
// MARK: Service tab
// MARK: service tab
// ===========================
// ServiceTab manages installation of the LaunchAgent or LaunchDaemon that
// keeps the index up to date while the GUI is closed.
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
// up to date while the GUI is closed. Surfaces install/uninstall, run-
// state (stop/start), and the gap between the version that's currently
// installed on disk vs the bundle the user is running right now.
private struct ServiceTab: View {
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var model: AppModel
// tick value to force the status block to recompute on a timer; the
// status doesn't observe @Published changes since it reads file
// system state directly, so we need an explicit refresh signal
@State private var statusTick: Int = 0
private var scope: ServiceInstaller.Scope? {
switch prefs.serviceMode {
case .none: return nil
case .userAgent: return .userAgent
case .rootDaemon: return .rootDaemon
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -249,20 +263,15 @@ private struct ServiceTab: View {
}
}
HStack {
Button("Install") {
Task { await model.performInstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Spacer()
if model.isWorking {
ProgressView().controlSize(.small)
}
if !model.workMessage.isEmpty {
installDescription
actionButtons
if !model.workMessage.isEmpty {
HStack(spacing: 6) {
if model.isWorking {
ProgressView().controlSize(.small)
}
Text(model.workMessage)
.font(.caption)
.foregroundColor(.secondary)
@@ -271,16 +280,141 @@ private struct ServiceTab: View {
Divider()
Text(currentStatusText())
.font(.caption)
.foregroundColor(.secondary)
statusBlock
}
.onAppear { statusTick &+= 1 }
.onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in
statusTick &+= 1
}
.onChange(of: model.isWorking) { _, _ in statusTick &+= 1 }
}
// ===========================
// MARK: Install explanation
// ===========================
@ViewBuilder
private var installDescription: some View {
if let vScope = scope {
GroupBox {
VStack(alignment: .leading, spacing: 4) {
Text("Install will:")
.font(.caption.bold())
Text("• Copy the running binary to \(daemonBinaryPath(for: vScope)) so the daemon has a stable on-disk path that won't break if you move Allofit.app.")
.font(.caption)
Text("• Write the launchd plist that runs it as \(vScope == .rootDaemon ? "root" : "your user").")
.font(.caption)
Text("• Start the daemon via launchctl bootstrap.")
.font(.caption)
if vScope == .rootDaemon {
Text("• Prompt once for your administrator password (steps run as one privileged script).")
.font(.caption)
}
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func currentStatusText() -> String {
let vUser = ServiceInstaller.isInstalled(inScope: .userAgent) ? "installed" : "not installed"
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed"
return "User agent: \(vUser) · Root daemon: \(vRoot)"
// ===========================
// MARK: Action buttons
// ===========================
private var actionButtons: some View {
let vInstalled = scope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = scope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
_ = statusTick // re-read the file-system status on each tick
return HStack {
Button("Install") {
Task { await model.performInstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
Button("Stop") {
Task { await model.performStopService() }
}
.disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") {
Task { await model.performStartService() }
}
.disabled(!vInstalled || vRunning || model.isWorking)
Spacer()
}
}
// ===========================
// MARK: Status block
// ===========================
private var statusBlock: some View {
_ = statusTick // ensure recomputation each tick
let vScope = scope
let vInstalled = vScope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = vScope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
let vInstalledVer = vScope.flatMap { ServiceInstaller.installedVersion(inScope: $0) }
let vBundleVer = ServiceInstaller.bundleVersion()
let vStale = vInstalled && vInstalledVer != nil && vInstalledVer != vBundleVer
return VStack(alignment: .leading, spacing: 4) {
LabeledContent("Service installed") {
Text(vInstalled ? "yes" : "no")
.foregroundColor(vInstalled ? .primary : .secondary)
.font(.callout)
}
LabeledContent("Currently running") {
Text(vRunning ? "yes" : "no")
.foregroundColor(vRunning ? .green : .secondary)
.font(.callout)
}
LabeledContent("Installed version") {
Text(vInstalledVer ?? "-")
.font(.callout)
.monospacedDigit()
}
LabeledContent("This app's version") {
HStack(spacing: 6) {
Text(vBundleVer)
.font(.callout)
.monospacedDigit()
if vStale {
Text("Reinstall to update")
.font(.caption)
.foregroundColor(.orange)
}
}
}
if let vScope = vScope {
LabeledContent("Other scope") {
Text(otherScopeStatusText(currentScope: vScope))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
// path of the daemon-renamed binary copy, used in the explanatory blurb
private func daemonBinaryPath(for inScope: ServiceInstaller.Scope) -> String {
return ServiceInstaller.daemonBinaryPath(inScope: inScope)
}
// summary of the *other* scope's install state so the user can see at
// a glance that they don't have a stray install on the unused side
private func otherScopeStatusText(currentScope inCurrent: ServiceInstaller.Scope) -> String {
let vOther: ServiceInstaller.Scope = (inCurrent == .userAgent) ? .rootDaemon : .userAgent
let vLabel = (vOther == .userAgent) ? "User agent" : "Root daemon"
let vInstalled = ServiceInstaller.isInstalled(inScope: vOther)
let vRunning = vInstalled && ServiceInstaller.isRunning(inScope: vOther)
if vRunning { return "\(vLabel): running" }
if vInstalled { return "\(vLabel): installed (stopped)" }
return "\(vLabel): not installed"
}
}
@@ -317,7 +451,7 @@ private struct CacheTab: View {
.monospacedDigit()
}
LabeledContent("Last modified") {
Text(cacheFileMtime() ?? "—")
Text(cacheFileMtime() ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -394,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)
@@ -414,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 {
@@ -443,7 +577,7 @@ private struct DiagnosticsTab: View {
.monospacedDigit()
}
LabeledContent("Cache mtime") {
Text(cacheMtimeString() ?? "—")
Text(cacheMtimeString() ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -565,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))
}
}