mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Security - Elevated copies: root only reads the original; the copy is written by the user (sudo -u tee), so root never chowns / chmods a user-controlled path. Staging folder forced to 0700. - Service logs moved from fixed /tmp names to /Library/Logs/Allofit (root-owned) and ~/Library/Logs/Allofit; Diagnostics reveals instead of opening the log. - Index files are owner-only (0600; the root daemon's belongs to the installing user), set on the temp file before an atomic rename. - Root install passes the plist inline (base64, plutil -lint) instead of a user-writable temp file; the binary comes from Bundle.main. - Cache loader caps and checks the declared payload size; each save uses its own temp file. - Release action pinned to a commit; non-system LC_RPATHs stripped. Correctness - Move to Trash removes the files from the index (the watcher ignores the app's own operations) and registers Undo (Put Back); trashed folders are matched with the original URLs; failures are shown. - The saved event id stays below pending subtree walks (GUI and service). - Roots / exclusions changes restart the watcher from the snapshot's id. - Case-only renames no longer leave a ghost entry. - Service mode is saved only after a successful install; a saved but missing service falls back to the in-process indexer. Performance - New folders are merged without the O(n) removal pass. - Size / date sorts use a compact key array (539 -> 47 ms for 630k). - Selection, preview and actions use the selected records directly. - Service saves at most every 15 s; reader reloads pause while hidden and are deferred instead of dropped; window close saves only when dirty. Usability - Results appear during the first index; empty-list explanations. - Down arrow moves to the results, Up on the first row back; history on Up / Option-Up / Option-Down. - Search syntax popover and Help menu; shortcuts shown in the context menu; confirmations for Clear Cache and Uninstall; privacy usage strings; Group Containers excluded by default; wording, VoiceOver labels, plural.
358 lines
14 KiB
Swift
358 lines
14 KiB
Swift
import Foundation
|
|
|
|
// ServiceInstaller writes a launchd plist for the background indexer.
|
|
// Two scopes are supported:
|
|
// * userAgent -> ~/Library/LaunchAgents (no admin needed)
|
|
// * rootDaemon -> /Library/LaunchDaemons (requires administrator password)
|
|
// In root mode the daemon can scan every readable file on the disk; the user
|
|
// still needs to grant Full Disk Access in System Settings the first time.
|
|
enum ServiceInstaller {
|
|
|
|
// launchd job label shared by both scopes (mirrors the .app bundle id
|
|
// with a .service suffix so the two are easy to correlate in `launchctl`)
|
|
static let kLabel = "com.bitsycore.allofit.service"
|
|
|
|
// installation scope
|
|
enum Scope {
|
|
case userAgent
|
|
case rootDaemon
|
|
}
|
|
|
|
// errors surfaced to the ui
|
|
enum InstallError: Error, LocalizedError {
|
|
case binaryNotFound
|
|
case authorizationFailed(String)
|
|
case launchctlFailed(String)
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .binaryNotFound: return "Could not resolve the Allofit binary path."
|
|
case .authorizationFailed(let vMsg): return "Authorization failed: \(vMsg)"
|
|
case .launchctlFailed(let vMsg): return "launchctl failed: \(vMsg)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try? FileManager.default.createDirectory(
|
|
atPath: (logPath(inScope: .userAgent) as NSString).deletingLastPathComponent,
|
|
withIntermediateDirectories: true
|
|
)
|
|
try vPlistData.write(to: vTarget, options: .atomic)
|
|
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
|
|
let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path])
|
|
if vResult.exitCode != 0 {
|
|
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
|
|
}
|
|
|
|
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
|
|
// The plist travels inside the script as base64 (only
|
|
// [A-Za-z0-9+/=], safe in single quotes) instead of a temp
|
|
// file a user process could swap before root copies it in.
|
|
let vPlistBase64 = vPlistData.base64EncodedString()
|
|
let vTarget = rootDaemonPath()
|
|
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
|
|
let vLogDir = (logPath(inScope: .rootDaemon) as NSString).deletingLastPathComponent
|
|
let vScript = """
|
|
mkdir -p \(AdminShell.quote(vDaemonDir)) \
|
|
&& rm -f \(AdminShell.quote(vDaemonBinary)) \
|
|
&& cp \(AdminShell.quote(vBinary)) \(AdminShell.quote(vDaemonBinary)) \
|
|
&& chown root:wheel \(AdminShell.quote(vDaemonBinary)) \
|
|
&& chmod 755 \(AdminShell.quote(vDaemonBinary)) \
|
|
&& printf '%s' \(AdminShell.quote(vVersionString)) > \(AdminShell.quote(vVersionFile)) \
|
|
&& chmod 644 \(AdminShell.quote(vVersionFile)) \
|
|
&& mkdir -p \(AdminShell.quote(vLogDir)) \
|
|
&& chown root:wheel \(AdminShell.quote(vLogDir)) \
|
|
&& chmod 755 \(AdminShell.quote(vLogDir)) \
|
|
&& printf '%s' \(AdminShell.quote(vPlistBase64)) | /usr/bin/base64 -D > \(AdminShell.quote(vTarget.path)) \
|
|
&& /usr/bin/plutil -lint -s \(AdminShell.quote(vTarget.path)) \
|
|
&& chown root:wheel \(AdminShell.quote(vTarget.path)) \
|
|
&& chmod 644 \(AdminShell.quote(vTarget.path)) \
|
|
; /bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
|
|
; /bin/launchctl bootstrap system \(AdminShell.quote(vTarget.path))
|
|
"""
|
|
try runWithAdminPrivileges(inScript: vScript)
|
|
}
|
|
}
|
|
|
|
// 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 \(AdminShell.quote(vTarget.path)) 2>/dev/null \
|
|
; rm -f \(AdminShell.quote(vTarget.path)) \(AdminShell.quote(vDaemonBinary)) \(AdminShell.quote(vVersionFile))
|
|
"""
|
|
try runWithAdminPrivileges(inScript: vScript)
|
|
}
|
|
}
|
|
|
|
// returns true when the plist file exists for the given scope
|
|
static func isInstalled(inScope: Scope) -> Bool {
|
|
switch inScope {
|
|
case .userAgent: return FileManager.default.fileExists(atPath: userAgentPath().path)
|
|
case .rootDaemon: return FileManager.default.fileExists(atPath: rootDaemonPath().path)
|
|
}
|
|
}
|
|
|
|
// 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 \(AdminShell.quote(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 \(AdminShell.quote(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
|
|
// rewrite it from its in-memory copy on the next autosave.
|
|
static func clearCacheAndRestart(inScope: Scope, inCacheURL: URL) throws {
|
|
switch inScope {
|
|
case .userAgent:
|
|
let vPlist = userAgentPath().path
|
|
_ = runLaunchctl(inArgs: ["unload", vPlist])
|
|
try? FileManager.default.removeItem(at: inCacheURL)
|
|
let vResult = runLaunchctl(inArgs: ["load", "-w", vPlist])
|
|
if vResult.exitCode != 0 {
|
|
throw InstallError.launchctlFailed(vResult.stderr)
|
|
}
|
|
case .rootDaemon:
|
|
let vPlist = rootDaemonPath().path
|
|
let vCache = inCacheURL.path
|
|
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist)) 2>/dev/null ; rm -f \(AdminShell.quote(vCache)) ; /bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
|
|
try runWithAdminPrivileges(inScript: vScript)
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Internals
|
|
// ===========================
|
|
|
|
// location of the per-user LaunchAgent plist
|
|
private static func userAgentPath() -> URL {
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/LaunchAgents/\(kLabel).plist")
|
|
}
|
|
|
|
// location of the system-wide LaunchDaemon plist
|
|
private static func rootDaemonPath() -> URL {
|
|
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"
|
|
}
|
|
}
|
|
|
|
// where the service's stdout / stderr go: a root-owned folder for the
|
|
// daemon, the user's own Logs folder for the agent
|
|
static func logPath(inScope: Scope) -> String {
|
|
switch inScope {
|
|
case .userAgent:
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/Logs/Allofit/service.log").path
|
|
case .rootDaemon:
|
|
return "/Library/Logs/Allofit/service.log"
|
|
}
|
|
}
|
|
|
|
// resolves an absolute path to the currently running binary
|
|
private static func resolveBinaryPath() throws -> String {
|
|
// the loaded executable, not argv[0] (which a caller can set to
|
|
// anything, including a relative path)
|
|
if let vUrl = Bundle.main.executableURL?.resolvingSymlinksInPath(),
|
|
vUrl.path.hasPrefix("/"),
|
|
FileManager.default.fileExists(atPath: vUrl.path) {
|
|
return vUrl.path
|
|
}
|
|
let vArg0 = CommandLine.arguments[0]
|
|
let vAbs = (vArg0 as NSString).standardizingPath
|
|
if vAbs.hasPrefix("/") && FileManager.default.fileExists(atPath: vAbs) {
|
|
return vAbs
|
|
}
|
|
throw InstallError.binaryNotFound
|
|
}
|
|
|
|
// builds the launchd plist dictionary serialized as XML data
|
|
private static func makePlistData(inBinary: String, inScope: Scope) throws -> Data {
|
|
var vDict: [String: Any] = [
|
|
"Label": kLabel,
|
|
"ProgramArguments": [inBinary, "--service"],
|
|
"RunAtLoad": true,
|
|
"KeepAlive": true,
|
|
// never /tmp: a fixed name there can be pre-created as a symlink
|
|
// by any local user, and launchd opens it as root for the daemon
|
|
"StandardOutPath": logPath(inScope: inScope),
|
|
"StandardErrorPath": logPath(inScope: inScope),
|
|
"ThrottleInterval": 10
|
|
]
|
|
if inScope == .rootDaemon {
|
|
vDict["UserName"] = "root"
|
|
// root daemon writes the cache to a system-wide location so any
|
|
// user can read it (rw for root, r for everyone else). The owner
|
|
// env vars let the daemon's Preferences.init read the GUI user's
|
|
// plist directly - otherwise it would see /var/root's empty
|
|
// defaults and only index a couple hundred system files.
|
|
vDict["EnvironmentVariables"] = [
|
|
"ALLOFIT_SYSTEM_INDEX": "1",
|
|
"ALLOFIT_OWNER_USER": NSUserName(),
|
|
"ALLOFIT_OWNER_HOME": FileManager.default.homeDirectoryForCurrentUser.path
|
|
]
|
|
}
|
|
return try PropertyListSerialization.data(
|
|
fromPropertyList: vDict,
|
|
format: .xml,
|
|
options: 0
|
|
)
|
|
}
|
|
|
|
// invokes /bin/launchctl with the given arguments and captures output
|
|
private static func runLaunchctl(inArgs: [String]) -> (exitCode: Int32, stdout: String, stderr: String) {
|
|
let vProcess = Process()
|
|
vProcess.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
|
vProcess.arguments = inArgs
|
|
let vOut = Pipe()
|
|
let vErr = Pipe()
|
|
vProcess.standardOutput = vOut
|
|
vProcess.standardError = vErr
|
|
do {
|
|
try vProcess.run()
|
|
vProcess.waitUntilExit()
|
|
} catch {
|
|
return (-1, "", "\(error)")
|
|
}
|
|
let vOutStr = String(data: vOut.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
let vErrStr = String(data: vErr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
return (vProcess.terminationStatus, vOutStr, vErrStr)
|
|
}
|
|
|
|
// runs a shell script with administrator privileges. Bridges
|
|
// AdminShell.Error into ServiceInstaller.InstallError so the calling
|
|
// SettingsView ui gets a single error type to surface.
|
|
private static func runWithAdminPrivileges(inScript: String) throws {
|
|
do {
|
|
_ = try AdminShell.run(inScript)
|
|
} catch let vErr as AdminShell.Error {
|
|
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
|
|
}
|
|
}
|
|
}
|