mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876122ff98 | ||
|
|
eb9ee9abd9 | ||
|
|
17137a6f43 | ||
|
|
cd1d8a756e | ||
|
|
132430e3cc | ||
|
|
547753cc4d | ||
|
|
35ffc86e46 | ||
|
|
147c4a9353 | ||
|
|
4600e28b9c | ||
|
|
29aa518a9e | ||
|
|
4e050decff | ||
|
|
64437d70fb | ||
|
|
9f4d47a25a | ||
|
|
c68b05ab9a | ||
|
|
3e67da4634 | ||
|
|
bac2ca0287 |
@@ -25,3 +25,6 @@ jobs:
|
||||
|
||||
- name: Build release
|
||||
run: swift build -c release
|
||||
|
||||
- name: Test
|
||||
run: swift test
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
+10
-1
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,251 +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.
|
||||
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
||||
for vRoot in vRoots {
|
||||
NSLog("[Allofit] scanning %@", vRoot.path)
|
||||
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
||||
vState.lock.lock()
|
||||
for vRec in vBatch {
|
||||
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
||||
if vState.pathIndex[vRec.fullPath] == nil {
|
||||
vState.pathIndex[vRec.fullPath] = vState.records.count
|
||||
vState.records.append(vRec)
|
||||
}
|
||||
}
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
|
||||
}
|
||||
// force one save right after the scan finishes, so the GUI sees a
|
||||
// stable count even if no FSEvents come in for a while afterwards
|
||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
|
||||
|
||||
// FSEvents watcher
|
||||
let vWatcher = FileWatcher()
|
||||
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
|
||||
vWatcher.start(
|
||||
inRoots: vRoots.map { $0.path },
|
||||
inSinceWhen: FSEventStreamEventId(vStartId)
|
||||
) { vChanges in
|
||||
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
|
||||
vChanges.count,
|
||||
vChanges.first?.path ?? "—")
|
||||
vState.lock.lock()
|
||||
defer { vState.lock.unlock() }
|
||||
var vRescanPrefixes: [String] = []
|
||||
var vAdded = 0
|
||||
var vUpdated = 0
|
||||
// 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> = []
|
||||
|
||||
for vChange in vChanges {
|
||||
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
||||
if vChange.mustScanSubDirs {
|
||||
vRescanPrefixes.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 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.
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let kCompactEvery = 20
|
||||
var vSavesSinceCompact = 0
|
||||
while true {
|
||||
sleep(3)
|
||||
vState.lock.lock()
|
||||
let vShouldSave = vState.dirty
|
||||
let vSnapshot = vState.records
|
||||
vState.dirty = false
|
||||
vState.lock.unlock()
|
||||
if !vShouldSave { continue }
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,737 +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 {
|
||||
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 {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// FileIndexer walks a filesystem root and emits lightweight FileRecord entries.
|
||||
// It is a stateless utility so it can be invoked safely from any thread.
|
||||
//
|
||||
// Pre-fetching a fixed set of URLResourceKey values lets FileManager batch the
|
||||
// attribute reads through getattrlistbulk under the hood, which is the macOS
|
||||
// equivalent of the bulk MFT scan that makes voidtools Everything so fast.
|
||||
enum FileIndexer {
|
||||
|
||||
// the set of resource keys we ask for up front so they are pre-fetched in bulk
|
||||
static let kPrefetchKeys: [URLResourceKey] = [
|
||||
.nameKey,
|
||||
.isDirectoryKey,
|
||||
.fileSizeKey,
|
||||
.creationDateKey,
|
||||
.contentModificationDateKey,
|
||||
.fileResourceIdentifierKey
|
||||
]
|
||||
|
||||
// recursively walks inRoot, yielding records in batches via inBatch.
|
||||
// The batched form lets long scans publish partial progress: the daemon
|
||||
// can have its autosave write what it has so far while the rest of the
|
||||
// filesystem is still being walked. Excluded paths and their descendants
|
||||
// are pruned from the output.
|
||||
static func walkRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inBatchSize: Int = 2000,
|
||||
inBatch: (_ inRecords: [FileRecord]) -> Void) {
|
||||
// exclude the root itself
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: inRoot.path) {
|
||||
return
|
||||
}
|
||||
|
||||
guard let vEnumerator = FileManager.default.enumerator(
|
||||
at: inRoot,
|
||||
includingPropertiesForKeys: kPrefetchKeys,
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants],
|
||||
errorHandler: { (_, _) in true }
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
var vBatch: [FileRecord] = []
|
||||
vBatch.reserveCapacity(inBatchSize)
|
||||
|
||||
// include the root directory itself in the first batch
|
||||
if let vRootRecord = makeRecord(inURL: inRoot) {
|
||||
vBatch.append(vRootRecord)
|
||||
}
|
||||
|
||||
for vCase in vEnumerator {
|
||||
guard let vURL = vCase as? URL else { continue }
|
||||
|
||||
// short-circuit excluded entries (and don't descend into them)
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
|
||||
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
if vIsDir { vEnumerator.skipDescendants() }
|
||||
continue
|
||||
}
|
||||
|
||||
if let vRecord = makeRecord(inURL: vURL) {
|
||||
vBatch.append(vRecord)
|
||||
if vBatch.count >= inBatchSize {
|
||||
inBatch(vBatch)
|
||||
vBatch.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vBatch.isEmpty {
|
||||
inBatch(vBatch)
|
||||
}
|
||||
}
|
||||
|
||||
// convenience wrapper that materializes the full record list. Used by the
|
||||
// GUI's in-process reindex (which builds everything before swapping the
|
||||
// index in atomically). For the daemon, prefer walkRoot directly so the
|
||||
// in-memory index grows incrementally and the autosave can persist partial
|
||||
// progress while the scan continues.
|
||||
static func indexRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inProgress: ((Int) -> Void)? = nil) -> [FileRecord] {
|
||||
var vRecords: [FileRecord] = []
|
||||
vRecords.reserveCapacity(100_000)
|
||||
var vLastReport = Date.distantPast
|
||||
let kReportInterval: TimeInterval = 0.25
|
||||
walkRoot(inRoot: inRoot, inExclusions: inExclusions) { vBatch in
|
||||
vRecords.append(contentsOf: vBatch)
|
||||
let vNow = Date()
|
||||
if vNow.timeIntervalSince(vLastReport) >= kReportInterval {
|
||||
inProgress?(vRecords.count)
|
||||
vLastReport = vNow
|
||||
}
|
||||
}
|
||||
inProgress?(vRecords.count)
|
||||
return vRecords
|
||||
}
|
||||
|
||||
// builds a FileRecord from a URL's pre-fetched resource values.
|
||||
// Clears the URL's resource-value cache first so we always re-stat the
|
||||
// file - a file modified between two FSEvents batches would otherwise
|
||||
// silently return the cached pre-edit mtime.
|
||||
static func makeRecord(inURL: URL) -> FileRecord? {
|
||||
var vUrl = inURL
|
||||
vUrl.removeAllCachedResourceValues()
|
||||
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
|
||||
return nil
|
||||
}
|
||||
let vName = vValues.name ?? inURL.lastPathComponent
|
||||
let vParent = inURL.deletingLastPathComponent().path
|
||||
let vIsDir = vValues.isDirectory ?? false
|
||||
let vSize = Int64(vValues.fileSize ?? 0)
|
||||
let vCreated = vValues.creationDate ?? .distantPast
|
||||
let vModified = vValues.contentModificationDate ?? .distantPast
|
||||
// derive a stable identifier from the absolute path
|
||||
var vHasher = Hasher()
|
||||
vHasher.combine(inURL.path)
|
||||
let vId = UInt64(bitPattern: Int64(vHasher.finalize()))
|
||||
return FileRecord(
|
||||
id: vId,
|
||||
name: vName,
|
||||
parentPath: vParent,
|
||||
size: vSize,
|
||||
dateCreated: vCreated,
|
||||
dateModified: vModified,
|
||||
isDirectory: vIsDir
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Main is the binary's entry point. It routes between two modes based on
|
||||
// command-line arguments: --service starts the headless indexer/watcher
|
||||
// daemon, anything else launches the SwiftUI GUI application.
|
||||
@main
|
||||
struct Main {
|
||||
static func main() {
|
||||
if CommandLine.arguments.contains("--service") {
|
||||
AllofitService.run()
|
||||
} else {
|
||||
AllofitApp.main()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env swift
|
||||
//
|
||||
// Generates a 1024x1024 placeholder app icon at icons/icon.png so build-app.sh
|
||||
// has something to bake into Allofit.app. Designed to be "Icon Composer ready"
|
||||
// - the output is a single flat 1024x1024 PNG, which is exactly what Apple's
|
||||
// Icon Composer (macOS 26+) takes as a base layer when you want to refine
|
||||
// the icon with Liquid Glass effects.
|
||||
//
|
||||
// Run from the project root:
|
||||
// swift make-placeholder-icon.swift
|
||||
// ./build-app.sh # picks up icons/icon.png automatically
|
||||
//
|
||||
// Tweak the colors / shape constants below and re-run to iterate.
|
||||
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import Foundation
|
||||
|
||||
// ==================
|
||||
// MARK: Tunables
|
||||
// ==================
|
||||
|
||||
// Final image side length (px). 1024 is the largest slot the macOS iconset
|
||||
// asks for so it works for every output size after sips downscales.
|
||||
let kSizePixels: Int = 1024
|
||||
// Apple's rounded-square corner radius is ~22.37% of side (classic) or
|
||||
// ~25% (macOS 26 "Tahoe"). 22.37% is a safe middle ground.
|
||||
let kCornerFactor: CGFloat = 0.2237
|
||||
// Gradient stops (top-left to bottom-right): blue → teal
|
||||
let kGradientStart = CGColor(srgbRed: 0.00, green: 0.48, blue: 1.00, alpha: 1)
|
||||
let kGradientEnd = CGColor(srgbRed: 0.13, green: 0.71, blue: 0.92, alpha: 1)
|
||||
// Magnifying glass styling (in normalised 0..1 of the canvas)
|
||||
let kGlassCenterX: CGFloat = 0.42
|
||||
let kGlassCenterY: CGFloat = 0.58
|
||||
let kGlassRadius: CGFloat = 0.20
|
||||
let kHandleLength: CGFloat = 0.22
|
||||
let kStrokeWidth: CGFloat = 0.06
|
||||
// Output relative path
|
||||
let kOutputPath = "icons/icon.png"
|
||||
|
||||
// ==================
|
||||
// MARK: Draw
|
||||
// ==================
|
||||
|
||||
let vSize = CGFloat(kSizePixels)
|
||||
guard let vColorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
|
||||
fputs("could not create sRGB color space\n", stderr); exit(1)
|
||||
}
|
||||
guard let ctx = CGContext(
|
||||
data: nil,
|
||||
width: kSizePixels,
|
||||
height: kSizePixels,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: vColorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
fputs("could not create CG context\n", stderr); exit(2)
|
||||
}
|
||||
|
||||
// Rounded-square mask matching Apple's app icon silhouette
|
||||
let vRect = CGRect(x: 0, y: 0, width: vSize, height: vSize)
|
||||
let vCornerRadius = vSize * kCornerFactor
|
||||
let vMaskPath = CGPath(roundedRect: vRect,
|
||||
cornerWidth: vCornerRadius,
|
||||
cornerHeight: vCornerRadius,
|
||||
transform: nil)
|
||||
|
||||
// Gradient background, clipped to the rounded square
|
||||
ctx.saveGState()
|
||||
ctx.addPath(vMaskPath)
|
||||
ctx.clip()
|
||||
let vGradient = CGGradient(
|
||||
colorsSpace: vColorSpace,
|
||||
colors: [kGradientStart, kGradientEnd] as CFArray,
|
||||
locations: [0.0, 1.0]
|
||||
)!
|
||||
ctx.drawLinearGradient(vGradient,
|
||||
start: CGPoint(x: 0, y: vSize),
|
||||
end: CGPoint(x: vSize, y: 0),
|
||||
options: [])
|
||||
ctx.restoreGState()
|
||||
|
||||
// White magnifying glass on top
|
||||
ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
|
||||
ctx.setLineWidth(vSize * kStrokeWidth)
|
||||
ctx.setLineCap(.round)
|
||||
|
||||
let vGlassCenter = CGPoint(x: vSize * kGlassCenterX, y: vSize * kGlassCenterY)
|
||||
let vGlassR = vSize * kGlassRadius
|
||||
ctx.strokeEllipse(in: CGRect(
|
||||
x: vGlassCenter.x - vGlassR,
|
||||
y: vGlassCenter.y - vGlassR,
|
||||
width: vGlassR * 2,
|
||||
height: vGlassR * 2
|
||||
))
|
||||
|
||||
// Handle: from the lower-right edge of the glass, going down-right at -45°
|
||||
let vAngle: CGFloat = -.pi / 4
|
||||
let vHandleStart = CGPoint(
|
||||
x: vGlassCenter.x + vGlassR * cos(vAngle),
|
||||
y: vGlassCenter.y + vGlassR * sin(vAngle)
|
||||
)
|
||||
let vHandleLen = vSize * kHandleLength
|
||||
let vHandleEnd = CGPoint(
|
||||
x: vHandleStart.x + vHandleLen * cos(vAngle),
|
||||
y: vHandleStart.y + vHandleLen * sin(vAngle)
|
||||
)
|
||||
ctx.move(to: vHandleStart)
|
||||
ctx.addLine(to: vHandleEnd)
|
||||
ctx.strokePath()
|
||||
|
||||
// ==================
|
||||
// MARK: Write PNG
|
||||
// ==================
|
||||
|
||||
guard let vCgImage = ctx.makeImage() else {
|
||||
fputs("could not create CGImage from context\n", stderr); exit(3)
|
||||
}
|
||||
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: (kOutputPath as NSString).deletingLastPathComponent,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let vOutputURL = URL(fileURLWithPath: kOutputPath)
|
||||
guard let vDest = CGImageDestinationCreateWithURL(
|
||||
vOutputURL as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
fputs("could not create image destination\n", stderr); exit(4)
|
||||
}
|
||||
CGImageDestinationAddImage(vDest, vCgImage, nil)
|
||||
guard CGImageDestinationFinalize(vDest) else {
|
||||
fputs("PNG finalize failed\n", stderr); exit(5)
|
||||
}
|
||||
|
||||
print("Wrote \(kOutputPath) (\(kSizePixels)x\(kSizePixels))")
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,12 +84,23 @@ 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)
|
||||
}
|
||||
|
||||
// writes the records and event id atomically to a specific URL
|
||||
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||
// writes the records and event id atomically to a specific URL.
|
||||
// Wrapped in autoreleasepool because every call autoreleases a number
|
||||
// of Foundation objects (the compressed NSData, NSURLs created by
|
||||
// FileManager.replaceItem, etc.) - if the caller is a long-running
|
||||
// block whose own pool never drains, those would accumulate forever.
|
||||
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
|
||||
autoreleasepool {
|
||||
saveImpl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -126,38 +134,73 @@ 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)
|
||||
}
|
||||
|
||||
// reads the persisted index back from a specific URL
|
||||
// reads the persisted index back from a specific URL. Wrapped in
|
||||
// autoreleasepool so the decompressed NSData and the per-record
|
||||
// String allocations don't linger in the caller's pool.
|
||||
static func load(from inUrl: URL) -> LoadResult? {
|
||||
return autoreleasepool {
|
||||
loadImpl(from: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -167,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),
|
||||
@@ -184,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),
|
||||
@@ -201,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 }
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user