mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68b05ab9a | ||
|
|
3e67da4634 | ||
|
|
bac2ca0287 | ||
|
|
9292c3ee29 |
@@ -1,177 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreServices
|
|
||||||
|
|
||||||
// AllofitService is the headless runtime invoked by launchd when the binary
|
|
||||||
// is launched with the --service argument. It builds an initial index, then
|
|
||||||
// listens to FSEvents and writes the cache to disk every few seconds.
|
|
||||||
//
|
|
||||||
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
|
|
||||||
// (a leftover launchd job, or someone running --service manually) exits
|
|
||||||
// quietly instead of fighting for the cache file.
|
|
||||||
enum AllofitService {
|
|
||||||
|
|
||||||
// Shared mutable state across the FSEvents callback queue and the
|
|
||||||
// autosave loop. Wrapped in a class so closures capture by reference
|
|
||||||
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
|
|
||||||
// Sendable because every access goes through the NSLock below.
|
|
||||||
private final class State: @unchecked Sendable {
|
|
||||||
var records: [FileRecord] = []
|
|
||||||
var pathIndex: [String: Int] = [:]
|
|
||||||
var dirty: Bool = false
|
|
||||||
let lock = NSLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runs the indexer/watcher loop forever (never returns)
|
|
||||||
static func run() -> Never {
|
|
||||||
// ensure files we create are world-readable (root daemon writes the
|
|
||||||
// cache; the GUI runs as the user and must be able to read it)
|
|
||||||
umask(0o022)
|
|
||||||
NSLog("[Allofit] service starting (uid=\(getuid()))")
|
|
||||||
|
|
||||||
// figure out whether we are the root daemon writing to /Library or
|
|
||||||
// the user agent writing to ~/Library, then acquire the lock
|
|
||||||
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
|
|
||||||
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
|
|
||||||
if !vLock.tryLock() {
|
|
||||||
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
|
|
||||||
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
|
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let vPrefs = Preferences.shared
|
|
||||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs)
|
|
||||||
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
|
|
||||||
// log the actual configuration so the user can verify owner-prefs sync
|
|
||||||
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
|
|
||||||
NSLog("[Allofit] roots: %@", vRoots.map { $0.path }.joined(separator: ", "))
|
|
||||||
NSLog("[Allofit] excluded paths (%d): %@",
|
|
||||||
vPrefs.excludedPaths.count,
|
|
||||||
vPrefs.excludedPaths.joined(separator: ", "))
|
|
||||||
|
|
||||||
let vState = State()
|
|
||||||
|
|
||||||
// initial scan: streaming the walker through the shared state lock so
|
|
||||||
// the autosave thread (every 3s) can write partial progress while we
|
|
||||||
// continue walking. Without this, large filesystems leave the cache
|
|
||||||
// empty for many minutes and the GUI shows nothing.
|
|
||||||
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
|
||||||
for vRoot in vRoots {
|
|
||||||
NSLog("[Allofit] scanning %@", vRoot.path)
|
|
||||||
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
|
||||||
vState.lock.lock()
|
|
||||||
for vRec in vBatch {
|
|
||||||
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
|
||||||
if vState.pathIndex[vRec.fullPath] == nil {
|
|
||||||
vState.pathIndex[vRec.fullPath] = vState.records.count
|
|
||||||
vState.records.append(vRec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vState.dirty = true
|
|
||||||
vState.lock.unlock()
|
|
||||||
}
|
|
||||||
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
|
|
||||||
}
|
|
||||||
// force one save right after the scan finishes, so the GUI sees a
|
|
||||||
// stable count even if no FSEvents come in for a while afterwards
|
|
||||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
|
||||||
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
|
|
||||||
|
|
||||||
// FSEvents watcher
|
|
||||||
let vWatcher = FileWatcher()
|
|
||||||
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
|
|
||||||
vWatcher.start(
|
|
||||||
inRoots: vRoots.map { $0.path },
|
|
||||||
inSinceWhen: FSEventStreamEventId(vStartId)
|
|
||||||
) { vChanges in
|
|
||||||
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
|
|
||||||
vChanges.count,
|
|
||||||
vChanges.first?.path ?? "—")
|
|
||||||
vState.lock.lock()
|
|
||||||
defer { vState.lock.unlock() }
|
|
||||||
var vRescanPrefixes: [String] = []
|
|
||||||
var vAdded = 0
|
|
||||||
var vUpdated = 0
|
|
||||||
var vRemovedCount = 0
|
|
||||||
|
|
||||||
for vChange in vChanges {
|
|
||||||
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
|
||||||
if vChange.mustScanSubDirs {
|
|
||||||
vRescanPrefixes.append(vChange.path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let vUrl = URL(fileURLWithPath: vChange.path)
|
|
||||||
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
|
||||||
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
|
||||||
if let vIdx = vState.pathIndex[vRec.fullPath] {
|
|
||||||
vState.records[vIdx] = vRec
|
|
||||||
vUpdated += 1
|
|
||||||
} else {
|
|
||||||
vState.pathIndex[vRec.fullPath] = vState.records.count
|
|
||||||
vState.records.append(vRec)
|
|
||||||
vAdded += 1
|
|
||||||
}
|
|
||||||
} else if let vIdx = vState.pathIndex[vChange.path] {
|
|
||||||
vState.records.remove(at: vIdx)
|
|
||||||
vState.pathIndex.removeAll(keepingCapacity: true)
|
|
||||||
for (vI, vR) in vState.records.enumerated() {
|
|
||||||
vState.pathIndex[vR.fullPath] = vI
|
|
||||||
}
|
|
||||||
vRemovedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if vAdded + vUpdated + vRemovedCount > 0 {
|
|
||||||
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
|
||||||
vAdded, vUpdated, vRemovedCount, vState.records.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !vRescanPrefixes.isEmpty {
|
|
||||||
NSLog("[Allofit] rescanning \(vRescanPrefixes.count) subtree(s) (history lost)")
|
|
||||||
let vNormalized = vRescanPrefixes.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
|
|
||||||
vState.records.removeAll { vRec in
|
|
||||||
let vP = vRec.fullPath
|
|
||||||
for vPre in vNormalized where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for vPath in vRescanPrefixes {
|
|
||||||
let vList = FileIndexer.indexRoot(
|
|
||||||
inRoot: URL(fileURLWithPath: vPath),
|
|
||||||
inExclusions: vMatcher
|
|
||||||
)
|
|
||||||
vState.records.append(contentsOf: vList)
|
|
||||||
}
|
|
||||||
vState.pathIndex.removeAll(keepingCapacity: true)
|
|
||||||
for (vI, vR) in vState.records.enumerated() {
|
|
||||||
vState.pathIndex[vR.fullPath] = vI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vState.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// periodic save loop (background thread). 3-second check interval so
|
|
||||||
// new files appear in the GUI within a few seconds of being created.
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
while true {
|
|
||||||
sleep(3)
|
|
||||||
vState.lock.lock()
|
|
||||||
let vShouldSave = vState.dirty
|
|
||||||
let vSnapshot = vState.records
|
|
||||||
vState.dirty = false
|
|
||||||
vState.lock.unlock()
|
|
||||||
if vShouldSave {
|
|
||||||
NSLog("[Allofit] autosaving %d records", vSnapshot.count)
|
|
||||||
IndexStore.save(
|
|
||||||
inRecords: vSnapshot,
|
|
||||||
inLastEventId: vWatcher.latestEventId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// block forever on the runloop so launchd keeps us alive
|
|
||||||
RunLoop.current.run()
|
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,14 +49,21 @@ enum FileIndexer {
|
|||||||
vBatch.append(vRootRecord)
|
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 {
|
for vCase in vEnumerator {
|
||||||
guard let vURL = vCase as? URL else { continue }
|
autoreleasepool {
|
||||||
|
guard let vURL = vCase as? URL else { return }
|
||||||
|
|
||||||
// short-circuit excluded entries (and don't descend into them)
|
// short-circuit excluded entries (and don't descend into them)
|
||||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
|
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
|
||||||
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||||
if vIsDir { vEnumerator.skipDescendants() }
|
if vIsDir { vEnumerator.skipDescendants() }
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let vRecord = makeRecord(inURL: vURL) {
|
if let vRecord = makeRecord(inURL: vURL) {
|
||||||
@@ -67,6 +74,7 @@ enum FileIndexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !vBatch.isEmpty {
|
if !vBatch.isEmpty {
|
||||||
inBatch(vBatch)
|
inBatch(vBatch)
|
||||||
}
|
}
|
||||||
@@ -99,7 +107,11 @@ enum FileIndexer {
|
|||||||
// builds a FileRecord from a URL's pre-fetched resource values.
|
// 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
|
// Clears the URL's resource-value cache first so we always re-stat the
|
||||||
// file - a file modified between two FSEvents batches would otherwise
|
// file - a file modified between two FSEvents batches would otherwise
|
||||||
// silently return the cached pre-edit mtime.
|
// silently return the cached pre-edit mtime. Callers (walkRoot's
|
||||||
|
// enumerator loop, applyFileSystemChanges, AllofitService) already
|
||||||
|
// wrap each invocation in an autoreleasepool, so the autoreleased
|
||||||
|
// NSDate / NSNumber / NSURL objects from resourceValues() drain at
|
||||||
|
// the caller's pool boundary.
|
||||||
static func makeRecord(inURL: URL) -> FileRecord? {
|
static func makeRecord(inURL: URL) -> FileRecord? {
|
||||||
var vUrl = inURL
|
var vUrl = inURL
|
||||||
vUrl.removeAllCachedResourceValues()
|
vUrl.removeAllCachedResourceValues()
|
||||||
@@ -91,8 +91,18 @@ enum IndexStore {
|
|||||||
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
|
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writes the records and event id atomically to a specific URL
|
// writes the records and event id atomically to a specific URL.
|
||||||
|
// Wrapped in autoreleasepool because every call autoreleases a number
|
||||||
|
// of Foundation objects (the compressed NSData, NSURLs created by
|
||||||
|
// FileManager.replaceItem, etc.) - if the caller is a long-running
|
||||||
|
// block whose own pool never drains, those would accumulate forever.
|
||||||
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||||
|
autoreleasepool {
|
||||||
|
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||||
var vPayload = Data()
|
var vPayload = Data()
|
||||||
vPayload.reserveCapacity(16 + inRecords.count * 80)
|
vPayload.reserveCapacity(16 + inRecords.count * 80)
|
||||||
writeU64(into: &vPayload, value: inLastEventId)
|
writeU64(into: &vPayload, value: inLastEventId)
|
||||||
@@ -151,8 +161,16 @@ enum IndexStore {
|
|||||||
return load(from: cacheURL)
|
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? {
|
static func load(from inUrl: URL) -> LoadResult? {
|
||||||
|
return autoreleasepool {
|
||||||
|
load_impl(from: inUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func load_impl(from inUrl: URL) -> LoadResult? {
|
||||||
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
|
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
|
||||||
var vOffset = 0
|
var vOffset = 0
|
||||||
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
|
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreServices
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
// AllofitService is the headless runtime invoked by launchd when the binary
|
||||||
|
// is launched with the --service argument. It builds an initial index, then
|
||||||
|
// listens to FSEvents and writes the cache to disk every few seconds.
|
||||||
|
//
|
||||||
|
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
|
||||||
|
// (a leftover launchd job, or someone running --service manually) exits
|
||||||
|
// quietly instead of fighting for the cache file.
|
||||||
|
enum AllofitService {
|
||||||
|
|
||||||
|
// Shared mutable state across the FSEvents callback queue and the
|
||||||
|
// autosave loop. Wrapped in a class so closures capture by reference
|
||||||
|
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
|
||||||
|
// Sendable because every access goes through the NSLock below.
|
||||||
|
private final class State: @unchecked Sendable {
|
||||||
|
var records: [FileRecord] = []
|
||||||
|
var pathIndex: [String: Int] = [:]
|
||||||
|
var dirty: Bool = false
|
||||||
|
let lock = NSLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runs the indexer/watcher loop forever (never returns)
|
||||||
|
static func run() -> Never {
|
||||||
|
// ensure files we create are world-readable (root daemon writes the
|
||||||
|
// cache; the GUI runs as the user and must be able to read it)
|
||||||
|
umask(0o022)
|
||||||
|
NSLog("[Allofit] service starting (uid=\(getuid()))")
|
||||||
|
|
||||||
|
// figure out whether we are the root daemon writing to /Library or
|
||||||
|
// the user agent writing to ~/Library, then acquire the lock
|
||||||
|
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
|
||||||
|
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
|
||||||
|
if !vLock.tryLock() {
|
||||||
|
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
|
||||||
|
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let vPrefs = Preferences.shared
|
||||||
|
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs)
|
||||||
|
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
|
||||||
|
// log the actual configuration so the user can verify owner-prefs sync
|
||||||
|
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
|
||||||
|
NSLog("[Allofit] roots: %@", vRoots.map { $0.path }.joined(separator: ", "))
|
||||||
|
NSLog("[Allofit] excluded paths (%d): %@",
|
||||||
|
vPrefs.excludedPaths.count,
|
||||||
|
vPrefs.excludedPaths.joined(separator: ", "))
|
||||||
|
|
||||||
|
let vState = State()
|
||||||
|
|
||||||
|
// initial scan: streaming the walker through the shared state lock so
|
||||||
|
// the autosave thread (every 3s) can write partial progress while we
|
||||||
|
// continue walking. Without this, large filesystems leave the cache
|
||||||
|
// empty for many minutes and the GUI shows nothing.
|
||||||
|
//
|
||||||
|
// Both the per-root and per-batch callback bodies run inside their
|
||||||
|
// own autoreleasepool so the autoreleased NSURL / NSDate / NSNumber
|
||||||
|
// from the file enumeration don't pile up until the entire scan
|
||||||
|
// finishes - on a million-file scan that "pile" was peaking at
|
||||||
|
// well over a gig of dead allocations before the main thread's
|
||||||
|
// runloop got a chance to drain.
|
||||||
|
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
||||||
|
for vRoot in vRoots {
|
||||||
|
autoreleasepool {
|
||||||
|
NSLog("[Allofit] scanning %@", vRoot.path)
|
||||||
|
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
||||||
|
autoreleasepool {
|
||||||
|
vState.lock.lock()
|
||||||
|
for vRec in vBatch {
|
||||||
|
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
||||||
|
if vState.pathIndex[vRec.fullPath] == nil {
|
||||||
|
vState.pathIndex[vRec.fullPath] = vState.records.count
|
||||||
|
vState.records.append(vRec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vState.dirty = true
|
||||||
|
vState.lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// force one save right after the scan finishes, so the GUI sees a
|
||||||
|
// stable count even if no FSEvents come in for a while afterwards
|
||||||
|
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||||
|
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
|
||||||
|
|
||||||
|
// FSEvents watcher
|
||||||
|
let vWatcher = FileWatcher()
|
||||||
|
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
|
||||||
|
vWatcher.start(
|
||||||
|
inRoots: vRoots.map { $0.path },
|
||||||
|
inSinceWhen: FSEventStreamEventId(vStartId)
|
||||||
|
) { vChanges in
|
||||||
|
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
|
||||||
|
vChanges.count,
|
||||||
|
vChanges.first?.path ?? "—")
|
||||||
|
vState.lock.lock()
|
||||||
|
defer { vState.lock.unlock() }
|
||||||
|
var vRescanPrefixes: [String] = []
|
||||||
|
var vAdded = 0
|
||||||
|
var vUpdated = 0
|
||||||
|
// batch removals into a set so we do one bulk allRecords pass
|
||||||
|
// and rebuild pathIndex once per FSEvents batch, instead of
|
||||||
|
// O(records) per individual removal - that nested rebuild was
|
||||||
|
// dominating CPU and turning into the daemon's memory churn
|
||||||
|
var vRemoved: Set<String> = []
|
||||||
|
|
||||||
|
// Per-change autoreleasepool: a large FSEvents batch (e.g.
|
||||||
|
// `rm -rf` of a deep tree) can deliver thousands of paths in
|
||||||
|
// one callback. Each path's NSURL + reachability check + the
|
||||||
|
// makeRecord internals autorelease - per-change drain keeps
|
||||||
|
// peak memory bounded by a single record's worth, not the
|
||||||
|
// whole batch.
|
||||||
|
for vChange in vChanges {
|
||||||
|
autoreleasepool {
|
||||||
|
if vMatcher.isExcluded(inPath: vChange.path) { return }
|
||||||
|
if vChange.mustScanSubDirs {
|
||||||
|
vRescanPrefixes.append(vChange.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// skip paths we've already queued for removal in this batch
|
||||||
|
if vRemoved.contains(vChange.path) { return }
|
||||||
|
let vUrl = URL(fileURLWithPath: vChange.path)
|
||||||
|
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
||||||
|
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
||||||
|
if let vIdx = vState.pathIndex[vRec.fullPath] {
|
||||||
|
vState.records[vIdx] = vRec
|
||||||
|
vUpdated += 1
|
||||||
|
} else {
|
||||||
|
vState.pathIndex[vRec.fullPath] = vState.records.count
|
||||||
|
vState.records.append(vRec)
|
||||||
|
vAdded += 1
|
||||||
|
}
|
||||||
|
} else if vState.pathIndex[vChange.path] != nil {
|
||||||
|
vRemoved.insert(vChange.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !vRemoved.isEmpty {
|
||||||
|
// single bulk removeAll + single pathIndex rebuild
|
||||||
|
vState.records.removeAll { vRemoved.contains($0.fullPath) }
|
||||||
|
rebuildPathIndex(vState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vAdded + vUpdated + vRemoved.count > 0 {
|
||||||
|
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
||||||
|
vAdded, vUpdated, vRemoved.count, vState.records.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !vRescanPrefixes.isEmpty {
|
||||||
|
NSLog("[Allofit] rescanning \(vRescanPrefixes.count) subtree(s) (history lost)")
|
||||||
|
let vNormalized = vRescanPrefixes.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
|
||||||
|
vState.records.removeAll { vRec in
|
||||||
|
let vP = vRec.fullPath
|
||||||
|
for vPre in vNormalized where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for vPath in vRescanPrefixes {
|
||||||
|
let vList = FileIndexer.indexRoot(
|
||||||
|
inRoot: URL(fileURLWithPath: vPath),
|
||||||
|
inExclusions: vMatcher
|
||||||
|
)
|
||||||
|
vState.records.append(contentsOf: vList)
|
||||||
|
}
|
||||||
|
rebuildPathIndex(vState)
|
||||||
|
}
|
||||||
|
|
||||||
|
vState.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodic save loop (background thread). 3-second check interval so
|
||||||
|
// new files appear in the GUI within a few seconds of being created.
|
||||||
|
// Every N saves we also compact the in-memory containers so Swift's
|
||||||
|
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
|
||||||
|
// doesn't drift into multi-GB territory after a day of heavy file
|
||||||
|
// activity. RSS is logged each save so the trajectory is visible.
|
||||||
|
//
|
||||||
|
// CRITICAL: each iteration runs inside its own autoreleasepool. The
|
||||||
|
// outer GCD block has an autorelease pool that drains when the block
|
||||||
|
// returns - which for our `while true` never happens. Without an
|
||||||
|
// inner pool, every save's autoreleased NSData (returned by
|
||||||
|
// .compressed(using: .lz4) and friends) accumulates forever and the
|
||||||
|
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let kCompactEvery = 20
|
||||||
|
var vSavesSinceCompact = 0
|
||||||
|
while true {
|
||||||
|
sleep(3)
|
||||||
|
autoreleasepool {
|
||||||
|
vState.lock.lock()
|
||||||
|
let vShouldSave = vState.dirty
|
||||||
|
let vSnapshot = vState.records
|
||||||
|
vState.dirty = false
|
||||||
|
vState.lock.unlock()
|
||||||
|
if !vShouldSave { return }
|
||||||
|
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
|
||||||
|
vSnapshot.count, processFootprintMB())
|
||||||
|
IndexStore.save(
|
||||||
|
inRecords: vSnapshot,
|
||||||
|
inLastEventId: vWatcher.latestEventId
|
||||||
|
)
|
||||||
|
vSavesSinceCompact += 1
|
||||||
|
if vSavesSinceCompact >= kCompactEvery {
|
||||||
|
vSavesSinceCompact = 0
|
||||||
|
compactContainers(vState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// block forever on the runloop so launchd keeps us alive
|
||||||
|
RunLoop.current.run()
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuilds pathIndex from records. Used after a bulk allRecords mutation
|
||||||
|
// (batched removal or subtree rescan) - much cheaper than incrementally
|
||||||
|
// maintaining pathIndex during the mutation, and the reserveCapacity
|
||||||
|
// lets the dict size to its target without re-bucketing on each insert.
|
||||||
|
private static func rebuildPathIndex(_ inState: State) {
|
||||||
|
var vIndex: [String: Int] = [:]
|
||||||
|
vIndex.reserveCapacity(inState.records.count)
|
||||||
|
for (vI, vR) in inState.records.enumerated() {
|
||||||
|
vIndex[vR.fullPath] = vI
|
||||||
|
}
|
||||||
|
inState.pathIndex = vIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// recreates records and pathIndex with capacities matched to their
|
||||||
|
// actual element count. Swift Array/Dict only grow their backing
|
||||||
|
// allocations under churn, never auto-shrink, so a daemon that's
|
||||||
|
// been processing FSEvents for days can hold huge dead capacity
|
||||||
|
// (records.capacity >> records.count) that shows up as multi-GB
|
||||||
|
// RSS. Forcing fresh containers reclaims it.
|
||||||
|
private static func compactContainers(_ inState: State) {
|
||||||
|
inState.lock.lock()
|
||||||
|
defer { inState.lock.unlock() }
|
||||||
|
let vBefore = processFootprintMB()
|
||||||
|
// Array(_:) creates a fresh array sized exactly to the source -
|
||||||
|
// the old buffer's slack capacity is released
|
||||||
|
let vCompactRecords = Array(inState.records)
|
||||||
|
var vCompactIndex: [String: Int] = [:]
|
||||||
|
vCompactIndex.reserveCapacity(vCompactRecords.count)
|
||||||
|
for (vI, vR) in vCompactRecords.enumerated() {
|
||||||
|
vCompactIndex[vR.fullPath] = vI
|
||||||
|
}
|
||||||
|
inState.records = vCompactRecords
|
||||||
|
inState.pathIndex = vCompactIndex
|
||||||
|
let vAfter = processFootprintMB()
|
||||||
|
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
|
||||||
|
vCompactRecords.count, vBefore, vAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resident-memory size in MB matching Activity Monitor's "Memory" column
|
||||||
|
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
|
||||||
|
// of pages owned by the task minus shared/clean pages.
|
||||||
|
private static func processFootprintMB() -> Double {
|
||||||
|
var vInfo = task_vm_info_data_t()
|
||||||
|
var vCount = mach_msg_type_number_t(
|
||||||
|
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
|
||||||
|
)
|
||||||
|
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
|
||||||
|
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
|
||||||
|
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vResult == KERN_SUCCESS {
|
||||||
|
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
-5
@@ -32,13 +32,36 @@ enum ServiceInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// installs (or replaces) the launchd plist for the given scope
|
// installs (or replaces) the launchd plist for the given scope. Also
|
||||||
|
// copies the binary to a renamed location ("Allofit Service") so the
|
||||||
|
// daemon process shows up distinctly from the GUI in Activity Monitor /
|
||||||
|
// `ps` - both used to be called "Allofit" because they share a binary.
|
||||||
static func install(inScope: Scope) throws {
|
static func install(inScope: Scope) throws {
|
||||||
let vBinary = try resolveBinaryPath()
|
let vBinary = try resolveBinaryPath()
|
||||||
let vPlistData = try makePlistData(inBinary: vBinary, inScope: inScope)
|
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
|
||||||
|
let vVersionFile = vDaemonBinary + ".version"
|
||||||
|
let vVersionString = bundleVersion()
|
||||||
|
let vPlistData = try makePlistData(inBinary: vDaemonBinary, inScope: inScope)
|
||||||
|
|
||||||
switch inScope {
|
switch inScope {
|
||||||
case .userAgent:
|
case .userAgent:
|
||||||
|
// user agent install: filesystem ops as the current user,
|
||||||
|
// no sudo needed for either the binary copy or the plist
|
||||||
|
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
atPath: vDaemonDir,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try? FileManager.default.removeItem(atPath: vDaemonBinary)
|
||||||
|
try FileManager.default.copyItem(atPath: vBinary, toPath: vDaemonBinary)
|
||||||
|
// sidecar .version file - lets the GUI show which build is
|
||||||
|
// installed without having to run the copied binary
|
||||||
|
try? vVersionString.write(
|
||||||
|
toFile: vVersionFile,
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
let vTarget = userAgentPath()
|
let vTarget = userAgentPath()
|
||||||
try? FileManager.default.createDirectory(
|
try? FileManager.default.createDirectory(
|
||||||
at: vTarget.deletingLastPathComponent(),
|
at: vTarget.deletingLastPathComponent(),
|
||||||
@@ -52,12 +75,23 @@ enum ServiceInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .rootDaemon:
|
case .rootDaemon:
|
||||||
|
// root daemon install: binary copy + plist install + boot
|
||||||
|
// happen inside a single admin-priv script so the user
|
||||||
|
// gets ONE password prompt covering all of it
|
||||||
let vTmp = FileManager.default.temporaryDirectory
|
let vTmp = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("\(kLabel).plist")
|
.appendingPathComponent("\(kLabel).plist")
|
||||||
try vPlistData.write(to: vTmp, options: .atomic)
|
try vPlistData.write(to: vTmp, options: .atomic)
|
||||||
let vTarget = rootDaemonPath()
|
let vTarget = rootDaemonPath()
|
||||||
|
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
|
||||||
let vScript = """
|
let vScript = """
|
||||||
cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
|
mkdir -p \(shellQuote(inString: vDaemonDir)) \
|
||||||
|
&& rm -f \(shellQuote(inString: vDaemonBinary)) \
|
||||||
|
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \
|
||||||
|
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \
|
||||||
|
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \
|
||||||
|
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \
|
||||||
|
&& chmod 644 \(shellQuote(inString: vVersionFile)) \
|
||||||
|
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
|
||||||
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \
|
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \
|
||||||
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
|
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
|
||||||
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
|
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
|
||||||
@@ -67,17 +101,25 @@ enum ServiceInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// removes the launchd plist for the given scope
|
// removes the launchd plist AND the renamed binary copy AND the
|
||||||
|
// sidecar .version file for the given scope
|
||||||
static func uninstall(inScope: Scope) throws {
|
static func uninstall(inScope: Scope) throws {
|
||||||
|
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
|
||||||
|
let vVersionFile = vDaemonBinary + ".version"
|
||||||
switch inScope {
|
switch inScope {
|
||||||
case .userAgent:
|
case .userAgent:
|
||||||
let vTarget = userAgentPath()
|
let vTarget = userAgentPath()
|
||||||
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
|
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
|
||||||
try? FileManager.default.removeItem(at: vTarget)
|
try? FileManager.default.removeItem(at: vTarget)
|
||||||
|
try? FileManager.default.removeItem(atPath: vDaemonBinary)
|
||||||
|
try? FileManager.default.removeItem(atPath: vVersionFile)
|
||||||
|
|
||||||
case .rootDaemon:
|
case .rootDaemon:
|
||||||
let vTarget = rootDaemonPath()
|
let vTarget = rootDaemonPath()
|
||||||
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null ; rm -f \(shellQuote(inString: vTarget.path))"
|
let vScript = """
|
||||||
|
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
|
||||||
|
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile))
|
||||||
|
"""
|
||||||
try runWithAdminPrivileges(inScript: vScript)
|
try runWithAdminPrivileges(inScript: vScript)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +132,74 @@ enum ServiceInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stops the running daemon for the given scope without uninstalling.
|
||||||
|
// The plist file stays on disk so a later start() (or app relaunch
|
||||||
|
// since RunAtLoad=true) brings it back. Use cases: temporary disable,
|
||||||
|
// freeing FSEvents resources, or pre-flight before a manual reindex.
|
||||||
|
static func stop(inScope: Scope) throws {
|
||||||
|
switch inScope {
|
||||||
|
case .userAgent:
|
||||||
|
let vPlist = userAgentPath().path
|
||||||
|
let vResult = runLaunchctl(inArgs: ["unload", vPlist])
|
||||||
|
if vResult.exitCode != 0 {
|
||||||
|
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
|
||||||
|
}
|
||||||
|
case .rootDaemon:
|
||||||
|
let vPlist = rootDaemonPath().path
|
||||||
|
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
|
||||||
|
try runWithAdminPrivileges(inScript: vScript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// starts a previously-installed but currently-stopped daemon.
|
||||||
|
static func start(inScope: Scope) throws {
|
||||||
|
switch inScope {
|
||||||
|
case .userAgent:
|
||||||
|
let vPlist = userAgentPath().path
|
||||||
|
let vResult = runLaunchctl(inArgs: ["load", "-w", vPlist])
|
||||||
|
if vResult.exitCode != 0 {
|
||||||
|
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
|
||||||
|
}
|
||||||
|
case .rootDaemon:
|
||||||
|
let vPlist = rootDaemonPath().path
|
||||||
|
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
|
||||||
|
try runWithAdminPrivileges(inScript: vScript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// true if the daemon process for the given scope is currently alive.
|
||||||
|
// We check by reading the indexer lock file's PID and probing with
|
||||||
|
// kill(pid, 0) - cheaper and non-privileged compared to launchctl.
|
||||||
|
static func isRunning(inScope: Scope) -> Bool {
|
||||||
|
let vSystem = (inScope == .rootDaemon)
|
||||||
|
let vLockPath = IndexStore.lockURL(forSystem: vSystem).path
|
||||||
|
guard let vPid = IndexerLock.readHolderPid(path: vLockPath) else { return false }
|
||||||
|
// signal 0 = check-only; EPERM means "process exists but we lack
|
||||||
|
// permission to signal it", which is still "alive"
|
||||||
|
let vRc = kill(vPid, 0)
|
||||||
|
if vRc == 0 { return true }
|
||||||
|
if vRc == -1 && errno == EPERM { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns the version stamped into the installed daemon binary's
|
||||||
|
// sidecar .version file (written at install time). nil if the
|
||||||
|
// service isn't installed or the sidecar is missing/corrupt.
|
||||||
|
static func installedVersion(inScope: Scope) -> String? {
|
||||||
|
let vPath = daemonBinaryPath(inScope: inScope) + ".version"
|
||||||
|
guard let vText = try? String(contentsOfFile: vPath, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let vTrimmed = vText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return vTrimmed.isEmpty ? nil : vTrimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns the version of the running .app bundle (what would be
|
||||||
|
// installed if the user clicks Install right now)
|
||||||
|
static func bundleVersion() -> String {
|
||||||
|
return (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?"
|
||||||
|
}
|
||||||
|
|
||||||
// stops the service, removes the cache file, and starts the service again
|
// stops the service, removes the cache file, and starts the service again
|
||||||
// as a single privileged operation. Necessary for the root daemon because
|
// as a single privileged operation. Necessary for the root daemon because
|
||||||
// the cache file is owned by root and the daemon would otherwise just
|
// the cache file is owned by root and the daemon would otherwise just
|
||||||
@@ -125,6 +235,21 @@ enum ServiceInstaller {
|
|||||||
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
|
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filesystem path where the daemon's binary is installed. We use a
|
||||||
|
// renamed copy ("Allofit Service") so the daemon process is named
|
||||||
|
// differently from the GUI in Activity Monitor / ps - both used to
|
||||||
|
// be just "Allofit" because they shared the same on-disk binary.
|
||||||
|
// The filename embedded in argv[0] is what those tools display.
|
||||||
|
static func daemonBinaryPath(inScope: Scope) -> String {
|
||||||
|
switch inScope {
|
||||||
|
case .userAgent:
|
||||||
|
return NSHomeDirectory()
|
||||||
|
+ "/Library/Application Support/Allofit/Allofit Service"
|
||||||
|
case .rootDaemon:
|
||||||
|
return "/Library/Application Support/Allofit/Allofit Service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resolves an absolute path to the currently running binary
|
// resolves an absolute path to the currently running binary
|
||||||
private static func resolveBinaryPath() throws -> String {
|
private static func resolveBinaryPath() throws -> String {
|
||||||
let vArg0 = CommandLine.arguments[0]
|
let vArg0 = CommandLine.arguments[0]
|
||||||
@@ -17,12 +17,7 @@ struct AllofitApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("Allofit") {
|
WindowGroup("Allofit") {
|
||||||
ContentView()
|
AllofitWindowContent(model: model, access: access)
|
||||||
.environmentObject(model)
|
|
||||||
.environmentObject(Preferences.shared)
|
|
||||||
.environmentObject(access)
|
|
||||||
.frame(minWidth: 760, minHeight: 480)
|
|
||||||
.background(MainWindowMarker())
|
|
||||||
}
|
}
|
||||||
.windowToolbarStyle(.unified)
|
.windowToolbarStyle(.unified)
|
||||||
.defaultSize(width: 1100, height: 640)
|
.defaultSize(width: 1100, height: 640)
|
||||||
@@ -37,7 +32,11 @@ struct AllofitApp: App {
|
|||||||
}
|
}
|
||||||
.keyboardShortcut("r", modifiers: [.command])
|
.keyboardShortcut("r", modifiers: [.command])
|
||||||
}
|
}
|
||||||
CommandGroup(replacing: .newItem) { }
|
// SwiftUI provides File > New Window (⌘N) automatically for a
|
||||||
|
// WindowGroup; nothing to add here. Additional windows share the
|
||||||
|
// AppModel/AccessManager StateObjects defined above, so the index
|
||||||
|
// (and current search/sort) stays in sync across them - only the
|
||||||
|
// per-window selection / column-customization differ.
|
||||||
// ⌘F focuses the search field. Standard Find-style shortcut.
|
// ⌘F focuses the search field. Standard Find-style shortcut.
|
||||||
// SearchField's Coordinator observes the notification and calls
|
// SearchField's Coordinator observes the notification and calls
|
||||||
// makeFirstResponder on its underlying NSSearchField.
|
// makeFirstResponder on its underlying NSSearchField.
|
||||||
@@ -57,6 +56,39 @@ struct AllofitApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllofitWindowContent is the per-window root: it creates a fresh
|
||||||
|
// WindowSearchModel for each window so the query / visible slice are
|
||||||
|
// independent, while the shared AppModel + AccessManager + Preferences
|
||||||
|
// are injected from the App level.
|
||||||
|
//
|
||||||
|
// The model/access StateObjects must be passed in via init so the
|
||||||
|
// @StateObject autoclosure for WindowSearchModel can capture the shared
|
||||||
|
// AppModel instance - @EnvironmentObject isn't available at init time.
|
||||||
|
struct AllofitWindowContent: View {
|
||||||
|
|
||||||
|
let model: AppModel
|
||||||
|
let access: AccessManager
|
||||||
|
@StateObject private var searchModel: WindowSearchModel
|
||||||
|
|
||||||
|
init(model inModel: AppModel, access inAccess: AccessManager) {
|
||||||
|
self.model = inModel
|
||||||
|
self.access = inAccess
|
||||||
|
// @autoclosure: SwiftUI evaluates this exactly once when the view
|
||||||
|
// first appears, so re-renders won't keep allocating new search models
|
||||||
|
_searchModel = StateObject(wrappedValue: WindowSearchModel(model: inModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(model)
|
||||||
|
.environmentObject(Preferences.shared)
|
||||||
|
.environmentObject(access)
|
||||||
|
.environmentObject(searchModel)
|
||||||
|
.frame(minWidth: 760, minHeight: 480)
|
||||||
|
.background(MainWindowMarker())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MainWindowMarker captures the main WindowGroup's NSWindow into
|
// MainWindowMarker captures the main WindowGroup's NSWindow into
|
||||||
// AppDelegate.mainWindow so applicationShouldHandleReopen can re-show that
|
// AppDelegate.mainWindow so applicationShouldHandleReopen can re-show that
|
||||||
// specific window without triggering AppKit's default behavior of unhiding
|
// specific window without triggering AppKit's default behavior of unhiding
|
||||||
@@ -133,6 +165,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// bundle - covers the SwiftPM "swift run" case
|
// bundle - covers the SwiftPM "swift run" case
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay.
|
||||||
|
// The system default is ~2 s; that makes truncated Name/Path cells
|
||||||
|
// feel unreadable. Registered as a default so a user-set value in
|
||||||
|
// the global domain still wins. Seconds.
|
||||||
|
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 0.3])
|
||||||
// wipe any elevated-access staging files left over from a previous
|
// wipe any elevated-access staging files left over from a previous
|
||||||
// run so a crash or hard-kill doesn't accumulate privileged copies
|
// run so a crash or hard-kill doesn't accumulate privileged copies
|
||||||
// in ~/Library/Caches across sessions
|
// in ~/Library/Caches across sessions
|
||||||
@@ -160,6 +197,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dock-icon right-click contextual menu: surface a "New Window" entry so
|
||||||
|
// the user can spawn an additional window without bringing the app to the
|
||||||
|
// front first. The action defers to whatever the File > New Window menu
|
||||||
|
// item does (SwiftUI auto-generates that item for WindowGroup) so we stay
|
||||||
|
// compatible with whichever underlying selector SwiftUI uses.
|
||||||
|
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||||
|
let vMenu = NSMenu()
|
||||||
|
let vItem = NSMenuItem(title: "New Window",
|
||||||
|
action: #selector(newWindowFromDock(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
vItem.target = self
|
||||||
|
vMenu.addItem(vItem)
|
||||||
|
return vMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
// finds the ⌘N main-menu item (File > New Window) and re-invokes its
|
||||||
|
// action. We match on the keyboard shortcut rather than the title so the
|
||||||
|
// lookup survives localized menus.
|
||||||
|
@objc func newWindowFromDock(_ sender: Any?) {
|
||||||
|
guard let vMain = NSApp.mainMenu else { return }
|
||||||
|
for vTop in vMain.items {
|
||||||
|
guard let vSub = vTop.submenu else { continue }
|
||||||
|
for vItem in vSub.items {
|
||||||
|
if vItem.keyEquivalent == "n",
|
||||||
|
vItem.keyEquivalentModifierMask == [.command],
|
||||||
|
let vAction = vItem.action {
|
||||||
|
NSApp.sendAction(vAction, to: vItem.target, from: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dock-icon click while no windows are visible: re-show only the main
|
// 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
|
// window and return false so AppKit doesn't run its default "unhide every
|
||||||
// hidden window" action (which would also resurrect the Settings window).
|
// hidden window" action (which would also resurrect the Settings window).
|
||||||
@@ -23,10 +23,11 @@ private final class BackgroundMtime: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppModel is the central observable state for the application.
|
// AppModel is the shared backing state for the application.
|
||||||
// It owns the in-memory file index, drives the background indexer and the
|
// It owns the in-memory file index, drives the background indexer and the
|
||||||
// FSEvents watcher, debounces the search query and exposes a filtered/sorted
|
// FSEvents watcher, and exposes the active sort descriptor. The per-window
|
||||||
// slice of the index to SwiftUI.
|
// search query and filtered/visible slice live in WindowSearchModel so two
|
||||||
|
// windows can run independent searches against this same shared index.
|
||||||
//
|
//
|
||||||
// Threading rule of thumb: this class is @MainActor so all Published properties
|
// Threading rule of thumb: this class is @MainActor so all Published properties
|
||||||
// are written from main. Heavy work (LZ4 (de)compression, filtering, sorting,
|
// are written from main. Heavy work (LZ4 (de)compression, filtering, sorting,
|
||||||
@@ -39,8 +40,6 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
// the canonical full index of every entry observed so far
|
// the canonical full index of every entry observed so far
|
||||||
@Published private(set) var allRecords: [FileRecord] = []
|
@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)
|
// total number of entries indexed (used for the status bar)
|
||||||
@Published private(set) var indexedCount: Int = 0
|
@Published private(set) var indexedCount: Int = 0
|
||||||
// true while a full reindex is in progress
|
// true while a full reindex is in progress
|
||||||
@@ -49,37 +48,14 @@ final class AppModel: ObservableObject {
|
|||||||
@Published private(set) var isLoadingCache: Bool = false
|
@Published private(set) var isLoadingCache: Bool = false
|
||||||
// true when this process owns the index (got the lock or built-in mode)
|
// true when this process owns the index (got the lock or built-in mode)
|
||||||
@Published private(set) var isIndexer: Bool = false
|
@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)
|
// how often the index is written to disk while running (seconds)
|
||||||
private let kAutosaveSeconds: TimeInterval = 30
|
private let kAutosaveSeconds: TimeInterval = 30
|
||||||
|
|
||||||
private let prefs = Preferences.shared
|
private let prefs = Preferences.shared
|
||||||
// background queues isolated by concern - keeps the slow stuff off main
|
// background queues isolated by concern - keeps the slow stuff off main
|
||||||
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
|
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)
|
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
|
// watcher used in indexer mode for live updates
|
||||||
private let watcher = FileWatcher()
|
private let watcher = FileWatcher()
|
||||||
// watcher used in reader mode to detect cache file refreshes
|
// watcher used in reader mode to detect cache file refreshes
|
||||||
@@ -106,8 +82,6 @@ final class AppModel: ObservableObject {
|
|||||||
// runloop) so the periodic stat() doesn't compete with NSTableView click
|
// runloop) so the periodic stat() doesn't compete with NSTableView click
|
||||||
// handling - main-runloop timers were the source of dropped clicks.
|
// handling - main-runloop timers were the source of dropped clicks.
|
||||||
private var cachePollSource: DispatchSourceTimer?
|
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
|
// 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
|
// 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
|
// any human-perceptible "stale" threshold but caps the worst-case churn
|
||||||
@@ -117,10 +91,8 @@ final class AppModel: ObservableObject {
|
|||||||
private var lastReloadAt: Date?
|
private var lastReloadAt: Date?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// only the cheap UI state is restored synchronously - the cache file
|
// no synchronous UI-state restoration needed; the cache file is
|
||||||
// is loaded off-main in start() so the window appears instantly
|
// 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
|
// kicks off background activity for the first time. Subsequent calls are
|
||||||
@@ -152,8 +124,6 @@ final class AppModel: ObservableObject {
|
|||||||
cachePollSource = nil
|
cachePollSource = nil
|
||||||
indexerLock?.unlock()
|
indexerLock?.unlock()
|
||||||
indexerLock = nil
|
indexerLock = nil
|
||||||
filterTask?.cancel()
|
|
||||||
filterTask = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// determines indexer vs reader role, loads the cache from the appropriate
|
// determines indexer vs reader role, loads the cache from the appropriate
|
||||||
@@ -199,16 +169,9 @@ final class AppModel: ObservableObject {
|
|||||||
vSelf.pathIndex = [:]
|
vSelf.pathIndex = [:]
|
||||||
vSelf.indexedCount = 0
|
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.isLoadingCache = false
|
||||||
vSelf.applyFilterAndSort()
|
// WindowSearchModel(s) observe allRecords and will refilter;
|
||||||
|
// no need to kick a filter here ourselves
|
||||||
if vSelf.isIndexer {
|
if vSelf.isIndexer {
|
||||||
vSelf.startIndexerMode()
|
vSelf.startIndexerMode()
|
||||||
} else {
|
} else {
|
||||||
@@ -241,6 +204,10 @@ final class AppModel: ObservableObject {
|
|||||||
var vAccumulated: [FileRecord] = []
|
var vAccumulated: [FileRecord] = []
|
||||||
vAccumulated.reserveCapacity(200_000)
|
vAccumulated.reserveCapacity(200_000)
|
||||||
for vRoot in vRoots {
|
for vRoot in vRoots {
|
||||||
|
// per-root autoreleasepool so the file-enumeration's
|
||||||
|
// autoreleased NSURL/NSDate/NSNumber objects don't pile
|
||||||
|
// up across roots inside this long-running async block
|
||||||
|
autoreleasepool {
|
||||||
let vBaseline = vAccumulated.count
|
let vBaseline = vAccumulated.count
|
||||||
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -249,6 +216,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
vAccumulated.append(contentsOf: vList)
|
vAccumulated.append(contentsOf: vList)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let vFinal = vAccumulated
|
let vFinal = vAccumulated
|
||||||
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
||||||
// persist off-main before bouncing back so main never sees the
|
// persist off-main before bouncing back so main never sees the
|
||||||
@@ -309,6 +277,28 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stops the running daemon for the current serviceMode preference
|
||||||
|
// without uninstalling. Plist stays on disk so the next launch (or
|
||||||
|
// performStartService) brings it back.
|
||||||
|
func performStopService() async {
|
||||||
|
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in
|
||||||
|
try ServiceInstaller.stop(inScope: vScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// starts a stopped-but-installed daemon for the current serviceMode
|
||||||
|
// preference. Hot-swaps the GUI into reader mode on success so the
|
||||||
|
// reader watcher picks up the daemon's first cache write.
|
||||||
|
func performStartService() async {
|
||||||
|
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in
|
||||||
|
try ServiceInstaller.start(inScope: vScope)
|
||||||
|
}
|
||||||
|
if vOk {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
switchToCurrentMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// uninstalls the launchd service for the current serviceMode preference.
|
// uninstalls the launchd service for the current serviceMode preference.
|
||||||
// On success the GUI hot-swaps back into built-in indexer mode.
|
// On success the GUI hot-swaps back into built-in indexer mode.
|
||||||
func performUninstallService() async {
|
func performUninstallService() async {
|
||||||
@@ -454,7 +444,8 @@ final class AppModel: ObservableObject {
|
|||||||
indexedCount = allRecords.count
|
indexedCount = allRecords.count
|
||||||
lastEventId = watcher.latestEventId
|
lastEventId = watcher.latestEventId
|
||||||
dirty = true
|
dirty = true
|
||||||
scheduleSearch()
|
// WindowSearchModel(s) observe @Published allRecords and refilter
|
||||||
|
// on debounce; no need to fire a manual filter pass here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,12 +463,17 @@ final class AppModel: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for vPath in inPaths {
|
for vPath in inPaths {
|
||||||
|
// per-subtree autoreleasepool keeps the rescan's
|
||||||
|
// autoreleased URL/stat objects from accumulating across
|
||||||
|
// subtrees inside this long-running async block
|
||||||
|
autoreleasepool {
|
||||||
let vList = FileIndexer.indexRoot(
|
let vList = FileIndexer.indexRoot(
|
||||||
inRoot: URL(fileURLWithPath: vPath),
|
inRoot: URL(fileURLWithPath: vPath),
|
||||||
inExclusions: inExclusions
|
inExclusions: inExclusions
|
||||||
)
|
)
|
||||||
vKept.append(contentsOf: vList)
|
vKept.append(contentsOf: vList)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let vFinal = vKept
|
let vFinal = vKept
|
||||||
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -485,7 +481,6 @@ final class AppModel: ObservableObject {
|
|||||||
self?.pathIndex = vLookup
|
self?.pathIndex = vLookup
|
||||||
self?.indexedCount = vFinal.count
|
self?.indexedCount = vFinal.count
|
||||||
self?.dirty = true
|
self?.dirty = true
|
||||||
self?.scheduleSearch()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,19 +538,6 @@ final class AppModel: ObservableObject {
|
|||||||
cachePollSource = vSource
|
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.
|
// reloads the on-disk cache off-main and swaps it in atomically.
|
||||||
//
|
//
|
||||||
// Two guards keep this from disrupting Table interactions:
|
// Two guards keep this from disrupting Table interactions:
|
||||||
@@ -593,7 +575,6 @@ final class AppModel: ObservableObject {
|
|||||||
self?.pathIndex = vLookup
|
self?.pathIndex = vLookup
|
||||||
self?.indexedCount = vCache.records.count
|
self?.indexedCount = vCache.records.count
|
||||||
self?.lastEventId = vCache.lastEventId
|
self?.lastEventId = vCache.lastEventId
|
||||||
self?.applyFilterAndSort()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,7 +583,9 @@ final class AppModel: ObservableObject {
|
|||||||
// MARK: Internals
|
// MARK: Internals
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
// installs a freshly-built index and refreshes the visible slice
|
// installs a freshly-built index and starts watching for changes. The
|
||||||
|
// per-window WindowSearchModel(s) observe allRecords and will refilter
|
||||||
|
// themselves; no need to fire a filter pass from here.
|
||||||
private func applyFreshIndex(inRecords: [FileRecord],
|
private func applyFreshIndex(inRecords: [FileRecord],
|
||||||
inLookup: [String: Int],
|
inLookup: [String: Int],
|
||||||
inEventId: UInt64) {
|
inEventId: UInt64) {
|
||||||
@@ -612,68 +595,11 @@ final class AppModel: ObservableObject {
|
|||||||
lastEventId = inEventId
|
lastEventId = inEventId
|
||||||
isIndexing = false
|
isIndexing = false
|
||||||
dirty = false
|
dirty = false
|
||||||
applyFilterAndSort()
|
|
||||||
if isIndexer {
|
if isIndexer {
|
||||||
startWatching(inSinceWhen: inEventId)
|
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.
|
// builds a fresh path -> array-index dictionary for the given records.
|
||||||
// nonisolated so any background queue can call it without an actor hop.
|
// nonisolated so any background queue can call it without an actor hop.
|
||||||
private nonisolated static func buildPathLookup(inRecords: [FileRecord]) -> [String: Int] {
|
private nonisolated static func buildPathLookup(inRecords: [FileRecord]) -> [String: Int] {
|
||||||
@@ -686,8 +612,8 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sorts the provided slice in place. nonisolated so the detached filter
|
// sorts the provided slice in place. nonisolated so the detached filter
|
||||||
// task can call it without an actor hop.
|
// task can call it without an actor hop. Called by WindowSearchModel.
|
||||||
private nonisolated static func sortInPlace(inRecords: inout [FileRecord],
|
nonisolated static func sortInPlace(inRecords: inout [FileRecord],
|
||||||
inDescriptor: FileSortDescriptor) {
|
inDescriptor: FileSortDescriptor) {
|
||||||
switch inDescriptor {
|
switch inDescriptor {
|
||||||
case .nameAscending:
|
case .nameAscending:
|
||||||
@@ -10,6 +10,9 @@ struct ContentView: View {
|
|||||||
@EnvironmentObject var model: AppModel
|
@EnvironmentObject var model: AppModel
|
||||||
@EnvironmentObject var prefs: Preferences
|
@EnvironmentObject var prefs: Preferences
|
||||||
@EnvironmentObject var access: AccessManager
|
@EnvironmentObject var access: AccessManager
|
||||||
|
// per-window search model: owns this window's query + filtered slice so
|
||||||
|
// two windows can run independent searches against the shared AppModel
|
||||||
|
@EnvironmentObject var searchModel: WindowSearchModel
|
||||||
@State private var selection: Set<FileRecord.ID> = []
|
@State private var selection: Set<FileRecord.ID> = []
|
||||||
// drives the Table's drag-to-reorder and column-visibility customization.
|
// drives the Table's drag-to-reorder and column-visibility customization.
|
||||||
// Initial value is hydrated from UserDefaults so the user's column order
|
// Initial value is hydrated from UserDefaults so the user's column order
|
||||||
@@ -26,12 +29,13 @@ struct ContentView: View {
|
|||||||
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
|
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
|
||||||
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
|
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
|
||||||
|
|
||||||
// Computed binding for the Table's sortOrder: reads/writes
|
// Computed binding for the Table's sortOrder: reads/writes the
|
||||||
// model.sortDescriptor directly so the sort state survives any number
|
// per-window searchModel.sortDescriptor so clicking a column header
|
||||||
// of window closes / reopens.
|
// only re-sorts this window. The last-clicked sort is mirrored into
|
||||||
|
// Preferences so a fresh window opens with the most recent choice.
|
||||||
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
|
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
|
get: { [Self.comparatorFor(inDescriptor: searchModel.sortDescriptor)] },
|
||||||
set: { vNewOrder in
|
set: { vNewOrder in
|
||||||
guard let vFirst = vNewOrder.first else { return }
|
guard let vFirst = vNewOrder.first else { return }
|
||||||
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
|
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
|
||||||
@@ -39,7 +43,7 @@ struct ContentView: View {
|
|||||||
// model while NSTableView is still in its sort delegate
|
// model while NSTableView is still in its sort delegate
|
||||||
// callback (avoids the reentrant-operation AppKit warning)
|
// callback (avoids the reentrant-operation AppKit warning)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
model.sortDescriptor = vDescriptor
|
searchModel.sortDescriptor = vDescriptor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -136,7 +140,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var searchBar: some View {
|
private var searchBar: some View {
|
||||||
SearchField(
|
SearchField(
|
||||||
text: $model.query,
|
text: $searchModel.query,
|
||||||
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
||||||
initiallyFirstResponder: true
|
initiallyFirstResponder: true
|
||||||
)
|
)
|
||||||
@@ -170,6 +174,7 @@ struct ContentView: View {
|
|||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
Text(vRecord.name)
|
Text(vRecord.name)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.help(vRecord.name)
|
||||||
// When the preview pane is closed, surface the
|
// When the preview pane is closed, surface the
|
||||||
// elevate-permission affordance on the selected row
|
// elevate-permission affordance on the selected row
|
||||||
// itself so the user has a way to authorize without
|
// itself so the user has a way to authorize without
|
||||||
@@ -181,7 +186,11 @@ struct ContentView: View {
|
|||||||
selection.contains(vRecord.id),
|
selection.contains(vRecord.id),
|
||||||
access.needsAuthorization(for: vRecord) {
|
access.needsAuthorization(for: vRecord) {
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
AuthorizeBadge(record: vRecord)
|
// pass access explicitly: Table cells live in
|
||||||
|
// detached NSHostingViews that don't reliably
|
||||||
|
// inherit @EnvironmentObject, which was the cause
|
||||||
|
// of repeated EnvironmentObject.error() crashes
|
||||||
|
AuthorizeBadge(access: access, record: vRecord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +202,7 @@ struct ContentView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.help(vRecord.parentPath)
|
||||||
}
|
}
|
||||||
.width(min: 200, ideal: 380)
|
.width(min: 200, ideal: 380)
|
||||||
.customizationID("path")
|
.customizationID("path")
|
||||||
@@ -221,7 +231,7 @@ struct ContentView: View {
|
|||||||
.width(140)
|
.width(140)
|
||||||
.customizationID("modified")
|
.customizationID("modified")
|
||||||
} rows: {
|
} rows: {
|
||||||
ForEach(model.visibleRecords) { vRecord in
|
ForEach(searchModel.visibleRecords) { vRecord in
|
||||||
TableRow(vRecord)
|
TableRow(vRecord)
|
||||||
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
||||||
}
|
}
|
||||||
@@ -282,7 +292,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
|
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
|
||||||
return model.visibleRecords.filter { inIds.contains($0.id) }
|
return searchModel.visibleRecords.filter { inIds.contains($0.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================
|
// ===========================
|
||||||
@@ -328,6 +338,7 @@ private struct StatusBarView: View {
|
|||||||
|
|
||||||
@EnvironmentObject var model: AppModel
|
@EnvironmentObject var model: AppModel
|
||||||
@EnvironmentObject var prefs: Preferences
|
@EnvironmentObject var prefs: Preferences
|
||||||
|
@EnvironmentObject var searchModel: WindowSearchModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -336,7 +347,7 @@ private struct StatusBarView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
Text("Indexing… \(model.indexedCount) entries")
|
Text("Indexing… \(model.indexedCount) entries")
|
||||||
} else {
|
} else {
|
||||||
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
|
Text("\(searchModel.visibleRecords.count) shown · \(model.indexedCount) indexed")
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(model.isIndexer ? "Indexer" : "Reader")
|
Text(model.isIndexer ? "Indexer" : "Reader")
|
||||||
@@ -346,7 +357,7 @@ private struct StatusBarView: View {
|
|||||||
case .userAgent: Text("· User service").foregroundColor(.secondary)
|
case .userAgent: Text("· User service").foregroundColor(.secondary)
|
||||||
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
|
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
if !model.query.isEmpty {
|
if !searchModel.query.isEmpty {
|
||||||
Text("· Filtered").foregroundColor(.secondary)
|
Text("· Filtered").foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,14 +29,20 @@ struct QuickLookPreviewView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizeBadge is the small lock icon that appears either inside the
|
// AuthorizeBadge is the small lock icon that appears at the right of the
|
||||||
// preview pane (when the selected file isn't user-readable) or at the
|
// selected row when the preview pane is closed. Clicking it kicks off the
|
||||||
// right of the selected row when the preview pane is closed. Clicking
|
// sudo cp + chown via AdminShell - the system prompts for the password
|
||||||
// it kicks off the sudo cp + chown via AdminShell - the system prompts
|
// the first time inside the admin-auth-cache window.
|
||||||
// for the password the first time inside the admin-auth-cache window.
|
//
|
||||||
|
// access is taken as an @ObservedObject property (not @EnvironmentObject)
|
||||||
|
// because this view is hosted inside a SwiftUI Table cell, and Table cell
|
||||||
|
// content is rendered in its own NSHostingView. That hosting view does
|
||||||
|
// not reliably inherit the parent's environment objects; a missing
|
||||||
|
// access lookup triggers EnvironmentObject.error() → SIGTRAP. Passing
|
||||||
|
// the AccessManager explicitly sidesteps that entire failure mode.
|
||||||
struct AuthorizeBadge: View {
|
struct AuthorizeBadge: View {
|
||||||
|
|
||||||
@EnvironmentObject var access: AccessManager
|
@ObservedObject var access: AccessManager
|
||||||
let record: FileRecord
|
let record: FileRecord
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -71,13 +77,14 @@ struct PreviewPane: View {
|
|||||||
|
|
||||||
@EnvironmentObject var model: AppModel
|
@EnvironmentObject var model: AppModel
|
||||||
@EnvironmentObject var access: AccessManager
|
@EnvironmentObject var access: AccessManager
|
||||||
|
@EnvironmentObject var searchModel: WindowSearchModel
|
||||||
// passed in from ContentView (its @State) so this view re-renders
|
// passed in from ContentView (its @State) so this view re-renders
|
||||||
// whenever the user's selection changes
|
// whenever the user's selection changes
|
||||||
let selection: Set<FileRecord.ID>
|
let selection: Set<FileRecord.ID>
|
||||||
|
|
||||||
private var selectedRecord: FileRecord? {
|
private var selectedRecord: FileRecord? {
|
||||||
guard selection.count == 1, let vId = selection.first else { return nil }
|
guard selection.count == 1, let vId = selection.first else { return nil }
|
||||||
return model.visibleRecords.first(where: { $0.id == vId })
|
return searchModel.visibleRecords.first(where: { $0.id == vId })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -90,6 +90,12 @@ struct SearchField: NSViewRepresentable {
|
|||||||
private var navigationIndex: Int?
|
private var navigationIndex: Int?
|
||||||
// text the user typed before starting to navigate; restored on rollback
|
// text the user typed before starting to navigate; restored on rollback
|
||||||
private var savedQuery: String = ""
|
private var savedQuery: String = ""
|
||||||
|
// debounced auto-save to recents: if the user stops typing for this
|
||||||
|
// many nanoseconds, the current query is added to recents as though
|
||||||
|
// they had pressed Return
|
||||||
|
private static let kAutoSaveDelayNanos: UInt64 = 4_000_000_000
|
||||||
|
// pending auto-save task; cancelled+rescheduled on every keystroke
|
||||||
|
private var autoSaveTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(_ inParent: SearchField) {
|
init(_ inParent: SearchField) {
|
||||||
parent = inParent
|
parent = inParent
|
||||||
@@ -109,9 +115,12 @@ struct SearchField: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fired by the Find menu item; brings the search field to first
|
// 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() {
|
@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.window?.makeFirstResponder(vField)
|
||||||
vField.selectText(nil)
|
vField.selectText(nil)
|
||||||
}
|
}
|
||||||
@@ -128,6 +137,28 @@ struct SearchField: NSViewRepresentable {
|
|||||||
self?.parent.text = vValue
|
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
|
// NSSearchField's action target: fires on Return and on recent-pick
|
||||||
@@ -213,12 +213,26 @@ private struct VolumesTab: View {
|
|||||||
// MARK: Service tab
|
// MARK: Service tab
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
// ServiceTab manages installation of the LaunchAgent or LaunchDaemon that
|
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
|
||||||
// keeps the index up to date while the GUI is closed.
|
// up to date while the GUI is closed. Surfaces install/uninstall, run-
|
||||||
|
// state (stop/start), and the gap between the version that's currently
|
||||||
|
// installed on disk vs the bundle the user is running right now.
|
||||||
private struct ServiceTab: View {
|
private struct ServiceTab: View {
|
||||||
|
|
||||||
@EnvironmentObject var prefs: Preferences
|
@EnvironmentObject var prefs: Preferences
|
||||||
@EnvironmentObject var model: AppModel
|
@EnvironmentObject var model: AppModel
|
||||||
|
// tick value to force the status block to recompute on a timer; the
|
||||||
|
// status doesn't observe @Published changes since it reads file
|
||||||
|
// system state directly, so we need an explicit refresh signal
|
||||||
|
@State private var statusTick: Int = 0
|
||||||
|
|
||||||
|
private var scope: ServiceInstaller.Scope? {
|
||||||
|
switch prefs.serviceMode {
|
||||||
|
case .none: return nil
|
||||||
|
case .userAgent: return .userAgent
|
||||||
|
case .rootDaemon: return .rootDaemon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -249,20 +263,15 @@ private struct ServiceTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
installDescription
|
||||||
Button("Install") {
|
|
||||||
Task { await model.performInstallService() }
|
actionButtons
|
||||||
}
|
|
||||||
.disabled(prefs.serviceMode == .none || model.isWorking)
|
if !model.workMessage.isEmpty {
|
||||||
Button("Uninstall") {
|
HStack(spacing: 6) {
|
||||||
Task { await model.performUninstallService() }
|
|
||||||
}
|
|
||||||
.disabled(prefs.serviceMode == .none || model.isWorking)
|
|
||||||
Spacer()
|
|
||||||
if model.isWorking {
|
if model.isWorking {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
if !model.workMessage.isEmpty {
|
|
||||||
Text(model.workMessage)
|
Text(model.workMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -271,16 +280,141 @@ private struct ServiceTab: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text(currentStatusText())
|
statusBlock
|
||||||
|
}
|
||||||
|
.onAppear { statusTick &+= 1 }
|
||||||
|
.onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in
|
||||||
|
statusTick &+= 1
|
||||||
|
}
|
||||||
|
.onChange(of: model.isWorking) { _, _ in statusTick &+= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// MARK: Install explanation
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var installDescription: some View {
|
||||||
|
if let vScope = scope {
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Install will:")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("• Copy the running binary to \(daemonBinaryPath(for: vScope)) so the daemon has a stable on-disk path that won't break if you move Allofit.app.")
|
||||||
|
.font(.caption)
|
||||||
|
Text("• Write the launchd plist that runs it as \(vScope == .rootDaemon ? "root" : "your user").")
|
||||||
|
.font(.caption)
|
||||||
|
Text("• Start the daemon via launchctl bootstrap.")
|
||||||
|
.font(.caption)
|
||||||
|
if vScope == .rootDaemon {
|
||||||
|
Text("• Prompt once for your administrator password (steps run as one privileged script).")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// MARK: Action buttons
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
let vInstalled = scope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
|
||||||
|
let vRunning = scope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
|
||||||
|
_ = statusTick // re-read the file-system status on each tick
|
||||||
|
return HStack {
|
||||||
|
Button("Install") {
|
||||||
|
Task { await model.performInstallService() }
|
||||||
|
}
|
||||||
|
.disabled(prefs.serviceMode == .none || model.isWorking)
|
||||||
|
Button("Uninstall") {
|
||||||
|
Task { await model.performUninstallService() }
|
||||||
|
}
|
||||||
|
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
|
||||||
|
Button("Stop") {
|
||||||
|
Task { await model.performStopService() }
|
||||||
|
}
|
||||||
|
.disabled(!vInstalled || !vRunning || model.isWorking)
|
||||||
|
Button("Start") {
|
||||||
|
Task { await model.performStartService() }
|
||||||
|
}
|
||||||
|
.disabled(!vInstalled || vRunning || model.isWorking)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// MARK: Status block
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
private var statusBlock: some View {
|
||||||
|
_ = statusTick // ensure recomputation each tick
|
||||||
|
let vScope = scope
|
||||||
|
let vInstalled = vScope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
|
||||||
|
let vRunning = vScope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
|
||||||
|
let vInstalledVer = vScope.flatMap { ServiceInstaller.installedVersion(inScope: $0) }
|
||||||
|
let vBundleVer = ServiceInstaller.bundleVersion()
|
||||||
|
let vStale = vInstalled && vInstalledVer != nil && vInstalledVer != vBundleVer
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
LabeledContent("Service installed") {
|
||||||
|
Text(vInstalled ? "yes" : "no")
|
||||||
|
.foregroundColor(vInstalled ? .primary : .secondary)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
LabeledContent("Currently running") {
|
||||||
|
Text(vRunning ? "yes" : "no")
|
||||||
|
.foregroundColor(vRunning ? .green : .secondary)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
LabeledContent("Installed version") {
|
||||||
|
Text(vInstalledVer ?? "—")
|
||||||
|
.font(.callout)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
LabeledContent("This app's version") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(vBundleVer)
|
||||||
|
.font(.callout)
|
||||||
|
.monospacedDigit()
|
||||||
|
if vStale {
|
||||||
|
Text("Reinstall to update")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let vScope = vScope {
|
||||||
|
LabeledContent("Other scope") {
|
||||||
|
Text(otherScopeStatusText(currentScope: vScope))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func currentStatusText() -> String {
|
// path of the daemon-renamed binary copy, used in the explanatory blurb
|
||||||
let vUser = ServiceInstaller.isInstalled(inScope: .userAgent) ? "installed" : "not installed"
|
private func daemonBinaryPath(for inScope: ServiceInstaller.Scope) -> String {
|
||||||
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed"
|
return ServiceInstaller.daemonBinaryPath(inScope: inScope)
|
||||||
return "User agent: \(vUser) · Root daemon: \(vRoot)"
|
}
|
||||||
|
|
||||||
|
// summary of the *other* scope's install state so the user can see at
|
||||||
|
// a glance that they don't have a stray install on the unused side
|
||||||
|
private func otherScopeStatusText(currentScope inCurrent: ServiceInstaller.Scope) -> String {
|
||||||
|
let vOther: ServiceInstaller.Scope = (inCurrent == .userAgent) ? .rootDaemon : .userAgent
|
||||||
|
let vLabel = (vOther == .userAgent) ? "User agent" : "Root daemon"
|
||||||
|
let vInstalled = ServiceInstaller.isInstalled(inScope: vOther)
|
||||||
|
let vRunning = vInstalled && ServiceInstaller.isRunning(inScope: vOther)
|
||||||
|
if vRunning { return "\(vLabel): running" }
|
||||||
|
if vInstalled { return "\(vLabel): installed (stopped)" }
|
||||||
|
return "\(vLabel): not installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 it via Combine 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() }
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the filtered, sorted and capped records currently shown in the table
|
||||||
|
@Published private(set) var visibleRecords: [FileRecord] = []
|
||||||
|
|
||||||
|
// 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 @Published allRecords
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
// pending filter+sort task, cancelled if a newer one supersedes it
|
||||||
|
private var filterTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// maximum rows handed to SwiftUI Table for snappy scrolling
|
||||||
|
private let kMaxVisibleRows = 2000
|
||||||
|
// debounce delay between keystroke / index update and filter rebuild
|
||||||
|
private let kSearchDebounceSeconds: Double = 0.5
|
||||||
|
|
||||||
|
init(model inModel: AppModel) {
|
||||||
|
self.model = inModel
|
||||||
|
self.sortDescriptor = Preferences.shared.lastSort
|
||||||
|
// Re-filter when the shared index changes. @Published fires the
|
||||||
|
// current value on subscribe; dropFirst skips that replay and we
|
||||||
|
// run one explicit scheduleFilter() below so the initial debounce
|
||||||
|
// delay applies consistently.
|
||||||
|
inModel.$allRecords
|
||||||
|
.dropFirst()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.scheduleFilter()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
scheduleFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounces filter rebuilds so we don't refilter on every keystroke
|
||||||
|
// or every FSEvents batch mutation of AppModel.allRecords. The same
|
||||||
|
// debounce window covers all input changes, which is what the old
|
||||||
|
// single-model code did before this split.
|
||||||
|
private func scheduleFilter() {
|
||||||
|
filterTask?.cancel()
|
||||||
|
// snapshot inputs on main; the detached task is self-contained
|
||||||
|
let vQuery = query
|
||||||
|
let vSort = sortDescriptor
|
||||||
|
let vRecords = model.allRecords
|
||||||
|
let vMax = kMaxVisibleRows
|
||||||
|
let vDelayNanos = UInt64(kSearchDebounceSeconds * 1_000_000_000)
|
||||||
|
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: vDelayNanos)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
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.
|
||||||
|
if vSelf.visibleRecords == vCapped { return }
|
||||||
|
vSelf.visibleRecords = vCapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,11 @@ if [[ -f "$kSystemDaemonPlist" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Killing any leftover daemon processes"
|
echo "==> Killing any leftover daemon processes"
|
||||||
vRun pkill -f "Allofit --service" >/dev/null 2>&1 || true
|
# `pkill -f` matches against the full command line; the daemon binary is
|
||||||
|
# now installed as ".../Allofit Service" (renamed copy), so the literal
|
||||||
|
# "Allofit --service" no longer appears - use a regex that handles both
|
||||||
|
# the legacy and the renamed binary by anchoring on "Allofit...--service"
|
||||||
|
vRun pkill -f "Allofit.*--service" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# ==================
|
# ==================
|
||||||
# MARK: Cache files
|
# MARK: Cache files
|
||||||
|
|||||||
@@ -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))")
|
|
||||||
Reference in New Issue
Block a user