14 Commits
Author SHA1 Message Date
Bitsy 876122ff98 Audit fixes: security hardening, index correctness, performance, UX
Security
- Elevated copies: root only reads the original; the copy is written by
  the user (sudo -u tee), so root never chowns / chmods a user-controlled
  path. Staging folder forced to 0700.
- Service logs moved from fixed /tmp names to /Library/Logs/Allofit
  (root-owned) and ~/Library/Logs/Allofit; Diagnostics reveals instead of
  opening the log.
- Index files are owner-only (0600; the root daemon's belongs to the
  installing user), set on the temp file before an atomic rename.
- Root install passes the plist inline (base64, plutil -lint) instead of a
  user-writable temp file; the binary comes from Bundle.main.
- Cache loader caps and checks the declared payload size; each save uses
  its own temp file.
- Release action pinned to a commit; non-system LC_RPATHs stripped.

Correctness
- Move to Trash removes the files from the index (the watcher ignores the
  app's own operations) and registers Undo (Put Back); trashed folders are
  matched with the original URLs; failures are shown.
- The saved event id stays below pending subtree walks (GUI and service).
- Roots / exclusions changes restart the watcher from the snapshot's id.
- Case-only renames no longer leave a ghost entry.
- Service mode is saved only after a successful install; a saved but
  missing service falls back to the in-process indexer.

Performance
- New folders are merged without the O(n) removal pass.
- Size / date sorts use a compact key array (539 -> 47 ms for 630k).
- Selection, preview and actions use the selected records directly.
- Service saves at most every 15 s; reader reloads pause while hidden and
  are deferred instead of dropped; window close saves only when dirty.

Usability
- Results appear during the first index; empty-list explanations.
- Down arrow moves to the results, Up on the first row back; history on
  Up / Option-Up / Option-Down.
- Search syntax popover and Help menu; shortcuts shown in the context menu;
  confirmations for Clear Cache and Uninstall; privacy usage strings;
  Group Containers excluded by default; wording, VoiceOver labels, plural.
2026-10-02 14:44:49 +02:00
Bitsy eb9ee9abd9 Hover overlay for cut-off cells, hold Option to freeze the list
- Resting the pointer 0.5 s on a cut-off name or path shows its full text
  (with highlights) in a click-through panel over the cell. It stays while
  hovered, updates in place when the list refreshes, and hides on move,
  exit, scroll, click or key press. Replaces AppKit's expansion tooltip,
  whose timing can't be set and which vanished when touched.
- Holding Option alone freezes the results: background refreshes are held
  back (the index keeps updating) and applied once on release, keeping the
  scroll position. Typing, sorting and filtering still apply. Released
  automatically when the app deactivates. "Frozen" shows in the status bar.
- README: current keyboard shortcuts, hover and Finder action.
  CHANGELOG: freeze key and hover overlay in 1.0.9.
2026-10-02 14:24:53 +02:00
Bitsy 17137a6f43 Instant full-text hover for cut-off names and paths, changelog for 1.0.9
- Result cells use expansion tooltips: hovering a truncated name or path
  shows the full text over the cell right away, like Finder, instead of a
  help tag waiting for the tooltip delay.
- Register NSInitialToolTipDelay in milliseconds (250); it was registered
  as 0.3, read as milliseconds.
- CHANGELOG.md with the 1.0.6 and 1.0.9 notes.
2026-10-02 11:11:53 +02:00
Bitsy cd1d8a756e Unlimited results: native results table, chunked index store
- Replace the SwiftUI Table (capped at 2000 rows) with an NSTableView that
  only builds the visible rows, so every match is listed and scrolls
  smoothly. Column order / width / visibility autosave, header menu to hide
  columns, Finder-accurate drag (file URLs), Edit > Copy copies files, keys
  and context menu as before, "Authorize Access..." in the menu for
  unreadable files, bold highlights readable on selected rows.
- Results arrive in two steps for broad queries: the first 2000 rows in
  milliseconds, then the complete sort (~0.2 s for 600k entries). Background
  refreshes publish once so the scroll position is kept.
- Store the index in 16k-record chunks (RecordStore): snapshots are cheap and
  a change copies only the touched chunk. Results are positions into a
  snapshot (ResultList), 4 bytes per row instead of a copy of each record.
  The parallel filter walks the store chunk by chunk.
2026-10-02 10:59:40 +02:00
Bitsy 132430e3cc Everything-style UI: filters, highlights, hotkey, menu bar, Finder service
- Filter menu next to the search field (Everything, Folders, Documents,
  Pictures, Audio, Video, Archives, Applications, Code), ANDed with the query.
- Bold highlighting of matched parts in names and, for path terms, folders.
- Global shortcut (default Option-Space) toggling the window, via Carbon
  RegisterEventHotKey; menu bar icon (NSStatusItem) with Show / Settings /
  Quit; start at login through SMAppService.
- Finder "Search in Allofit" service and folder document type: right-click a
  folder, drop it on the Dock icon or open -a Allofit <folder> to search
  inside it.
- Results: Finder-style keys (Return, Cmd-Return, Cmd-C copies files,
  Opt-Cmd-C copies paths, Cmd-Y, Cmd-Delete to Trash), richer context menu
  (Open With, Copy Name, Move to Trash), selection count and size in the
  status bar, configurable double-click action. Rebuild Index moves to
  Shift-Cmd-R.
- Settings regrouped into General / Indexes / Performance / Advanced, with
  aligned labels and top-aligned sub-pages.
2026-10-02 10:47:25 +02:00
Bitsy 547753cc4d Status bar stats, background throttling, Performance settings, drag fix
- Status bar: last search time, memory footprint, CPU use (highlighted when
  busy) and role, with a tooltip for peak memory, threads, index age, cache
  size and roots. Sampled every 2 s, redraws only the status bar; match count
  and search time live in a separate object so the table isn't re-rendered.
- Background throttling: file changes are collected longer and open results
  refresh less often when Allofit isn't focused or no window is visible;
  sampling pauses when hidden. Showing a window applies pending changes at
  once.
- Settings > Performance: the update delays (focused / another app focused /
  no window) and result refresh intervals, with an estimate of how soon a
  changed file appears and Restore Defaults. Defaults 2 s / 15 s / 1 min and
  1 s / 5 s.
- Drag rows as file URLs (like Finder) instead of a plain link, so browsers
  upload the file instead of navigating to it.
- Canonicalize roots with realpath: roots under /tmp, /var or /etc never
  matched their own entries because Foundation strips /private.
2026-10-02 10:20:45 +02:00
Bitsy 35ffc86e46 Fix idle CPU drain from open path queries, general code cleanup
Fix
- An open window with a path query (term containing "/") re-ran the search
  over the whole index on every FSEvents update, keeping several cores busy
  while files changed. Index-driven refilters are now throttled to one per
  second, skipped while the window is closed, minimized or covered, and
  replayed once when it becomes visible again. Typing stays instant.
- Path terms no longer build and lowercase the full path of every record:
  the folded folder path is precomputed once per folder (interned like
  parentPath) and matched together with the folded name without allocating
  the joined string. Path-only queries are about 3x faster.

Cleanup
- Remove dead code: FSChange.isDir, FileWatcher.latestEventId,
  IndexState.record(atPath:), the never-set lastQuery preference,
  AccessManager.reset(), SearchField.onSubmit, an unreachable return.
- Merge duplicates: the two Settings size formatters become
  Formatters.sizeOrDash, the shellQuote wrapper is replaced by
  AdminShell.quote.
- Naming conventions: "in"-prefixed parameter names everywhere (delegate
  methods, binary helpers, initializers), camelCase impl helpers.
- Comment every function and type; fix stale comments.
- Debug and release builds are warning-free.
2026-10-02 09:29:18 +02:00
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
59 changed files with 7039 additions and 2282 deletions
+3
View File
@@ -25,3 +25,6 @@ jobs:
- name: Build release
run: swift build -c release
- name: Test
run: swift test
+10 -7
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" \
@@ -60,13 +62,14 @@ jobs:
| tee "Allofit-${{ steps.version.outputs.version }}.sha256"
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
# pinned to a commit: this step holds a write token for the repo
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Allofit ${{ steps.version.outputs.version }}
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
+116
View File
@@ -0,0 +1,116 @@
# Changelog
All notable changes to Allofit are documented here.
## [1.0.9] - 2026-10-02
### Added
- **Unlimited results.** Every match is listed, with no 2,000-row cap. The results list is a native macOS table that only builds the rows on screen, so scrolling through hundreds of thousands of entries stays smooth. For broad searches the first rows appear in milliseconds and the complete sorted list follows a fraction of a second later.
- **Filter menu** next to the search box, like Everything: Everything, Folders, Documents, Pictures, Audio, Video, Archives, Applications, Code.
- **Match highlighting:** the matched parts of names, and of folder paths for path terms, are shown in bold. It can be turned off in Settings.
- **Global shortcut** (default ⌥Space) to show or hide Allofit from any app, with a choice of shortcuts in Settings and a warning when one is already taken.
- **Menu bar icon** with Show Allofit, Settings… and Quit, which can be hidden.
- **Start at login** option.
- **"Search in Allofit" in Finder:** right-click a folder → Quick Actions (or Services). Dropping a folder on the Dock icon, or `open -a Allofit <folder>`, also searches inside it.
- **Keyboard shortcuts** in the results:
| Key | Action |
|---|---|
| Return | Open, or Reveal (choice in Settings) |
| ⌘Return | Reveal in Finder |
| ⌘C | Copy the files |
| ⌥⌘C | Copy the paths |
| ⌘Y or Space | Quick Look |
| ⌘⌫ | Move to Trash |
- **Context menu:** Open With (default app first), Copy, Copy Name, Copy Path, Move to Trash, and Authorize Access… for files you can't read.
- **Freeze the list:** hold ⌥ Option to stop background updates from moving rows while you read or select; releasing it catches up once. The status bar shows "Frozen" meanwhile.
- **Column options:** right-click the column header to show or hide columns. Column order, width and visibility are remembered.
- **Status bar:**
- The last search time, Allofit's memory and CPU use, and the selection count and size ("3 selected (12 MB)").
- Hovering it shows more detail: peak memory, threads, when the index last changed, cache size and roots.
- **Settings → Performance:** how quickly file changes reach the index and the open results, separately for when Allofit is focused (default about 3 s), when another app is focused (about 20 s) and when no window is visible (1 min). Showing a window always applies pending changes at once.
### Fixed
- **High CPU use with a path search open.** An open window searching with a `/` term re-ran the whole search on every file change and could keep several CPU cores busy. Path searches are also about three times faster.
- **Dragging files into a browser** opened the file in place of the web page instead of uploading it. Dragged rows now carry real files, like Finder.
- **Roots under `/tmp`, `/var` or `/etc`** never matched their own entries.
- **Files moved to the Trash from Allofit** came back in the results a moment later; they now leave the index, and ⌘Z puts them back.
- **Interrupted rescans** (quit or crash while a folder was being re-read) could lose that folder's contents until the next full rebuild.
- **Changing roots or exclusions** could drop file changes made at the same moment.
- **Renaming a file by case only** (`Foo` → `foo`) left a ghost entry.
- **Picking a service mode in Settings** took effect before Install, which could leave the app waiting for a service that didn't exist; the mode is now saved only when Install succeeds, and a missing service falls back to the built-in indexer.
- **Long names and paths:** hovering a cut-off name or path for half a second shows the full text over the cell, and it stays while you hover, even when the list refreshes.
### Security
- The "Authorize Access" copy no longer lets root change the owner or permissions of a path the user controls (it could be redirected to take over a system file).
- The background service no longer logs to fixed file names in `/tmp`; logs go to `/Library/Logs/Allofit/` (system service) or `~/Library/Logs/Allofit/` (user service).
- The system service's index, which lists every file name it can see, is readable only by root and the user who installed the service (it was readable by every account). Your own index is now private to you too.
- Installing the system service no longer copies a plist from a temporary file a user process could swap, and always uses the real app binary.
- Corrupt or oversized cache files are rejected instead of being expanded in memory, and two saves can no longer overwrite each other's temporary file.
- The release workflow pins its publishing action to an exact commit, and the app binary no longer searches the Xcode folder for libraries.
### Changed
- **Much less background work:** file changes are batched longer and open results refresh less often while Allofit isn't focused, and the status bar stops sampling when no window is visible.
- **Faster:** sorting a large list by date or size is about 10× faster, selecting rows no longer scans every result, new folders are merged without a pass over the whole index, and the background service saves at most every 15 s instead of every 3 s.
- **First launch:** results appear while the index is being built, an empty list explains why (loading, indexing, no match, filter on), and the macOS privacy prompts explain what Allofit reads.
- **Keyboard:** ↓ moves from the search field to the results and ↑ on the first row comes back; search history moved to ↑ / ⌥↑ / ⌥↓. The right-click menu shows the shortcuts.
- **Help:** a ? button and Help > Search Syntax show the query syntax. Clear Cache and Uninstall ask for confirmation, and Move to Trash reports failures.
- **Lower memory use:** the index is stored in chunks, so search results take 4 bytes per row and a file change copies only a small part of the index.
- **Settings** are regrouped into four tabs: General, Indexes (folders, exclusions, volumes), Performance and Advanced (service, cache, diagnostics).
- **Rebuild Index** moved from ⌘R to ⇧⌘R, so it's harder to trigger by accident.
## [1.0.6] - 2026-10-01
### Fixed
- **Memory blow-up on relaunch.** After the app had been closed for a while, reopening it (or opening its window while it ran in the background) could fill RAM and swap with many gigabytes within seconds. Catching up on file changes made while the app was closed no longer copies the whole index once per changed file. Memory now peaks under 1 GB during a multi-day catch-up and settles around 300 MB for 600k files.
- **Endless catch-up.** The index is now saved as soon as Allofit has caught up on file changes, and again on quit (⌘Q). Before, a launch that never got as far as saving had to replay an ever-growing backlog on every relaunch.
- **Ghost results.** Moving a folder to the Trash, or renaming it, left its whole contents searchable. Now the folder's contents are removed with it, and a folder moved or renamed into an indexed location is scanned.
- **Duplicate results** for files whose names contain accents (for example `Envoyés`).
- **Selection jumping** when a file changed or after a relaunch: each file's identity is now derived from its path, so it stays stable.
- **Flicker during file activity.** The results table and the Quick Look preview no longer redraw every time the index updates.
- **Hidden-folder content** (dot-folders such as `.git` or `.gradle`, and hidden folders at the top of a root) is no longer added by live updates when the initial scan skipped it. Existing entries are cleaned up on the next launch.
- **Excluded or removed paths** are dropped from the index on launch instead of lingering until a full reindex.
- Overlapping roots (one root inside another) are no longer indexed twice.
- A saved cache that can't be resumed (for example after the disk was erased or the cache was copied from another Mac) now triggers a background rescan instead of silently going stale.
- Minor memory leaks in the file-watching code.
### Added
- **Everything-style search syntax:**
| Query | Meaning |
|---|---|
| `annual report` | AND: both words in the name |
| `*.png \| *.jpg` | OR (binds tighter than AND) |
| `report !draft` | NOT |
| `"my file"` | quotes keep spaces inside one term |
| `ext:pdf;docx` | by extension |
| `file:` / `folder:` | files or folders only, alone or as a prefix (`folder:build`) |
| `src/main` | a term with `/` is matched against the full path |
| `"some/folder/**/path" IMG_????.heic` | folder-aware path wildcards: `*` within one folder name, `**` across any number of folders |
| `photos/**/` | a trailing `/` means anything inside that folder |
- Searches ignore case and accents.
- The status bar shows the total number of results, not only the rows listed.
- Changes to roots, exclusions and volume options take effect immediately in built-in mode: new roots are scanned and removed ones dropped, with no reindex needed.
- Command-line service control: `Allofit install|uninstall|start|stop [user|root]`, `Allofit mode <none|user|root>`, `Allofit status`.
- Unit tests for the search syntax and the index logic, run in CI.
### Changed
- **Much faster search.** Results appear almost as you type: the delay after a keystroke dropped from 0.5 s to 0.04 s. Matching is spread across all CPU cores, and only the 2,000 rows shown are sorted instead of every match. A 600k-file index answers typical queries in a few milliseconds.
- Sorting by name is now a fast, case-insensitive alphabetical order. Number-aware ordering (`file2` before `file10`) is no longer used.
- The background service resumes from its saved index on start instead of rescanning every root, and can no longer mistake an interrupted first scan for a complete one.
- The index uses less memory: files in the same folder share one copy of the folder path.
- Build scripts moved to `scripts/`, and build output goes to `outputs/`.
- Added the MIT license.
## [1.0.5] - 2026-06-18
Previous release.
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)
]
+33 -10
View File
@@ -18,36 +18,59 @@ 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 |
Every match is listed, in the order of the column you click; the filter menu next to the search box narrows to one kind of item, and the ? button shows this syntax in the app. Hidden files and folders (dot-files, `~/Library` when hidden) are not indexed.
## Shortcuts
| Key | Action |
|---|---|
| ⌥Space | Show / hide Allofit from any app (changeable in Settings) |
| ⌘F | Focus search |
| ⌘R | Reindex |
| ⌘, | Preferences |
| ↑ ↓ | Cycle search history |
| ↑ ↓ (in the search field) | Cycle search history |
| Return | Open the selection (or reveal it, see Settings) |
| ⌘Return | Reveal in Finder |
| Space / ⌘Y | Quick Look |
| ⌘C / ⌥⌘C | Copy the files / their paths |
| ⌘⌫ | Move to Trash |
| Hold ⌥ | Freeze the list: no background updates until released |
| ⇧⌘R | Rebuild the index |
| ⌘, | Settings |
Hovering a cut-off name or path for half a second shows it in full. In Finder, right-click a folder → Quick Actions → **Search in Allofit** to search inside it.
## Background service (optional)
**Settings → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed.
**Settings → Advanced → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed. The chosen mode only takes effect once Install succeeds. Service logs go to `~/Library/Logs/Allofit/` (user) or `/Library/Logs/Allofit/` (root).
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. The Diagnostics tab shows the exact path to add.
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. **Settings → Advanced → Diagnostics** shows the exact path to add. The root daemon's index is readable only by root and the user who installed it.
## 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
-183
View File
@@ -1,183 +0,0 @@
import SwiftUI
import AppKit
// AllofitApp is the SwiftUI App for the GUI mode. The real entry point lives
// in Main.swift, which routes between this and the headless --service mode.
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()
// 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)
.environmentObject(access)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
}
.windowToolbarStyle(.unified)
.defaultSize(width: 1100, height: 640)
.commands {
// custom About panel with a clickable repo link in the credits
CommandGroup(replacing: .appInfo) {
Button("About Allofit") { showAboutPanel() }
}
CommandGroup(after: .appInfo) {
Button("Reindex All") {
Task { await model.performReindex() }
}
.keyboardShortcut("r", modifiers: [.command])
}
CommandGroup(replacing: .newItem) { }
// ⌘F focuses the search field. Standard Find-style shortcut.
// SearchField's Coordinator observes the notification and calls
// makeFirstResponder on its underlying NSSearchField.
CommandGroup(after: .pasteboard) {
Button("Find") {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
}
.keyboardShortcut("f", modifiers: [.command])
}
}
Settings {
SettingsView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.environmentObject(access)
}
}
}
// 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
// every hidden window the process holds (which would also unhide the
// Settings window when the user just wants the main one back).
//
// Uses an NSView subclass so we can hook viewDidMoveToWindow - that is the
// reliable place where the view's .window property is guaranteed non-nil.
// DispatchQueue.main.async sometimes ran before SwiftUI attached the view.
struct MainWindowMarker: NSViewRepresentable {
final class MarkerView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let vWindow = self.window else { return }
// stop AppKit from deallocating the window when the user clicks
// the red close button - we hold a strong ref in AppDelegate and
// re-show this same window object on the next dock-icon click.
// Otherwise the weak ref would die and dock-click would do nothing.
vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow
}
}
func makeNSView(context: Context) -> NSView {
return MarkerView(frame: .zero)
}
func updateNSView(_ nsView: NSView, context: Context) {
// rebind if SwiftUI ever swaps us into a different window
if let vWindow = nsView.window, AppDelegate.mainWindow !== vWindow {
vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow
}
}
}
// Opens the standard macOS About panel with a clickable GitHub link in the
// Credits section. App name + version come from Info.plist automatically.
private func showAboutPanel() {
let vUrlString = "https://github.com/bitsycore/Allofit"
let vCredits = NSMutableAttributedString(
string: vUrlString,
attributes: [
.link: URL(string: vUrlString) as Any,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
.foregroundColor: NSColor.linkColor
]
)
NSApplication.shared.orderFrontStandardAboutPanel(options: [
.credits: vCredits
])
NSApp.activate(ignoringOtherApps: true)
}
// AppDelegate handles the activation lifecycle. It also keeps the app
// resident after the window closes so the in-memory index stays warm and
// the next window open is instant - the same pattern Finder and Safari use.
final class AppDelegate: NSObject, NSApplicationDelegate {
// strong ref to the main app window so it stays in RAM after the user
// closes it (paired with isReleasedWhenClosed=false on the window). This
// lets a dock-icon click bring the *same* window back instead of letting
// AppKit unhide every hidden window the app holds (Settings included).
// Strong, not weak, because SwiftUI tears down its content view tree on
// close - the weak ref would die and dock-click would silently no-op.
// nonisolated(unsafe) because all reads/writes happen on the main thread
// (NSView callbacks + the AppKit delegate methods are all @MainActor).
nonisolated(unsafe) static var mainWindow: NSWindow?
// called once when the application has finished launching
func applicationDidFinishLaunching(_ notification: Notification) {
// run as a regular foreground app even when launched outside a .app
// bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// 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 {
vWindow.makeKeyAndOrderFront(nil)
vWindow.orderFrontRegardless()
break
}
}
}
// 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.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return false
}
// 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).
func applicationShouldHandleReopen(_ sender: NSApplication,
hasVisibleWindows inHasVisible: Bool) -> Bool {
if inHasVisible { return true }
if let vMain = AppDelegate.mainWindow {
// hide any other hidden windows (e.g. Settings) so AppKit's
// default behavior, if it triggers, doesn't bring them up
for vOther in NSApp.windows where vOther !== vMain && vOther.isVisible {
vOther.orderOut(nil)
}
vMain.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return false
}
// no captured window (shouldn't happen) - let AppKit do its default;
// SwiftUI will spawn a fresh WindowGroup window since the app is alive
return true
}
}
-279
View File
@@ -1,279 +0,0 @@
import Foundation
import CoreServices
import Darwin
// AllofitService is the headless runtime invoked by launchd when the binary
// is launched with the --service argument. It builds an initial index, then
// listens to FSEvents and writes the cache to disk every few seconds.
//
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
// (a leftover launchd job, or someone running --service manually) exits
// quietly instead of fighting for the cache file.
enum AllofitService {
// Shared mutable state across the FSEvents callback queue and the
// autosave loop. Wrapped in a class so closures capture by reference
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
// Sendable because every access goes through the NSLock below.
private final class State: @unchecked Sendable {
var records: [FileRecord] = []
var pathIndex: [String: Int] = [:]
var dirty: Bool = false
let lock = NSLock()
}
// runs the indexer/watcher loop forever (never returns)
static func run() -> Never {
// ensure files we create are world-readable (root daemon writes the
// cache; the GUI runs as the user and must be able to read it)
umask(0o022)
NSLog("[Allofit] service starting (uid=\(getuid()))")
// figure out whether we are the root daemon writing to /Library or
// the user agent writing to ~/Library, then acquire the lock
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
if !vLock.tryLock() {
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
exit(0)
}
let vPrefs = Preferences.shared
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs)
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
// log the actual configuration so the user can verify owner-prefs sync
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
NSLog("[Allofit] roots: %@", vRoots.map { $0.path }.joined(separator: ", "))
NSLog("[Allofit] excluded paths (%d): %@",
vPrefs.excludedPaths.count,
vPrefs.excludedPaths.joined(separator: ", "))
let vState = State()
// initial scan: streaming the walker through the shared state lock so
// the autosave thread (every 3s) can write partial progress while we
// continue walking. Without this, large filesystems leave the cache
// empty for many minutes and the GUI shows nothing.
//
// Both the per-root and per-batch callback bodies run inside their
// own autoreleasepool so the autoreleased NSURL / NSDate / NSNumber
// from the file enumeration don't pile up until the entire scan
// finishes - on a million-file scan that "pile" was peaking at
// well over a gig of dead allocations before the main thread's
// runloop got a chance to drain.
let vStartId = UInt64(FSEventsGetCurrentEventId())
for vRoot in vRoots {
autoreleasepool {
NSLog("[Allofit] scanning %@", vRoot.path)
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
autoreleasepool {
vState.lock.lock()
for vRec in vBatch {
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
if vState.pathIndex[vRec.fullPath] == nil {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
}
}
vState.dirty = true
vState.lock.unlock()
}
}
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
}
}
// force one save right after the scan finishes, so the GUI sees a
// stable count even if no FSEvents come in for a while afterwards
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
// FSEvents watcher
let vWatcher = FileWatcher()
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
vWatcher.start(
inRoots: vRoots.map { $0.path },
inSinceWhen: FSEventStreamEventId(vStartId)
) { vChanges in
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
vChanges.count,
vChanges.first?.path ?? "—")
vState.lock.lock()
defer { vState.lock.unlock() }
var vRescanPrefixes: [String] = []
var vAdded = 0
var vUpdated = 0
// batch removals into a set so we do one bulk allRecords pass
// and rebuild pathIndex once per FSEvents batch, instead of
// O(records) per individual removal - that nested rebuild was
// dominating CPU and turning into the daemon's memory churn
var vRemoved: Set<String> = []
// Per-change autoreleasepool: a large FSEvents batch (e.g.
// `rm -rf` of a deep tree) can deliver thousands of paths in
// one callback. Each path's NSURL + reachability check + the
// makeRecord internals autorelease - per-change drain keeps
// peak memory bounded by a single record's worth, not the
// whole batch.
for vChange in vChanges {
autoreleasepool {
if vMatcher.isExcluded(inPath: vChange.path) { return }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
return
}
// skip paths we've already queued for removal in this batch
if vRemoved.contains(vChange.path) { return }
let vUrl = URL(fileURLWithPath: vChange.path)
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
if let vIdx = vState.pathIndex[vRec.fullPath] {
vState.records[vIdx] = vRec
vUpdated += 1
} else {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
vAdded += 1
}
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
}
}
}
if !vRemoved.isEmpty {
// single bulk removeAll + single pathIndex rebuild
vState.records.removeAll { vRemoved.contains($0.fullPath) }
rebuildPathIndex(vState)
}
if vAdded + vUpdated + vRemoved.count > 0 {
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
vAdded, vUpdated, vRemoved.count, vState.records.count)
}
if !vRescanPrefixes.isEmpty {
NSLog("[Allofit] rescanning \(vRescanPrefixes.count) subtree(s) (history lost)")
let vNormalized = vRescanPrefixes.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
vState.records.removeAll { vRec in
let vP = vRec.fullPath
for vPre in vNormalized where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
return true
}
return false
}
for vPath in vRescanPrefixes {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: vMatcher
)
vState.records.append(contentsOf: vList)
}
rebuildPathIndex(vState)
}
vState.dirty = true
}
// periodic save loop (background thread). 3-second check interval so
// new files appear in the GUI within a few seconds of being created.
// Every N saves we also compact the in-memory containers so Swift's
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file
// activity. RSS is logged each save so the trajectory is visible.
//
// CRITICAL: each iteration runs inside its own autoreleasepool. The
// outer GCD block has an autorelease pool that drains when the block
// returns - which for our `while true` never happens. Without an
// inner pool, every save's autoreleased NSData (returned by
// .compressed(using: .lz4) and friends) accumulates forever and the
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20
var vSavesSinceCompact = 0
while true {
sleep(3)
autoreleasepool {
vState.lock.lock()
let vShouldSave = vState.dirty
let vSnapshot = vState.records
vState.dirty = false
vState.lock.unlock()
if !vShouldSave { return }
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB())
IndexStore.save(
inRecords: vSnapshot,
inLastEventId: vWatcher.latestEventId
)
vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0
compactContainers(vState)
}
}
}
}
// block forever on the runloop so launchd keeps us alive
RunLoop.current.run()
exit(0)
}
// rebuilds pathIndex from records. Used after a bulk allRecords mutation
// (batched removal or subtree rescan) - much cheaper than incrementally
// maintaining pathIndex during the mutation, and the reserveCapacity
// lets the dict size to its target without re-bucketing on each insert.
private static func rebuildPathIndex(_ inState: State) {
var vIndex: [String: Int] = [:]
vIndex.reserveCapacity(inState.records.count)
for (vI, vR) in inState.records.enumerated() {
vIndex[vR.fullPath] = vI
}
inState.pathIndex = vIndex
}
// recreates records and pathIndex with capacities matched to their
// actual element count. Swift Array/Dict only grow their backing
// allocations under churn, never auto-shrink, so a daemon that's
// been processing FSEvents for days can hold huge dead capacity
// (records.capacity >> records.count) that shows up as multi-GB
// RSS. Forcing fresh containers reclaims it.
private static func compactContainers(_ inState: State) {
inState.lock.lock()
defer { inState.lock.unlock() }
let vBefore = processFootprintMB()
// Array(_:) creates a fresh array sized exactly to the source -
// the old buffer's slack capacity is released
let vCompactRecords = Array(inState.records)
var vCompactIndex: [String: Int] = [:]
vCompactIndex.reserveCapacity(vCompactRecords.count)
for (vI, vR) in vCompactRecords.enumerated() {
vCompactIndex[vR.fullPath] = vI
}
inState.records = vCompactRecords
inState.pathIndex = vCompactIndex
let vAfter = processFootprintMB()
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
vCompactRecords.count, vBefore, vAfter)
}
// resident-memory size in MB matching Activity Monitor's "Memory" column
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
// of pages owned by the task minus shared/clean pages.
private static func processFootprintMB() -> Double {
var vInfo = task_vm_info_data_t()
var vCount = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
}
}
if vResult == KERN_SUCCESS {
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
}
return -1
}
}
-747
View File
@@ -1,747 +0,0 @@
import Foundation
import SwiftUI
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
// Lets the DispatchSource timer compare a new stat() result against the
// previously seen value without touching any @MainActor state, so the
// poll only hops to main when the cache has actually changed on disk.
private final class BackgroundMtime: @unchecked Sendable {
private var mtime: Date?
private let lock = NSLock()
// returns true if the supplied mtime differs from the stored value
// (and updates the stored value), false otherwise
func updateIfChanged(_ inMtime: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
if mtime != inMtime {
mtime = inMtime
return true
}
return false
}
}
// AppModel is the central observable 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.
//
// 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.
@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] = []
// total number of entries indexed (used for the status bar)
@Published private(set) var indexedCount: Int = 0
// true while a full reindex is in progress
@Published private(set) var isIndexing: Bool = false
// true while the cache file is being loaded into memory at startup
@Published private(set) var isLoadingCache: Bool = false
// true when this process owns the index (got the lock or built-in mode)
@Published private(set) var isIndexer: Bool = false
// 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
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] = [:]
// autosave timer when running in indexer mode
private var autosaveTimer: Timer?
// true when allRecords has changed since the last save
private var dirty = false
// last FSEvents event id known to be reflected in allRecords
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
// true while a privileged service operation is in flight (Reindex / Clear)
@Published private(set) var isWorking: Bool = false
// last user-facing status string for the Settings buttons
@Published private(set) var workMessage: String = ""
// polling backup for reader-mode cache changes; FSEvents alone can miss
// updates if the watched directory didn't exist when the stream started.
// Uses DispatchSourceTimer on a background queue (not Timer on the main
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// 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
// during heavy filesystem activity.
private let kMinReloadInterval: TimeInterval = 1.0
// timestamp of the last reloadFromCache() that actually swapped data in
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
}
// kicks off background activity for the first time. Subsequent calls are
// no-ops so we don't double-bootstrap when the user reopens the window.
// To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
// call switchToCurrentMode() instead.
func start() {
if hasStarted { return }
hasStarted = true
bootstrap()
}
// re-runs the mode-detection + cache-load + watcher-setup sequence.
// Used after the user changes the service installation state so the GUI
// can hot-swap between indexer and reader without a full app restart.
func switchToCurrentMode() {
NSLog("[Allofit GUI] switching mode (serviceMode=%@)", prefs.serviceMode.rawValue)
bootstrap()
}
// stops all watchers/timers and releases the indexer lock, leaving the
// model ready for a fresh bootstrap()
private func tearDownActiveMode() {
watcher.stop()
cacheWatcher.stop()
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
// path, and starts the right watcher set. Safe to call repeatedly.
private func bootstrap() {
tearDownActiveMode()
if prefs.serviceMode != .none {
isIndexer = false
} else {
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
if vLock.tryLock() {
indexerLock = vLock
isIndexer = true
} else {
isIndexer = false
NSLog("[Allofit GUI] indexer lock held by another process, running as reader")
}
}
isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
prefs.serviceMode.rawValue,
isIndexer ? "true" : "false",
vCacheURL.path)
ioQueue.async { [weak self] in
// decompress + parse off-main (this is 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 ?? [])
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
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.isLoadingCache = false
vSelf.applyFilterAndSort()
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
vSelf.startReaderMode()
}
}
}
}
// manually triggered cache reload; surfaced as a button in the Cache tab
// so users can verify the GUI reads what the daemon wrote. Bypasses
// reloadFromCache's rate-limit / same-eventId guards since the user
// explicitly asked.
func forceReloadCache() {
NSLog("[Allofit GUI] manual reload triggered")
lastReloadAt = nil
lastEventId = 0
reloadFromCache()
}
// reindexes from scratch on the next idle moment
func reindex() {
guard isIndexer, !isIndexing else { return }
isIndexing = true
indexedCount = 0
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vStartId = UInt64(FSEventsGetCurrentEventId())
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// 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)
}
}
let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
// persist off-main before bouncing back so main never sees the
// LZ4 compression cost
IndexStore.save(inRecords: vFinal, inLastEventId: vStartId)
DispatchQueue.main.async {
self?.applyFreshIndex(inRecords: vFinal, inLookup: vLookup, inEventId: vStartId)
}
}
}
// dispatches a reindex appropriate for the current mode. In built-in mode
// it triggers an in-process FileIndexer pass. In service mode it stops
// the daemon, deletes its cache, and starts the daemon again so the
// daemon's fresh process performs the scan from scratch. The privileged
// portion runs on a detached task so the main thread (and the password
// dialog from NSAppleScript) doesn't freeze the UI.
func performReindex() async {
if isIndexer {
reindex()
return
}
await runPrivilegedAction(inLabel: "Reindexing") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
// removes the on-disk cache, with the right privilege escalation per mode.
// In built-in mode the file is owned by the current user, so a plain
// removeItem suffices. In service mode the cache may be owned by root and
// the daemon would re-write it on its next autosave, so we tunnel the
// delete through the same stop/delete/start admin script.
func performClearCache() async {
switch prefs.serviceMode {
case .none:
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
workMessage = "Cache cleared."
if isIndexer { reindex() }
case .userAgent, .rootDaemon:
await runPrivilegedAction(inLabel: "Clearing cache") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
}
// installs a launchd service for the current serviceMode preference,
// off-main so the password dialog doesn't freeze the GUI. On success
// the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
func performInstallService() async {
let vOk = await runPrivilegedAction(inLabel: "Installing service") { vScope, _ in
try ServiceInstaller.install(inScope: vScope)
}
if vOk {
// give launchd a moment to bring the daemon up before we switch
// the GUI into reader mode and start watching the cache file
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService() 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 {
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service") { vScope, _ in
try ServiceInstaller.uninstall(inScope: vScope)
}
if vOk {
// give launchd a moment to actually tear the daemon down so its
// indexer lock is released before the GUI tries to grab it
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// shared helper: runs a service-mode operation on a detached queue while
// publishing isWorking/workMessage so the UI can show progress. Returns
// true if the action ran without throwing.
@discardableResult
private func runPrivilegedAction(inLabel: String,
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool {
let vMode = prefs.serviceMode
guard vMode != .none else { return false }
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
let vUrl = IndexStore.cacheURL(forServiceMode: vMode)
// flush UserDefaults so the daemon reads up-to-date settings from
// our plist after it restarts (cfprefsd can buffer writes for minutes)
Preferences.flushToDisk()
isWorking = true
workMessage = "\(inLabel)…"
do {
try await Task.detached(priority: .userInitiated) {
try inBody(vScope, vUrl)
}.value
workMessage = "\(inLabel): done."
isWorking = false
return true
} catch {
workMessage = "\(inLabel) failed: \(error.localizedDescription)"
NSLog("[Allofit] %@ failed: %@", inLabel, "\(error)")
isWorking = false
return false
}
}
// writes the current index to disk (called on quit and by autosave)
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)
dirty = false
ioQueue.async {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
}
}
// ===========================
// MARK: Indexer mode
// ===========================
private func startIndexerMode() {
if allRecords.isEmpty {
reindex()
} else {
startWatching(inSinceWhen: lastEventId)
}
startAutosaveTimer()
}
private func startWatching(inSinceWhen: UInt64) {
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
watcher.start(
inRoots: vRoots.map { $0.path },
inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { vChanges in
DispatchQueue.main.async { [weak self] in
self?.applyFileSystemChanges(inChanges: vChanges)
}
}
}
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
// .common mode so the timer fires even while SwiftUI is busy
let vTimer = Timer(timeInterval: kAutosaveSeconds, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let vSelf = self, vSelf.dirty else { return }
vSelf.saveCache()
}
}
RunLoop.main.add(vTimer, forMode: .common)
autosaveTimer = vTimer
}
// 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
dirty = true
scheduleSearch()
}
}
// 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 + "/" }
indexQueue.async { [weak self] in
var vKept = vCurrent.filter { vRec in
let vP = vRec.fullPath
for vPre in vPrefixes where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
return false
}
return true
}
for vPath in inPaths {
// per-subtree autoreleasepool keeps the rescan's
// autoreleased URL/stat objects from accumulating across
// subtrees inside this long-running async block
autoreleasepool {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
}
let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
DispatchQueue.main.async {
self?.allRecords = vFinal
self?.pathIndex = vLookup
self?.indexedCount = vFinal.count
self?.dirty = true
self?.scheduleSearch()
}
}
}
// 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)
}
// ===========================
// MARK: Reader mode
// ===========================
private func startReaderMode() {
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
// FSEvents-based watcher for low-latency updates. We watch the
// parent dir (FSEvents needs a real path) but only fire the reload
// when the cache file itself changed - other files in the dir
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
// trigger main.async hops, which contributed to dropped clicks.
cacheWatcher.start(inRoots: [vDir]) { vChanges in
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
guard vCacheChanged else { return }
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
DispatchQueue.main.async { [weak self] in
self?.reloadFromCache()
}
}
// Background-queue polling backup at 2s. The stat() runs off main,
// and a lock-guarded background-side mtime tracker means we ONLY
// hop to main when the cache actually changed - the steady state
// puts zero work on the main runloop, leaving NSTableView's click
// handling uninterrupted.
cachePollSource?.cancel()
let vPolledPath = vUrl.path
let vBgMtime = BackgroundMtime() // captured by the closure
let vSource = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
vSource.schedule(deadline: .now() + 2.0, repeating: 2.0)
vSource.setEventHandler { [weak self] in
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vPolledPath),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
// Background-side change detection; only proceeds when mtime moved
guard vBgMtime.updateIfChanged(vMtime) else { return }
DispatchQueue.main.async {
self?.reloadFromCache()
}
}
vSource.resume()
cachePollSource = vSource
}
// 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.
// 2. A 1-second floor between reloads. Even when changes do happen,
// we don't need to re-render the whole list at FSEvents' rate -
// the next tick will catch any newer state.
private func reloadFromCache() {
let vNow = Date()
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
return
}
lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vCurrentEventId = lastEventId
ioQueue.async { [weak self] in
let vCache = IndexStore.load(from: vUrl)
// 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 ?? [])
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()
}
}
}
// ===========================
// 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
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 }
}
}
}
-358
View File
@@ -1,358 +0,0 @@
import SwiftUI
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 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
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
@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
// survives app launches; subsequent changes flow back via .onChange.
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization()
// debounced background save task for columnCustomization changes.
// 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.
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
set: { vNewOrder in
guard let vFirst = vNewOrder.first else { return }
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
// defer one runloop tick so we don't write back into the
// model while NSTableView is still in its sort delegate
// callback (avoids the reentrant-operation AppKit warning)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
}
}
)
}
var body: some View {
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")
}
.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
// on main here would freeze the drag delegate. Cancel any
// pending task and reschedule so we encode at most once per
// drag (300 ms after the user lets go).
columnSaveTask?.cancel()
columnSaveTask = Task.detached(priority: .utility) {
try? await Task.sleep(nanoseconds: Self.kColumnSaveDebounceNanos)
if Task.isCancelled { return }
ContentView.saveColumnCustomization(vNew)
}
}
}
// 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
// ===========================
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode(
TableColumnCustomization<FileRecord>.self,
from: vData
)
else {
return TableColumnCustomization<FileRecord>()
}
return vCustom
}
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
}
// ===========================
// MARK: Search bar
// ===========================
private var searchBar: some View {
SearchField(
text: $model.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
.frame(minHeight: 24)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
// ===========================
// MARK: Results table
// ===========================
private var resultsTable: some View {
// 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,
columnCustomization: $columnCustomization) {
TableColumn("Name", value: \FileRecord.name) { vRecord in
HStack(spacing: 6) {
Image(nsImage: IconCache.icon(
forName: vRecord.name,
isDirectory: vRecord.isDirectory
))
.resizable()
.frame(width: 16, height: 16)
Text(vRecord.name)
.lineLimit(1)
// 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)
AuthorizeBadge(record: vRecord)
}
}
}
.width(min: 200, ideal: 320)
.customizationID("name")
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
Text(vRecord.parentPath)
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
}
.width(min: 200, ideal: 380)
.customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Formatters.size(bytes: vRecord.size))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(90)
.customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("modified")
} rows: {
ForEach(model.visibleRecords) { vRecord in
TableRow(vRecord)
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
}
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
Button("Open") { openSelection(inIds: vIds) }
Button("Reveal in Finder") { revealSelection(inIds: vIds) }
Button("Quick Look") { quickLookSelection(inIds: vIds) }
Divider()
Button("Copy Path") { copyPaths(inIds: vIds) }
} 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
}
}
// ===========================
// MARK: Selection actions
// ===========================
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>) {
// 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(access.effectiveURL(for: vRecord))
}
}
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
return model.visibleRecords.filter { inIds.contains($0.id) }
}
// ===========================
// MARK: Sort mapping
// ===========================
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath
if vKp == \FileRecord.name { return vAsc ? .nameAscending : .nameDescending }
if vKp == \FileRecord.parentPath { return vAsc ? .pathAscending : .pathDescending }
if vKp == \FileRecord.size { return vAsc ? .sizeAscending : .sizeDescending }
if vKp == \FileRecord.dateCreated { return vAsc ? .createdAscending : .createdDescending }
if vKp == \FileRecord.dateModified { return vAsc ? .modifiedAscending : .modifiedDescending }
return .nameAscending
}
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
case .nameDescending: return KeyPathComparator(\FileRecord.name, order: .reverse)
case .pathAscending: return KeyPathComparator(\FileRecord.parentPath, order: .forward)
case .pathDescending: return KeyPathComparator(\FileRecord.parentPath, order: .reverse)
case .sizeAscending: return KeyPathComparator(\FileRecord.size, order: .forward)
case .sizeDescending: return KeyPathComparator(\FileRecord.size, order: .reverse)
case .createdAscending: return KeyPathComparator(\FileRecord.dateCreated, order: .forward)
case .createdDescending: return KeyPathComparator(\FileRecord.dateCreated, order: .reverse)
case .modifiedAscending: return KeyPathComparator(\FileRecord.dateModified, order: .forward)
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
}
}
}
// ===========================
// MARK: Status bar
// ===========================
// 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 leaf view rather than the ContentView body
// that contains the Table.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
var body: some View {
HStack(spacing: 8) {
if model.isIndexing {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount) entries")
} else {
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
.foregroundColor(.secondary)
switch prefs.serviceMode {
case .none: EmptyView()
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
}
if !model.query.isEmpty {
Text("· Filtered").foregroundColor(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.font(.caption)
.foregroundColor(.secondary)
}
}
-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
}
}
-146
View File
@@ -1,146 +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)
}
// 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) {
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. The whole body is wrapped
// in autoreleasepool so the autoreleased NSDate / NSNumber / NSURL
// objects returned by resourceValues() are released at function exit
// rather than at the caller's pool drain.
static func makeRecord(inURL: URL) -> FileRecord? {
return autoreleasepool { () -> FileRecord? in
makeRecord_impl(inURL: inURL)
}
}
private static func makeRecord_impl(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 }
}
-32
View File
@@ -1,32 +0,0 @@
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)
}
}
-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
}
}
+72 -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
# ==================
@@ -96,6 +103,14 @@ echo "==> Assembling ${kAppName}.app"
rm -rf "${vAppBundle}"
mkdir -p "${vMacOSDir}" "${vResourcesDir}"
cp "${vBinarySrc}" "${vMacOSDir}/${kAppName}"
# drop library search paths that point outside the system (the toolchain
# embeds one into /Applications/Xcode.app): the same binary runs as the root
# daemon, which must never look for libraries in an admin-writable folder
while read -r vRpath; do
[[ -z "${vRpath}" ]] && continue
install_name_tool -delete_rpath "${vRpath}" "${vMacOSDir}/${kAppName}"
done < <(otool -l "${vMacOSDir}/${kAppName}" | awk '/cmd LC_RPATH/{getline; getline; print $2}' | grep -v '^/usr/lib' || true)
chmod +x "${vMacOSDir}/${kAppName}"
# ==================
@@ -190,7 +205,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>
@@ -204,6 +219,54 @@ ${vIconPlistEntry}
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- text shown in the macOS privacy prompts triggered by the first scan -->
<key>NSDesktopFolderUsageDescription</key>
<string>Allofit indexes file names on your Desktop so they show up in searches. File contents are never read.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Allofit indexes file names in Documents so they show up in searches. File contents are never read.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>Allofit indexes file names in Downloads so they show up in searches. File contents are never read.</string>
<key>NSRemovableVolumesUsageDescription</key>
<string>Allofit indexes file names on external drives you choose to include in searches.</string>
<key>NSNetworkVolumesUsageDescription</key>
<string>Allofit indexes file names on network volumes you choose to include in searches.</string>
<!-- Finder > right-click a folder > Services / Quick Actions > Search in Allofit -->
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Search in Allofit</string>
</dict>
<key>NSMessage</key>
<string>searchInAllofit</string>
<key>NSPortName</key>
<string>${kAppName}</string>
<key>NSRequiredContext</key>
<dict/>
<key>NSSendFileTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
<!-- folders dropped on the Dock icon / open -a Allofit <folder> start a search inside them -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Folder</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>None</string>
<key>LSItemContentTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
</dict>
</plist>
EOF
+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
+27 -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
# ==================
@@ -22,14 +23,19 @@ kSystemDaemonPlist="/Library/LaunchDaemons/${kServicePlistName}"
kUserCacheDir="$HOME/Library/Application Support/Allofit"
kSystemCacheDir="/Library/Application Support/Allofit"
kUserPrefsPlist="$HOME/Library/Preferences/${kBundleId}.plist"
# old /tmp log names (versions up to 1.0.9) and the current log folders
kServiceLogStdout="/tmp/allofit-service.log"
kServiceLogStderr="/tmp/allofit-service.err"
kUserLogDir="$HOME/Library/Logs/Allofit"
kSystemLogDir="/Library/Logs/Allofit"
# ==================
# MARK: Args
# ==================
vScriptDir="$(cd "$(dirname "$0")" && pwd)"
# Resolve the project root from the script's own location (scripts/..),
# so this script works regardless of the caller's CWD.
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
vKeepBuild=0
vKeepPrefs=0
vDryRun=0
@@ -105,7 +111,7 @@ fi
echo "==> Killing any leftover daemon processes"
# `pkill -f` matches against the full command line; the daemon binary is
# now installed as ".../Allofit Service" (renamed copy), so the literal
# now installed as ".../Allofit service" (renamed copy), so the literal
# "Allofit --service" no longer appears - use a regex that handles both
# the legacy and the renamed binary by anchoring on "Allofit...--service"
vRun pkill -f "Allofit.*--service" >/dev/null 2>&1 || true
@@ -166,19 +172,28 @@ if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
fi
done
fi
if [[ -d "$kUserLogDir" ]]; then
echo "==> Removing user service logs"
vRun rm -rf "$kUserLogDir"
fi
if [[ -d "$kSystemLogDir" ]]; then
echo "==> Removing system service logs (needs sudo)"
vNeedsSudo
vRun sudo rm -rf "$kSystemLogDir"
fi
# ==================
# MARK: Build artifacts
# ==================
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 {
// entry point: routes to the GUI, the headless service or the CLI
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()
}
// 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
}
}
+194
View File
@@ -0,0 +1,194 @@
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
}
// live updates only: on a case-insensitive volume a case-only
// rename (Foo -> foo) still resolves the old spelling, which would
// leave a ghost "Foo" entry. If the name stored on disk differs from
// the reported path by case only, the reported path is gone.
if inSkipHidden, let vStored = try? vUrl.resourceValues(forKeys: [.nameKey]).name {
let vReported = inURL.lastPathComponent
// .nameKey shows a ":" in an HFS name as "/"; compare composed forms
let vOnDisk = vStored.replacingOccurrences(of: "/", with: ":").precomposedStringWithCanonicalMapping
let vAsReported = vReported.precomposedStringWithCanonicalMapping
if vOnDisk != vAsReported && vOnDisk.lowercased() == vAsReported.lowercased() {
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)
return FileRecord(
name: vName,
parentPath: vParent?.path ?? vParentRaw,
parentLower: vParent?.folded,
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
// (plus its search form), so the thousands of records of a directory share
// a single parentPath / parentLower buffer instead of each holding a copy. Not thread-safe: use one
// per walk / load.
final class PathInterner {
// distinct value -> (shared instance, shared search form)
private var table: [String: (path: String, folded: String)] = [:]
// returns the shared instance equal to inString and its search form
func intern(_ inString: String) -> (path: String, folded: String) {
if let vShared = table[inString] { return vShared }
let vEntry = (path: inString, folded: FileRecord.sharedSearchForm(of: inString))
table[inString] = vEntry
return vEntry
}
}
+138
View File
@@ -0,0 +1,138 @@
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
// lowercased, NFC-normalized parent path for path terms. Interned like
// parentPath when built through a PathInterner, so a folder's entries
// share one copy and path searches do no per-record string work.
let parentLower: String
// builds a record; inParentLower is the precomputed search form of the
// parent path (computed here when nil)
init(name inName: String,
parentPath inParentPath: String,
parentLower inParentLower: String? = nil,
size inSize: Int64,
dateCreated inDateCreated: Date,
dateModified inDateModified: Date,
isDirectory inIsDirectory: Bool) {
self.id = FileRecord.pathHash(inParent: inParentPath, inName: inName)
self.name = inName
self.parentPath = inParentPath
self.parentLower = inParentLower ?? FileRecord.sharedSearchForm(of: inParentPath)
self.size = inSize
self.dateCreated = inDateCreated
self.dateModified = inDateModified
self.isDirectory = inIsDirectory
self.nameLower = FileRecord.sharedSearchForm(of: inName)
}
// 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
}
// search form that reuses the input's storage when nothing changes
static func sharedSearchForm(of inString: String) -> String {
let vForm = searchForm(of: inString)
return vForm == inString ? inString : vForm
}
// 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,16 +10,19 @@ 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 {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) != 0
}
// true when this event references a directory
var isDir: Bool {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsDir) != 0
}
}
// FileWatcher streams real-time filesystem change notifications using FSEvents.
@@ -44,18 +47,11 @@ final class FileWatcher: @unchecked Sendable {
// guards stream/handler against concurrent access
private let lock = NSLock()
// stops the stream so its callback never outlives the watcher
deinit {
stop()
}
// returns the latest event id observed by the running stream (or 0)
var latestEventId: UInt64 {
lock.lock()
defer { lock.unlock() }
guard let vStream = stream else { return 0 }
return UInt64(FSEventStreamGetLatestEventId(vStream))
}
// starts watching inRoots, delivering batches of changes to inHandler
// starting from inSinceWhen (defaults to "now")
func start(inRoots: [String],
@@ -75,7 +71,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 +79,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
+345
View File
@@ -0,0 +1,345 @@
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
// (chunked: snapshots are cheap and a change copies only one chunk)
private(set) var records = RecordStore()
// 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] = []) {
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
}
// ===========================
// 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], newFolders: [String]) {
var vChanged = false
var vRescans: [String] = []
var vNewFolders: [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 {
vNewFolders.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)
}
let vMinimalRescans = SubtreeMatcher.minimalRoots(inPaths: vRescans)
// a new folder inside a kernel rescan is walked by that rescan
let vCovered = SubtreeMatcher(inRoots: vMinimalRescans)
let vFolders = SubtreeMatcher.minimalRoots(inPaths: vNewFolders).filter { vFolder in
!vMinimalRescans.contains(vFolder) && !vCovered.containsChild(inParentPath: (vFolder as NSString).deletingLastPathComponent)
}
return (vChanged, vMinimalRescans, vFolders)
}
// adds the records found by walking folders that just appeared. Nothing
// below such a folder can be indexed yet (except entries upserted from
// the same FSEvents batch, which upsert replaces), so unlike
// replaceSubtrees no O(n) removal pass over the whole index is needed.
mutating func mergeNewFolders(inRecords: [FileRecord]) {
for vRecord in inRecords {
upsert(vRecord)
}
}
// 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 = RecordStore(records)
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
}
}
@@ -49,14 +49,11 @@ enum IndexStore {
in: .userDomainMask
).first!
}
let vDir = vBase.appendingPathComponent("Allofit", isDirectory: true)
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
// for the system-wide path the directory may have been created by the
// root daemon - make sure non-root users (the GUI) can traverse it
if inSystem {
chmod(vDir.path, 0o755)
}
return vDir.appendingPathComponent("index.bin")
// pure path computation (no disk access: it's called from views);
// saveImpl creates the folder when it writes
return vBase
.appendingPathComponent("Allofit", isDirectory: true)
.appendingPathComponent("index.bin")
}
// returns the URL appropriate for a given service mode
@@ -87,7 +84,7 @@ enum IndexStore {
// ===========================
// writes the records and event id atomically to the default cacheURL
static func save(inRecords: [FileRecord], inLastEventId: UInt64) {
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64) {
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
}
@@ -96,13 +93,14 @@ enum IndexStore {
// 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) {
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
autoreleasepool {
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
saveImpl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
}
}
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
// encodes, LZ4-compresses and atomically writes the cache (body of save)
private static func saveImpl(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId)
@@ -136,26 +134,51 @@ enum IndexStore {
writeU64(into: &vEnvelope, value: vUncompressedSize)
vEnvelope.append(vCompressed)
let vTmp = inUrl.appendingPathExtension("tmp")
// the folder may not exist yet (first save). The system-wide one must
// stay traversable by the GUI user; the index inside is owner-only
// (see restrictAccess)
let vDir = inUrl.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
if getuid() == 0 {
chmod(vDir.path, 0o755)
}
// unique per save: two saves running at once (reindex and autosave
// on different queues) must not write the same temp file
let vTmp = inUrl.deletingLastPathComponent()
.appendingPathComponent(".\(inUrl.lastPathComponent).\(UUID().uuidString).tmp")
do {
try vEnvelope.write(to: vTmp, options: .atomic)
_ = try FileManager.default.replaceItem(
at: inUrl,
withItemAt: vTmp,
backupItemName: nil,
options: [],
resultingItemURL: nil
)
// the root daemon writes this file owned by root. The GUI runs
// as the user and must be able to read it, so force world-read
// permissions regardless of the process's umask.
chmod(inUrl.path, 0o644)
try vEnvelope.write(to: vTmp)
// owner-only access, set on the temp file *before* it becomes
// the cache, so the index (a listing of every file name) is
// never readable by other local users, even for a moment
restrictAccess(inPath: vTmp.path)
// rename(2) is atomic and keeps the temp file's owner and mode
// (FileManager.replaceItem may carry over the old file's)
guard rename(vTmp.path, inUrl.path) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
} catch {
NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)")
try? FileManager.default.removeItem(at: vTmp)
}
}
// makes a cache file readable by its owner only. The root daemon hands
// it to the user who installed the service (the GUI reads it as that
// user); everyone else gets nothing.
private static func restrictAccess(inPath: String) {
if getuid() == 0,
let vOwner = ProcessInfo.processInfo.environment["ALLOFIT_OWNER_USER"],
let vEntry = getpwnam(vOwner) {
chown(inPath, vEntry.pointee.pw_uid, vEntry.pointee.pw_gid)
}
chmod(inPath, 0o600)
}
// largest uncompressed payload accepted from a cache file (a corrupt or
// crafted header must not make the loader allocate without bound)
private static let kMaxPayloadBytes: UInt64 = 8 << 30
// reads the persisted index back from the default cacheURL
static func load() -> LoadResult? {
return load(from: cacheURL)
@@ -166,16 +189,18 @@ enum IndexStore {
// String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool {
load_impl(from: inUrl)
loadImpl(from: inUrl)
}
}
private static func load_impl(from inUrl: URL) -> LoadResult? {
// reads, decompresses and decodes the cache (body of load)
private static func loadImpl(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 }
guard let vVersion = readU32(from: vData, offset: &vOffset), vVersion == kVersion else { return nil }
guard let _ = readU64(from: vData, offset: &vOffset) else { return nil } // uncompressed size, advisory only
guard let vDeclaredSize = readU64(from: vData, offset: &vOffset),
vDeclaredSize <= kMaxPayloadBytes else { return nil }
let vCompressed = vData.subdata(in: vOffset..<vData.count)
let vPayload: Data
@@ -185,14 +210,22 @@ enum IndexStore {
NSLog("[Allofit] LZ4 decompression failed: \(error)")
return nil
}
guard UInt64(vPayload.count) == vDeclaredSize else { return nil }
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),
@@ -202,10 +235,11 @@ enum IndexStore {
else {
return nil
}
let vShared = vInterner.intern(vParent)
vRecords.append(FileRecord(
id: vId,
name: vName,
parentPath: vParent,
parentPath: vShared.path,
parentLower: vShared.folded,
size: vSize,
dateCreated: Date(timeIntervalSince1970: vCreated),
dateModified: Date(timeIntervalSince1970: vModified),
@@ -219,69 +253,94 @@ enum IndexStore {
// MARK: Binary helpers
// ===========================
private static func writeU32(into ioData: inout Data, value: UInt32) {
var vV = value.littleEndian
// appends a little-endian UInt32
private static func writeU32(into ioData: inout Data, value inValue: UInt32) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeU64(into ioData: inout Data, value: UInt64) {
var vV = value.littleEndian
// appends a little-endian UInt64
private static func writeU64(into ioData: inout Data, value inValue: UInt64) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeI64(into ioData: inout Data, value: Int64) {
var vV = value.littleEndian
// appends a little-endian Int64
private static func writeI64(into ioData: inout Data, value inValue: Int64) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeF64(into ioData: inout Data, value: Double) {
var vV = value.bitPattern.littleEndian
// appends a Double as its little-endian bit pattern
private static func writeF64(into ioData: inout Data, value inValue: Double) {
var vV = inValue.bitPattern.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeString(into ioData: inout Data, value: String) {
let vBytes = Array(value.utf8)
// appends a UInt32 byte length followed by the UTF-8 bytes
private static func writeString(into ioData: inout Data, value inValue: String) {
let vBytes = Array(inValue.utf8)
writeU32(into: &ioData, value: UInt32(vBytes.count))
ioData.append(contentsOf: vBytes)
}
private static func readU8(from inData: Data, offset: inout Int) -> UInt8? {
guard offset + 1 <= inData.count else { return nil }
let vV = inData[inData.startIndex + offset]
offset += 1
// reads one byte at ioOffset and advances it; nil past the end
private static func readU8(from inData: Data, offset ioOffset: inout Int) -> UInt8? {
guard ioOffset + 1 <= inData.count else { return nil }
let vV = inData[inData.startIndex + ioOffset]
ioOffset += 1
return vV
}
private static func readU32(from inData: Data, offset: inout Int) -> UInt32? {
guard offset + 4 <= inData.count else { return nil }
// reads a little-endian UInt32 at ioOffset and advances it
private static func readU32(from inData: Data, offset ioOffset: inout Int) -> UInt32? {
guard ioOffset + 4 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt32 in
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt32.self).littleEndian
}
offset += 4
ioOffset += 4
return vV
}
private static func readU64(from inData: Data, offset: inout Int) -> UInt64? {
guard offset + 8 <= inData.count else { return nil }
// reads a little-endian UInt64 at ioOffset and advances it
private static func readU64(from inData: Data, offset ioOffset: inout Int) -> UInt64? {
guard ioOffset + 8 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt64 in
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt64.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt64.self).littleEndian
}
offset += 8
ioOffset += 8
return vV
}
private static func readI64(from inData: Data, offset: inout Int) -> Int64? {
guard offset + 8 <= inData.count else { return nil }
// reads a little-endian Int64 at ioOffset and advances it
private static func readI64(from inData: Data, offset ioOffset: inout Int) -> Int64? {
guard ioOffset + 8 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> Int64 in
vPtr.loadUnaligned(fromByteOffset: offset, as: Int64.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: Int64.self).littleEndian
}
offset += 8
ioOffset += 8
return vV
}
private static func readF64(from inData: Data, offset: inout Int) -> Double? {
guard let vBits = readU64(from: inData, offset: &offset) else { return nil }
// reads a Double stored as its little-endian bit pattern
private static func readF64(from inData: Data, offset ioOffset: inout Int) -> Double? {
guard let vBits = readU64(from: inData, offset: &ioOffset) else { return nil }
return Double(bitPattern: vBits)
}
private static func readString(from inData: Data, offset: inout Int) -> String? {
guard let vLen = readU32(from: inData, offset: &offset) else { return nil }
// reads a UInt32-length-prefixed UTF-8 string and advances ioOffset
private static func readString(from inData: Data, offset ioOffset: inout Int) -> String? {
guard let vLen = readU32(from: inData, offset: &ioOffset) else { return nil }
let vLength = Int(vLen)
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)
guard ioOffset + vLength <= inData.count else { return nil }
let vStart = ioOffset
ioOffset += vLength
// native decode (no Foundation bridging); invalid bytes are repaired
return inData.withUnsafeBytes { vPtr in
String(decoding: vPtr[vStart..<(vStart + vLength)], as: UTF8.self)
}
}
}
@@ -18,10 +18,12 @@ final class IndexerLock {
// open descriptor, -1 when not locked
private var fileDescriptor: Int32 = -1
init(path: String) {
self.path = path
// creates an unlocked handle for the sentinel file at inPath
init(path inPath: String) {
self.path = inPath
}
// releases the lock together with the handle
deinit {
unlock()
}
@@ -74,8 +76,8 @@ final class IndexerLock {
}
// reads the pid currently holding the lock, if any (best-effort)
static func readHolderPid(path: String) -> Int32? {
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: path)),
static func readHolderPid(path inPath: String) -> Int32? {
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: inPath)),
let vStr = String(data: vData, encoding: .utf8),
let vPid = Int32(vStr.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil }
@@ -39,15 +39,72 @@ final class Preferences: ObservableObject, @unchecked Sendable {
@Published var serviceMode: ServiceMode {
didSet { UserDefaults.standard.set(serviceMode.rawValue, forKey: Self.kServiceModeKey) }
}
// last search query, restored on next launch
@Published var lastQuery: String {
didSet { UserDefaults.standard.set(lastQuery, forKey: Self.kLastQueryKey) }
}
// last sort descriptor used, restored on next launch
@Published var lastSort: FileSortDescriptor {
didSet { UserDefaults.standard.set(lastSort.rawValue, forKey: Self.kLastSortKey) }
}
// seconds file changes are collected before being applied to the index,
// while Allofit is focused / another app is focused / no window shows
@Published var updateDelayForeground: Double {
didSet { UserDefaults.standard.set(updateDelayForeground, forKey: Self.kUpdateDelayForegroundKey) }
}
@Published var updateDelayBackground: Double {
didSet { UserDefaults.standard.set(updateDelayBackground, forKey: Self.kUpdateDelayBackgroundKey) }
}
@Published var updateDelayHidden: Double {
didSet { UserDefaults.standard.set(updateDelayHidden, forKey: Self.kUpdateDelayHiddenKey) }
}
// minimum seconds between two result refreshes caused by index changes,
// while Allofit is focused / another app is focused
@Published var refreshIntervalForeground: Double {
didSet { UserDefaults.standard.set(refreshIntervalForeground, forKey: Self.kRefreshForegroundKey) }
}
@Published var refreshIntervalBackground: Double {
didSet { UserDefaults.standard.set(refreshIntervalBackground, forKey: Self.kRefreshBackgroundKey) }
}
// true to emphasize the matched parts of names and paths in the results
@Published var highlightMatches: Bool {
didSet { UserDefaults.standard.set(highlightMatches, forKey: Self.kHighlightMatchesKey) }
}
// what a double-click / Return on a result does
@Published var primaryAction: PrimaryAction {
didSet { UserDefaults.standard.set(primaryAction.rawValue, forKey: Self.kPrimaryActionKey) }
}
// true to show the Allofit icon in the menu bar
@Published var showMenuBarIcon: Bool {
didSet { UserDefaults.standard.set(showMenuBarIcon, forKey: Self.kShowMenuBarIconKey) }
}
// system-wide shortcut that shows / hides the main window
@Published var globalHotKey: HotKeyPreset {
didSet { UserDefaults.standard.set(globalHotKey.rawValue, forKey: Self.kGlobalHotKeyKey) }
}
// actions available for double-click / Return on a result
enum PrimaryAction: String, CaseIterable, Identifiable {
case open // open with the default app
case reveal // reveal in Finder
var id: String { rawValue }
}
// default values of the update / refresh timings, in seconds
static let kDefaultUpdateDelayForeground: Double = 2
static let kDefaultUpdateDelayBackground: Double = 15
static let kDefaultUpdateDelayHidden: Double = 60
static let kDefaultRefreshForeground: Double = 1
static let kDefaultRefreshBackground: Double = 5
// puts every update / refresh timing back to its default
func resetTimings() {
updateDelayForeground = Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.kDefaultRefreshBackground
}
// service installation modes
enum ServiceMode: String, CaseIterable, Identifiable {
case none // no service: GUI indexes in its own process
@@ -61,8 +118,16 @@ final class Preferences: ObservableObject, @unchecked Sendable {
private static let kMountedKey = "Allofit.includeMountedVolumes"
private static let kNetworkKey = "Allofit.includeNetworkVolumes"
private static let kServiceModeKey = "Allofit.serviceMode"
private static let kLastQueryKey = "Allofit.lastQuery"
private static let kLastSortKey = "Allofit.lastSort"
private static let kHighlightMatchesKey = "Allofit.highlightMatches"
private static let kShowMenuBarIconKey = "Allofit.showMenuBarIcon"
private static let kGlobalHotKeyKey = "Allofit.globalHotKey"
private static let kPrimaryActionKey = "Allofit.primaryAction"
private static let kUpdateDelayForegroundKey = "Allofit.updateDelayForeground"
private static let kUpdateDelayBackgroundKey = "Allofit.updateDelayBackground"
private static let kUpdateDelayHiddenKey = "Allofit.updateDelayHidden"
private static let kRefreshForegroundKey = "Allofit.refreshIntervalForeground"
private static let kRefreshBackgroundKey = "Allofit.refreshIntervalBackground"
// flushes pending UserDefaults writes to disk so the daemon (which reads
// the plist file directly) picks up the latest settings on next start
@@ -70,6 +135,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
UserDefaults.standard.synchronize()
}
// loads every setting, from the owner's plist when running as the root daemon
private init() {
// When we are the root daemon, the standard UserDefaults points at
// /var/root/Library/Preferences/... which is *not* where the GUI user
@@ -88,13 +154,28 @@ final class Preferences: ObservableObject, @unchecked Sendable {
} else {
serviceMode = .none
}
lastQuery = Self.readString(forKey: Self.kLastQueryKey, from: vSourceDict) ?? ""
if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict),
let vSort = FileSortDescriptor(rawValue: vRaw) {
lastSort = vSort
} else {
lastSort = .nameAscending
}
highlightMatches = Self.readBool(forKey: Self.kHighlightMatchesKey, from: vSourceDict) ?? true
showMenuBarIcon = Self.readBool(forKey: Self.kShowMenuBarIconKey, from: vSourceDict) ?? true
globalHotKey = Self.readString(forKey: Self.kGlobalHotKeyKey, from: vSourceDict)
.flatMap(HotKeyPreset.init(rawValue:)) ?? .optionSpace
primaryAction = Self.readString(forKey: Self.kPrimaryActionKey, from: vSourceDict)
.flatMap(PrimaryAction.init(rawValue:)) ?? .open
updateDelayForeground = Self.readDouble(forKey: Self.kUpdateDelayForegroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.readDouble(forKey: Self.kUpdateDelayBackgroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.readDouble(forKey: Self.kUpdateDelayHiddenKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.readDouble(forKey: Self.kRefreshForegroundKey, from: vSourceDict)
?? Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.readDouble(forKey: Self.kRefreshBackgroundKey, from: vSourceDict)
?? Self.kDefaultRefreshBackground
}
// ===========================
@@ -133,6 +214,11 @@ final class Preferences: ObservableObject, @unchecked Sendable {
if let vDict = inDict { return vDict[inKey] as? Bool }
return UserDefaults.standard.object(forKey: inKey) as? Bool
}
// reads a number from either the supplied plist dict or UserDefaults
private static func readDouble(forKey inKey: String, from inDict: [String: Any]?) -> Double? {
if let vDict = inDict { return (vDict[inKey] as? NSNumber)?.doubleValue }
return (UserDefaults.standard.object(forKey: inKey) as? NSNumber)?.doubleValue
}
// reads a string from either the supplied plist dict or UserDefaults
private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? {
if let vDict = inDict { return vDict[inKey] as? String }
@@ -153,6 +239,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
return [FileManager.default.homeDirectoryForCurrentUser.path]
}
// default exclusions: system noise for the daemon, caches and trash for a user
private static func defaultExcludedPaths() -> [String] {
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
return [
@@ -170,6 +257,9 @@ final class Preferences: ObservableObject, @unchecked Sendable {
return [
"~/Library/Caches",
"~/Library/Containers",
// other apps' shared data: reading it triggers macOS 15's
// "access data from other apps" prompt
"~/Library/Group Containers",
"~/.Trash",
"/private/var/folders"
].map { ($0 as NSString).expandingTildeInPath }
+109
View File
@@ -0,0 +1,109 @@
import Foundation
// RecordStore holds the index's records in fixed-size chunks instead of one
// flat array. Copies (snapshots for searches and result lists) only share
// chunk buffers, and a mutation copies just the chunk it touches: changing
// one record while a snapshot is alive costs ~1.6 MB instead of the whole
// 60+ MB index. That lets every window keep its results as plain positions
// into a snapshot, at 4 bytes per row.
struct RecordStore: RandomAccessCollection, MutableCollection, Sendable {
// records per chunk: 2^kShift
static let kShift = 14
// chunk size and index mask derived from kShift
static let kChunkSize = 1 << kShift
static let kMask = kChunkSize - 1
// the records, kChunkSize per chunk (the last one may be shorter)
private var chunks: [[FileRecord]] = []
// total number of records
private(set) var count: Int = 0
// empty store
init() {}
// store holding inRecords, in order
init<S: Sequence>(_ inRecords: S) where S.Element == FileRecord {
for vRecord in inRecords {
append(vRecord)
}
}
// first index (always 0)
var startIndex: Int { 0 }
// one past the last index
var endIndex: Int { count }
// record at inPosition
subscript(inPosition: Int) -> FileRecord {
get { chunks[inPosition >> Self.kShift][inPosition & Self.kMask] }
set { chunks[inPosition >> Self.kShift][inPosition & Self.kMask] = newValue }
}
// number of chunks
var chunkCount: Int { chunks.count }
// the records of chunk inIndex (store positions inIndex << kShift and
// up). Hot loops walk chunks to read records with plain array access.
func chunk(at inIndex: Int) -> [FileRecord] {
return chunks[inIndex]
}
// adds a record at the end
mutating func append(_ inRecord: FileRecord) {
if chunks.isEmpty || chunks[chunks.count - 1].count == Self.kChunkSize {
var vChunk: [FileRecord] = []
vChunk.reserveCapacity(Self.kChunkSize)
chunks.append(vChunk)
}
chunks[chunks.count - 1].append(inRecord)
count += 1
}
// removes the last record
mutating func removeLast() {
chunks[chunks.count - 1].removeLast()
count -= 1
if chunks[chunks.count - 1].isEmpty {
chunks.removeLast()
}
}
}
// ResultList is one window's search result: positions into a snapshot of
// the index, in display order. Cheap to keep around (4 bytes per row) and
// immune to later index changes, since the snapshot doesn't move.
struct ResultList: RandomAccessCollection, Sendable {
// the index as it was when the search ran
let store: RecordStore
// positions of the matching records in store, sorted for display
let positions: [Int32]
// empty result
init() {
store = RecordStore()
positions = []
}
// result for the given snapshot and positions
init(inStore: RecordStore, inPositions: [Int32]) {
store = inStore
positions = inPositions
}
// first index (always 0)
var startIndex: Int { 0 }
// number of results
var endIndex: Int { positions.count }
// record shown at inRow
subscript(inRow: Int) -> FileRecord {
return store[Int(positions[inRow])]
}
// the same list without the records whose ids are in inIds
func removing(inIds: Set<FileRecord.ID>) -> ResultList {
return ResultList(inStore: store, inPositions: positions.filter { !inIds.contains(store[Int($0)].id) })
}
}
+130
View File
@@ -0,0 +1,130 @@
import Foundation
// ResultSorter orders the matching positions of a search. The full order of
// a large result takes a while (~0.2 s for 600k entries), so the first rows
// can be computed on their own: a bounded max-heap of the best N candidates
// is O(m log N), about one comparison per match once the heap is warm, which
// lets the top of the list appear before the complete sort finishes.
enum ResultSorter {
// returns the first inLimit records of inPositions, ordered by inDescriptor
static func top<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [FileRecord]
where C.Index == Int, C.Element == FileRecord {
return topPositions(inRecords: inRecords, inPositions: inPositions, inLimit: inLimit, inDescriptor: inDescriptor)
.map { inRecords[Int($0)] }
}
// same as top, returning positions into inRecords instead of copies
static func topPositions<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == 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 {
switch inDescriptor {
case .nameAscending, .nameDescending, .pathAscending, .pathDescending:
// string keys: comparing in place is as fast or faster
// (moving tuples of strings during the sort costs more)
return inPositions.sorted(by: vBefore)
default:
// numeric keys: ~10x faster sorted as a compact key array
return sortedByKey(inRecords: inRecords, inPositions: inPositions, inDescriptor: inDescriptor)
}
}
// 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)
}
// complete sort by size or date. Each record's key is read once into a
// compact array, which is then sorted: comparing through the records
// copies two whole FileRecords (with their strings' retain / release)
// per comparison - 539 ms vs 47 ms for 630k entries by date. Ties are
// broken by id, matching the top-N path.
private static func sortedByKey<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == FileRecord {
let vDescending = inDescriptor == .sizeDescending
|| inDescriptor == .createdDescending
|| inDescriptor == .modifiedDescending
var vKeys = inPositions.map { vPos -> (Double, UInt64, Int32) in
let vRecord = inRecords[Int(vPos)]
let vKey: Double
switch inDescriptor {
case .sizeAscending, .sizeDescending: vKey = Double(vRecord.size)
case .createdAscending, .createdDescending: vKey = vRecord.dateCreated.timeIntervalSinceReferenceDate
default: vKey = vRecord.dateModified.timeIntervalSinceReferenceDate
}
return (vKey, vRecord.id, vPos)
}
vKeys.sort { vA, vB in
if vA.0 != vB.0 { return vDescending ? vA.0 > vB.0 : vA.0 < vB.0 }
return vA.1 < vB.1
}
return vKeys.map(\.2)
}
// 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 }
}
}
}
+455
View File
@@ -0,0 +1,455 @@
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
// the term's text as typed (no "!" / modifier prefix), used to
// emphasize matches in the results; empty for ext: and bare modifiers
var text: String = ""
}
// literal pieces of the query to emphasize in the results, matched
// case- and accent-insensitively against the displayed name / folder
struct Highlights: Equatable, Sendable {
// pieces to emphasize in names
var name: [String] = []
// pieces to emphasize in folder paths
var path: [String] = []
}
// 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 scan the folder path
// too, so they only run on records that survived the name terms
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 }
// what to emphasize in the results: the literal runs of every positive
// name / path term (wildcards and folder separators split the runs)
var highlights: Highlights {
var vResult = Highlights()
for vTerm in groups.joined() where !vTerm.negated && !vTerm.text.isEmpty {
let vSeparators: Set<Character> = vTerm.matchesPath ? ["*", "?", "/"] : ["*", "?"]
let vPieces = vTerm.text
.split(whereSeparator: { vSeparators.contains($0) })
.map(String.init)
.filter { !$0.isEmpty }
if vTerm.matchesPath {
vResult.path.append(contentsOf: vPieces)
} else {
vResult.name.append(contentsOf: vPieces)
}
}
return vResult
}
// 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,
text: String(vText)
)
}
// ===========================
// 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 {
vResult = matchesPath(inPattern: vPattern, inFoldedParent: inRecord.parentLower, inFoldedName: inRecord.nameLower)
} else {
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
}
}
return inTerm.negated ? !vResult : vResult
}
// runs a pattern against an already case-folded name
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: JoinedBytes(inHead: UnsafeBufferPointer(start: nil, count: 0), inTail: vBytes))
}
case .extensions(let vExts):
return hasExtension(inName: vBytes, inExtensions: vExts)
}
}
}
// runs a path pattern against folder + "/" + name without building the
// joined string (both parts already case-folded)
private static func matchesPath(inPattern: Pattern, inFoldedParent: String, inFoldedName: String) -> Bool {
var vName = inFoldedName
var vParent = inFoldedParent
return vName.withUTF8 { vNameBytes in
vParent.withUTF8 { vParentBytes in
let vText = JoinedBytes(inHead: vParentBytes, inTail: vNameBytes)
switch inPattern {
case .substring(let vNeedle):
return joinedContains(inText: vText, inNeedle: vNeedle)
case .pathGlob(let vGlob), .glob(let vGlob):
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vText) }
case .extensions(let vExts):
return hasExtension(inName: vNameBytes, 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
}
}
// substring search over folder + "/" + name: inside the folder part,
// inside the name, or straddling the "/" between them
private static func joinedContains(inText: JoinedBytes, inNeedle: [UInt8]) -> Bool {
if containsBytes(inHaystack: inText.head, inNeedle: inNeedle) { return true }
if containsBytes(inHaystack: inText.tail, inNeedle: inNeedle) { return true }
if inNeedle.count < 2 || inNeedle.count > inText.count { return inNeedle.count == 1 && inText.hasSeparator && inNeedle[0] == UInt8(ascii: "/") }
// window around the junction: up to needle-1 bytes on each side
let vStart = max(0, inText.head.count - (inNeedle.count - 1))
let vEnd = min(inText.count, inText.head.count + (inText.hasSeparator ? 1 : 0) + (inNeedle.count - 1))
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: vEnd - vStart) { vWindow in
for vI in vStart..<vEnd {
vWindow[vI - vStart] = inText[vI]
}
return containsBytes(inHaystack: UnsafeBufferPointer(vWindow), inNeedle: inNeedle)
}
}
// 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
}
// same as nextScalar, over a joined folder + name
private static func nextScalar(in inText: JoinedBytes, 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: JoinedBytes) -> 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: JoinedBytes,
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)
}
}
// JoinedBytes presents folder bytes + "/" + name bytes as one read-only
// byte sequence, so path matching never allocates the joined path.
private struct JoinedBytes {
// case-folded folder path ("" for none, "/" for the root)
let head: UnsafeBufferPointer<UInt8>
// case-folded name
let tail: UnsafeBufferPointer<UInt8>
// true when a "/" separates head and tail (not after "" or "/")
let hasSeparator: Bool
// total length including the separator
let count: Int
// joins a folder and a name
init(inHead: UnsafeBufferPointer<UInt8>, inTail: UnsafeBufferPointer<UInt8>) {
head = inHead
tail = inTail
hasSeparator = !inHead.isEmpty && !(inHead.count == 1 && inHead[0] == UInt8(ascii: "/"))
count = inHead.count + (hasSeparator ? 1 : 0) + inTail.count
}
// byte at inIndex of the joined sequence
subscript(inIndex: Int) -> UInt8 {
if inIndex < head.count { return head[inIndex] }
var vI = inIndex - head.count
if hasSeparator {
if vI == 0 { return UInt8(ascii: "/") }
vI -= 1
}
return tail[vI]
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
// SearchFilter is the category menu next to the search field (Everything's
// Filters dropdown). Each filter is an extra query clause ANDed with what the
// user typed, written in the regular search syntax.
enum SearchFilter: String, CaseIterable, Identifiable, Sendable {
case everything, folders, documents, pictures, audio, video, archives, applications, code
var id: String { rawValue }
// menu label
var title: String {
switch self {
case .everything: return "Everything"
case .folders: return "Folders"
case .documents: return "Documents"
case .pictures: return "Pictures"
case .audio: return "Audio"
case .video: return "Video"
case .archives: return "Archives"
case .applications: return "Applications"
case .code: return "Code"
}
}
// SF Symbol shown in the menu
var symbolName: String {
switch self {
case .everything: return "square.grid.2x2"
case .folders: return "folder"
case .documents: return "doc.text"
case .pictures: return "photo"
case .audio: return "music.note"
case .video: return "film"
case .archives: return "archivebox"
case .applications: return "app"
case .code: return "chevron.left.forwardslash.chevron.right"
}
}
// query clause added to the user's query; nil for no restriction
var clause: String? {
switch self {
case .everything:
return nil
case .folders:
return "folder:"
case .documents:
return Self.extClause(["pdf", "doc", "docx", "odt", "rtf", "txt", "md", "pages", "key", "numbers",
"xls", "xlsx", "ods", "ppt", "pptx", "odp", "csv", "epub"])
case .pictures:
return Self.extClause(["jpg", "jpeg", "png", "gif", "heic", "heif", "tif", "tiff", "bmp", "webp",
"svg", "raw", "cr2", "cr3", "nef", "arw", "dng", "psd", "ai", "ico", "icns"])
case .audio:
return Self.extClause(["mp3", "m4a", "aac", "wav", "aif", "aiff", "flac", "ogg", "opus", "wma",
"caf", "mid", "midi"])
case .video:
return Self.extClause(["mp4", "m4v", "mov", "avi", "mkv", "webm", "wmv", "flv", "mpg", "mpeg", "3gp"])
case .archives:
return Self.extClause(["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst", "dmg", "iso",
"pkg", "jar", "apk", "aab", "xip"])
case .applications:
return "folder:ext:app"
case .code:
return Self.extClause(["swift", "c", "h", "m", "mm", "cpp", "hpp", "cc", "java", "kt", "kts", "js",
"mjs", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "php", "cs", "sh", "zsh",
"json", "yaml", "yml", "toml", "xml", "html", "css", "scss", "gradle", "sql"])
}
}
// the user's query with this filter's clause appended
func apply(toQuery inQuery: String) -> String {
guard let vClause = clause else { return inQuery }
return inQuery + " " + vClause
}
// "ext:a;b;c" for a list of extensions
private static func extClause(_ inExtensions: [String]) -> String {
return "ext:" + inExtensions.joined(separator: ";")
}
}
@@ -48,16 +48,37 @@ 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 canonicalized 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 { canonicalPath(inPath: $0) }
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(canonicalPath(inPath: vVol.url.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) }
}
// the path the kernel reports for inPath (symlinks resolved, e.g.
// /tmp -> /private/tmp), which is what FSEvents and the walker produce.
// Foundation's standardizedFileURL / resolvingSymlinksInPath strip the
// /private prefix instead, so a root under /tmp, /var or /etc would never
// match its own entries. Falls back to the given path (minus a trailing
// slash) when it doesn't exist, e.g. an unmounted volume.
static func canonicalPath(inPath: String) -> String {
if let vResolved = realpath(inPath, nil) {
defer { free(vResolved) }
return String(cString: vResolved)
}
return URL(fileURLWithPath: inPath).path
}
}
+267
View File
@@ -0,0 +1,267 @@
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)
if vResult.changed { vState.dirty = true }
// with no walk pending the batch is fully merged: advance the
// saved event id now; otherwise only once the walks are in,
// so a restart mid-walk replays the changes that caused them
let vHasWalks = !vResult.rescans.isEmpty || !vResult.newFolders.isEmpty
if !vHasWalks {
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
}
vState.lock.unlock()
if vResolved.historyDone {
NSLog("[Allofit] FSEvents history replay done")
vState.lock.lock()
vState.dirty = true
vState.lock.unlock()
}
guard vHasWalks else { return }
// history lost (kernel rescans): replace those subtrees
if !vResult.rescans.isEmpty {
NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count)
let vFresh = walk(inPaths: vResult.rescans, inExclusions: vMatcher)
vState.lock.lock()
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
vState.dirty = true
vState.lock.unlock()
}
// folders that appeared (created, moved or renamed in)
if !vResult.newFolders.isEmpty {
let vFresh = walk(inPaths: vResult.newFolders, inExclusions: vMatcher)
vState.lock.lock()
vState.index.mergeNewFolders(inRecords: vFresh)
vState.dirty = true
vState.lock.unlock()
}
vState.lock.lock()
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
vState.lock.unlock()
}
}
// block forever on the runloop so launchd keeps us alive
RunLoop.current.run()
exit(0)
}
// walks the given subtrees, returning every indexable entry below them
private static func walk(inPaths: [String], inExclusions: ExclusionMatcher) -> [FileRecord] {
var vResult: [FileRecord] = []
for vPath in inPaths {
autoreleasepool {
vResult.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
))
}
}
return vResult
}
// periodic save loop (background thread): checks every 3 s and saves
// at most every 15 s, so new files reach the GUI within ~15 s.
// 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
// minimum gap between two saves: a save writes the whole index
// (~25 MB for 600k entries) and makes the app reload all of it,
// so under constant file activity every 3 s was ~8 MB/s of disk
// writes plus over a second of CPU per cycle across both processes
let kMinSaveIntervalSeconds: TimeInterval = 15
var vSavesSinceCompact = 0
var vLastSaveAt = Date.distantPast
while true {
sleep(3)
if Date().timeIntervalSince(vLastSaveAt) < kMinSaveIntervalSeconds { continue }
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)
vLastSaveAt = Date()
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
}
}
+262
View File
@@ -0,0 +1,262 @@
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)")
}
}
}
// starts the installed service(s) named by the arguments
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)")
}
}
}
// stops the running service(s) named by the arguments
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).")
}
// stores the service mode in one preferences domain
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()
}
// prints the install / run state of both service scopes
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"
// which scopes a command targets when none is given
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 inDefaultMode: 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 inDefaultMode {
case .firstInstalled:
return vInstalled.first.map { [$0] } ?? []
case .everyInstalled:
return vInstalled
}
}
// maps a user / root argument (and aliases) to a scope
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
}
}
// human-readable name of a scope
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)
}
// prints the command-line usage
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.
""")
}
}
@@ -54,6 +54,9 @@ enum ElevatedAccess {
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// creation attributes don't apply to an existing folder: enforce
// owner-only access every time (the copies may be other users' files)
chmod(vDir.path, 0o700)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
@@ -63,12 +66,16 @@ enum ElevatedAccess {
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.
// Root only *reads* the original; the copy is written by the user
// (sudo -u ... tee), so root never creates, chowns or chmods a path
// inside a user-writable folder. A cp + chown + chmod as root could
// be redirected through a swapped-in symlink / hard link to take
// ownership of any system file. pipefail makes a failed read fail
// the script instead of leaving an empty copy behind.
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))
set -o pipefail; \
/bin/cat \(AdminShell.quote(inUrl.path)) \
| /usr/bin/sudo -u \(AdminShell.quote(NSUserName())) /usr/bin/tee \(AdminShell.quote(vDst.path)) > /dev/null
"""
_ = 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)
@@ -33,7 +33,7 @@ enum ServiceInstaller {
}
// installs (or replaces) the launchd plist for the given scope. Also
// copies the binary to a renamed location ("Allofit Service") so the
// copies the binary to a renamed location ("Allofit service") so the
// daemon process shows up distinctly from the GUI in Activity Monitor /
// `ps` - both used to be called "Allofit" because they share a binary.
static func install(inScope: Scope) throws {
@@ -67,6 +67,10 @@ enum ServiceInstaller {
at: vTarget.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.createDirectory(
atPath: (logPath(inScope: .userAgent) as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
try vPlistData.write(to: vTarget, options: .atomic)
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path])
@@ -78,24 +82,30 @@ enum ServiceInstaller {
// 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)
// The plist travels inside the script as base64 (only
// [A-Za-z0-9+/=], safe in single quotes) instead of a temp
// file a user process could swap before root copies it in.
let vPlistBase64 = vPlistData.base64EncodedString()
let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
let vLogDir = (logPath(inScope: .rootDaemon) as NSString).deletingLastPathComponent
let vScript = """
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 \
; /bin/launchctl bootstrap system \(shellQuote(inString: vTarget.path))
mkdir -p \(AdminShell.quote(vDaemonDir)) \
&& rm -f \(AdminShell.quote(vDaemonBinary)) \
&& cp \(AdminShell.quote(vBinary)) \(AdminShell.quote(vDaemonBinary)) \
&& chown root:wheel \(AdminShell.quote(vDaemonBinary)) \
&& chmod 755 \(AdminShell.quote(vDaemonBinary)) \
&& printf '%s' \(AdminShell.quote(vVersionString)) > \(AdminShell.quote(vVersionFile)) \
&& chmod 644 \(AdminShell.quote(vVersionFile)) \
&& mkdir -p \(AdminShell.quote(vLogDir)) \
&& chown root:wheel \(AdminShell.quote(vLogDir)) \
&& chmod 755 \(AdminShell.quote(vLogDir)) \
&& printf '%s' \(AdminShell.quote(vPlistBase64)) | /usr/bin/base64 -D > \(AdminShell.quote(vTarget.path)) \
&& /usr/bin/plutil -lint -s \(AdminShell.quote(vTarget.path)) \
&& chown root:wheel \(AdminShell.quote(vTarget.path)) \
&& chmod 644 \(AdminShell.quote(vTarget.path)) \
; /bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
; /bin/launchctl bootstrap system \(AdminShell.quote(vTarget.path))
"""
try runWithAdminPrivileges(inScript: vScript)
}
@@ -117,8 +127,8 @@ enum ServiceInstaller {
case .rootDaemon:
let vTarget = rootDaemonPath()
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))
/bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
; rm -f \(AdminShell.quote(vTarget.path)) \(AdminShell.quote(vDaemonBinary)) \(AdminShell.quote(vVersionFile))
"""
try runWithAdminPrivileges(inScript: vScript)
}
@@ -146,7 +156,7 @@ enum ServiceInstaller {
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -162,7 +172,7 @@ enum ServiceInstaller {
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -217,7 +227,7 @@ enum ServiceInstaller {
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vCache = inCacheURL.path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist)) 2>/dev/null ; rm -f \(shellQuote(inString: vCache)) ; /bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist)) 2>/dev/null ; rm -f \(AdminShell.quote(vCache)) ; /bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -226,17 +236,19 @@ enum ServiceInstaller {
// MARK: Internals
// ===========================
// location of the per-user LaunchAgent plist
private static func userAgentPath() -> URL {
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(kLabel).plist")
}
// location of the system-wide LaunchDaemon plist
private static func rootDaemonPath() -> URL {
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
// 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.
@@ -250,15 +262,32 @@ enum ServiceInstaller {
}
}
// where the service's stdout / stderr go: a root-owned folder for the
// daemon, the user's own Logs folder for the agent
static func logPath(inScope: Scope) -> String {
switch inScope {
case .userAgent:
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/Allofit/service.log").path
case .rootDaemon:
return "/Library/Logs/Allofit/service.log"
}
}
// resolves an absolute path to the currently running binary
private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0]
let vAbs = (vArg0 as NSString).standardizingPath
if FileManager.default.fileExists(atPath: vAbs) { return vAbs }
if let vUrl = Bundle.main.executableURL,
// the loaded executable, not argv[0] (which a caller can set to
// anything, including a relative path)
if let vUrl = Bundle.main.executableURL?.resolvingSymlinksInPath(),
vUrl.path.hasPrefix("/"),
FileManager.default.fileExists(atPath: vUrl.path) {
return vUrl.path
}
let vArg0 = CommandLine.arguments[0]
let vAbs = (vArg0 as NSString).standardizingPath
if vAbs.hasPrefix("/") && FileManager.default.fileExists(atPath: vAbs) {
return vAbs
}
throw InstallError.binaryNotFound
}
@@ -269,8 +298,10 @@ enum ServiceInstaller {
"ProgramArguments": [inBinary, "--service"],
"RunAtLoad": true,
"KeepAlive": true,
"StandardOutPath": "/tmp/allofit-service.log",
"StandardErrorPath": "/tmp/allofit-service.err",
// never /tmp: a fixed name there can be pre-created as a symlink
// by any local user, and launchd opens it as root for the daemon
"StandardOutPath": logPath(inScope: inScope),
"StandardErrorPath": logPath(inScope: inScope),
"ThrottleInterval": 10
]
if inScope == .rootDaemon {
@@ -315,7 +346,7 @@ enum ServiceInstaller {
// runs a shell script with administrator privileges. Bridges
// AdminShell.Error into ServiceInstaller.InstallError so the calling
// SettingsView UI gets a single error type to surface.
// SettingsView ui gets a single error type to surface.
private static func runWithAdminPrivileges(inScript: String) throws {
do {
_ = try AdminShell.run(inScript)
@@ -323,10 +354,4 @@ enum ServiceInstaller {
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
}
}
// 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 AdminShell.quote(inString)
}
}
@@ -68,12 +68,4 @@ final class AccessManager: ObservableObject {
}
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
}
}
+435
View File
@@ -0,0 +1,435 @@
import SwiftUI
import AppKit
import Combine
// AllofitApp is the SwiftUI App for the GUI mode. The real entry point lives
// in Main.swift, which routes between this and the headless --service mode.
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. 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") {
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) {
Button("About Allofit") { showAboutPanel() }
}
CommandGroup(after: .appInfo) {
// ⇧⌘R rather than ⌘R: a full rebuild is too costly to trigger
// by accident
Button("Rebuild Index") {
Task { await model.performReindex() }
}
.keyboardShortcut("r", modifiers: [.command, .shift])
}
// 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.
// Help: the syntax cheat sheet and the project page
CommandGroup(replacing: .help) {
Button("Search Syntax") { showSyntaxWindow() }
Button("Allofit on GitHub") {
NSWorkspace.shared.open(URL(string: "https://github.com/bitsycore/Allofit#search")!)
}
}
CommandGroup(after: .pasteboard) {
Button("Find") {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
}
.keyboardShortcut("f", modifiers: [.command])
}
}
Settings {
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
// creates the per-window content around the shared models
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())
.background(WindowVisibilityReporter { vVisible in
searchModel.setWindowVisible(vVisible)
})
.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
// every hidden window the process holds (which would also unhide the
// Settings window when the user just wants the main one back).
//
// Uses an NSView subclass so we can hook viewDidMoveToWindow - that is the
// reliable place where the view's .window property is guaranteed non-nil.
// DispatchQueue.main.async sometimes ran before SwiftUI attached the view.
struct MainWindowMarker: NSViewRepresentable {
// NSView that records its window as the main window
final class MarkerView: NSView {
// captures the window once the view is attached to it
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let vWindow = self.window else { return }
// stop AppKit from deallocating the window when the user clicks
// the red close button - we hold a strong ref in AppDelegate and
// re-show this same window object on the next dock-icon click.
// Otherwise the weak ref would die and dock-click would do nothing.
vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow
}
}
// creates the marker view
func makeNSView(context inContext: Context) -> NSView {
return MarkerView(frame: .zero)
}
// rebinds if SwiftUI ever swaps us into a different window
func updateNSView(_ inView: NSView, context inContext: Context) {
if let vWindow = inView.window, AppDelegate.mainWindow !== vWindow {
vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow
}
}
}
// WindowVisibilityReporter tells its owner whether the hosting window is
// actually on screen (AppKit occlusion state), so a closed, minimized or
// fully covered window can skip work nobody would see.
struct WindowVisibilityReporter: NSViewRepresentable {
// called on main with true when the window becomes visible
let onChange: (Bool) -> Void
// NSView that follows its window's occlusion notifications
final class ReporterView: NSView {
// latest callback from the SwiftUI side
var onChange: ((Bool) -> Void)?
// occlusion observer for the current window
private var observer: NSObjectProtocol?
// re-targets the observer whenever the view changes window
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let vObserver = observer {
NotificationCenter.default.removeObserver(vObserver)
observer = nil
}
guard let vWindow = window else {
report(false)
return
}
observer = NotificationCenter.default.addObserver(
forName: NSWindow.didChangeOcclusionStateNotification,
object: vWindow,
queue: .main
) { [weak self, weak vWindow] _ in
guard let vWin = vWindow else { return }
MainActor.assumeIsolated {
self?.report(vWin.occlusionState.contains(.visible))
}
}
report(vWindow.occlusionState.contains(.visible))
}
// forwards a visibility change to the owner and the app-wide tracker
private func report(_ inVisible: Bool) {
onChange?(inVisible)
AppActivity.shared.setWindow(ObjectIdentifier(self), visible: inVisible)
}
// removes the occlusion observer; a destroyed window no longer
// counts as visible
deinit {
if let vObserver = observer {
NotificationCenter.default.removeObserver(vObserver)
}
let vId = ObjectIdentifier(self)
DispatchQueue.main.async {
AppActivity.shared.setWindow(vId, visible: false)
}
}
}
// creates the reporter view
func makeNSView(context inContext: Context) -> NSView {
let vView = ReporterView(frame: .zero)
vView.onChange = onChange
return vView
}
// keeps the view's callback current
func updateNSView(_ inView: NSView, context inContext: Context) {
(inView as? ReporterView)?.onChange = onChange
}
}
// the Help > Search Syntax window (kept so a second click just brings it back)
@MainActor private var syntaxWindow: NSWindow?
// shows the search syntax cheat sheet in a small utility window
@MainActor private func showSyntaxWindow() {
if syntaxWindow == nil {
let vWindow = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
vWindow.title = "Allofit Search Syntax"
vWindow.isReleasedWhenClosed = false
vWindow.contentView = NSHostingView(rootView: SyntaxHelpView())
vWindow.setContentSize(vWindow.contentView?.fittingSize ?? NSSize(width: 560, height: 420))
vWindow.center()
syntaxWindow = vWindow
}
syntaxWindow?.makeKeyAndOrderFront(nil)
}
// Opens the standard macOS About panel with a clickable GitHub link in the
// Credits section. App name + version come from Info.plist automatically.
private func showAboutPanel() {
let vUrlString = "https://github.com/bitsycore/Allofit"
let vCredits = NSMutableAttributedString(
string: vUrlString,
attributes: [
.link: URL(string: vUrlString) as Any,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
.foregroundColor: NSColor.linkColor
]
)
NSApplication.shared.orderFrontStandardAboutPanel(options: [
.credits: vCredits
])
NSApp.activate(ignoringOtherApps: true)
}
// AppDelegate handles the activation lifecycle. It also keeps the app
// resident after the window closes so the in-memory index stays warm and
// the next window open is instant - the same pattern Finder and Safari use.
final class AppDelegate: NSObject, NSApplicationDelegate {
// strong ref to the main app window so it stays in RAM after the user
// closes it (paired with isReleasedWhenClosed=false on the window). This
// lets a dock-icon click bring the *same* window back instead of letting
// AppKit unhide every hidden window the app holds (Settings included).
// Strong, not weak, because SwiftUI tears down its content view tree on
// close - the weak ref would die and dock-click would silently no-op.
// nonisolated(unsafe) because all reads/writes happen on the main thread
// (NSView callbacks + the AppKit delegate methods are all @MainActor).
nonisolated(unsafe) static var mainWindow: NSWindow?
// keeps the global-shortcut setting subscription alive
private var hotKeySubscription: AnyCancellable?
// keeps the menu-bar-icon setting subscription alive
private var menuBarSubscription: AnyCancellable?
// the menu bar icon, nil while hidden
private var statusItem: NSStatusItem?
// called once when the application has finished launching
func applicationDidFinishLaunching(_ inNotification: Notification) {
// run as a regular foreground app even when launched outside a .app
// bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay
// from the ~1-2 s system default. Registered as a default so a
// user-set value in the global domain still wins. The value is in
// milliseconds (it used to be registered as 0.3, read as 0 ms).
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 250])
// 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()
// Finder > Services > "Search in Allofit"
NSApp.servicesProvider = ServicesProvider()
NSUpdateDynamicServices()
// system-wide shortcut, re-registered whenever the setting changes
GlobalHotKey.shared.onPressed = { WindowPresenter.toggle() }
hotKeySubscription = Preferences.shared.$globalHotKey
.removeDuplicates()
.sink { vPreset in
MainActor.assumeIsolated {
_ = GlobalHotKey.shared.register(inPreset: vPreset)
}
}
// menu bar icon (Everything's tray icon), shown per the setting.
// Plain AppKit: a SwiftUI MenuBarExtra scene crashed the app at
// launch (runtime recursion resolving the App's scene type).
menuBarSubscription = Preferences.shared.$showMenuBarIcon
.removeDuplicates()
.sink { [weak self] vShow in
MainActor.assumeIsolated {
self?.setStatusItemVisible(vShow)
}
}
// bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey {
vWindow.makeKeyAndOrderFront(nil)
vWindow.orderFrontRegardless()
break
}
}
}
// 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(_ inNotification: 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.
func applicationShouldTerminateAfterLastWindowClosed(_ inSender: NSApplication) -> Bool {
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(_ inSender: 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(_ inSender: Any?) {
MainActor.assumeIsolated {
WindowPresenter.openNewWindow()
}
}
// adds or removes the menu bar icon and its menu
private func setStatusItemVisible(_ inVisible: Bool) {
guard inVisible else {
if let vItem = statusItem {
NSStatusBar.system.removeStatusItem(vItem)
statusItem = nil
}
return
}
guard statusItem == nil else { return }
let vItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
vItem.button?.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Allofit")
let vMenu = NSMenu()
vMenu.addItem(withTitle: "Show Allofit", action: #selector(statusShow(_:)), keyEquivalent: "").target = self
vMenu.addItem(.separator())
vMenu.addItem(withTitle: "Settings…", action: #selector(statusSettings(_:)), keyEquivalent: "").target = self
vMenu.addItem(.separator())
vMenu.addItem(withTitle: "Quit Allofit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
vItem.menu = vMenu
statusItem = vItem
}
// menu bar > Show Allofit
@objc private func statusShow(_ inSender: Any?) {
MainActor.assumeIsolated {
WindowPresenter.show()
}
}
// menu bar > Settings…
@objc private func statusSettings(_ inSender: Any?) {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
// folders dropped on the Dock icon or opened with the app
// (open -a Allofit <folder>) start a search inside that folder
func application(_ inApplication: NSApplication, open inUrls: [URL]) {
guard let vFirst = inUrls.first else { return }
let vIsDir = (try? vFirst.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
WindowPresenter.search(inFolder: vIsDir ? vFirst.path : vFirst.deletingLastPathComponent().path)
}
// 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).
func applicationShouldHandleReopen(_ inSender: NSApplication,
hasVisibleWindows inHasVisible: Bool) -> Bool {
if inHasVisible { return true }
if let vMain = AppDelegate.mainWindow {
// hide any other hidden windows (e.g. Settings) so AppKit's
// default behavior, if it triggers, doesn't bring them up
for vOther in NSApp.windows where vOther !== vMain && vOther.isVisible {
vOther.orderOut(nil)
}
vMain.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return false
}
// no captured window (shouldn't happen) - let AppKit do its default;
// SwiftUI will spawn a fresh WindowGroup window since the app is alive
return true
}
}
+83
View File
@@ -0,0 +1,83 @@
import AppKit
import Combine
// AppActivity tracks how visible Allofit currently is, so background work
// can slow down when nobody is looking:
// - foreground: the app is frontmost and one of its windows is on screen
// - background: a window is on screen but another app has focus
// - hidden: no window is on screen (closed, minimized, fully covered,
// or the app is hidden)
// Windows report their own visibility through WindowVisibilityReporter.
@MainActor
final class AppActivity: ObservableObject {
// shared tracker for the whole app
static let shared = AppActivity()
// how visible the app is, from least to most
enum Level: Int, Comparable {
case hidden = 0
case background = 1
case foreground = 2
// ordering by visibility
static func < (inLhs: Level, inRhs: Level) -> Bool {
return inLhs.rawValue < inRhs.rawValue
}
}
// current visibility level
@Published private(set) var level: Level = .foreground
// windows (by reporter identity) currently on screen
private var visibleWindows = Set<ObjectIdentifier>()
// true while Allofit is the frontmost app
private var isAppActive = true
// app activation observers
private var cancellables: Set<AnyCancellable> = []
// starts following app activation / hiding
private init() {
isAppActive = NSApplication.shared.isActive
let vCenter = NotificationCenter.default
for vName in [NSApplication.didBecomeActiveNotification,
NSApplication.didResignActiveNotification,
NSApplication.didHideNotification,
NSApplication.didUnhideNotification] {
vCenter.publisher(for: vName)
.sink { [weak self] _ in
MainActor.assumeIsolated {
guard let vSelf = self else { return }
vSelf.isAppActive = NSApplication.shared.isActive && !NSApplication.shared.isHidden
vSelf.recompute()
}
}
.store(in: &cancellables)
}
}
// records whether the window identified by inId is on screen
func setWindow(_ inId: ObjectIdentifier, visible inVisible: Bool) {
if inVisible {
visibleWindows.insert(inId)
} else {
visibleWindows.remove(inId)
}
recompute()
}
// derives the level from the window set and the app's active state
private func recompute() {
let vLevel: Level
if visibleWindows.isEmpty || NSApplication.shared.isHidden {
vLevel = .hidden
} else if isAppActive {
vLevel = .foreground
} else {
vLevel = .background
}
if level != vLevel {
level = vLevel
}
}
}
+933
View File
@@ -0,0 +1,933 @@
import Foundation
import SwiftUI
import Combine
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
// Lets the DispatchSource timer compare a new stat() result against the
// previously seen value without touching any @MainActor state, so the
// poll only hops to main when the cache has actually changed on disk.
private final class BackgroundMtime: @unchecked Sendable {
private var mtime: Date?
private let lock = NSLock()
// returns true if the supplied mtime differs from the stored value
// (and updates the stored value), false otherwise
func updateIfChanged(_ inMtime: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
if mtime != inMtime {
mtime = inMtime
return true
}
return false
}
}
// Thread-safe accumulator for FSEvents batches. The FSEvents callback appends
// here from its own queue and only schedules a flush when none is pending, so
// a replay that delivers thousands of batches in a burst becomes a handful of
// coalesced flushes instead of thousands of main-queue hops.
private final class PendingChanges: @unchecked Sendable {
// changes received since the last drain
private var changes: [FSChange] = []
// true while a flush is scheduled but has not drained yet
private var flushScheduled = false
// how long changes are collected before a flush (follows AppActivity)
private var delaySeconds: TimeInterval = 2
// guards every field above
private let lock = NSLock()
// current collection delay before a flush
var coalesceDelay: TimeInterval {
lock.lock()
defer { lock.unlock() }
return delaySeconds
}
// changes the collection delay used for the next flushes
func setCoalesceDelay(_ inSeconds: TimeInterval) {
lock.lock()
delaySeconds = inSeconds
lock.unlock()
}
// true when changes are waiting to be flushed
var hasPending: Bool {
lock.lock()
defer { lock.unlock() }
return !changes.isEmpty
}
// appends a batch; returns true if the caller must schedule a flush
func append(inChanges: [FSChange]) -> Bool {
lock.lock()
defer { lock.unlock() }
changes.append(contentsOf: inChanges)
if flushScheduled { return false }
flushScheduled = true
return true
}
// takes every pending change and re-arms flush scheduling
func drain() -> [FSChange] {
lock.lock()
defer { lock.unlock() }
let vResult = changes
changes = []
flushScheduled = false
return vResult
}
// discards pending changes (used when the watcher is torn down)
func reset() {
_ = drain()
}
}
// AppModel is the shared backing state for the application.
// It owns the in-memory file index, drives the background indexer and the
// FSEvents watcher, and exposes the active sort descriptor. The per-window
// search query and filtered/visible slice live in WindowSearchModel so two
// windows can run independent searches against this same shared index.
//
// Threading rule of thumb: this class is @MainActor so all state is written
// from main. Heavy work (LZ4 (de)compression, filtering, sorting, pruning,
// filesystem walks and stats) happens on background queues; the result is
// then merged back on main. Snapshots of value types (Array, IndexState)
// cross thread boundaries via Swift COW semantics.
//
// The record array is deliberately NOT @Published: every mutation of a
// @Published array emits the whole array, and any subscriber that buffers
// those values (receive(on:)) forces a full copy of the index per change -
// that is what used to blow up to tens of GB during an FSEvents replay.
// Views observe the small @Published counters; search models listen to the
// payload-free recordsChanged signal and read allRecords when they refilter.
@MainActor
final class AppModel: ObservableObject {
// the canonical index (records + id lookup), main-thread only
private var index = IndexState()
// every entry observed so far, in no particular order
var allRecords: RecordStore { index.records }
// fires on main after allRecords changed (no payload by design)
let recordsChanged = PassthroughSubject<Void, Never>()
// when the index last changed; read by the status bar on its own
// refresh tick, so deliberately not @Published
private(set) var lastIndexChangeAt: Date?
// total number of entries indexed (used for the status bar)
@Published private(set) var indexedCount: Int = 0
// true while a full reindex is in progress
@Published private(set) var isIndexing: Bool = false
// true while the cache file is being loaded into memory at startup
@Published private(set) var isLoadingCache: Bool = false
// true when this process owns the index (got the lock or built-in mode)
@Published private(set) var isIndexer: Bool = false
// how often the index is written to disk while running (seconds)
private let kAutosaveSeconds: TimeInterval = 30
// delay before a roots / exclusions edit is applied to the live index
private let kConfigDebounceSeconds: TimeInterval = 1.0
private let prefs = Preferences.shared
// background queues isolated by concern - keeps the slow stuff off main
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility)
// watcher used in indexer mode for live updates
private let watcher = FileWatcher()
// watcher used in reader mode to detect cache file refreshes
private let cacheWatcher = FileWatcher()
// FSEvents batches waiting to be resolved and applied (indexer mode)
private let pendingChanges = PendingChanges()
// roots the watcher was started with; their own records need no parent
private var watchedRoots: Set<String> = []
// exclusions the running watcher resolves changes with
private var currentExclusions: ExclusionMatcher?
// subtree walks started but not merged yet
private var rescansInFlight = 0
// event id to save while rescans are in flight: the id from before the
// changes that triggered them, so a quit or crash mid-walk replays those
// changes instead of losing the folders' contents for good
private var rescanFloorEventId: UInt64?
// a reader-mode reload skipped (throttled or no window visible)
private var pendingReaderReload = false
// service mode the current role was started with (see activeServiceMode)
private(set) var bootMode: Preferences.ServiceMode = .none
// bumped whenever the whole index is replaced, so a subtree rescan that
// started against the previous index doesn't merge into the new one
private var indexGeneration = 0
// autosave timer when running in indexer mode
private var autosaveTimer: Timer?
// true when the index has changed since the last save
private var dirty = false
// last FSEvents event id known to be reflected in the index
private var lastEventId: UInt64 = 0
// process-wide indexer mutex (nil until acquired)
private var indexerLock: IndexerLock?
// guards start() from running twice across window close/reopen
private var hasStarted = false
// subscriptions to Preferences / app lifecycle
private var cancellables: Set<AnyCancellable> = []
// true while a privileged service operation is in flight (Reindex / Clear)
@Published private(set) var isWorking: Bool = false
// last user-facing status string for the Settings buttons
@Published private(set) var workMessage: String = ""
// polling backup for reader-mode cache changes; FSEvents alone can miss
// updates if the watched directory didn't exist when the stream started.
// Uses DispatchSourceTimer on a background queue (not Timer on the main
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// minimum gap between two reader-mode reloads. Prevents the Table from
// being re-rendered while the user is mid-click. 1 second is well below
// any human-perceptible "stale" threshold but caps the worst-case churn
// during heavy filesystem activity.
private let kMinReloadInterval: TimeInterval = 1.0
// timestamp of the last reloadFromCache() that actually swapped data in
private var lastReloadAt: Date?
// wires up the quit and settings observers (loading happens in start)
init() {
// the cache file is loaded off-main in start() so the window
// appears instantly; here we only wire up observers.
// Persist on Cmd+Q: the autosave only runs every 30 s and an async
// save would be killed with the process.
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
MainActor.assumeIsolated {
self?.saveCacheNow()
}
}
.store(in: &cancellables)
// slow index updates down while the app isn't being looked at, and
// catch up at once when a window comes back
AppActivity.shared.$level
.removeDuplicates()
.sink { [weak self] vLevel in
self?.activityChanged(inLevel: vLevel)
}
.store(in: &cancellables)
// the delays are user settings: apply edits right away
Publishers.Merge3(
prefs.$updateDelayForeground,
prefs.$updateDelayBackground,
prefs.$updateDelayHidden
)
.dropFirst(3)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.activityChanged(inLevel: AppActivity.shared.level)
}
.store(in: &cancellables)
// apply roots / exclusions / volume edits to the live index
Publishers.Merge4(
prefs.$rootPaths.map { _ in () },
prefs.$excludedPaths.map { _ in () },
prefs.$includeMountedVolumes.map { _ in () },
prefs.$includeNetworkVolumes.map { _ in () }
)
.dropFirst(4)
.debounce(for: .seconds(kConfigDebounceSeconds), scheduler: DispatchQueue.main)
.sink { [weak self] in
self?.applyConfigurationChange()
}
.store(in: &cancellables)
}
// the service mode actually in effect: the saved mode, but only if that
// service is really installed. A mode saved without a working install
// (or a service removed behind the app's back) falls back to indexing
// in-process instead of waiting forever on a cache nobody writes.
var activeServiceMode: Preferences.ServiceMode {
switch prefs.serviceMode {
case .none: return .none
case .userAgent: return ServiceInstaller.isInstalled(inScope: .userAgent) ? .userAgent : .none
case .rootDaemon: return ServiceInstaller.isInstalled(inScope: .rootDaemon) ? .rootDaemon : .none
}
}
// kicks off background activity for the first time. Subsequent calls are
// no-ops so we don't double-bootstrap when the user reopens the window.
// To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
// call switchToCurrentMode() instead.
func start() {
if hasStarted { return }
hasStarted = true
bootstrap()
}
// re-runs the mode-detection + cache-load + watcher-setup sequence.
// Used after the user changes the service installation state so the GUI
// can hot-swap between indexer and reader without a full app restart.
func switchToCurrentMode() {
NSLog("[Allofit GUI] switching mode (serviceMode=%@)", prefs.serviceMode.rawValue)
bootstrap()
}
// stops all watchers/timers and releases the indexer lock, leaving the
// model ready for a fresh bootstrap()
private func tearDownActiveMode() {
watcher.stop()
cacheWatcher.stop()
pendingChanges.reset()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollSource?.cancel()
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
}
// determines indexer vs reader role, loads the cache from the appropriate
// path, and starts the right watcher set. Safe to call repeatedly.
private func bootstrap() {
tearDownActiveMode()
let vMode = activeServiceMode
bootMode = vMode
if vMode != prefs.serviceMode {
NSLog("[Allofit GUI] service mode %@ saved but not installed, indexing in-process", prefs.serviceMode.rawValue)
}
if vMode != .none {
isIndexer = false
} else {
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
if vLock.tryLock() {
indexerLock = vLock
isIndexer = true
} else {
isIndexer = false
NSLog("[Allofit GUI] indexer lock held by another process, running as reader")
}
}
isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: vMode)
let vIsIndexer = isIndexer
let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path })
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
vMode.rawValue,
vIsIndexer ? "true" : "false",
vCacheURL.path)
ioQueue.async { [weak self] in
// decompress + parse + reconcile off-main (the expensive part)
let vCache = IndexStore.load(from: vCacheURL)
var vState = IndexState(inRecords: vCache?.records ?? [])
// as the indexer, drop what no longer matches the configuration
// (removed roots, new exclusions, orphans from older versions)
let vPruned = vIsIndexer ? vState.prune(inRoots: vRoots, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden) : 0
DispatchQueue.main.async {
guard let vSelf = self else { return }
if let vCache = vCache {
NSLog("[Allofit GUI] loaded %d records from cache (%d pruned)",
vState.count, vPruned)
vSelf.lastEventId = vCache.lastEventId
} else {
NSLog("[Allofit GUI] cache load returned nil (file missing or invalid)")
vSelf.lastEventId = 0
}
vSelf.replaceIndex(inState: vState)
vSelf.dirty = vPruned > 0
vSelf.isLoadingCache = false
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
vSelf.startReaderMode()
}
}
}
}
// manually triggered cache reload; surfaced as a button in the Cache tab
// so users can verify the GUI reads what the daemon wrote. Bypasses
// reloadFromCache's rate-limit / same-eventId guards since the user
// explicitly asked.
func forceReloadCache() {
NSLog("[Allofit GUI] manual reload triggered")
lastReloadAt = nil
lastEventId = 0
reloadFromCache()
}
// reindexes from scratch on the next idle moment
func reindex() {
guard isIndexer, !isIndexing else { return }
isIndexing = true
indexedCount = 0
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vStartId = UInt64(FSEventsGetCurrentEventId())
let vIoQueue = ioQueue
// partial results only when there is nothing to show yet (first
// run): a manual rebuild keeps the old index visible until the end
let vPublishPartial = index.count == 0
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
// the results fill in while the walk runs (instead of an empty
// window until the end of a scan that can take minutes)
let kPartialPublishSeconds: TimeInterval = 3
var vLastPublish = Date()
for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block
autoreleasepool {
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
vAccumulated.append(contentsOf: vBatch)
let vCount = vAccumulated.count
let vNow = Date()
guard vPublishPartial, vNow.timeIntervalSince(vLastPublish) >= kPartialPublishSeconds else {
DispatchQueue.main.async { self?.indexedCount = vCount }
return
}
vLastPublish = vNow
let vPartial = IndexState(inRecords: vAccumulated)
DispatchQueue.main.async {
guard let vSelf = self, vSelf.isIndexing else { return }
vSelf.replaceIndex(inState: vPartial)
}
}
}
}
let vState = IndexState(inRecords: vAccumulated)
// persist off-main (on the save queue, so it never races another
// save) before bouncing back: main never sees the LZ4 cost
vIoQueue.sync {
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
}
DispatchQueue.main.async {
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
}
}
}
// dispatches a reindex appropriate for the current mode. In built-in mode
// it triggers an in-process FileIndexer pass. In service mode it stops
// the daemon, deletes its cache, and starts the daemon again so the
// daemon's fresh process performs the scan from scratch. The privileged
// portion runs on a detached task so the main thread (and the password
// dialog from NSAppleScript) doesn't freeze the ui.
func performReindex() async {
if isIndexer {
reindex()
return
}
await runPrivilegedAction(inLabel: "Reindexing") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
// removes the on-disk cache, with the right privilege escalation per mode.
// In built-in mode the file is owned by the current user, so a plain
// removeItem suffices. In service mode the cache may be owned by root and
// the daemon would re-write it on its next autosave, so we tunnel the
// delete through the same stop/delete/start admin script.
func performClearCache() async {
switch activeServiceMode {
case .none:
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
workMessage = "Cache cleared."
if isIndexer { reindex() }
case .userAgent, .rootDaemon:
await runPrivilegedAction(inLabel: "Clearing cache") { vScope, vUrl in
try ServiceInstaller.clearCacheAndRestart(inScope: vScope, inCacheURL: vUrl)
}
}
}
// installs a launchd service for the current serviceMode preference,
// off-main so the password dialog doesn't freeze the GUI. On success
// the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
// inMode is the mode chosen in Settings: it is only saved once the
// install succeeded, so a failed or cancelled install changes nothing
func performInstallService(inMode: Preferences.ServiceMode) async {
let vOk = await runPrivilegedAction(inLabel: "Installing service", inMode: inMode) { vScope, _ in
try ServiceInstaller.install(inScope: vScope)
}
if vOk {
prefs.serviceMode = inMode
// give launchd a moment to bring the daemon up before we switch
// the GUI into reader mode and start watching the cache file
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService(inMode: Preferences.ServiceMode? = nil) async {
await runPrivilegedAction(inLabel: "Stopping service", inMode: inMode) { vScope, _ in
try ServiceInstaller.stop(inScope: vScope)
}
}
// starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write.
func performStartService(inMode: Preferences.ServiceMode? = nil) async {
let vOk = await runPrivilegedAction(inLabel: "Starting service", inMode: inMode) { vScope, _ in
try ServiceInstaller.start(inScope: vScope)
}
if vOk {
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService(inMode: Preferences.ServiceMode? = nil) async {
let vMode = inMode ?? prefs.serviceMode
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service", inMode: vMode) { vScope, _ in
try ServiceInstaller.uninstall(inScope: vScope)
}
if vOk {
// back to the built-in indexer when the active service went away
if vMode == prefs.serviceMode {
prefs.serviceMode = .none
}
// give launchd a moment to actually tear the daemon down so its
// indexer lock is released before the GUI tries to grab it
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// shared helper: runs a service-mode operation on a detached queue while
// publishing isWorking/workMessage so the ui can show progress. Returns
// true if the action ran without throwing.
@discardableResult
// inMode defaults to the saved mode (the installed service)
private func runPrivilegedAction(inLabel: String,
inMode: Preferences.ServiceMode? = nil,
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool {
let vMode = inMode ?? prefs.serviceMode
guard vMode != .none else { return false }
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
let vUrl = IndexStore.cacheURL(forServiceMode: vMode)
// flush UserDefaults so the daemon reads up-to-date settings from
// our plist after it restarts (cfprefsd can buffer writes for minutes)
Preferences.flushToDisk()
isWorking = true
workMessage = "\(inLabel)…"
do {
try await Task.detached(priority: .userInitiated) {
try inBody(vScope, vUrl)
}.value
workMessage = "\(inLabel): done."
isWorking = false
return true
} catch {
workMessage = "\(inLabel) failed: \(error.localizedDescription)"
NSLog("[Allofit] %@ failed: %@", inLabel, "\(error)")
isWorking = false
return false
}
}
// writes the current index to disk (autosave, window close, replay end).
// Snapshots on main (cheap COW), compresses off-main.
// inForce saves even when nothing changed (to persist replay progress)
func saveCache(inForce: Bool = false) {
guard isIndexer, dirty || inForce else { return }
let vRecords = index.records
// lastEventId tracks what is actually merged into the index; the
// stream's latest id can be ahead (changes still being resolved),
// and saving that would make the next launch skip those changes.
// While subtree walks are pending, the id from before them is kept.
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.async {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
}
}
// synchronous save used at quit; ioQueue.sync also waits for any
// asynchronous save still in flight so the newest state wins
private func saveCacheNow() {
guard isIndexer, dirty else { return }
let vRecords = index.records
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.sync {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
}
}
// ===========================
// MARK: Indexer mode
// ===========================
// starts live updates after the cache was loaded. Falls back to a full
// reindex when there is no cache, and to background rescans when the
// saved event id can't be replayed or a configured root is missing.
private func startIndexerMode() {
startAutosaveTimer()
if index.count == 0 {
reindex()
return
}
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
let vCurrentId = UInt64(FSEventsGetCurrentEventId())
if lastEventId == 0 || lastEventId > vCurrentId {
// the id is from another FSEvents database (volume erased,
// cache copied from another Mac...): replay would silently
// deliver nothing, so re-walk everything in the background
// while the cached results stay searchable
NSLog("[Allofit GUI] saved event id %llu not replayable (current %llu), rescanning roots",
lastEventId, vCurrentId)
lastEventId = vCurrentId
startWatching(inSinceWhen: vCurrentId)
// floor 0: an interrupted walk makes the next launch start over
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0)
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, inFloorEventId: lastEventId)
}
}
// starts the FSEvents watcher from inSinceWhen. Batches are coalesced in
// pendingChanges, resolved (stat'ed) on indexQueue, and only the final
// in-memory merge runs on main - so replaying days of history after a
// relaunch neither floods the main queue nor freezes the ui.
private func startWatching(inSinceWhen: UInt64) {
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
watchedRoots = Set(vRoots)
pendingChanges.reset()
let vPending = pendingChanges
let vQueue = indexQueue
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
currentExclusions = vMatcher
watcher.start(
inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { [weak self] vChanges in
guard vPending.append(inChanges: vChanges) else { return }
vQueue.asyncAfter(deadline: .now() + vPending.coalesceDelay) { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
}
// resolves everything pending (on the calling background queue) and
// merges the result on main
private nonisolated static func flush(inPending: PendingChanges,
inExclusions: ExclusionMatcher,
inModel: AppModel?) {
let vChanges = inPending.drain()
// an early catch-up flush may already have taken everything
guard !vChanges.isEmpty else { return }
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: inExclusions)
DispatchQueue.main.async { [weak inModel] in
inModel?.applyResolvedChanges(inResolved: vResolved)
}
}
// adapts the update cadence to the visibility level (delays from
// Settings > Performance). Longer collection windows when nobody looks
// mean fewer wake-ups and fewer stats, since repeated writes to one file
// collapse into one check. When the app becomes more visible (or a
// delay is shortened), pending changes are applied right away.
private func activityChanged(inLevel: AppActivity.Level) {
// reader mode: a reload skipped while hidden happens now
if inLevel != .hidden && pendingReaderReload && !isIndexer {
pendingReaderReload = false
reloadFromCache()
}
let vDelay: TimeInterval
switch inLevel {
case .foreground: vDelay = prefs.updateDelayForeground
case .background: vDelay = prefs.updateDelayBackground
case .hidden: vDelay = prefs.updateDelayHidden
}
let vPrevious = pendingChanges.coalesceDelay
pendingChanges.setCoalesceDelay(vDelay)
guard vDelay < vPrevious, isIndexer, pendingChanges.hasPending, let vMatcher = currentExclusions else { return }
let vPending = pendingChanges
indexQueue.async { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
// saves the index every kAutosaveSeconds while it has unsaved changes
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
// .common mode so the timer fires even while SwiftUI is busy
let vTimer = Timer(timeInterval: kAutosaveSeconds, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let vSelf = self, vSelf.dirty else { return }
vSelf.saveCache()
}
}
RunLoop.main.add(vTimer, forMode: .common)
autosaveTimer = vTimer
}
// merges a resolved set of FSEvents changes into the index (pure
// in-memory work; the stats were done off-main by IndexState.resolve)
private func applyResolvedChanges(inResolved: ResolvedChanges) {
guard isIndexer else { return }
let vEventIdBefore = lastEventId
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
lastEventId = max(lastEventId, inResolved.maxEventId)
if vResult.changed {
dirty = true
publishRecords()
}
if !vResult.rescans.isEmpty {
kickRescanSubtrees(inPaths: vResult.rescans, inFloorEventId: vEventIdBefore)
}
if !vResult.newFolders.isEmpty {
kickRescanSubtrees(inPaths: vResult.newFolders, inFloorEventId: vEventIdBefore, inNewFolders: true)
}
// persist right after a relaunch replay finishes so the next launch
// resumes from here instead of replaying the same history again
if inResolved.historyDone {
NSLog("[Allofit GUI] FSEvents history replay done, saving cache")
saveCache(inForce: true)
}
}
// re-walks the given subtrees in the background, then swaps their
// content in on main. Used for kernel-requested rescans, folders that
// were moved/renamed into the tree, and newly added roots.
// inFloorEventId is saved instead of lastEventId until the walk is
// merged. inNewFolders: the folders just appeared, so their records are
// merged without the O(n) removal pass of a kernel-requested rescan.
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false) {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
rescansInFlight += 1
indexQueue.async { [weak self] in
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
))
}
}
let vFinal = vRecords
DispatchQueue.main.async {
guard let vSelf = self else { return }
vSelf.rescansInFlight -= 1
if vSelf.rescansInFlight <= 0 {
vSelf.rescansInFlight = 0
vSelf.rescanFloorEventId = nil
}
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
if inNewFolders {
vSelf.index.mergeNewFolders(inRecords: vFinal)
} else {
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
}
vSelf.dirty = true
vSelf.publishRecords()
}
}
}
// applies edited roots / exclusions / volume options to the live index
// without a full reindex: prunes what fell out of scope, walks roots
// that are new, and restarts the watcher on the new root set
private func applyConfigurationChange() {
guard isIndexer, !isIndexing, !isLoadingCache else { return }
NSLog("[Allofit GUI] configuration changed, reconciling index")
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
let vRootSet = Set(vRoots)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vState = index
let vGeneration = indexGeneration
// replay point: changes merged after this snapshot (and those whose
// subtree walks are still running) would be lost when the pruned
// copy replaces the live index, so the watcher restarts from here
let vSnapshotEventId = rescanFloorEventId ?? lastEventId
watcher.stop()
indexQueue.async { [weak self] in
var vPruned = vState
let vRemoved = vPruned.prune(inRoots: vRootSet, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden)
DispatchQueue.main.async {
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
vSelf.replaceIndex(inState: vPruned)
vSelf.dirty = true
vSelf.lastEventId = vSnapshotEventId
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty {
vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId)
}
}
}
}
// drops paths (and what's below them) from the index right away. Used
// after Move to Trash from Allofit: the watcher ignores this process's
// own file operations, so FSEvents would never report the removal
func forget(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
if index.removeSubtrees(inPaths: inPaths) > 0 {
dirty = true
publishRecords()
}
}
// (re)adds paths this app created or moved back itself (undo of Move to
// Trash), which FSEvents doesn't report to it. Resolved like FSEvents
// changes, so restored folders get walked too.
func notice(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
let vChanges = inPaths.map { FSChange(path: $0, flags: 0, eventId: 0) }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
indexQueue.async { [weak self] in
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
DispatchQueue.main.async {
self?.applyResolvedChanges(inResolved: vResolved)
}
}
}
// ===========================
// MARK: Reader mode
// ===========================
// follows the cache written by another indexer (service or second instance)
private func startReaderMode() {
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
// FSEvents-based watcher for low-latency updates. We watch the
// parent dir (FSEvents needs a real path) but only fire the reload
// when the cache file itself changed - other files in the dir
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
// trigger main.async hops, which contributed to dropped clicks.
cacheWatcher.start(inRoots: [vDir]) { [weak self] vChanges in
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
guard vCacheChanged else { return }
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
DispatchQueue.main.async { [weak self] in
self?.reloadFromCache()
}
}
// Background-queue polling backup at 2s. The stat() runs off main,
// and a lock-guarded background-side mtime tracker means we ONLY
// hop to main when the cache actually changed - the steady state
// puts zero work on the main runloop, leaving NSTableView's click
// handling uninterrupted.
cachePollSource?.cancel()
let vPolledPath = vUrl.path
let vBgMtime = BackgroundMtime() // captured by the closure
let vSource = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
vSource.schedule(deadline: .now() + 2.0, repeating: 2.0)
vSource.setEventHandler { [weak self] in
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vPolledPath),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
// Background-side change detection; only proceeds when mtime moved
guard vBgMtime.updateIfChanged(vMtime) else { return }
DispatchQueue.main.async {
self?.reloadFromCache()
}
}
vSource.resume()
cachePollSource = vSource
}
// reloads the on-disk cache off-main and swaps it in atomically.
//
// Two guards keep this from disrupting Table interactions:
// 1. lastEventId comparison - the daemon advances its event id every
// time it actually persists new state, so a matching id means the
// records on disk are identical to what we have and we can bail
// before touching any published state.
// 2. A 1-second floor between reloads. Even when changes do happen,
// we don't need to re-render the whole list at FSEvents' rate -
// the next tick will catch any newer state.
private func reloadFromCache() {
guard !isIndexer else { return }
// nobody looks: reload once a window shows up again
if AppActivity.shared.level == .hidden {
pendingReaderReload = true
return
}
let vNow = Date()
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
// throttled: catch up when the interval ends instead of
// dropping the change (the mtime poll has already seen it)
if !pendingReaderReload {
pendingReaderReload = true
let vWait = kMinReloadInterval - vNow.timeIntervalSince(vLast)
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
guard let vSelf = self, vSelf.pendingReaderReload else { return }
vSelf.pendingReaderReload = false
vSelf.reloadFromCache()
}
}
return
}
pendingReaderReload = false
lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vCurrentEventId = lastEventId
let vCurrentCount = index.count
ioQueue.async { [weak self] in
guard let vCache = IndexStore.load(from: vUrl) else { return }
// short-circuit when the on-disk content matches what we already
// have - common when the daemon resaves on a noise-only FSEvents
// burst (e.g. spotlight reindexing, temp files in /var). The
// count check lets partial saves of a first scan through (the
// daemon keeps the event id at 0 until the scan completes).
if vCache.lastEventId == vCurrentEventId && vCache.records.count == vCurrentCount { return }
let vState = IndexState(inRecords: vCache.records)
DispatchQueue.main.async {
guard let vSelf = self, !vSelf.isIndexer else { return }
vSelf.lastEventId = vCache.lastEventId
vSelf.replaceIndex(inState: vState)
}
}
}
// ===========================
// MARK: Internals
// ===========================
// installs a whole new index (cache load, reindex, reload, reconcile)
private func replaceIndex(inState: IndexState) {
index = inState
indexGeneration &+= 1
publishRecords()
}
// notifies observers that the records changed
private func publishRecords() {
lastIndexChangeAt = Date()
if indexedCount != index.count {
indexedCount = index.count
}
recordsChanged.send()
}
// installs a freshly-built index and starts watching for changes
private func applyFreshIndex(inState: IndexState, inEventId: UInt64) {
replaceIndex(inState: inState)
lastEventId = inEventId
isIndexing = false
dirty = false
if isIndexer {
startWatching(inSinceWhen: inEventId)
}
}
}
+409
View File
@@ -0,0 +1,409 @@
import SwiftUI
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), the results list 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 {
// AppModel is intentionally not observed here: its counters change on
// every index update and would re-evaluate this body each time.
// StatusBarView observes it instead.
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
// per-window search model: owns this window's query + results so two
// windows can run independent searches against the shared AppModel
@EnvironmentObject var searchModel: WindowSearchModel
// selected records, in row order (shared with the table, preview and
// status bar)
@State private var selection: [FileRecord] = []
// window undo stack (Move to Trash can be undone)
@Environment(\.undoManager) private var undoManager
// 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
// search syntax popover
@State private var showSyntaxHelp = false
var body: some View {
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: {
// a real label so VoiceOver reads it, shown as an icon
Label(showPreviewPane ? "Hide Preview" : "Show Preview",
systemImage: showPreviewPane ? "sidebar.right" : "sidebar.squares.right")
.labelStyle(.iconOnly)
}
.help(showPreviewPane ? "Hide preview" : "Show preview")
}
ToolbarItem(placement: .primaryAction) {
SettingsLink {
Label("Settings", systemImage: "gearshape")
.labelStyle(.iconOnly)
}
.help("Settings (⌘,)")
}
}
}
// search bar + results + status bar - everything except the preview pane
private var mainColumn: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
// isolated so its refreshes don't re-evaluate this body
StatusBarView(stats: searchModel.stats, selectionText: selectionSummary)
}
}
// ===========================
// MARK: Search bar
// ===========================
// search field plus the Everything-style category filter
private var searchBar: some View {
HStack(spacing: 8) {
SearchField(
text: $searchModel.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
.frame(minHeight: 24)
// category filter, ANDed with the query
Picker("Filter", selection: $searchModel.filter) {
ForEach(SearchFilter.allCases) { vFilter in
Label(vFilter.title, systemImage: vFilter.symbolName).tag(vFilter)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Show only one kind of item")
// search syntax cheat sheet
Button {
showSyntaxHelp.toggle()
} label: {
Label("Search Syntax", systemImage: "questionmark.circle")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Search syntax")
.popover(isPresented: $showSyntaxHelp, arrowEdge: .bottom) {
SyntaxHelpView()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
// ===========================
// MARK: Results
// ===========================
// native table with every result (see ResultsTable)
private var resultsTable: some View {
ResultsTable(
records: searchModel.results,
version: searchModel.resultsVersion,
highlights: searchModel.highlights,
highlightEnabled: prefs.highlightMatches,
selection: $selection,
sort: $searchModel.sortDescriptor,
needsAuthorization: { vRecord in access.needsAuthorization(for: vRecord) },
perform: { vAction, vRecords in perform(inAction: vAction, inRecords: vRecords) }
)
// explains an empty list (loading, indexing, no match, filter on)
.overlay {
EmptyResultsView(stats: searchModel.stats, filter: searchModel.filter, hasQuery: !searchModel.query.isEmpty)
.allowsHitTesting(false)
}
}
// "3 selected (12 MB)" for the status bar, nil without a selection
private var selectionSummary: String? {
guard !selection.isEmpty else { return nil }
let vBytes = selection.filter { !$0.isDirectory }.reduce(Int64(0)) { $0 + $1.size }
let vCount = "\(selection.count.formatted()) selected"
return vBytes > 0 ? "\(vCount) (\(Formatters.size(bytes: vBytes)))" : vCount
}
// ===========================
// MARK: Actions
// ===========================
// runs an action requested by the table (menu, keyboard, double-click)
private func perform(inAction: ResultAction, inRecords: [FileRecord]) {
switch inAction {
case .primary:
switch prefs.primaryAction {
case .open: openRecords(inRecords)
case .reveal: revealRecords(inRecords)
}
case .open: openRecords(inRecords)
case .openWith(let vApp): openRecords(inRecords, withApp: vApp)
case .reveal: revealRecords(inRecords)
case .quickLook:
// prefer the staged URL when one exists - QLPreviewPanel
// renders it without permission issues
QuickLookCoordinator.shared.show(inUrls: inRecords.map { access.effectiveURL(for: $0) })
case .copyFiles:
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects(inRecords.map { URL(fileURLWithPath: $0.fullPath) as NSURL })
case .copyNames:
copyLines(inRecords.map(\.name))
case .copyPaths:
// always the original path - a staged copy's tmp path means
// nothing outside this session
copyLines(inRecords.map(\.fullPath))
case .trash:
trashRecords(inRecords)
case .authorize:
if let vRecord = inRecords.first {
Task { await access.authorize(vRecord) }
}
}
}
// reveals the records in Finder (the original files, not staged copies)
private func revealRecords(_ inRecords: [FileRecord]) {
NSWorkspace.shared.activateFileViewerSelecting(inRecords.map { URL(fileURLWithPath: $0.fullPath) })
}
// puts one string per line on the pasteboard
private func copyLines(_ inLines: [String]) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(inLines.joined(separator: "\n"), forType: .string)
}
// moves the records to the Trash: their rows go away at once, ⌘Z puts
// them back, and a failure is reported instead of silently ignored
private func trashRecords(_ inRecords: [FileRecord]) {
let vUrls = inRecords.map { URL(fileURLWithPath: $0.fullPath) }
let vUndo = undoManager
NSWorkspace.shared.recycle(vUrls) { vTrashed, vError in
// look the results up with the very URL objects passed in (a
// folder's URL gains a trailing slash, rebuilt ones wouldn't match)
var vMoved: [(record: FileRecord, original: URL, trashed: URL)] = []
for (vRecord, vUrl) in zip(inRecords, vUrls) {
if let vInTrash = vTrashed[vUrl] {
vMoved.append((vRecord, vUrl, vInTrash))
}
}
DispatchQueue.main.async {
if !vMoved.isEmpty {
searchModel.removeTrashed(inRecords: vMoved.map(\.record))
let vGone = Set(vMoved.map(\.record.id))
selection.removeAll { vGone.contains($0.id) }
registerPutBack(inMoves: vMoved.map { ($0.original, $0.trashed) }, inUndo: vUndo)
}
if let vError = vError {
let vAlert = NSAlert(error: vError)
vAlert.messageText = vMoved.isEmpty
? "Couldn't move to the Trash"
: "Some items couldn't be moved to the Trash"
vAlert.runModal()
}
}
}
}
// registers "Undo Move to Trash": moves the items back and tells the
// index (the watcher ignores this app's own file operations)
private func registerPutBack(inMoves: [(original: URL, trashed: URL)], inUndo: UndoManager?) {
guard let vUndo = inUndo else { return }
let vModel = searchModel
vUndo.registerUndo(withTarget: vModel) { vTarget in
var vRestored: [String] = []
for vMove in inMoves where (try? FileManager.default.moveItem(at: vMove.trashed, to: vMove.original)) != nil {
vRestored.append(vMove.original.path)
}
vTarget.noticeRestored(inPaths: vRestored)
}
vUndo.setActionName(inMoves.count == 1 ? "Move to Trash" : "Move \(inMoves.count) Items to Trash")
}
// opens the records with a specific app
private func openRecords(_ inRecords: [FileRecord], withApp inApp: URL) {
let vUrls = inRecords.map { access.effectiveURL(for: $0) }
NSWorkspace.shared.open(vUrls, withApplicationAt: inApp, configuration: NSWorkspace.OpenConfiguration())
}
// opens the records with their default app (the staged copy when one
// exists, so the app can read it)
private func openRecords(_ inRecords: [FileRecord]) {
for vRecord in inRecords {
NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
}
}
}
// ===========================
// MARK: Status bar
// ===========================
// Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip,
// the resource sampler's 2-second tick) only re-evaluate this small leaf
// view rather than the ContentView body that contains the results.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var searchModel: WindowSearchModel
// per-window match count and search time
@ObservedObject var stats: SearchStats
// "3 selected (12 MB)", nil without a selection
let selectionText: String?
// process memory / CPU sampler shared by all windows
@ObservedObject private var process = ProcessStats.shared
// freeze key state, shown while held
@ObservedObject private var freeze = FreezeKey.shared
// CPU use above which the figure is highlighted (100 = one core)
private let kBusyCpuPercent: Double = 50
var body: some View {
HStack(spacing: 8) {
if model.isLoadingCache {
ProgressView()
.controlSize(.small)
Text("Loading index…")
} else if model.isIndexing {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount.formatted()) entries")
} else {
Text(resultsText)
if let vSelection = selectionText {
Text("·")
Text(vSelection)
}
}
Spacer()
if freeze.isHeld {
Label("Frozen", systemImage: "snowflake")
.foregroundColor(.accentColor)
.help("Holding ⌥ Option pauses list updates; release to catch up")
}
HStack(spacing: 6) {
Text(String(format: "%.0f ms", stats.lastSearchMilliseconds))
Text("·")
Text("RAM \(Formatters.memory(bytes: process.footprintBytes))")
Text("·")
Text(String(format: "CPU %.0f%%", process.cpuPercent))
.foregroundColor(process.cpuPercent >= kBusyCpuPercent ? .orange : .secondary)
Text("·")
Text(roleText)
}
.monospacedDigit()
.help(detailsText)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.font(.caption)
.foregroundColor(.secondary)
}
// "N results · M indexed"
private var resultsText: String {
let vCount = stats.matchCount
return "\(vCount.formatted()) \(vCount == 1 ? "result" : "results") · \(model.indexedCount.formatted()) indexed"
}
// indexer / reader role, plus the service kind when one is used
private var roleText: String {
let vRole = model.isIndexer ? "Indexer" : "Reader"
switch prefs.serviceMode {
case .none: return vRole
case .userAgent: return vRole + " · User service"
case .rootDaemon: return vRole + " · Root service"
}
}
// multi-line tooltip with the full set of figures
private var detailsText: String {
let vCacheBytes = IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))
let vUpdated = model.lastIndexChangeAt.map { $0.formatted(.relative(presentation: .named)) } ?? "never"
let vLines = [
"Memory: \(Formatters.memory(bytes: process.footprintBytes)) (peak \(Formatters.memory(bytes: process.peakFootprintBytes)))",
String(format: "CPU: %.1f%% (100%% = one core)", process.cpuPercent) + " · \(process.threadCount) threads",
String(format: "Last search: %.1f ms over ", stats.lastSearchMilliseconds) + "\(model.indexedCount.formatted()) entries",
"Index updated: \(vUpdated)",
"Cache on disk: \(Formatters.sizeOrDash(bytes: vCacheBytes))",
"Roots: \(prefs.rootPaths.count) · Role: \(roleText)"
]
return vLines.joined(separator: "\n")
}
}
// ===========================
// MARK: Empty state
// ===========================
// EmptyResultsView is shown over an empty results list and says why it's
// empty: the index is loading or being built, nothing matches, or a filter
// hides everything. It observes AppModel itself so ContentView doesn't have to.
private struct EmptyResultsView: View {
@EnvironmentObject var model: AppModel
// match count of this window
@ObservedObject var stats: SearchStats
// active category filter
let filter: SearchFilter
// true when something is typed in the search field
let hasQuery: Bool
var body: some View {
if stats.matchCount == 0 {
VStack(spacing: 8) {
if model.isLoadingCache {
ProgressView()
Text("Loading the index…")
.font(.headline)
} else if model.isIndexing {
ProgressView()
Text("Building the index…")
.font(.headline)
Text("\(model.indexedCount.formatted()) files so far. Results appear as they are found.")
.font(.callout)
} else if model.indexedCount == 0 {
Text("Nothing indexed yet")
.font(.headline)
Text("Add folders in Settings > Indexes.")
.font(.callout)
} else {
Text("No results")
.font(.headline)
if filter != .everything {
Text("Only \(filter.title.lowercased()) are shown. Choose Everything in the filter menu to search all items.")
.font(.callout)
} else if hasQuery {
Text("Click ? next to the search field for the search syntax.")
.font(.callout)
}
}
}
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
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 thousands of rows.
enum Formatters {
// file-size formatter (decimal units, like Finder)
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)
}
// memory formatter (binary units, like Activity Monitor)
private static let kMemoryFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .memory
return vF
}()
// memory amount, e.g. "312 MB" (binary units, like Activity Monitor)
static func memory(bytes inBytes: UInt64) -> String {
return kMemoryFormatter.string(fromByteCount: Int64(clamping: inBytes))
}
// same as size(bytes:), but "-" for zero / unknown (missing files)
static func sizeOrDash(bytes inBytes: Int64) -> String {
return inBytes <= 0 ? "-" : size(bytes: inBytes)
}
// short date + short time, in the user's locale
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
// short date+time, with "-" for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "-" }
return kDateFormatter.string(from: inDate)
}
}
+58
View File
@@ -0,0 +1,58 @@
import AppKit
// FreezeKey reports whether the "freeze" key (⌥ Option) is held down while
// Allofit is active. Holding it pauses the background refreshes of the
// results, so rows don't move while the user reads or selects them; typing,
// sorting and filtering still apply. Released (or the app deactivated), the
// lists catch up once.
@MainActor
final class FreezeKey: ObservableObject {
// shared state for every window
static let shared = FreezeKey()
// true while ⌥ Option is held in Allofit
@Published private(set) var isHeld = false
// modifier-change monitor, alive for the app's lifetime
private var monitor: Any?
// app activation observers
private var observers: [NSObjectProtocol] = []
// starts following the modifier keys
private init() {
monitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] vEvent in
MainActor.assumeIsolated {
self?.update(inFlags: vEvent.modifierFlags)
}
return vEvent
}
// a key released while another app was frontmost never reaches the
// local monitor: re-read the state on (de)activation
for vName in [NSApplication.didResignActiveNotification, NSApplication.didBecomeActiveNotification] {
observers.append(NotificationCenter.default.addObserver(forName: vName, object: nil, queue: .main) { [weak self] vNote in
MainActor.assumeIsolated {
if vNote.name == NSApplication.didResignActiveNotification {
self?.setHeld(false)
} else {
self?.update(inFlags: NSEvent.modifierFlags)
}
}
})
}
}
// held when Option is down on its own (shortcuts such as ⌥⌘C don't
// freeze the list)
private func update(inFlags: NSEvent.ModifierFlags) {
let vMods = inFlags.intersection([.command, .option, .control, .shift])
setHeld(vMods == [.option])
}
// publishes a change of state
private func setHeld(_ inHeld: Bool) {
if isHeld != inHeld {
isHeld = inHeld
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import AppKit
import Carbon.HIToolbox
// HotKeyPreset lists the global shortcuts offered in Settings. A short list
// of presets avoids a recorder UI and only offers combinations that don't
// clash with standard macOS shortcuts (⌘Space is Spotlight).
enum HotKeyPreset: String, CaseIterable, Identifiable {
case none
case optionSpace
case controlSpace
case controlOptionSpace
case optionCommandSpace
case shiftCommandSpace
var id: String { rawValue }
// menu label, e.g. "⌥ Space"
var title: String {
switch self {
case .none: return "None"
case .optionSpace: return "⌥ Space"
case .controlSpace: return "⌃ Space"
case .controlOptionSpace: return "⌃⌥ Space"
case .optionCommandSpace: return "⌥⌘ Space"
case .shiftCommandSpace: return "⇧⌘ Space"
}
}
// Carbon modifier mask for RegisterEventHotKey (nil for none)
var carbonModifiers: UInt32? {
switch self {
case .none: return nil
case .optionSpace: return UInt32(optionKey)
case .controlSpace: return UInt32(controlKey)
case .controlOptionSpace: return UInt32(controlKey | optionKey)
case .optionCommandSpace: return UInt32(optionKey | cmdKey)
case .shiftCommandSpace: return UInt32(shiftKey | cmdKey)
}
}
}
// GlobalHotKey registers one system-wide shortcut through Carbon's
// RegisterEventHotKey, which needs no Accessibility permission (unlike an
// NSEvent global monitor) and swallows the key press for us.
@MainActor
final class GlobalHotKey: ObservableObject {
// shared registration for the app
static let shared = GlobalHotKey()
// false when the chosen shortcut is already taken by another app
@Published private(set) var isAvailable = true
// called on main when the shortcut is pressed
var onPressed: (() -> Void)?
// the active registration, nil when none
private var hotKeyRef: EventHotKeyRef?
// the Carbon event handler, installed once
private var handlerRef: EventHandlerRef?
// four-char signature identifying our hot key ("ALFT")
private let kSignature: OSType = 0x414C_4654
// registers inPreset (replacing any previous one). Returns false when
// the combination is already taken by another app.
@discardableResult
func register(inPreset: HotKeyPreset) -> Bool {
unregister()
guard let vModifiers = inPreset.carbonModifiers else {
isAvailable = true
return true
}
installHandlerIfNeeded()
var vRef: EventHotKeyRef?
let vId = EventHotKeyID(signature: kSignature, id: 1)
let vStatus = RegisterEventHotKey(
UInt32(kVK_Space),
vModifiers,
vId,
GetApplicationEventTarget(),
0,
&vRef
)
guard vStatus == noErr, let vRegistered = vRef else {
NSLog("[Allofit GUI] global shortcut %@ unavailable (status %d)", inPreset.title, vStatus)
isAvailable = false
return false
}
hotKeyRef = vRegistered
isAvailable = true
return true
}
// removes the current registration, if any
func unregister() {
if let vRef = hotKeyRef {
UnregisterEventHotKey(vRef)
hotKeyRef = nil
}
}
// installs the application-level handler that receives hot key events
private func installHandlerIfNeeded() {
guard handlerRef == nil else { return }
var vType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let vHandler: EventHandlerUPP = { _, _, _ in
DispatchQueue.main.async {
MainActor.assumeIsolated {
GlobalHotKey.shared.onPressed?()
}
}
return noErr
}
InstallEventHandler(GetApplicationEventTarget(), vHandler, 1, &vType, nil, &handlerRef)
}
}
@@ -12,7 +12,8 @@ struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
func makeNSView(context: Context) -> NSView {
// creates the embedded Quick Look view
func makeNSView(context inContext: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
@@ -23,44 +24,13 @@ struct QuickLookPreviewView: NSViewRepresentable {
return vView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let vQlView = nsView as? QLPreviewView else { return }
// shows the current file
func updateNSView(_ inView: NSView, context inContext: Context) {
guard let vQlView = inView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// AuthorizeBadge is the small lock icon that appears either inside the
// preview pane (when the selected file isn't user-readable) or 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.
struct AuthorizeBadge: View {
@EnvironmentObject 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
@@ -69,15 +39,15 @@ struct AuthorizeBadge: View {
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var access: AccessManager
@EnvironmentObject var searchModel: WindowSearchModel
// passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes
let selection: Set<FileRecord.ID>
let selection: [FileRecord]
// the record to preview: only with exactly one selected
private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil }
return model.visibleRecords.first(where: { $0.id == vId })
return selection.count == 1 ? selection.first : nil
}
var body: some View {
+148
View File
@@ -0,0 +1,148 @@
import Foundation
import Combine
import Darwin
// ProcessStats samples this process's resource usage for the status bar:
// memory footprint (the figure Activity Monitor shows), its peak, and CPU
// use. One shared instance ticks every kSampleSeconds; only the status bar
// observes it, so a tick never re-renders the results table.
@MainActor
final class ProcessStats: ObservableObject {
// shared sampler used by every window's status bar
static let shared = ProcessStats()
// current physical footprint in bytes
@Published private(set) var footprintBytes: UInt64 = 0
// highest footprint since launch, in bytes
@Published private(set) var peakFootprintBytes: UInt64 = 0
// CPU use over the last interval, 100 = one full core
@Published private(set) var cpuPercent: Double = 0
// number of threads in the process
@Published private(set) var threadCount: Int = 0
// refresh interval while focused / while another app is focused;
// sampling stops entirely when no window is on screen
private let kSampleForegroundSeconds: TimeInterval = 2
private let kSampleBackgroundSeconds: TimeInterval = 5
// repeating sampler
private var timer: Timer?
// user + system CPU seconds at the previous sample
private var lastCpuSeconds: Double = 0
// wall clock of the previous sample
private var lastSampleAt = Date()
// activity subscription
private var cancellable: AnyCancellable?
// takes a first sample and ticks at a pace that follows AppActivity
private init() {
lastCpuSeconds = ProcessStats.cpuSeconds()
sample()
cancellable = AppActivity.shared.$level
.removeDuplicates()
.sink { [weak self] vLevel in
self?.reschedule(inLevel: vLevel)
}
}
// restarts the timer for the given visibility level (none when hidden)
private func reschedule(inLevel: AppActivity.Level) {
timer?.invalidate()
timer = nil
let vInterval: TimeInterval
switch inLevel {
case .hidden: return
case .background: vInterval = kSampleBackgroundSeconds
case .foreground: vInterval = kSampleForegroundSeconds
}
sample()
let vTimer = Timer(timeInterval: vInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
self?.sample()
}
}
// .common so it keeps ticking during scrolling / live resize
RunLoop.main.add(vTimer, forMode: .common)
timer = vTimer
}
// reads the current figures and publishes the ones that changed
private func sample() {
let vMemory = ProcessStats.memory()
let vNow = Date()
let vCpu = ProcessStats.cpuSeconds()
let vElapsed = vNow.timeIntervalSince(lastSampleAt)
let vPercent = vElapsed > 0 ? max(0, (vCpu - lastCpuSeconds) / vElapsed * 100) : 0
lastCpuSeconds = vCpu
lastSampleAt = vNow
let vThreads = ProcessStats.threadCount()
if footprintBytes != vMemory.footprint { footprintBytes = vMemory.footprint }
if peakFootprintBytes != vMemory.peak { peakFootprintBytes = vMemory.peak }
// round so tiny jitter doesn't republish every tick
let vRounded = (vPercent * 10).rounded() / 10
if cpuPercent != vRounded { cpuPercent = vRounded }
if threadCount != vThreads { threadCount = vThreads }
}
// ===========================
// MARK: System calls
// ===========================
// physical footprint and its lifetime peak (task_vm_info)
private nonisolated static func memory() -> (footprint: UInt64, peak: UInt64) {
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)
}
}
guard vResult == KERN_SUCCESS else { return (0, 0) }
let vFootprint = vInfo.phys_footprint
return (vFootprint, max(vFootprint, UInt64(vInfo.ledger_phys_footprint_peak)))
}
// total user + system CPU time consumed so far, in seconds
private nonisolated static func cpuSeconds() -> Double {
var vUsage = rusage()
guard getrusage(RUSAGE_SELF, &vUsage) == 0 else { return 0 }
let vUser = Double(vUsage.ru_utime.tv_sec) + Double(vUsage.ru_utime.tv_usec) / 1_000_000
let vSystem = Double(vUsage.ru_stime.tv_sec) + Double(vUsage.ru_stime.tv_usec) / 1_000_000
return vUser + vSystem
}
// number of threads of this task
private nonisolated static func threadCount() -> Int {
var vThreads: thread_act_array_t?
var vCount = mach_msg_type_number_t(0)
guard task_threads(mach_task_self_, &vThreads, &vCount) == KERN_SUCCESS, let vList = vThreads else {
return 0
}
// the port array and each thread port must be released
for vI in 0..<Int(vCount) {
mach_port_deallocate(mach_task_self_, vList[vI])
}
vm_deallocate(
mach_task_self_,
vm_address_t(UInt(bitPattern: vList)),
vm_size_t(Int(vCount) * MemoryLayout<thread_t>.stride)
)
return Int(vCount)
}
}
// SearchStats holds the per-window search figures shown by the status bar.
// Kept apart from WindowSearchModel so updating them (every refilter)
// doesn't invalidate ContentView, which observes the search model for the
// table rows.
@MainActor
final class SearchStats: ObservableObject {
// number of records matching the current query
@Published var matchCount: Int = 0
// wall time of the last completed filter + sort, in milliseconds
@Published var lastSearchMilliseconds: Double = 0
}
@@ -28,11 +28,13 @@ final class QuickLookCoordinator: NSObject, @preconcurrency QLPreviewPanelDataSo
// MARK: QLPreviewPanelDataSource
// ===========================
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
// number of files shown by the panel
func numberOfPreviewItems(in inPanel: QLPreviewPanel!) -> Int {
return urls.count
}
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
return urls[index] as NSURL
// file shown at the given panel position
func previewPanel(_ inPanel: QLPreviewPanel!, previewItemAt inIndex: Int) -> QLPreviewItem! {
return urls[inIndex] as NSURL
}
}
+792
View File
@@ -0,0 +1,792 @@
import SwiftUI
import AppKit
// Actions the results table asks its owner to perform on a set of records.
enum ResultAction {
case primary // double-click / Return (open or reveal, per Settings)
case open // open with the default app
case openWith(URL) // open with a specific app
case reveal // reveal in Finder
case quickLook // Quick Look panel
case copyFiles // copy the files themselves
case copyNames // copy names, one per line
case copyPaths // copy full paths, one per line
case trash // move to Trash
case authorize // stage an unreadable file through an admin prompt
}
// ResultsTable shows every search result in a native NSTableView. Only the
// rows on screen get views, so the list isn't capped: a query matching the
// whole index scrolls as smoothly as one matching ten files. It also gives
// Finder-accurate drag and drop (file URLs on the pasteboard), column
// order / width / visibility autosave, and Finder-style keyboard shortcuts.
struct ResultsTable: NSViewRepresentable {
// all results, already filtered and sorted
let records: ResultList
// bumped by the search model whenever records change (cheap identity)
let version: Int
// what to emphasize in names / folders
let highlights: SearchEngine.Highlights
// false to show plain text (Settings > General)
let highlightEnabled: Bool
// selected records, in row order (handed out directly so the window
// never has to look them up among hundreds of thousands of results)
@Binding var selection: [FileRecord]
// active sort, driven by header clicks
@Binding var sort: FileSortDescriptor
// true when the record isn't readable without authorization
let needsAuthorization: (FileRecord) -> Bool
// runs an action on records
let perform: (ResultAction, [FileRecord]) -> Void
// column identifiers, also the autosave keys
static let kNameColumn = NSUserInterfaceItemIdentifier("name")
static let kPathColumn = NSUserInterfaceItemIdentifier("path")
static let kSizeColumn = NSUserInterfaceItemIdentifier("size")
static let kCreatedColumn = NSUserInterfaceItemIdentifier("created")
static let kModifiedColumn = NSUserInterfaceItemIdentifier("modified")
// creates the coordinator acting as data source and delegate
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
// builds the scroll view, table and columns
func makeNSView(context inContext: Context) -> NSScrollView {
let vTable = ResultsNSTableView()
vTable.coordinator = inContext.coordinator
vTable.allowsMultipleSelection = true
vTable.allowsColumnReordering = true
vTable.allowsColumnResizing = true
vTable.usesAlternatingRowBackgroundColors = true
vTable.style = .fullWidth
vTable.columnAutoresizingStyle = .noColumnAutoresizing
vTable.intercellSpacing = NSSize(width: 6, height: 2)
for (vId, vTitle, vWidth, vMin) in [
(Self.kNameColumn, "Name", 320.0, 160.0),
(Self.kPathColumn, "Path", 380.0, 120.0),
(Self.kSizeColumn, "Size", 90.0, 60.0),
(Self.kCreatedColumn, "Created", 140.0, 90.0),
(Self.kModifiedColumn, "Modified", 140.0, 90.0)
] {
let vColumn = NSTableColumn(identifier: vId)
vColumn.title = vTitle
vColumn.width = vWidth
vColumn.minWidth = vMin
vColumn.sortDescriptorPrototype = NSSortDescriptor(key: vId.rawValue, ascending: true)
vTable.addTableColumn(vColumn)
}
// remembers column order, widths and hidden columns across launches
vTable.autosaveName = "Allofit.ResultsTable"
vTable.autosaveTableColumns = true
vTable.dataSource = inContext.coordinator
vTable.delegate = inContext.coordinator
vTable.target = inContext.coordinator
vTable.doubleAction = #selector(Coordinator.doubleClicked(_:))
// dragging out of the app copies the files (like Finder)
vTable.setDraggingSourceOperationMask(.copy, forLocal: false)
let vMenu = NSMenu()
vMenu.delegate = inContext.coordinator
vTable.menu = vMenu
inContext.coordinator.installHeaderMenu(on: vTable)
let vScroll = NSScrollView()
vScroll.documentView = vTable
vScroll.hasVerticalScroller = true
vScroll.hasHorizontalScroller = true
vScroll.autohidesScrollers = true
vScroll.borderType = .noBorder
inContext.coordinator.table = vTable
inContext.coordinator.apply(inParent: self, inForce: true)
return vScroll
}
// pushes new results / sort / selection into the table
func updateNSView(_ inView: NSScrollView, context inContext: Context) {
inContext.coordinator.apply(inParent: self, inForce: false)
}
// ===========================
// MARK: Sort mapping
// ===========================
// sort mode for a column key + direction
static func descriptor(forKey inKey: String, ascending inAscending: Bool) -> FileSortDescriptor {
switch inKey {
case kPathColumn.rawValue: return inAscending ? .pathAscending : .pathDescending
case kSizeColumn.rawValue: return inAscending ? .sizeAscending : .sizeDescending
case kCreatedColumn.rawValue: return inAscending ? .createdAscending : .createdDescending
case kModifiedColumn.rawValue: return inAscending ? .modifiedAscending : .modifiedDescending
default: return inAscending ? .nameAscending : .nameDescending
}
}
// column key + direction for a sort mode
static func sortDescriptor(for inSort: FileSortDescriptor) -> NSSortDescriptor {
switch inSort {
case .nameAscending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: true)
case .nameDescending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: false)
case .pathAscending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: true)
case .pathDescending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: false)
case .sizeAscending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: true)
case .sizeDescending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: false)
case .createdAscending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: true)
case .createdDescending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: false)
case .modifiedAscending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: true)
case .modifiedDescending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: false)
}
}
// ===========================
// MARK: Coordinator
// ===========================
// Coordinator feeds the table from the current records and turns table
// events (selection, sort clicks, menus, drags) into SwiftUI updates.
@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
// latest SwiftUI description of the table
var parent: ResultsTable
// the table, set once created
weak var table: NSTableView?
// rows currently shown
private var records = ResultList()
// version of `records`, to skip redundant reloads
private var shownVersion = -1
// highlight settings the visible rows were drawn with
private var shownHighlights = SearchEngine.Highlights()
private var shownHighlightEnabled = true
// sort the header currently shows
private var shownSort: FileSortDescriptor?
// true while the coordinator itself changes selection / sort, so the
// resulting delegate callbacks aren't echoed back to SwiftUI
private var isApplying = false
// binds to the initial SwiftUI description
init(_ inParent: ResultsTable) {
parent = inParent
}
// syncs the table with a (possibly) new SwiftUI description
func apply(inParent: ResultsTable, inForce: Bool) {
parent = inParent
guard let vTable = table else { return }
isApplying = true
defer { isApplying = false }
if shownSort != inParent.sort {
shownSort = inParent.sort
vTable.sortDescriptors = [ResultsTable.sortDescriptor(for: inParent.sort)]
}
let vHighlightsChanged = shownHighlights != inParent.highlights
|| shownHighlightEnabled != inParent.highlightEnabled
if inForce || shownVersion != inParent.version || vHighlightsChanged {
shownVersion = inParent.version
shownHighlights = inParent.highlights
shownHighlightEnabled = inParent.highlightEnabled
records = inParent.records
vTable.reloadData()
// background refreshes reload often: keep the overlay of
// the hovered cell instead of making it flicker away
(vTable as? ResultsNSTableView)?.refreshHover()
restoreSelection(inTable: vTable)
} else if selectedIds(inTable: vTable) != Set(inParent.selection.map(\.id)) {
restoreSelection(inTable: vTable)
}
}
// selects the rows of the SwiftUI selection in the (new) list. One
// pass over the rows, only with a selection; records that are gone
// (trashed, filtered out) are dropped from the selection so later
// updates don't keep searching for them
private func restoreSelection(inTable: NSTableView) {
let vWanted = Set(parent.selection.map(\.id))
var vRows = IndexSet()
if !vWanted.isEmpty {
for vRow in 0..<records.count where vWanted.contains(records[vRow].id) {
vRows.insert(vRow)
if vRows.count == vWanted.count { break }
}
}
inTable.selectRowIndexes(vRows, byExtendingSelection: false)
let vFound = selectedRecords(inTable: inTable)
if vFound.count != parent.selection.count || vFound != parent.selection {
DispatchQueue.main.async { [weak self] in
self?.parent.selection = vFound
}
}
}
// selected rows' records, in row order (cost follows the selection)
private func selectedRecords(inTable: NSTableView) -> [FileRecord] {
return inTable.selectedRowIndexes.compactMap { $0 < records.count ? records[$0] : nil }
}
// ids of the rows selected in the table
private func selectedIds(inTable: NSTableView) -> Set<FileRecord.ID> {
var vIds = Set<FileRecord.ID>()
for vRow in inTable.selectedRowIndexes where vRow < records.count {
vIds.insert(records[vRow].id)
}
return vIds
}
// the selected records, for the actions
var currentSelection: [FileRecord] {
guard let vTable = table else { return [] }
return selectedRecords(inTable: vTable)
}
// runs an action on the current selection
func perform(_ inAction: ResultAction) {
let vRecords = currentSelection
guard !vRecords.isEmpty else { return }
parent.perform(inAction, vRecords)
}
// ===========================
// MARK: Data source
// ===========================
// number of results
func numberOfRows(in inTableView: NSTableView) -> Int {
return records.count
}
// drag: each row writes its file URL, exactly like a Finder drag
func tableView(_ inTableView: NSTableView, pasteboardWriterForRow inRow: Int) -> NSPasteboardWriting? {
guard inRow < records.count else { return nil }
return URL(fileURLWithPath: records[inRow].fullPath) as NSURL
}
// header click: switch the sort (written back after the callback)
func tableView(_ inTableView: NSTableView, sortDescriptorsDidChange inOld: [NSSortDescriptor]) {
guard !isApplying, let vFirst = inTableView.sortDescriptors.first, let vKey = vFirst.key else { return }
let vSort = ResultsTable.descriptor(forKey: vKey, ascending: vFirst.ascending)
shownSort = vSort
// deferred so SwiftUI isn't updated from inside a table callback
DispatchQueue.main.async { [weak self] in
self?.parent.sort = vSort
}
}
// ===========================
// MARK: Delegate
// ===========================
// cell view for one row / column, reused from the table's pool
func tableView(_ inTableView: NSTableView, viewFor inColumn: NSTableColumn?, row inRow: Int) -> NSView? {
guard let vColumn = inColumn, inRow < records.count else { return nil }
let vRecord = records[inRow]
let vCell = (inTableView.makeView(withIdentifier: vColumn.identifier, owner: nil) as? ResultCellView)
?? ResultCellView(inIdentifier: vColumn.identifier, inWithIcon: vColumn.identifier == ResultsTable.kNameColumn)
let vTerms = parent.highlightEnabled ? parent.highlights : SearchEngine.Highlights()
switch vColumn.identifier {
case ResultsTable.kNameColumn:
vCell.imageView?.image = IconCache.icon(forName: vRecord.name, isDirectory: vRecord.isDirectory)
vCell.show(inText: vRecord.name, inTerms: vTerms.name, inSecondary: false, inMiddleTruncation: false)
case ResultsTable.kPathColumn:
vCell.show(inText: vRecord.parentPath, inTerms: vTerms.path, inSecondary: true, inMiddleTruncation: true)
case ResultsTable.kSizeColumn:
vCell.show(inText: vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size), inTerms: [], inSecondary: true, inMiddleTruncation: false)
case ResultsTable.kCreatedColumn:
vCell.show(inText: Formatters.date(vRecord.dateCreated), inTerms: [], inSecondary: true, inMiddleTruncation: false)
default:
vCell.show(inText: Formatters.date(vRecord.dateModified), inTerms: [], inSecondary: true, inMiddleTruncation: false)
}
return vCell
}
// selection changed in the table: publish the ids
func tableViewSelectionDidChange(_ inNotification: Notification) {
guard !isApplying, let vTable = table else { return }
let vRecords = selectedRecords(inTable: vTable)
DispatchQueue.main.async { [weak self] in
guard let vSelf = self, vSelf.parent.selection != vRecords else { return }
vSelf.parent.selection = vRecords
}
}
// double-click on a row
@objc func doubleClicked(_ inSender: Any?) {
guard let vTable = table, vTable.clickedRow >= 0 else { return }
perform(.primary)
}
// ===========================
// MARK: Menus
// ===========================
// right-click menu, rebuilt for the clicked row: like Finder, a click
// outside the selection selects that row first
func menuNeedsUpdate(_ inMenu: NSMenu) {
inMenu.removeAllItems()
guard let vTable = table else { return }
let vClicked = vTable.clickedRow
if vClicked >= 0, !vTable.selectedRowIndexes.contains(vClicked) {
vTable.selectRowIndexes(IndexSet(integer: vClicked), byExtendingSelection: false)
}
let vSelected = currentSelection
guard !vSelected.isEmpty else { return }
// key equivalents are shown as hints (the table handles the keys)
inMenu.addItem(ClosureMenuItem("Open") { [weak self] in self?.perform(.open) }.showingKey("\r", []))
if let vFirst = vSelected.first {
inMenu.addItem(openWithItem(for: vFirst))
}
inMenu.addItem(ClosureMenuItem("Reveal in Finder") { [weak self] in self?.perform(.reveal) }.showingKey("\r", [.command]))
inMenu.addItem(ClosureMenuItem("Quick Look") { [weak self] in self?.perform(.quickLook) }.showingKey("y", [.command]))
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Copy") { [weak self] in self?.perform(.copyFiles) }.showingKey("c", [.command]))
inMenu.addItem(ClosureMenuItem("Copy Name") { [weak self] in self?.perform(.copyNames) })
inMenu.addItem(ClosureMenuItem("Copy Path") { [weak self] in self?.perform(.copyPaths) }.showingKey("c", [.command, .option]))
if vSelected.count == 1, let vOnly = vSelected.first, parent.needsAuthorization(vOnly) {
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Authorize Access…") { [weak self] in self?.perform(.authorize) })
}
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Move to Trash") { [weak self] in self?.perform(.trash) }.showingKey("\u{8}", [.command]))
}
// "Open With" submenu: default app first, then the other candidates
private func openWithItem(for inRecord: FileRecord) -> NSMenuItem {
let vItem = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "")
let vSub = NSMenu()
let vUrl = URL(fileURLWithPath: inRecord.fullPath)
let vDefault = NSWorkspace.shared.urlForApplication(toOpen: vUrl)
let vApps = [vDefault].compactMap { $0 }
+ NSWorkspace.shared.urlsForApplications(toOpen: vUrl).filter { $0 != vDefault }
for vApp in vApps {
let vTitle = FileManager.default.displayName(atPath: vApp.path) + (vApp == vDefault ? " (default)" : "")
let vAppItem = ClosureMenuItem(vTitle) { [weak self] in self?.perform(.openWith(vApp)) }
let vIcon = NSWorkspace.shared.icon(forFile: vApp.path)
vIcon.size = NSSize(width: 16, height: 16)
vAppItem.image = vIcon
vSub.addItem(vAppItem)
}
vItem.submenu = vSub
vItem.isEnabled = !vApps.isEmpty
return vItem
}
// header right-click menu to show / hide columns (Name stays)
func installHeaderMenu(on inTable: NSTableView) {
let vMenu = NSMenu()
for vColumn in inTable.tableColumns where vColumn.identifier != ResultsTable.kNameColumn {
let vItem = ClosureMenuItem(vColumn.title) { [weak vColumn] in
vColumn?.isHidden.toggle()
}
vItem.representedObject = vColumn
vMenu.addItem(vItem)
}
vMenu.delegate = HeaderMenuStates.shared
inTable.headerView?.menu = vMenu
}
}
}
// ResultsNSTableView adds Finder-style keyboard shortcuts and Edit > Copy to
// the results table.
final class ResultsNSTableView: NSTableView {
// owner that performs the actions
weak var coordinator: ResultsTable.Coordinator?
// how long the pointer rests on a cut-off cell before its full text shows
private let kHoverDelaySeconds: TimeInterval = 0.5
// cell under the pointer (row, column), nil when none
private var hoveredCell: (row: Int, column: Int)?
// pending overlay display for the hovered cell
private var hoverTimer: Timer?
// follows the pointer over the visible part of the table
override func updateTrackingAreas() {
super.updateTrackingAreas()
for vArea in trackingAreas where vArea.owner === self {
removeTrackingArea(vArea)
}
addTrackingArea(NSTrackingArea(
rect: .zero,
options: [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect],
owner: self,
userInfo: nil
))
}
// pointer moved: restart the hover delay when it enters another cell
override func mouseMoved(with inEvent: NSEvent) {
super.mouseMoved(with: inEvent)
let vPoint = convert(inEvent.locationInWindow, from: nil)
let vRow = row(at: vPoint)
let vColumn = column(at: vPoint)
if let vCurrent = hoveredCell, vCurrent.row == vRow, vCurrent.column == vColumn { return }
endHover()
guard vRow >= 0, vColumn >= 0 else { return }
hoveredCell = (vRow, vColumn)
hoverTimer = Timer.scheduledTimer(withTimeInterval: kHoverDelaySeconds, repeats: false) { [weak self] _ in
MainActor.assumeIsolated {
self?.showHoverOverlay(inRow: vRow, inColumn: vColumn)
}
}
}
// shows the full text if that cell is still hovered and cut off
private func showHoverOverlay(inRow: Int, inColumn: Int) {
guard let vCurrent = hoveredCell, vCurrent.row == inRow, vCurrent.column == inColumn,
let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow,
let vCell = view(atColumn: inColumn, row: inRow, makeIfNecessary: false) as? ResultCellView,
vCell.isTruncated, let vFrame = vCell.labelScreenFrame
else {
HoverOverlay.shared.hide()
return
}
// updated in place when already shown (no flicker on reloads)
HoverOverlay.shared.show(inText: vCell.overlayText(), inFrame: vFrame, inParent: vWindow)
}
// after a reload: redraw the overlay for the still-hovered cell (its
// content may have changed), or hide it if that text now fits
func refreshHover() {
guard let vCurrent = hoveredCell, hoverTimer?.isValid != true else { return }
layoutSubtreeIfNeeded()
showHoverOverlay(inRow: vCurrent.row, inColumn: vCurrent.column)
}
// listens for ↓ in the search field of the same window
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
NotificationCenter.default.removeObserver(self, name: .allofitFocusResults, object: nil)
if let vWindow = window {
NotificationCenter.default.addObserver(
self,
selector: #selector(focusFromSearchField(_:)),
name: .allofitFocusResults,
object: vWindow
)
}
}
// takes the focus from the search field and selects the first row
// when nothing is selected yet
@objc private func focusFromSearchField(_ inNotification: Notification) {
guard numberOfRows > 0 else { return }
window?.makeFirstResponder(self)
if selectedRow < 0 {
selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
}
scrollRowToVisible(max(selectedRow, 0))
}
// cancels the pending overlay and hides the shown one
func endHover() {
hoverTimer?.invalidate()
hoverTimer = nil
hoveredCell = nil
HoverOverlay.shared.hide()
}
// pointer left the table
override func mouseExited(with inEvent: NSEvent) {
super.mouseExited(with: inEvent)
endHover()
}
// scrolling moves the rows under the overlay: hide it
override func scrollWheel(with inEvent: NSEvent) {
endHover()
super.scrollWheel(with: inEvent)
}
// a click dismisses the overlay
override func mouseDown(with inEvent: NSEvent) {
endHover()
super.mouseDown(with: inEvent)
}
// leaving the window (closed / moved) dismisses the overlay
override func viewWillMove(toWindow inWindow: NSWindow?) {
endHover()
super.viewWillMove(toWindow: inWindow)
}
// Space / Return / ⌘Return / ⌘Y / ⌥⌘C / ⌘⌫; everything else (arrows,
// type-select, ⌘A) goes to NSTableView
override func keyDown(with inEvent: NSEvent) {
endHover()
let vMods = inEvent.modifierFlags.intersection([.command, .option, .shift, .control])
let vChars = inEvent.charactersIgnoringModifiers?.lowercased() ?? ""
let vAction: ResultAction?
switch (Int(inEvent.keyCode), vMods) {
case (49, []): vAction = .quickLook // Space
case (36, []), (76, []): vAction = .primary // Return / Enter
case (36, [.command]), (76, [.command]): vAction = .reveal
case (51, [.command]): vAction = .trash // ⌘⌫
case (126, []) where selectedRow <= 0: // ↑ on the first row
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
return
default:
if vChars == "y" && vMods == [.command] {
vAction = .quickLook
} else if vChars == "c" && vMods == [.command, .option] {
vAction = .copyPaths
} else {
vAction = nil
}
}
guard let vRun = vAction, selectedRow >= 0 else {
super.keyDown(with: inEvent)
return
}
coordinator?.perform(vRun)
}
// Edit > Copy (⌘C) copies the files themselves, like Finder
@objc func copy(_ inSender: Any?) {
coordinator?.perform(.copyFiles)
}
// enables Edit > Copy only with a selection
override func validateUserInterfaceItem(_ inItem: NSValidatedUserInterfaceItem) -> Bool {
if inItem.action == #selector(copy(_:)) {
return selectedRow >= 0
}
return super.validateUserInterfaceItem(inItem)
}
}
// ResultCellView is one cell: optional icon + a label. Highlights are drawn
// in bold; the colors follow the row's selection state so bold text stays
// readable on the blue selection.
final class ResultCellView: NSTableCellView {
// text, highlight terms and style of the current content
private var text = ""
private var terms: [String] = []
private var isSecondary = false
// builds the cell's subviews
init(inIdentifier: NSUserInterfaceItemIdentifier, inWithIcon: Bool) {
super.init(frame: .zero)
identifier = inIdentifier
let vLabel = NSTextField(labelWithString: "")
vLabel.translatesAutoresizingMaskIntoConstraints = false
vLabel.lineBreakMode = .byTruncatingTail
vLabel.cell?.usesSingleLineMode = true
// the full text of a cut-off cell comes from HoverOverlay (shown by
// the table after a short hover), not AppKit's expansion tooltip
vLabel.allowsExpansionToolTips = false
addSubview(vLabel)
textField = vLabel
if inWithIcon {
let vImage = NSImageView()
vImage.translatesAutoresizingMaskIntoConstraints = false
vImage.imageScaling = .scaleProportionallyDown
addSubview(vImage)
imageView = vImage
NSLayoutConstraint.activate([
vImage.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
vImage.centerYAnchor.constraint(equalTo: centerYAnchor),
vImage.widthAnchor.constraint(equalToConstant: 16),
vImage.heightAnchor.constraint(equalToConstant: 16),
vLabel.leadingAnchor.constraint(equalTo: vImage.trailingAnchor, constant: 6)
])
} else {
vLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2).isActive = true
}
NSLayoutConstraint.activate([
vLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -2),
vLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
// not used: cells are created in code
required init?(coder inCoder: NSCoder) {
fatalError("init(coder:) is not supported")
}
// sets the content and redraws it
func show(inText: String, inTerms: [String], inSecondary: Bool, inMiddleTruncation: Bool) {
text = inText
terms = inTerms
isSecondary = inSecondary
textField?.lineBreakMode = inMiddleTruncation ? .byTruncatingMiddle : .byTruncatingTail
render()
}
// selection highlight changed: recolor
override var backgroundStyle: NSView.BackgroundStyle {
didSet { render() }
}
// draws the label, bold where the terms match
private func render() {
guard let vLabel = textField else { return }
let vEmphasized = backgroundStyle == .emphasized
let vColor: NSColor = vEmphasized
? .alternateSelectedControlTextColor
: (isSecondary ? .secondaryLabelColor : .labelColor)
vLabel.attributedStringValue = styledText(inColor: vColor)
}
// the text in the cell's fonts, bold where the terms match
private func styledText(inColor: NSColor) -> NSAttributedString {
let vFont = isSecondary && terms.isEmpty
? NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
: NSFont.systemFont(ofSize: NSFont.systemFontSize)
let vString = NSMutableAttributedString(string: text, attributes: [.font: vFont, .foregroundColor: inColor])
let vBold = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
let vNs = text as NSString
for vTerm in terms {
var vSearch = NSRange(location: 0, length: vNs.length)
while vSearch.length > 0 {
let vFound = vNs.range(of: vTerm, options: [.caseInsensitive, .diacriticInsensitive], range: vSearch)
if vFound.location == NSNotFound || vFound.length == 0 { break }
vString.addAttribute(.font, value: vBold, range: vFound)
let vNext = vFound.location + vFound.length
vSearch = NSRange(location: vNext, length: vNs.length - vNext)
}
}
return vString
}
// true when the text doesn't fit and is shown cut off
var isTruncated: Bool {
guard let vLabel = textField else { return false }
let vAvailable = bounds.width - vLabel.frame.minX - 2
return styledText(inColor: .labelColor).size().width + 4 > vAvailable
}
// the full text for the hover overlay, in normal (unselected) colors
func overlayText() -> NSAttributedString {
return styledText(inColor: isSecondary ? .secondaryLabelColor : .labelColor)
}
// the label's frame in screen coordinates, where the overlay goes
var labelScreenFrame: NSRect? {
guard let vLabel = textField, let vWindow = window else { return nil }
return vWindow.convertToScreen(vLabel.convert(vLabel.bounds, to: nil))
}
}
// HoverOverlay is the panel showing the full text of a cut-off cell. It
// ignores the mouse, so it can't take the hover away from the table (the
// built-in expansion tooltip vanished when the pointer touched it), and it
// floats exactly over the cell's text.
@MainActor
final class HoverOverlay {
// shared overlay (only one cell is hovered at a time)
static let shared = HoverOverlay()
// the borderless panel, created on first use
private var panel: NSPanel?
// the label inside the panel
private let label = NSTextField(labelWithString: "")
// padding between the panel edge and the text
private let kPadding = NSSize(width: 4, height: 1)
// shows inText over inFrame (screen coordinates) of inParent's window
func show(inText: NSAttributedString, inFrame: NSRect, inParent: NSWindow) {
let vPanel = panel ?? makePanel()
label.attributedStringValue = inText
let vTextSize = inText.size()
var vFrame = NSRect(
x: inFrame.minX - kPadding.width,
y: inFrame.midY - (vTextSize.height / 2) - kPadding.height,
width: ceil(vTextSize.width) + kPadding.width * 2 + 4,
height: ceil(vTextSize.height) + kPadding.height * 2
)
// keep it on screen: shift left when it would run past the edge
if let vScreen = inParent.screen?.visibleFrame, vFrame.maxX > vScreen.maxX {
vFrame.origin.x = max(vScreen.minX, vScreen.maxX - vFrame.width)
}
vPanel.setFrame(vFrame, display: true)
label.frame = NSRect(x: kPadding.width, y: kPadding.height, width: vFrame.width - kPadding.width * 2, height: ceil(vTextSize.height))
if vPanel.parent !== inParent {
vPanel.parent?.removeChildWindow(vPanel)
inParent.addChildWindow(vPanel, ordered: .above)
}
vPanel.orderFront(nil)
}
// hides the overlay
func hide() {
guard let vPanel = panel, vPanel.isVisible else { return }
vPanel.parent?.removeChildWindow(vPanel)
vPanel.orderOut(nil)
}
// builds the panel: borderless, tooltip-like background, click-through
private func makePanel() -> NSPanel {
let vPanel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
vPanel.isOpaque = false
vPanel.backgroundColor = .clear
vPanel.hasShadow = true
vPanel.ignoresMouseEvents = true
vPanel.level = .floating
let vBackground = NSVisualEffectView()
vBackground.material = .toolTip
vBackground.state = .active
vBackground.wantsLayer = true
vBackground.layer?.cornerRadius = 4
vBackground.layer?.borderWidth = 0.5
vBackground.layer?.borderColor = NSColor.separatorColor.cgColor
vPanel.contentView = vBackground
label.lineBreakMode = .byClipping
vBackground.addSubview(label)
panel = vPanel
return vPanel
}
}
// ClosureMenuItem is an NSMenuItem that runs a closure.
final class ClosureMenuItem: NSMenuItem {
// what the item does
private let handler: () -> Void
// creates an item titled inTitle running inHandler
init(_ inTitle: String, _ inHandler: @escaping () -> Void) {
handler = inHandler
super.init(title: inTitle, action: #selector(run(_:)), keyEquivalent: "")
target = self
}
// not used: items are created in code
required init(coder inCoder: NSCoder) {
fatalError("init(coder:) is not supported")
}
// menu callback
@objc private func run(_ inSender: Any?) {
handler()
}
// shows a shortcut next to the title and returns the item
func showingKey(_ inKey: String, _ inModifiers: NSEvent.ModifierFlags) -> ClosureMenuItem {
keyEquivalent = inKey
keyEquivalentModifierMask = inModifiers
return self
}
}
// HeaderMenuStates ticks the visible columns each time the header menu opens.
final class HeaderMenuStates: NSObject, NSMenuDelegate {
// shared delegate for every results table's header menu
static let shared = HeaderMenuStates()
// marks shown columns with a checkmark
func menuNeedsUpdate(_ inMenu: NSMenu) {
for vItem in inMenu.items {
if let vColumn = vItem.representedObject as? NSTableColumn {
vItem.state = vColumn.isHidden ? .off : .on
}
}
}
}
@@ -5,6 +5,9 @@ import AppKit
// listens and re-grabs first responder, mimicking the standard Find shortcut.
extension Notification.Name {
static let allofitFocusSearch = Notification.Name("AllofitFocusSearch")
// posted by the search field (object: its window) when ↓ is pressed:
// that window's results list takes the keyboard focus
static let allofitFocusResults = Notification.Name("AllofitFocusResults")
}
// SearchField wraps AppKit's NSSearchField so we get native macOS behaviors
@@ -17,17 +20,15 @@ struct SearchField: NSViewRepresentable {
@Binding var text: String
// placeholder shown when the field is empty
var placeholder: String = ""
// fired when the user presses Return inside the field
var onSubmit: (() -> Void)? = nil
// set true to grab keyboard focus when the field is first inserted
var initiallyFirstResponder: Bool = false
// builds and configures the underlying NSSearchField
func makeNSView(context: Context) -> NSSearchField {
func makeNSView(context inContext: Context) -> NSSearchField {
let vField = NSSearchField()
vField.placeholderString = placeholder
vField.delegate = context.coordinator
vField.target = context.coordinator
vField.delegate = inContext.coordinator
vField.target = inContext.coordinator
vField.action = #selector(Coordinator.actionTriggered(_:))
// keep filtering live as the user types
vField.sendsSearchStringImmediately = false
@@ -54,7 +55,15 @@ struct SearchField: NSViewRepresentable {
vMenu.addItem(vClear)
vField.searchMenuTemplate = vMenu
context.coordinator.field = vField
inContext.coordinator.field = vField
// a query sent from outside before this window existed
if let vPending = WindowPresenter.pendingQuery {
WindowPresenter.pendingQuery = nil
vField.stringValue = vPending
DispatchQueue.main.async {
inContext.coordinator.parent.text = vPending
}
}
if initiallyFirstResponder {
DispatchQueue.main.async {
@@ -65,12 +74,13 @@ struct SearchField: NSViewRepresentable {
}
// keeps the NSSearchField's stringValue in sync with our binding
func updateNSView(_ nsView: NSSearchField, context: Context) {
if nsView.stringValue != text {
nsView.stringValue = text
func updateNSView(_ inView: NSSearchField, context inContext: Context) {
if inView.stringValue != text {
inView.stringValue = text
}
}
// creates the delegate bridging AppKit events to SwiftUI
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
@@ -90,7 +100,14 @@ 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>?
// binds to the SearchField and listens for the Find shortcut
init(_ inParent: SearchField) {
parent = inParent
super.init()
@@ -102,23 +119,47 @@ struct SearchField: NSViewRepresentable {
name: .allofitFocusSearch,
object: nil
)
// queries sent from outside (Finder service, folder dropped on
// the Dock icon) replace the text of the key window's field
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSetQuery(_:)),
name: .allofitSetQuery,
object: nil
)
}
// stops listening for the Find shortcut
deinit {
NotificationCenter.default.removeObserver(self)
}
// 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)
}
// puts an external query in the key window's field, caret at the end
// so the user can keep typing more terms
@objc private func handleSetQuery(_ inNotification: Notification) {
guard let vQuery = inNotification.object as? String,
let vField = field, vField.window?.isKeyWindow == true else { return }
WindowPresenter.pendingQuery = nil
vField.stringValue = vQuery
parent.text = vQuery
vField.window?.makeFirstResponder(vField)
vField.currentEditor()?.selectedRange = NSRange(location: (vQuery as NSString).length, length: 0)
}
// fired on every keystroke; reset navigation and push text to SwiftUI
func controlTextDidChange(_ notification: Notification) {
guard let vField = notification.object as? NSSearchField else { return }
func controlTextDidChange(_ inNotification: Notification) {
guard let vField = inNotification.object as? NSSearchField else { return }
navigationIndex = nil
let vValue = vField.stringValue
if parent.text != vValue {
@@ -128,30 +169,58 @@ 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
@objc func actionTriggered(_ sender: NSSearchField) {
if parent.text != sender.stringValue {
parent.text = sender.stringValue
@objc func actionTriggered(_ inSender: NSSearchField) {
if parent.text != inSender.stringValue {
parent.text = inSender.stringValue
}
parent.onSubmit?()
}
// intercept arrow-keys and Return for history nav and recents-update
func control(_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector) -> Bool {
guard let vField = control as? NSSearchField else { return false }
if commandSelector == #selector(NSResponder.moveUp(_:)) {
func control(_ inControl: NSControl,
textView inTextView: NSTextView,
doCommandBy inSelector: Selector) -> Bool {
guard let vField = inControl as? NSSearchField else { return false }
// ↓ goes to the results (like Everything); history moves to
// ↑ / ⌥↑ (older) and ⌥↓ (newer)
if inSelector == #selector(NSResponder.moveDown(_:)) {
NotificationCenter.default.post(name: .allofitFocusResults, object: vField.window)
return true
}
if inSelector == #selector(NSResponder.moveUp(_:))
|| inSelector == #selector(NSResponder.moveToBeginningOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: true)
return true
}
if commandSelector == #selector(NSResponder.moveDown(_:)) {
if inSelector == #selector(NSResponder.moveToEndOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: false)
return true
}
if commandSelector == #selector(NSControl.insertNewline(_:)) {
if inSelector == #selector(NSControl.insertNewline(_:)) {
addCurrentToRecents(field: vField)
return false
}
@@ -159,25 +228,25 @@ struct SearchField: NSViewRepresentable {
}
// adds the current text to the recents list (deduped, newest-first)
private func addCurrentToRecents(field: NSSearchField) {
let vQuery = field.stringValue.trimmingCharacters(in: .whitespaces)
private func addCurrentToRecents(field inField: NSSearchField) {
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
guard !vQuery.isEmpty else { return }
var vRecents = field.recentSearches
var vRecents = inField.recentSearches
vRecents.removeAll { $0 == vQuery }
vRecents.insert(vQuery, at: 0)
if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) }
field.recentSearches = vRecents
inField.recentSearches = vRecents
navigationIndex = nil
}
// walks one step through the recent-searches list
private func navigateHistory(field: NSSearchField, inOlder: Bool) {
let vHistory = field.recentSearches
private func navigateHistory(field inField: NSSearchField, inOlder: Bool) {
let vHistory = inField.recentSearches
if vHistory.isEmpty { return }
if inOlder {
if navigationIndex == nil {
savedQuery = field.stringValue
savedQuery = inField.stringValue
navigationIndex = 0
} else if let vI = navigationIndex, vI + 1 < vHistory.count {
navigationIndex = vI + 1
@@ -188,7 +257,7 @@ struct SearchField: NSViewRepresentable {
navigationIndex = vI - 1
} else {
navigationIndex = nil
field.stringValue = savedQuery
inField.stringValue = savedQuery
parent.text = savedQuery
return
}
@@ -199,7 +268,7 @@ struct SearchField: NSViewRepresentable {
if let vIdx = navigationIndex, vIdx < vHistory.count {
let vValue = vHistory[vIdx]
field.stringValue = vValue
inField.stringValue = vValue
parent.text = vValue
}
}
@@ -1,9 +1,10 @@
import SwiftUI
import AppKit
import ServiceManagement
// SettingsView is the multi-tab Preferences window. It exposes roots,
// exclusions, mounted volume inclusion, background service installation,
// and on-disk cache management.
// SettingsView is the Preferences window, grouped like Everything's options:
// General (app behavior), Indexes (what gets indexed), Performance (update
// timings) and Advanced (background service, cache, diagnostics).
struct SettingsView: View {
@EnvironmentObject var model: AppModel
@@ -11,24 +12,202 @@ struct SettingsView: View {
var body: some View {
TabView {
RootsTab()
.tabItem { Label("Roots", systemImage: "folder") }
ExclusionsTab()
.tabItem { Label("Exclusions", systemImage: "minus.circle") }
VolumesTab()
.tabItem { Label("Volumes", systemImage: "externaldrive") }
ServiceTab()
.tabItem { Label("Service", systemImage: "gearshape.2") }
CacheTab()
.tabItem { Label("Cache", systemImage: "internaldrive") }
DiagnosticsTab()
.tabItem { Label("Diagnostics", systemImage: "stethoscope") }
GeneralTab()
.tabItem { Label("General", systemImage: "gearshape") }
IndexesTab()
.tabItem { Label("Indexes", systemImage: "folder") }
PerformanceTab()
.tabItem { Label("Performance", systemImage: "speedometer") }
AdvancedTab()
.tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") }
}
.frame(width: 620, height: 480)
.frame(width: 620, height: 500)
.padding()
}
}
// ===========================
// MARK: General tab
// ===========================
// GeneralTab holds the app-level behavior: menu bar icon, global shortcut,
// start at login, and how results react to double-click / highlighting.
private struct GeneralTab: View {
@EnvironmentObject var prefs: Preferences
// availability of the chosen global shortcut
@ObservedObject private var hotKey = GlobalHotKey.shared
// current login item state, read from the system
@State private var loginStatus = SMAppService.mainApp.status
// last error from registering the login item
@State private var loginError: String?
var body: some View {
// plain sections like the other tabs: a .grouped Form drew hairline
// panel borders at fractional positions that flickered on refresh
VStack(alignment: .leading, spacing: 12) {
Text("Access")
.font(.headline)
Form {
// checkboxes get a left-column label like the pickers, so every
// control starts on the same vertical line
LabeledContent("Menu bar:") {
Toggle("Show Allofit in the menu bar", isOn: $prefs.showMenuBarIcon)
}
Picker("Global shortcut:", selection: $prefs.globalHotKey) {
ForEach(HotKeyPreset.allCases) { vPreset in
Text(vPreset.title).tag(vPreset)
}
}
.frame(maxWidth: 260)
if !hotKey.isAvailable {
Text("This shortcut is already used by another app. Pick another one.")
.font(.caption)
.foregroundColor(.orange)
}
LabeledContent("Login:") {
Toggle("Start Allofit at login", isOn: loginBinding)
}
if loginStatus == .requiresApproval {
HStack {
Text("Approve Allofit in System Settings > General > Login Items.")
.font(.caption)
.foregroundColor(.orange)
Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() }
.controlSize(.small)
}
}
if let vError = loginError {
Text(vError)
.font(.caption)
.foregroundColor(.red)
}
}
Divider()
Text("Results")
.font(.headline)
Form {
Picker("Double-click or Return:", selection: $prefs.primaryAction) {
Text("Opens the file").tag(Preferences.PrimaryAction.open)
Text("Reveals it in Finder").tag(Preferences.PrimaryAction.reveal)
}
.frame(maxWidth: 360)
LabeledContent("Highlighting:") {
Toggle("Highlight matches in names and paths", isOn: $prefs.highlightMatches)
}
}
Divider()
Text("Finder")
.font(.headline)
Text("Right-click a folder in Finder and choose Quick Actions (or Services) > Search in Allofit to search inside it. Dropping a folder on the Dock icon does the same.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.onAppear { loginStatus = SMAppService.mainApp.status }
}
// registers / unregisters the app as a login item
private var loginBinding: Binding<Bool> {
Binding(
get: { loginStatus == .enabled || loginStatus == .requiresApproval },
set: { vEnabled in
do {
if vEnabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
loginError = nil
} catch {
loginError = "Couldn't change the login item: \(error.localizedDescription)"
}
loginStatus = SMAppService.mainApp.status
}
)
}
}
// ===========================
// MARK: Grouping tabs
// ===========================
// IndexesTab groups what gets indexed: root folders, exclusions, volumes
private struct IndexesTab: View {
// sections of the tab
private enum Section: String, CaseIterable, Identifiable {
case folders = "Folders"
case exclusions = "Exclusions"
case volumes = "Volumes"
var id: String { rawValue }
}
// section currently shown
@State private var section: Section = .folders
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker("", selection: $section) {
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
// pinned to the top so every section starts right below the
// picker (a section without a trailing Spacer would otherwise
// be centered vertically)
Group {
switch section {
case .folders: RootsTab()
case .exclusions: ExclusionsTab()
case .volumes: VolumesTab()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
// AdvancedTab groups the power-user pages: background service, cache file
// and live diagnostics
private struct AdvancedTab: View {
// sections of the tab
private enum Section: String, CaseIterable, Identifiable {
case service = "Service"
case cache = "Cache"
case diagnostics = "Diagnostics"
var id: String { rawValue }
}
// section currently shown
@State private var section: Section = .service
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker("", selection: $section) {
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
// pinned to the top, see IndexesTab
Group {
switch section {
case .service: ServiceTab()
case .cache: CacheTab()
case .diagnostics: DiagnosticsTab()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
// ===========================
// MARK: Roots tab
// ===========================
@@ -64,7 +243,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)
}
@@ -112,7 +291,7 @@ private struct ExclusionsTab: View {
.frame(minHeight: 180)
HStack {
TextField("Path or tilde-expanded path", text: $newExclusion)
TextField("Path, e.g. ~/Projects/build", text: $newExclusion)
.textFieldStyle(.roundedBorder)
Button("Add") {
let vTrim = newExclusion.trimmingCharacters(in: .whitespaces)
@@ -131,12 +310,13 @@ 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)
}
}
// presents an Open panel and adds the picked folders as exclusions
private func chooseFolder() {
let vPanel = NSOpenPanel()
vPanel.canChooseDirectories = true
@@ -149,6 +329,7 @@ private struct ExclusionsTab: View {
}
}
// removes the currently-selected exclusion
private func removeSelected() {
guard let vSel = selection else { return }
prefs.excludedPaths.removeAll { $0 == vSel }
@@ -209,6 +390,99 @@ private struct VolumesTab: View {
}
}
// ===========================
// MARK: Performance tab
// ===========================
// PerformanceTab sets how quickly file changes reach the index and the open
// search results, per app state. Longer delays when Allofit isn't being
// looked at keep it nearly idle in the background; focusing a window always
// catches up at once.
private struct PerformanceTab: View {
@EnvironmentObject var prefs: Preferences
// preset choices offered by each picker, in seconds
private let kForegroundDelays: [Double] = [0.5, 1, 2, 3, 5, 10]
private let kBackgroundDelays: [Double] = [3, 5, 10, 15, 30, 60, 120]
private let kHiddenDelays: [Double] = [15, 30, 60, 120, 300, 600]
private let kForegroundRefreshes: [Double] = [0.5, 1, 2, 3, 5]
private let kBackgroundRefreshes: [Double] = [1, 3, 5, 10, 20, 30]
// FSEvents' own delivery latency, added to the estimates
private let kEventLatency: Double = 0.2
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Index updates")
.font(.headline)
Text("How long file changes are collected before they are applied to the index. Longer delays mean less work: repeated writes to the same file are checked only once.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Form {
secondsPicker("While Allofit is focused", inChoices: kForegroundDelays, inValue: $prefs.updateDelayForeground)
secondsPicker("While another app is focused", inChoices: kBackgroundDelays, inValue: $prefs.updateDelayBackground)
secondsPicker("With no window visible", inChoices: kHiddenDelays, inValue: $prefs.updateDelayHidden)
}
Divider()
Text("Result refresh")
.font(.headline)
Text("Minimum time between two refreshes of the open search results when files change. Typing always searches immediately.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Form {
secondsPicker("While Allofit is focused", inChoices: kForegroundRefreshes, inValue: $prefs.refreshIntervalForeground)
secondsPicker("While another app is focused", inChoices: kBackgroundRefreshes, inValue: $prefs.refreshIntervalBackground)
}
Divider()
Text(summaryText)
.font(.callout)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Restore Defaults") { prefs.resetTimings() }
}
Spacer()
}
}
// a picker over preset durations; a stored value outside the presets
// (set by hand) is kept selectable instead of showing a blank picker
private func secondsPicker(_ inLabel: String, inChoices: [Double], inValue: Binding<Double>) -> some View {
let vChoices = Array(Set(inChoices + [inValue.wrappedValue])).sorted()
return Picker(inLabel, selection: inValue) {
ForEach(vChoices, id: \.self) { vSeconds in
Text(Self.durationText(inSeconds: vSeconds)).tag(vSeconds)
}
}
.frame(maxWidth: 360)
}
// estimated delay before a changed file appears in the results
private var summaryText: String {
let vForeground = kEventLatency + prefs.updateDelayForeground + prefs.refreshIntervalForeground
let vBackground = kEventLatency + prefs.updateDelayBackground + prefs.refreshIntervalBackground
return "A changed file shows up in the results after about \(Self.durationText(inSeconds: vForeground)) while Allofit is focused, and \(Self.durationText(inSeconds: vBackground)) while another app is. With no window visible nothing is refreshed; showing a window applies every pending change at once."
}
// "0.5 s", "15 s", "1 min", "1 min 30 s"
private static func durationText(inSeconds: Double) -> String {
if inSeconds < 60 {
let vRounded = (inSeconds * 10).rounded() / 10
return vRounded == vRounded.rounded() ? "\(Int(vRounded)) s" : String(format: "%.1f s", vRounded)
}
let vMinutes = Int(inSeconds) / 60
let vRest = Int(inSeconds) % 60
return vRest == 0 ? "\(vMinutes) min" : "\(vMinutes) min \(vRest) s"
}
}
// ===========================
// MARK: Service tab
// ===========================
@@ -225,29 +499,53 @@ private struct ServiceTab: View {
// 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
// mode picked in the radio group; only saved by a successful Install
// (or Uninstall), so picking a mode alone never leaves the app waiting
// on a service that doesn't exist
@State private var draftMode: Preferences.ServiceMode?
// confirmation for Uninstall
@State private var confirmUninstall = false
// the mode shown and acted on: the draft, else the saved mode
private var chosenMode: Preferences.ServiceMode {
return draftMode ?? prefs.serviceMode
}
// launchd scope of the chosen mode, nil for Off
private var scope: ServiceInstaller.Scope? {
switch prefs.serviceMode {
switch chosenMode {
case .none: return nil
case .userAgent: return .userAgent
case .rootDaemon: return .rootDaemon
}
}
// the radio group writes the draft, not the preference
private var modeBinding: Binding<Preferences.ServiceMode> {
Binding(get: { chosenMode }, set: { draftMode = $0 })
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Background service")
.font(.headline)
Picker("Mode", selection: $prefs.serviceMode) {
Picker("Mode", selection: modeBinding) {
Text("Off (GUI maintains the index)").tag(Preferences.ServiceMode.none)
Text("User service (LaunchAgent, no admin needed)").tag(Preferences.ServiceMode.userAgent)
Text("System service (LaunchDaemon as root, scans everything)").tag(Preferences.ServiceMode.rootDaemon)
}
.pickerStyle(.radioGroup)
if prefs.serviceMode != .none && model.activeServiceMode == .none {
Text("The saved service isn't installed, so Allofit is indexing on its own. Install it again or choose Off.")
.font(.callout)
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
Group {
switch prefs.serviceMode {
switch chosenMode {
case .none:
Text("The GUI process indexes and saves the cache itself.")
.foregroundColor(.secondary)
@@ -310,7 +608,7 @@ private struct ServiceTab: View {
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.")
Text("Reinstall after updating Allofit: the service runs its own copy of the app, which doesn't update by itself.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
@@ -330,23 +628,38 @@ private struct ServiceTab: View {
_ = statusTick // re-read the file-system status on each tick
return HStack {
Button("Install") {
Task { await model.performInstallService() }
let vMode = chosenMode
Task {
await model.performInstallService(inMode: vMode)
draftMode = nil
}
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
.disabled(chosenMode == .none || model.isWorking)
Button("Uninstall…") { confirmUninstall = true }
.disabled(chosenMode == .none || !vInstalled || model.isWorking)
Button("Stop") {
Task { await model.performStopService() }
let vMode = chosenMode
Task { await model.performStopService(inMode: vMode) }
}
.disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") {
Task { await model.performStartService() }
let vMode = chosenMode
Task { await model.performStartService(inMode: vMode) }
}
.disabled(!vInstalled || vRunning || model.isWorking)
Spacer()
}
.confirmationDialog("Uninstall the background service?", isPresented: $confirmUninstall) {
Button("Uninstall", role: .destructive) {
let vMode = chosenMode
Task {
await model.performUninstallService(inMode: vMode)
draftMode = nil
}
}
} message: {
Text("Allofit goes back to indexing on its own while it's open.")
}
}
// ===========================
@@ -374,7 +687,7 @@ private struct ServiceTab: View {
.font(.callout)
}
LabeledContent("Installed version") {
Text(vInstalledVer ?? "—")
Text(vInstalledVer ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -428,6 +741,8 @@ private struct CacheTab: View {
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var model: AppModel
// confirmation for Clear Cache
@State private var confirmClear = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -446,12 +761,12 @@ private struct CacheTab: View {
.foregroundColor(cacheFileExists() ? .primary : .red)
}
LabeledContent("Size on disk") {
Text(formatSize(inBytes: IndexStore.cacheFileSize(at: currentCacheURL())))
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: currentCacheURL())))
.font(.callout)
.monospacedDigit()
}
LabeledContent("Last modified") {
Text(cacheFileMtime() ?? "—")
Text(cacheFileMtime() ?? "-")
.font(.callout)
.monospacedDigit()
}
@@ -485,10 +800,17 @@ private struct CacheTab: View {
if model.isWorking {
ProgressView().controlSize(.small)
}
Button("Clear Cache", role: .destructive) {
Task { await model.performClearCache() }
Button("Clear Cache…", role: .destructive) {
confirmClear = true
}
.disabled(model.isWorking)
.confirmationDialog("Clear the index cache?", isPresented: $confirmClear) {
Button("Clear and Rebuild", role: .destructive) {
Task { await model.performClearCache() }
}
} message: {
Text("The whole index is rebuilt from scratch, which can take a few minutes.")
}
}
if !model.workMessage.isEmpty {
@@ -525,14 +847,6 @@ private struct CacheTab: View {
vFormatter.timeStyle = .medium
return vFormatter.string(from: vMtime)
}
// formats a byte count as a human-friendly short string
private func formatSize(inBytes: Int64) -> String {
if inBytes <= 0 { return "—" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
}
}
// ===========================
@@ -548,8 +862,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 {
@@ -572,19 +886,19 @@ private struct DiagnosticsTab: View {
.textSelection(.enabled)
}
LabeledContent("Cache size") {
Text(formatSize(IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
.font(.callout)
.monospacedDigit()
}
LabeledContent("Cache mtime") {
Text(cacheMtimeString() ?? "—")
Text(cacheMtimeString() ?? "-")
.font(.callout)
.monospacedDigit()
}
LabeledContent("Indexer lock holder") {
Text(daemonStatus)
.font(.callout)
.foregroundColor(daemonStatus.contains("running") ? .primary : .red)
.foregroundColor(daemonStatus.hasPrefix("running") ? .primary : .red)
}
LabeledContent("Service plist") {
Text(servicePlistStatus())
@@ -594,7 +908,7 @@ private struct DiagnosticsTab: View {
Divider()
Text("Service log tail (/tmp/allofit-service.err)")
Text("Service log tail (\(DiagnosticsTab.logPath(inMode: prefs.serviceMode) ?? "no service"))")
.font(.subheadline)
ScrollView {
Text(serviceLogTail)
@@ -609,8 +923,12 @@ private struct DiagnosticsTab: View {
HStack {
Button("Refresh now") { refresh() }
Button("Open log") {
NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/allofit-service.err"))
// revealed rather than opened: Finder never runs it
if let vPath = DiagnosticsTab.logPath(inMode: prefs.serviceMode) {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: vPath)])
}
}
.disabled(DiagnosticsTab.logPath(inMode: prefs.serviceMode) == nil)
Spacer()
Text("auto-refresh every 2s")
.font(.caption)
@@ -627,7 +945,7 @@ private struct DiagnosticsTab: View {
private func refresh() {
Task.detached {
let vStatus = await DiagnosticsTab.computeDaemonStatus(inMode: Preferences.shared.serviceMode)
let vLog = DiagnosticsTab.readLogTail()
let vLog = DiagnosticsTab.readLogTail(inPath: DiagnosticsTab.logPath(inMode: Preferences.shared.serviceMode))
await MainActor.run {
self.daemonStatus = vStatus
self.serviceLogTail = vLog
@@ -665,9 +983,18 @@ private struct DiagnosticsTab: View {
return "stale lock (pid \(vPid) not running)"
}
// reads the last 30 lines of the service stderr log file
private nonisolated static func readLogTail() -> String {
let vPath = "/tmp/allofit-service.err"
// log file of the service for a mode, nil when no service is used
nonisolated static func logPath(inMode: Preferences.ServiceMode) -> String? {
switch inMode {
case .none: return nil
case .userAgent: return ServiceInstaller.logPath(inScope: .userAgent)
case .rootDaemon: return ServiceInstaller.logPath(inScope: .rootDaemon)
}
}
// reads the last 30 lines of the service log file
private nonisolated static func readLogTail(inPath: String?) -> String {
guard let vPath = inPath else { return "(no background service: Allofit indexes in its own process)" }
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: vPath)),
let vText = String(data: vData, encoding: .utf8)
else {
@@ -697,14 +1024,6 @@ private struct DiagnosticsTab: View {
return vF.string(from: vMtime)
}
// formats a byte count as a human-friendly short string
private func formatSize(_ inBytes: Int64) -> String {
if inBytes <= 0 { return "—" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
}
// prominent reminder + shortcuts to enable Full Disk Access for the
// root daemon's binary. Without FDA, the initial scan still works
// (root has direct filesystem access) but FSEvents will not deliver
+53
View File
@@ -0,0 +1,53 @@
import SwiftUI
// SyntaxHelpView is the search syntax cheat sheet shown by the "?" button
// next to the search field and by Help > Search Syntax.
struct SyntaxHelpView: View {
// one example query and what it matches
private struct Example: Identifiable {
let query: String
let meaning: String
var id: String { query }
}
// the rows of the cheat sheet, same order as the README
private let kExamples: [Example] = [
Example(query: "report", meaning: "names containing \"report\" (case and accents ignored)"),
Example(query: "annual report", meaning: "names containing both words (AND)"),
Example(query: "*.png | *.jpg", meaning: "either one (OR, binds tighter than AND)"),
Example(query: "report !draft", meaning: "without \"draft\" (NOT)"),
Example(query: "\"my file\"", meaning: "quotes keep spaces in one term"),
Example(query: "Start*.pdf", meaning: "* any characters, the whole name must match"),
Example(query: "IMG_????.heic", meaning: "? exactly one character"),
Example(query: "ext:pdf;docx", meaning: "by extension"),
Example(query: "file: folder:", meaning: "files or folders only (also as a prefix: folder:build)"),
Example(query: "src/main", meaning: "a term with / matches the full path"),
Example(query: "\"photos/**/\" *.heic", meaning: "** spans folders, * stays in one; trailing / = inside"),
]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Search syntax")
.font(.headline)
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) {
ForEach(kExamples) { vExample in
GridRow {
Text(vExample.query)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Text(vExample.meaning)
.foregroundColor(.secondary)
}
}
}
Divider()
Text("↓ moves from the search field to the results, ↑ on the first row comes back. Hold ⌥ to freeze the list while files change.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
.frame(width: 560)
}
}
+101
View File
@@ -0,0 +1,101 @@
import AppKit
// Posted with a query string as object: the key window's search field
// replaces its text with it (see SearchField.Coordinator).
extension Notification.Name {
static let allofitSetQuery = Notification.Name("AllofitSetQuery")
}
// WindowPresenter brings the main window up from anywhere: the global
// shortcut, the menu bar icon, the Dock, the Finder "Search in Allofit"
// service and folders opened with the app. It also carries a query to the
// window's search field.
@MainActor
enum WindowPresenter {
// query waiting for a search field that doesn't exist yet (the window
// is still being created); taken by the first field that appears
static var pendingQuery: String?
// shows and focuses the main window (creating one if needed) and puts
// the keyboard focus in the search field
static func show() {
NSApp.activate(ignoringOtherApps: true)
if let vMain = AppDelegate.mainWindow {
if vMain.isMiniaturized { vMain.deminiaturize(nil) }
vMain.makeKeyAndOrderFront(nil)
} else if !NSApp.windows.contains(where: { $0.isVisible && $0.canBecomeMain }) {
openNewWindow()
}
// one tick later the window is key, so the focus request reaches it
DispatchQueue.main.async {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
}
}
// global shortcut behavior (like Everything): hide Allofit when its
// window is already in front, show it otherwise
static func toggle() {
if NSApp.isActive, let vKey = NSApp.keyWindow, vKey === AppDelegate.mainWindow, vKey.isVisible {
NSApp.hide(nil)
} else {
show()
}
}
// shows the main window searching inside a folder: the query is the
// folder path with a trailing "/", which matches everything below it
static func search(inFolder inPath: String) {
let vFolder = VolumeManager.canonicalPath(inPath: inPath)
let vTerm = vFolder.hasSuffix("/") ? vFolder : vFolder + "/"
let vQuery = "\"\(vTerm)\" "
pendingQuery = vQuery
show()
// an existing window takes it through the notification; a window
// still being created takes pendingQuery when its field appears
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if pendingQuery != nil {
NotificationCenter.default.post(name: .allofitSetQuery, object: vQuery)
}
}
}
// triggers File > New Window, found by its ⌘N shortcut so the lookup
// survives localized menus
static func openNewWindow() {
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
}
}
}
}
}
// ServicesProvider implements the "Search in Allofit" entry of Finder's
// Services / Quick Actions menu (declared under NSServices in Info.plist).
final class ServicesProvider: NSObject {
// called by the system with the folders selected in Finder
@objc func searchInAllofit(_ inPasteboard: NSPasteboard,
userData inUserData: String?,
error inError: AutoreleasingUnsafeMutablePointer<NSString>?) {
let vUrls = inPasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
guard let vFirst = vUrls.first else {
inError?.pointee = "No folder selected" as NSString
return
}
// a file searches its folder
let vIsDir = (try? vFirst.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
let vFolder = vIsDir ? vFirst.path : vFirst.deletingLastPathComponent().path
DispatchQueue.main.async {
WindowPresenter.search(inFolder: vFolder)
}
}
}
+312
View File
@@ -0,0 +1,312 @@
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 category filter chosen in the menu next to the search field
@Published var filter: SearchFilter = .everything {
didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
}
// every record matching the query, sorted (the table shows them all;
// only the rows on screen get views)
@Published private(set) var results = ResultList()
// bumped on every change of results, so the table can tell a new list
// from the same one without comparing hundreds of thousands of records
@Published private(set) var resultsVersion = 0
// what to emphasize in the shown results (always matches results)
@Published private(set) var highlights = SearchEngine.Highlights()
// match count and timing for the status bar (observed separately so
// their updates don't re-render the table)
let stats = SearchStats()
// 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>?
// rows published first when a query matches more than this: the top of
// the list appears in milliseconds, the complete sort follows (a full
// sort of 600k entries takes ~0.2 s)
private nonisolated static let kQuickRows = 2000
// above this many results, skip the "same list" comparison (it would
// cost more than just reloading the visible rows)
private nonisolated static let kCompareLimit = 5000
// 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
// true while this model's window is on screen (not closed, minimized
// or fully covered)
private var isWindowVisible = true
// true while the user holds the freeze key (⌥ Option)
private var isFrozen = false
// true when the index changed while the window was not visible or
// the list was frozen
private var isStale = false
// true while a throttled index refilter is waiting to run
private var isIndexRefreshScheduled = false
// when the last index-driven refilter started
private var lastIndexRefreshAt = Date.distantPast
// subscribes to index changes and runs the first filter
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?.indexDidChange()
}
.store(in: &cancellables)
// holding ⌥ freezes the list; releasing it catches up once
FreezeKey.shared.$isHeld
.removeDuplicates()
.sink { [weak self] vHeld in
self?.setFrozen(vHeld)
}
.store(in: &cancellables)
scheduleFilter(inDelay: 0)
}
// reported by the window: refilters on reappearance if the index
// changed while the window was off screen
func setWindowVisible(_ inVisible: Bool) {
isWindowVisible = inVisible
if inVisible && isStale && !isFrozen {
isStale = false
scheduleFilter(inDelay: 0)
}
}
// freeze key pressed / released: while frozen, index changes don't
// touch the list; on release it is refreshed once, keeping the scroll
// position (no quick partial list)
private func setFrozen(_ inFrozen: Bool) {
isFrozen = inFrozen
if !inFrozen && isStale && isWindowVisible {
isStale = false
scheduleFilter(inDelay: 0, inProgressive: false)
}
}
// drops trashed records: their rows at once, and from the shared index
// (FSEvents doesn't report this process's own file operations)
func removeTrashed(inRecords: [FileRecord]) {
let vIds = Set(inRecords.map(\.id))
results = results.removing(inIds: vIds)
resultsVersion &+= 1
stats.matchCount = results.count
model.forget(inPaths: inRecords.map(\.fullPath))
}
// publishes a result list (and its count / highlights) on main
private func publish(inResults: ResultList,
inCount: Int,
inHighlights: SearchEngine.Highlights,
inMilliseconds: Double?) {
if stats.matchCount != inCount { stats.matchCount = inCount }
if let vMs = inMilliseconds { stats.lastSearchMilliseconds = vMs }
if highlights != inHighlights { highlights = inHighlights }
// skip a no-op reassignment of a small identical list (full record
// equality also catches size / date updates)
if inResults.count <= Self.kCompareLimit && results.count == inResults.count && results.elementsEqual(inResults) {
return
}
results = inResults
resultsVersion &+= 1
}
// files put back from the Trash (undo): adds them to the index again
func noticeRestored(inPaths: [String]) {
model.notice(inPaths: inPaths)
}
// throttled reaction to an index change: nothing while off screen,
// otherwise at most one refilter per refresh interval. The
// refilter reads the newest records when it runs, so skipped
// notifications lose nothing.
private func indexDidChange() {
guard isWindowVisible && !isFrozen else {
isStale = true
return
}
if isIndexRefreshScheduled { return }
isIndexRefreshScheduled = true
let vSinceLast = Date().timeIntervalSince(lastIndexRefreshAt)
// minimum gap between two index-driven refilters (Settings >
// Performance). Typing is unaffected; this caps the cost of an
// expensive query left open while files change constantly.
let vPrefs = Preferences.shared
let vInterval = AppActivity.shared.level == .foreground
? vPrefs.refreshIntervalForeground
: vPrefs.refreshIntervalBackground
let vWait = max(kIndexDebounceSeconds, vInterval - vSinceLast)
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
guard let vSelf = self else { return }
vSelf.isIndexRefreshScheduled = false
guard vSelf.isWindowVisible && !vSelf.isFrozen else {
vSelf.isStale = true
return
}
vSelf.lastIndexRefreshAt = Date()
// no quick partial list here: it would briefly cut the list to
// its first rows and lose the scroll position of a user
// browsing further down
vSelf.scheduleFilter(inDelay: 0, inProgressive: false)
}
}
// 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).
// inProgressive publishes the first rows before the complete sort
// when there are many results (typing, sort and filter changes).
private func scheduleFilter(inDelay: Double, inProgressive: Bool = true) {
filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained
let vQuery = filter.apply(toQuery: query)
let vHighlights = SearchEngine(inQuery: query).highlights
let vSort = sortDescriptor
let vRecords = model.allRecords
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 vStartedAt = DispatchTime.now()
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 vCount = vPositions.count
// timing covers the filter plus the first displayable sort
var vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
if inProgressive && vCount > WindowSearchModel.kQuickRows {
let vTop = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
inRecords: vRecords,
inPositions: vPositions,
inLimit: WindowSearchModel.kQuickRows,
inDescriptor: vSort
))
if Task.isCancelled { return }
vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
let vQuickMs = vMilliseconds
// hop back to main with DispatchQueue.main.async (rather than
// await MainActor.run) so the assignment lands on the next
// runloop tick, avoiding NSTableView reentrance when the
// search field is mid-edit
DispatchQueue.main.async {
self?.publish(inResults: vTop, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vQuickMs)
}
}
let vAll = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
inRecords: vRecords,
inPositions: vPositions,
inLimit: vCount,
inDescriptor: vSort
))
if Task.isCancelled { return }
let vFinalMs: Double? = (inProgressive && vCount > WindowSearchModel.kQuickRows)
? nil
: Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
DispatchQueue.main.async {
self?.publish(inResults: vAll, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vFinalMs)
}
}
}
// 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: RecordStore,
inEngine: SearchEngine,
inCancelled: ManagedAtomicFlag) -> [Int32]? {
if !inEngine.isActive {
return (0..<Int32(inRecords.count)).map { $0 }
}
// one parallel job per store chunk, reading its records directly
let vChunkCount = inRecords.chunkCount
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 vRecords = inRecords.chunk(at: vChunk)
let vBase = Int32(vChunk << RecordStore.kShift)
var vLocal: [Int32] = []
for (vI, vRecord) in vRecords.enumerated() where inEngine.match(inRecord: vRecord) {
vLocal.append(vBase + 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()
}
}
+247
View File
@@ -0,0 +1,247 @@
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.newFolders == ["/r/moved"])
#expect(vResult.rescans.isEmpty)
#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.newFolders == ["/r/renamed"])
}
@Test func newFolderInsideKernelRescanIsWalkedOnce() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [Self.dir("/r/a/fresh")]
vChanges.rescans = ["/r/a"]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.rescans == ["/r/a"])
#expect(vResult.newFolders.isEmpty)
// merging a new folder's walk only upserts (no removal pass)
vState.mergeNewFolders(inRecords: [Self.dir("/r/a/fresh"), Self.file("/r/a/fresh/x.txt")])
#expect(vState.contains(inPath: "/r/a/fresh/x.txt"))
#expect(vState.contains(inPath: "/r/a/b/2.txt"))
}
@Test func fullSortMatchesComparatorForNumericKeys() {
let vRecords = (0..<3000).map { Self.file("/r/f\($0)", size: Int64(($0 * 7919) % 97)) }
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
for vSort in [FileSortDescriptor.sizeAscending, .sizeDescending] {
let vFull = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
let vHeap = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
#expect(Array(vFull.prefix(100)) == vHeap, "sort \(vSort.rawValue)")
}
}
@Test func caseOnlyRenameDropsTheOldSpelling() throws {
let vDir = FileManager.default.temporaryDirectory.appendingPathComponent("allofit-case-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: vDir) }
let vOld = vDir.appendingPathComponent("Report.txt")
let vNew = vDir.appendingPathComponent("report.txt")
try Data("x".utf8).write(to: vOld)
try FileManager.default.moveItem(at: vOld, to: vNew)
// the old spelling still opens on a case-insensitive volume, but must
// not be re-added as a separate entry
#expect(FileIndexer.makeRecord(inURL: vOld, inSkipHidden: true) == nil)
#expect(FileIndexer.makeRecord(inURL: vNew, inSkipHidden: true)?.name == "report.txt")
}
@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 rootsAreCanonicalized() {
// /tmp is a symlink: FSEvents and the walker report /private/tmp
#expect(VolumeManager.canonicalPath(inPath: "/tmp") == "/private/tmp")
#expect(VolumeManager.canonicalPath(inPath: "/Users/") == "/Users")
#expect(VolumeManager.canonicalPath(inPath: "/no/such/root/") == "/no/such/root")
}
@Test func recordStoreChunksAndCopiesOnWrite() {
var vStore = RecordStore()
let vTotal = RecordStore.kChunkSize * 2 + 10
for vI in 0..<vTotal { vStore.append(Self.file("/r/f\(vI)")) }
#expect(vStore.count == vTotal)
#expect(vStore[RecordStore.kChunkSize].name == "f\(RecordStore.kChunkSize)")
// a snapshot keeps its values when the original changes
let vSnapshot = vStore
vStore[5] = Self.file("/r/changed")
#expect(vSnapshot[5].name == "f5")
#expect(vStore[5].name == "changed")
for _ in 0..<11 { vStore.removeLast() }
#expect(vStore.count == RecordStore.kChunkSize * 2 - 1)
#expect(vStore.last?.name == "f\(RecordStore.kChunkSize * 2 - 2)")
// result lists read through the snapshot
let vList = ResultList(inStore: vSnapshot, inPositions: [3, 1])
#expect(vList.map(\.name) == ["f3", "f1"])
#expect(vList.removing(inIds: [vSnapshot[3].id]).map(\.name) == ["f1"])
}
@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 = Array(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)
}
}
+122
View File
@@ -0,0 +1,122 @@
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))
}
@Test func highlightsKeepLiteralRuns() {
let vHigh = SearchEngine(inQuery: "IMG_????.heic !draft \"some/folder/**/path\" ext:pdf").highlights
#expect(vHigh.name == ["IMG_", ".heic"])
#expect(vHigh.path == ["some", "folder", "path"])
}
@Test func filtersAreAndedWithTheQuery() {
#expect(Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.HEIC"))
#expect(!Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.txt"))
#expect(Self.matches(SearchFilter.folders.apply(toQuery: ""), "/d/src", isDirectory: true))
#expect(Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/Applications/Xcode.app", isDirectory: true))
#expect(!Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/d/xcode.app.zip"))
#expect(SearchFilter.everything.apply(toQuery: "a b") == "a b")
}
}