Files
Bitsy 876122ff98 Audit fixes: security hardening, index correctness, performance, UX
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.
2026-10-02 14:44:49 +02:00

268 lines
12 KiB
Swift

import Foundation
import SwiftUI
// Preferences wraps the user-configurable settings persisted to UserDefaults.
//
// Cross-process subtlety: when this code runs inside the root LaunchDaemon
// (uid 0) the standard UserDefaults points at /var/root/Library/Preferences,
// which is a different namespace from the GUI user's defaults. To make the
// daemon see the GUI's roots/exclusions, ServiceInstaller stashes the
// installing user's home directory in the ALLOFIT_OWNER_HOME env var on
// the launchd plist. We detect that here and load the GUI user's plist file
// directly (root has plain filesystem read access to ~/Library/Preferences,
// no TCC involvement).
// @unchecked Sendable because: writes flow through SwiftUI bindings (main
// actor), and the daemon only reads after init - there is no concurrent
// mutation. UserDefaults itself is thread-safe.
final class Preferences: ObservableObject, @unchecked Sendable {
// shared singleton used by the GUI and the service runtime
static let shared = Preferences()
// the root directory paths to index
@Published var rootPaths: [String] {
didSet { UserDefaults.standard.set(rootPaths, forKey: Self.kRootPathsKey) }
}
// absolute paths to skip while indexing (descendants also skipped)
@Published var excludedPaths: [String] {
didSet { UserDefaults.standard.set(excludedPaths, forKey: Self.kExcludedPathsKey) }
}
// if true, also index external local volumes (USB, Thunderbolt, etc.)
@Published var includeMountedVolumes: Bool {
didSet { UserDefaults.standard.set(includeMountedVolumes, forKey: Self.kMountedKey) }
}
// if true, also index network volumes (SMB, AFP, NFS)
@Published var includeNetworkVolumes: Bool {
didSet { UserDefaults.standard.set(includeNetworkVolumes, forKey: Self.kNetworkKey) }
}
// background service installation mode
@Published var serviceMode: ServiceMode {
didSet { UserDefaults.standard.set(serviceMode.rawValue, forKey: Self.kServiceModeKey) }
}
// last sort descriptor used, restored on next launch
@Published var lastSort: FileSortDescriptor {
didSet { UserDefaults.standard.set(lastSort.rawValue, forKey: Self.kLastSortKey) }
}
// seconds file changes are collected before being applied to the index,
// while Allofit is focused / another app is focused / no window shows
@Published var updateDelayForeground: Double {
didSet { UserDefaults.standard.set(updateDelayForeground, forKey: Self.kUpdateDelayForegroundKey) }
}
@Published var updateDelayBackground: Double {
didSet { UserDefaults.standard.set(updateDelayBackground, forKey: Self.kUpdateDelayBackgroundKey) }
}
@Published var updateDelayHidden: Double {
didSet { UserDefaults.standard.set(updateDelayHidden, forKey: Self.kUpdateDelayHiddenKey) }
}
// minimum seconds between two result refreshes caused by index changes,
// while Allofit is focused / another app is focused
@Published var refreshIntervalForeground: Double {
didSet { UserDefaults.standard.set(refreshIntervalForeground, forKey: Self.kRefreshForegroundKey) }
}
@Published var refreshIntervalBackground: Double {
didSet { UserDefaults.standard.set(refreshIntervalBackground, forKey: Self.kRefreshBackgroundKey) }
}
// true to emphasize the matched parts of names and paths in the results
@Published var highlightMatches: Bool {
didSet { UserDefaults.standard.set(highlightMatches, forKey: Self.kHighlightMatchesKey) }
}
// what a double-click / Return on a result does
@Published var primaryAction: PrimaryAction {
didSet { UserDefaults.standard.set(primaryAction.rawValue, forKey: Self.kPrimaryActionKey) }
}
// true to show the Allofit icon in the menu bar
@Published var showMenuBarIcon: Bool {
didSet { UserDefaults.standard.set(showMenuBarIcon, forKey: Self.kShowMenuBarIconKey) }
}
// system-wide shortcut that shows / hides the main window
@Published var globalHotKey: HotKeyPreset {
didSet { UserDefaults.standard.set(globalHotKey.rawValue, forKey: Self.kGlobalHotKeyKey) }
}
// actions available for double-click / Return on a result
enum PrimaryAction: String, CaseIterable, Identifiable {
case open // open with the default app
case reveal // reveal in Finder
var id: String { rawValue }
}
// default values of the update / refresh timings, in seconds
static let kDefaultUpdateDelayForeground: Double = 2
static let kDefaultUpdateDelayBackground: Double = 15
static let kDefaultUpdateDelayHidden: Double = 60
static let kDefaultRefreshForeground: Double = 1
static let kDefaultRefreshBackground: Double = 5
// puts every update / refresh timing back to its default
func resetTimings() {
updateDelayForeground = Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.kDefaultRefreshBackground
}
// service installation modes
enum ServiceMode: String, CaseIterable, Identifiable {
case none // no service: GUI indexes in its own process
case userAgent // LaunchAgent runs as the current user
case rootDaemon // LaunchDaemon runs as root and can scan everything
var id: String { rawValue }
}
private static let kRootPathsKey = "Allofit.rootPaths"
private static let kExcludedPathsKey = "Allofit.excludedPaths"
private static let kMountedKey = "Allofit.includeMountedVolumes"
private static let kNetworkKey = "Allofit.includeNetworkVolumes"
private static let kServiceModeKey = "Allofit.serviceMode"
private static let kLastSortKey = "Allofit.lastSort"
private static let kHighlightMatchesKey = "Allofit.highlightMatches"
private static let kShowMenuBarIconKey = "Allofit.showMenuBarIcon"
private static let kGlobalHotKeyKey = "Allofit.globalHotKey"
private static let kPrimaryActionKey = "Allofit.primaryAction"
private static let kUpdateDelayForegroundKey = "Allofit.updateDelayForeground"
private static let kUpdateDelayBackgroundKey = "Allofit.updateDelayBackground"
private static let kUpdateDelayHiddenKey = "Allofit.updateDelayHidden"
private static let kRefreshForegroundKey = "Allofit.refreshIntervalForeground"
private static let kRefreshBackgroundKey = "Allofit.refreshIntervalBackground"
// flushes pending UserDefaults writes to disk so the daemon (which reads
// the plist file directly) picks up the latest settings on next start
static func flushToDisk() {
UserDefaults.standard.synchronize()
}
// loads every setting, from the owner's plist when running as the root daemon
private init() {
// When we are the root daemon, the standard UserDefaults points at
// /var/root/Library/Preferences/... which is *not* where the GUI user
// stored their settings. Read the owner's plist file directly.
let vSourceDict: [String: Any]? = Self.loadOwnerPlistIfDaemon()
rootPaths = Self.readArray(forKey: Self.kRootPathsKey, from: vSourceDict)
?? Self.defaultRootPaths()
excludedPaths = Self.readArray(forKey: Self.kExcludedPathsKey, from: vSourceDict)
?? Self.defaultExcludedPaths()
includeMountedVolumes = Self.readBool(forKey: Self.kMountedKey, from: vSourceDict) ?? false
includeNetworkVolumes = Self.readBool(forKey: Self.kNetworkKey, from: vSourceDict) ?? false
if let vRaw = Self.readString(forKey: Self.kServiceModeKey, from: vSourceDict),
let vMode = ServiceMode(rawValue: vRaw) {
serviceMode = vMode
} else {
serviceMode = .none
}
if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict),
let vSort = FileSortDescriptor(rawValue: vRaw) {
lastSort = vSort
} else {
lastSort = .nameAscending
}
highlightMatches = Self.readBool(forKey: Self.kHighlightMatchesKey, from: vSourceDict) ?? true
showMenuBarIcon = Self.readBool(forKey: Self.kShowMenuBarIconKey, from: vSourceDict) ?? true
globalHotKey = Self.readString(forKey: Self.kGlobalHotKeyKey, from: vSourceDict)
.flatMap(HotKeyPreset.init(rawValue:)) ?? .optionSpace
primaryAction = Self.readString(forKey: Self.kPrimaryActionKey, from: vSourceDict)
.flatMap(PrimaryAction.init(rawValue:)) ?? .open
updateDelayForeground = Self.readDouble(forKey: Self.kUpdateDelayForegroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.readDouble(forKey: Self.kUpdateDelayBackgroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.readDouble(forKey: Self.kUpdateDelayHiddenKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.readDouble(forKey: Self.kRefreshForegroundKey, from: vSourceDict)
?? Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.readDouble(forKey: Self.kRefreshBackgroundKey, from: vSourceDict)
?? Self.kDefaultRefreshBackground
}
// ===========================
// MARK: Source-of-truth helpers
// ===========================
// returns the owning user's preferences plist contents when running inside
// the root daemon. nil otherwise, in which case callers fall through to
// UserDefaults.standard.
private static func loadOwnerPlistIfDaemon() -> [String: Any]? {
let vEnv = ProcessInfo.processInfo.environment
guard vEnv["ALLOFIT_SYSTEM_INDEX"] == "1",
let vOwnerHome = vEnv["ALLOFIT_OWNER_HOME"],
!vOwnerHome.isEmpty
else { return nil }
let vPath = "\(vOwnerHome)/Library/Preferences/com.bitsycore.allofit.plist"
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: vPath)),
let vDict = (try? PropertyListSerialization.propertyList(
from: vData,
format: nil
)) as? [String: Any]
else {
NSLog("[Allofit] root daemon could not read owner prefs at %@; using defaults", vPath)
return [:]
}
return vDict
}
// reads an array from either the supplied plist dict or UserDefaults
private static func readArray(forKey inKey: String, from inDict: [String: Any]?) -> [String]? {
if let vDict = inDict { return vDict[inKey] as? [String] }
return UserDefaults.standard.array(forKey: inKey) as? [String]
}
// reads a bool from either the supplied plist dict or UserDefaults
private static func readBool(forKey inKey: String, from inDict: [String: Any]?) -> Bool? {
if let vDict = inDict { return vDict[inKey] as? Bool }
return UserDefaults.standard.object(forKey: inKey) as? Bool
}
// reads a number from either the supplied plist dict or UserDefaults
private static func readDouble(forKey inKey: String, from inDict: [String: Any]?) -> Double? {
if let vDict = inDict { return (vDict[inKey] as? NSNumber)?.doubleValue }
return (UserDefaults.standard.object(forKey: inKey) as? NSNumber)?.doubleValue
}
// reads a string from either the supplied plist dict or UserDefaults
private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? {
if let vDict = inDict { return vDict[inKey] as? String }
return UserDefaults.standard.string(forKey: inKey)
}
// ===========================
// MARK: Default sets
// ===========================
// defaults differ for the root daemon: a wide "/" root with system-path
// exclusions makes the "scan everything" semantics meaningful even when
// the owner's plist couldn't be read.
private static func defaultRootPaths() -> [String] {
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
return ["/"]
}
return [FileManager.default.homeDirectoryForCurrentUser.path]
}
// default exclusions: system noise for the daemon, caches and trash for a user
private static func defaultExcludedPaths() -> [String] {
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
return [
"/System",
"/private/var/folders",
"/private/var/db",
"/Library/Caches",
"/.fseventsd",
"/.Spotlight-V100",
"/.DocumentRevisions-V100",
"/.TemporaryItems",
"/.Trashes"
]
}
return [
"~/Library/Caches",
"~/Library/Containers",
// other apps' shared data: reading it triggers macOS 15's
// "access data from other apps" prompt
"~/Library/Group Containers",
"~/.Trash",
"/private/var/folders"
].map { ($0 as NSString).expandingTildeInPath }
}
}