mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9292c3ee29 |
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreServices
|
import CoreServices
|
||||||
|
import Darwin
|
||||||
|
|
||||||
// AllofitService is the headless runtime invoked by launchd when the binary
|
// AllofitService is the headless runtime invoked by launchd when the binary
|
||||||
// is launched with the --service argument. It builds an initial index, then
|
// is launched with the --service argument. It builds an initial index, then
|
||||||
@@ -91,7 +92,11 @@ enum AllofitService {
|
|||||||
var vRescanPrefixes: [String] = []
|
var vRescanPrefixes: [String] = []
|
||||||
var vAdded = 0
|
var vAdded = 0
|
||||||
var vUpdated = 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> = []
|
||||||
|
|
||||||
for vChange in vChanges {
|
for vChange in vChanges {
|
||||||
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
||||||
@@ -99,6 +104,8 @@ enum AllofitService {
|
|||||||
vRescanPrefixes.append(vChange.path)
|
vRescanPrefixes.append(vChange.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// skip paths we've already queued for removal in this batch
|
||||||
|
if vRemoved.contains(vChange.path) { continue }
|
||||||
let vUrl = URL(fileURLWithPath: vChange.path)
|
let vUrl = URL(fileURLWithPath: vChange.path)
|
||||||
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
||||||
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
||||||
@@ -110,18 +117,20 @@ enum AllofitService {
|
|||||||
vState.records.append(vRec)
|
vState.records.append(vRec)
|
||||||
vAdded += 1
|
vAdded += 1
|
||||||
}
|
}
|
||||||
} else if let vIdx = vState.pathIndex[vChange.path] {
|
} else if vState.pathIndex[vChange.path] != nil {
|
||||||
vState.records.remove(at: vIdx)
|
vRemoved.insert(vChange.path)
|
||||||
vState.pathIndex.removeAll(keepingCapacity: true)
|
|
||||||
for (vI, vR) in vState.records.enumerated() {
|
|
||||||
vState.pathIndex[vR.fullPath] = vI
|
|
||||||
}
|
|
||||||
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)",
|
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
||||||
vAdded, vUpdated, vRemovedCount, vState.records.count)
|
vAdded, vUpdated, vRemoved.count, vState.records.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !vRescanPrefixes.isEmpty {
|
if !vRescanPrefixes.isEmpty {
|
||||||
@@ -141,10 +150,7 @@ enum AllofitService {
|
|||||||
)
|
)
|
||||||
vState.records.append(contentsOf: vList)
|
vState.records.append(contentsOf: vList)
|
||||||
}
|
}
|
||||||
vState.pathIndex.removeAll(keepingCapacity: true)
|
rebuildPathIndex(vState)
|
||||||
for (vI, vR) in vState.records.enumerated() {
|
|
||||||
vState.pathIndex[vR.fullPath] = vI
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vState.dirty = true
|
vState.dirty = true
|
||||||
@@ -152,7 +158,13 @@ enum AllofitService {
|
|||||||
|
|
||||||
// periodic save loop (background thread). 3-second check interval so
|
// periodic save loop (background thread). 3-second check interval so
|
||||||
// new files appear in the GUI within a few seconds of being created.
|
// new files appear in the GUI within a few seconds of being created.
|
||||||
|
// Every N saves we also compact the in-memory containers so Swift's
|
||||||
|
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
|
||||||
|
// doesn't drift into multi-GB territory after a day of heavy file
|
||||||
|
// activity. RSS is logged each save so the trajectory is visible.
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let kCompactEvery = 20
|
||||||
|
var vSavesSinceCompact = 0
|
||||||
while true {
|
while true {
|
||||||
sleep(3)
|
sleep(3)
|
||||||
vState.lock.lock()
|
vState.lock.lock()
|
||||||
@@ -160,12 +172,17 @@ enum AllofitService {
|
|||||||
let vSnapshot = vState.records
|
let vSnapshot = vState.records
|
||||||
vState.dirty = false
|
vState.dirty = false
|
||||||
vState.lock.unlock()
|
vState.lock.unlock()
|
||||||
if vShouldSave {
|
if !vShouldSave { continue }
|
||||||
NSLog("[Allofit] autosaving %d records", vSnapshot.count)
|
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
|
||||||
IndexStore.save(
|
vSnapshot.count, processFootprintMB())
|
||||||
inRecords: vSnapshot,
|
IndexStore.save(
|
||||||
inLastEventId: vWatcher.latestEventId
|
inRecords: vSnapshot,
|
||||||
)
|
inLastEventId: vWatcher.latestEventId
|
||||||
|
)
|
||||||
|
vSavesSinceCompact += 1
|
||||||
|
if vSavesSinceCompact >= kCompactEvery {
|
||||||
|
vSavesSinceCompact = 0
|
||||||
|
compactContainers(vState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,4 +191,61 @@ enum AllofitService {
|
|||||||
RunLoop.current.run()
|
RunLoop.current.run()
|
||||||
exit(0)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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() }
|
if model.isWorking {
|
||||||
}
|
ProgressView().controlSize(.small)
|
||||||
.disabled(prefs.serviceMode == .none || model.isWorking)
|
}
|
||||||
Spacer()
|
|
||||||
if model.isWorking {
|
|
||||||
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
|
||||||
.font(.caption)
|
}
|
||||||
.foregroundColor(.secondary)
|
.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"
|
// MARK: Action buttons
|
||||||
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed"
|
// ===========================
|
||||||
return "User agent: \(vUser) · Root daemon: \(vRoot)"
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user