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

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

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

Usability
- Results appear during the first index; empty-list explanations.
- Down arrow moves to the results, Up on the first row back; history on
  Up / Option-Up / Option-Down.
- Search syntax popover and Help menu; shortcuts shown in the context menu;
  confirmations for Clear Cache and Uninstall; privacy usage strings;
  Group Containers excluded by default; wording, VoiceOver labels, plural.
2026-10-02 14:44:49 +02:00

277 lines
10 KiB
Swift

import SwiftUI
import AppKit
// Posted by AllofitApp's "Find" menu command (⌘F). SearchField's Coordinator
// 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
// the SwiftUI TextField cannot offer: a built-in recent-searches dropdown
// behind the magnifying glass, persistent history via recentsAutosaveName,
// and up/down arrow navigation through the history.
struct SearchField: NSViewRepresentable {
// the current search text (two-way binding with the field)
@Binding var text: String
// placeholder shown when the field is empty
var placeholder: String = ""
// 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 inContext: Context) -> NSSearchField {
let vField = NSSearchField()
vField.placeholderString = placeholder
vField.delegate = inContext.coordinator
vField.target = inContext.coordinator
vField.action = #selector(Coordinator.actionTriggered(_:))
// keep filtering live as the user types
vField.sendsSearchStringImmediately = false
vField.sendsWholeSearchString = false
// persist history in user defaults under our app namespace
vField.recentsAutosaveName = "Allofit.recentSearches"
vField.maximumRecents = 30
vField.font = .systemFont(ofSize: NSFont.systemFontSize + 2)
// recent-searches dropdown shown when the magnifying glass is clicked
let vMenu = NSMenu()
let vTitle = NSMenuItem(title: "Recent Searches", action: nil, keyEquivalent: "")
vTitle.tag = 1000 // NSSearchFieldRecentsTitleMenuItemTag
vMenu.addItem(vTitle)
let vRecents = NSMenuItem(title: "Recents", action: nil, keyEquivalent: "")
vRecents.tag = 1001 // NSSearchFieldRecentsMenuItemTag
vMenu.addItem(vRecents)
let vNoRecents = NSMenuItem(title: "No recent searches", action: nil, keyEquivalent: "")
vNoRecents.tag = 1003 // NSSearchFieldNoRecentsMenuItemTag
vMenu.addItem(vNoRecents)
vMenu.addItem(NSMenuItem.separator())
let vClear = NSMenuItem(title: "Clear Recent Searches", action: nil, keyEquivalent: "")
vClear.tag = 1002 // NSSearchFieldClearRecentsMenuItemTag
vMenu.addItem(vClear)
vField.searchMenuTemplate = vMenu
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 {
vField.window?.makeFirstResponder(vField)
}
}
return vField
}
// keeps the NSSearchField's stringValue in sync with our binding
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)
}
// Coordinator bridges AppKit delegate callbacks to the SwiftUI binding,
// adds submitted queries to the recents list, and implements arrow-key
// navigation through the search history. @MainActor since AppKit invokes
// every delegate method on main.
@MainActor
final class Coordinator: NSObject, NSSearchFieldDelegate {
// strong reference back to the SearchField struct (rebound by updates)
var parent: SearchField
// weak link to the live NSSearchField for use by history navigation
weak var field: NSSearchField?
// index in recentSearches of the entry currently shown by arrow nav
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()
// listen for the global ⌘F notification so menu Find re-focuses
// the search field even when it isn't first responder
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFocusRequest),
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.
// 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, 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(_ inNotification: Notification) {
guard let vField = inNotification.object as? NSSearchField else { return }
navigationIndex = nil
let vValue = vField.stringValue
if parent.text != vValue {
// defer the binding write so SwiftUI does not re-render the
// Table while AppKit is still in the NSSearchField delegate
DispatchQueue.main.async { [weak self] in
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(_ inSender: NSSearchField) {
if parent.text != inSender.stringValue {
parent.text = inSender.stringValue
}
}
// intercept arrow-keys and Return for history nav and recents-update
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 inSelector == #selector(NSResponder.moveToEndOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: false)
return true
}
if inSelector == #selector(NSControl.insertNewline(_:)) {
addCurrentToRecents(field: vField)
return false
}
return false
}
// adds the current text to the recents list (deduped, newest-first)
private func addCurrentToRecents(field inField: NSSearchField) {
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
guard !vQuery.isEmpty else { return }
var vRecents = inField.recentSearches
vRecents.removeAll { $0 == vQuery }
vRecents.insert(vQuery, at: 0)
if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) }
inField.recentSearches = vRecents
navigationIndex = nil
}
// walks one step through the recent-searches list
private func navigateHistory(field inField: NSSearchField, inOlder: Bool) {
let vHistory = inField.recentSearches
if vHistory.isEmpty { return }
if inOlder {
if navigationIndex == nil {
savedQuery = inField.stringValue
navigationIndex = 0
} else if let vI = navigationIndex, vI + 1 < vHistory.count {
navigationIndex = vI + 1
}
} else {
if let vI = navigationIndex {
if vI > 0 {
navigationIndex = vI - 1
} else {
navigationIndex = nil
inField.stringValue = savedQuery
parent.text = savedQuery
return
}
} else {
return
}
}
if let vIdx = navigationIndex, vIdx < vHistory.count {
let vValue = vHistory[vIdx]
inField.stringValue = vValue
parent.text = vValue
}
}
}
}