3 Commits
9 changed files with 532 additions and 240 deletions
+148 -46
View File
@@ -1,5 +1,6 @@
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
@@ -54,22 +55,33 @@ enum AllofitService {
// 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 {
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)
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()
}
}
vState.dirty = true
vState.lock.unlock()
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
}
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
@@ -91,37 +103,53 @@ enum AllofitService {
var vRescanPrefixes: [String] = []
var vAdded = 0
var vUpdated = 0
var vRemovedCount = 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 {
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
autoreleasepool {
if vMatcher.isExcluded(inPath: vChange.path) { return }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
return
}
} 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
// 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)
}
vRemovedCount += 1
}
}
if vAdded + vUpdated + vRemovedCount > 0 {
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, vRemovedCount, vState.records.count)
vAdded, vUpdated, vRemoved.count, vState.records.count)
}
if !vRescanPrefixes.isEmpty {
@@ -141,10 +169,7 @@ enum AllofitService {
)
vState.records.append(contentsOf: vList)
}
vState.pathIndex.removeAll(keepingCapacity: true)
for (vI, vR) in vState.records.enumerated() {
vState.pathIndex[vR.fullPath] = vI
}
rebuildPathIndex(vState)
}
vState.dirty = true
@@ -152,20 +177,40 @@ enum AllofitService {
// 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)
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)
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)
}
}
}
}
@@ -174,4 +219,61 @@ enum AllofitService {
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
}
}
+42 -10
View File
@@ -241,13 +241,18 @@ final class AppModel: ObservableObject {
var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block
autoreleasepool {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
}
}
vAccumulated.append(contentsOf: vList)
}
vAccumulated.append(contentsOf: vList)
}
let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
@@ -309,6 +314,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.
// On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService() async {
@@ -472,11 +499,16 @@ final class AppModel: ObservableObject {
return true
}
for vPath in inPaths {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
// per-subtree autoreleasepool keeps the rescan's
// autoreleased URL/stat objects from accumulating across
// subtrees inside this long-running async block
autoreleasepool {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
}
let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
+30 -13
View File
@@ -49,21 +49,29 @@ enum FileIndexer {
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 {
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)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() }
continue
}
// short-circuit excluded entries (and don't descend into them)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() }
return
}
if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
}
}
}
}
@@ -99,8 +107,17 @@ enum FileIndexer {
// builds a FileRecord from a URL's pre-fetched resource values.
// Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise
// silently return the cached pre-edit mtime.
// silently return the cached pre-edit mtime. The whole body is wrapped
// in autoreleasepool so the autoreleased NSDate / NSNumber / NSURL
// objects returned by resourceValues() are released at function exit
// rather than at the caller's pool drain.
static func makeRecord(inURL: URL) -> FileRecord? {
return autoreleasepool { () -> FileRecord? in
makeRecord_impl(inURL: inURL)
}
}
private static func makeRecord_impl(inURL: URL) -> FileRecord? {
var vUrl = inURL
vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
+20 -2
View File
@@ -91,8 +91,18 @@ enum IndexStore {
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) {
autoreleasepool {
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
}
}
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId)
@@ -151,8 +161,16 @@ enum IndexStore {
return load(from: cacheURL)
}
// reads the persisted index back from a specific URL
// reads the persisted index back from a specific URL. Wrapped in
// autoreleasepool so the decompressed NSData and the per-record
// String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool {
load_impl(from: inUrl)
}
}
private static func load_impl(from inUrl: URL) -> LoadResult? {
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
var vOffset = 0
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
+130 -5
View File
@@ -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 {
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 {
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()
try? FileManager.default.createDirectory(
at: vTarget.deletingLastPathComponent(),
@@ -52,12 +75,23 @@ enum ServiceInstaller {
}
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
.appendingPathComponent("\(kLabel).plist")
try vPlistData.write(to: vTmp, options: .atomic)
let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
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)) \
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
; /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 {
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
let vVersionFile = vDaemonBinary + ".version"
switch inScope {
case .userAgent:
let vTarget = userAgentPath()
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
try? FileManager.default.removeItem(at: vTarget)
try? FileManager.default.removeItem(atPath: vDaemonBinary)
try? FileManager.default.removeItem(atPath: vVersionFile)
case .rootDaemon:
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)
}
}
@@ -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
// as a single privileged operation. Necessary for the root daemon because
// 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")
}
// 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
private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0]
+157 -23
View File
@@ -213,12 +213,26 @@ private struct VolumesTab: View {
// MARK: Service tab
// ===========================
// ServiceTab manages installation of the LaunchAgent or LaunchDaemon that
// keeps the index up to date while the GUI is closed.
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
// 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 {
@EnvironmentObject var prefs: Preferences
@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 {
VStack(alignment: .leading, spacing: 12) {
@@ -249,20 +263,15 @@ private struct ServiceTab: View {
}
}
HStack {
Button("Install") {
Task { await model.performInstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Spacer()
if model.isWorking {
ProgressView().controlSize(.small)
}
if !model.workMessage.isEmpty {
installDescription
actionButtons
if !model.workMessage.isEmpty {
HStack(spacing: 6) {
if model.isWorking {
ProgressView().controlSize(.small)
}
Text(model.workMessage)
.font(.caption)
.foregroundColor(.secondary)
@@ -271,16 +280,141 @@ private struct ServiceTab: View {
Divider()
Text(currentStatusText())
.font(.caption)
.foregroundColor(.secondary)
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)
}
}
}
private func currentStatusText() -> String {
let vUser = ServiceInstaller.isInstalled(inScope: .userAgent) ? "installed" : "not installed"
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed"
return "User agent: \(vUser) · Root daemon: \(vRoot)"
// ===========================
// 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)
.foregroundColor(.secondary)
}
}
}
}
// path of the daemon-renamed binary copy, used in the explanatory blurb
private func daemonBinaryPath(for inScope: ServiceInstaller.Scope) -> String {
return ServiceInstaller.daemonBinaryPath(inScope: inScope)
}
// 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"
}
}
+5 -1
View File
@@ -104,7 +104,11 @@ if [[ -f "$kSystemDaemonPlist" ]]; then
fi
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
View File
-140
View File
@@ -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))")