mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-07 21:37:27 +00:00
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.
313 lines
12 KiB
Swift
313 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|