6 Commits
Author SHA1 Message Date
Bitsy 3e67da4634 Optimize memory management: add autorelease pools to reduce memory accumulation during file scanning and indexing 2026-06-16 21:15:48 +02:00
Bitsy bac2ca0287 Cleanup and remove make placeholder icon 2026-06-16 21:00:39 +02:00
Bitsy 9292c3ee29 Enhance service management: add start/stop functionality, improve daemon binary handling, and optimize memory usage 2026-06-16 14:41:58 +02:00
Bitsy cc386bef67 Add inline preview pane, spacebar Quick Look, sudo-elevated access
Inline preview pane

  PreviewPane wraps Quartz's QLPreviewView via NSViewRepresentable so
  the same renderer that powers the floating QLPreviewPanel also
  renders inline. ContentView layout switched to HSplitView (table
  on the left, preview pane on the right) with a metadata footer
  under the preview showing name / parent path / size / mtime.

  Toolbar gains a sidebar.right toggle to hide/show the pane; state
  persists across launches via @AppStorage("Allofit.showPreviewPane").
  Default window size bumped to 1100x640 so both panes fit on first
  launch.

Spacebar -> Quick Look

  .onKeyPress(.space) attached to the Table opens the floating
  QLPreviewPanel for the current selection. .onKeyPress is focus-
  scoped, so typing a literal space into the search field continues
  to work.

Elevated access for root-only files

  When the index includes files the GUI user can't read (root daemon
  with FDA indexed another user's home, /var/db, etc.), the preview
  pane becomes a single big tap-target showing a lock icon + "Click
  to authorize preview", and a small AuthorizeBadge appears at the
  right of the selected row when the pane is closed.

  Clicking either runs ElevatedAccess.stage() which sudo cp's the
  file into ~/Library/Caches/Allofit/elevated/, chowns it to the
  current user and chmod 0644. AdminShell wraps the
  NSAppleScript-based admin invocation that we previously had only
  inlined in ServiceInstaller; the system caches the password
  prompt for ~5 minutes so successive authorizations are silent.
  AccessManager holds the staged-URL mapping as a @MainActor
  ObservableObject shared between ContentView and PreviewPane, so
  the badge disappears and the preview switches to the staged copy
  as soon as the cp lands. Quick Look and Open now route through
  AccessManager.effectiveURL(for:) so the user-readable staged copy
  is used when available; Reveal in Finder and Copy Path keep using
  the original path. ElevatedAccess.cleanup() wipes the staging dir
  on app launch and on applicationWillTerminate so privileged
  copies don't accumulate across sessions.

Other tidies

  * Formatters.swift consolidates byte-count + date helpers used by
    both Table columns and the preview pane footer.
  * StatusBarView extracted from ContentView so @Published refreshes
    update only the leaf view rather than the Table closure scope.
  * ServiceInstaller's runWithAdminPrivileges and shellQuote now
    delegate to AdminShell so admin escalation has one definition.
2026-06-15 17:02:53 +02:00
Bitsy c8ba0a735c Fix dropped left-clicks via row-level draggable + UI cleanup
The Table's row left-click was racing with SwiftUI's `.draggable`
gesture recognizer on macOS 26 - the gesture watching mouseDown to
decide "is this a drag?" sometimes ate the click even when no drag
followed. Symptom was: right-click selects (bypasses the gesture
through contextMenu(forSelectionType:)), drag works (it's literally
what the gesture handles), but plain left-click drops the row
selection entirely.

Switched ContentView's Table from the implicit-rows form to the
explicit `rows: { ForEach { TableRow(...).draggable(...) } }` form.
Row-level draggable lives alongside NSTableView's row drag machinery
rather than embedded in cell hit-testing, so the click→selection
event chain is no longer interrupted.

Defensive cleanups in the same pass:

  * Extracted the bottom status bar into its own StatusBarView. Its
    @Published refreshes (indexedCount during scans, isIndexing,
    isWorking, etc.) now invalidate only the leaf view rather than
    re-evaluating the ContentView body that contains the Table.

  * Removed the dead `import UniformTypeIdentifiers` from ContentView.

  * Reader-mode FSEvents watcher in AppModel now filters with
    `$0.path == vTarget` exactly, instead of also catching events on
    the parent dir - the latter fired main.async hops for sibling
    files (indexer.lock, .tmp rename leftovers, .DS_Store) that had
    nothing to do with the cache.

  * Cache poll moved to a lock-guarded background BackgroundMtime
    helper so the change detection happens off-main; the main hop
    only fires when the on-disk mtime actually advanced.

  * Column-customization persistence: the JSONEncoder.encode used
    to run synchronously on main inside .onChange, freezing column
    drag (SwiftUI fires onChange on every micro-update). Save is now
    a debounced Task.detached that runs 300 ms after the user lets
    go of the column header.

  * clean.sh detects root-owned /tmp/allofit-service.{log,err} (left
    behind by a previous root daemon) and falls through to `sudo rm`
    instead of failing with "Permission denied". The /tmp sticky bit
    only lets owners delete, so user-mode rm can't touch root logs.

  * Added *.dmg to .gitignore so locally-built distribution
    artifacts don't show up in git status.
2026-06-15 16:18:34 +02:00
Bitsy 1a1235e215 UI + reload-pipeline polish: --version flags, sort binding, click drops
Search bar:
  Search field lives in the body's top row with `.background(.bar)` so
  it always reads as a continuation of the title bar (Liquid Glass on
  macOS 26, vibrant material on macOS 15) and can never be swallowed
  by toolbar overflow. The Settings cogwheel keeps its toolbar slot
  at .primaryAction.

Sort persistence across window close/reopen:
  Replaced the @State sortOrder + onChange dance with a computed
  Binding<[KeyPathComparator]> that reads/writes model.sortDescriptor
  directly. The Table's sortOrder binding now never goes out of sync
  with the persisted model state, and column-header clicks keep
  working after the window is closed and reopened on the still-alive
  process.

Click drops in reader mode:
  Previously the DispatchSource cache-poll posted a main.async every
  2 s and checked mtime on the main side, so the main runloop kept
  picking up an empty task at a fixed rate that could occasionally
  interrupt NSTableView click handling. New BackgroundMtime helper
  (lock-guarded mtime cell) does the change detection off-main; the
  main hop now only happens when the cache actually changed on disk.
  In the steady state the main runloop sees zero recurring work.

makeRecord cache invalidation:
  FileIndexer.makeRecord clears the URL's cached resource values
  before reading so file edits between two FSEvents batches surface
  the new mtime instead of the previous one.

CLI ergonomics:
  build-app.sh and build-dmg.sh both accept --version / -v (and
  build-app.sh accepts --build / -b). The env vars still work as a
  fallback, so the CI release path is unchanged.
2026-06-15 15:42:55 +02:00
19 changed files with 1431 additions and 429 deletions
+2 -1
View File
@@ -10,8 +10,9 @@ xcuserdata/
DerivedData/
*.xcuserstate
# Build output produced by ./build-app.sh
# Build output produced by ./build-app.sh and ./build-dmg.sh
Allofit.app/
*.dmg
# Service log files (also live in /tmp but local copies happen during dev)
allofit-service.log
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import SwiftUI
// AccessManager holds the in-memory mapping from FileRecord.ID to the
// staged user-readable copy produced by an "Authorize" tap. Both the
// Table (which shows a lock badge on the selected row when the preview
// pane is closed) and the PreviewPane (which gates the QLPreviewView
// behind the same badge) observe this so the badge disappears and the
// preview switches to the staged URL as soon as the sudo copy lands.
//
// All published mutations happen on the main actor; the actual sudo cp
// runs inside a Task.detached spawned by `authorize(_:)` so the AppleScript
// password prompt doesn't block the main runloop.
@MainActor
final class AccessManager: ObservableObject {
// id -> URL of the user-readable copy in ~/Library/Caches/Allofit/elevated
@Published private(set) var stagedURLs: [FileRecord.ID: URL] = [:]
// ids currently being authorized (admin script in flight); used to
// render a small spinner inside the lock badge
@Published private(set) var authorizingIds: Set<FileRecord.ID> = []
// most recent authorization error (cancelled prompt, sudo failure,
// etc); surfaced as the badge's accessibility / tooltip text
@Published private(set) var lastError: String?
// returns the URL to use when previewing / opening the file: the
// staged copy if we have one, otherwise the original path
func effectiveURL(for inRecord: FileRecord) -> URL {
if let vStaged = stagedURLs[inRecord.id] {
return vStaged
}
return URL(fileURLWithPath: inRecord.fullPath)
}
// true if the effective URL (staged or original) isn't readable by
// the current user - this is what drives the lock-badge visibility
func needsAuthorization(for inRecord: FileRecord) -> Bool {
let vUrl = effectiveURL(for: inRecord)
return !ElevatedAccess.canRead(path: vUrl.path)
}
// true while an authorize task is in flight for the given record id
func isAuthorizing(_ inId: FileRecord.ID) -> Bool {
return authorizingIds.contains(inId)
}
// runs the sudo cp + chown flow for one record. The first call inside
// the system's admin-auth-cache window (~5 min) prompts for the
// password; subsequent calls within that window are silent.
func authorize(_ inRecord: FileRecord) async {
let vId = inRecord.id
// idempotency: a double-tap on the badge shouldn't kick off two
// concurrent admin scripts for the same file
guard !authorizingIds.contains(vId) else { return }
authorizingIds.insert(vId)
lastError = nil
let vOriginalUrl = URL(fileURLWithPath: inRecord.fullPath)
do {
// Task.detached so the synchronous NSAppleScript admin prompt
// runs on a background thread; the prompt itself is shown on
// main by AppKit regardless of where we invoke it from
let vStaged = try await Task.detached(priority: .userInitiated) {
try ElevatedAccess.stage(vOriginalUrl)
}.value
stagedURLs[vId] = vStaged
} catch {
lastError = error.localizedDescription
}
authorizingIds.remove(vId)
}
// wipes the in-memory mapping. Called after ElevatedAccess.cleanup()
// removes the on-disk files so the two stay consistent.
func reset() {
stagedURLs.removeAll()
authorizingIds.removeAll()
lastError = nil
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
import AppKit
// AdminShell runs short shell commands with administrator privileges by
// wrapping them in `do shell script ... with administrator privileges`
// via NSAppleScript. The system shows its native password prompt the
// first time within a session; subsequent calls inside the auth-cache
// window (about 5 minutes) re-use the credential without re-prompting.
//
// Used by both ServiceInstaller (LaunchDaemon install/uninstall and
// cache-clear-and-restart) and ElevatedAccess (sudo cp of a single
// unreadable file into the per-user staging cache).
enum AdminShell {
// surfaces an AppleScript failure - typically the user clicked
// Cancel on the password prompt, or the embedded shell command
// returned a non-zero exit code
enum Error: Swift.Error, LocalizedError {
case scriptFailed(String)
var errorDescription: String? {
switch self {
case .scriptFailed(let vMsg): return vMsg
}
}
}
// runs inScript as root via NSAppleScript. Returns the script's
// stdout. Throws Error.scriptFailed if NSAppleScript reports an
// error (cancelled prompt, non-zero shell exit, etc).
@discardableResult
static func run(_ inScript: String) throws -> String {
// AppleScript string literal needs backslashes and double quotes
// escaped before we embed the shell command
let vEscaped = inScript
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let vSource = "do shell script \"\(vEscaped)\" with administrator privileges"
let vAppleScript = NSAppleScript(source: vSource)
var vErr: NSDictionary?
let vResult = vAppleScript?.executeAndReturnError(&vErr)
guard let vDescriptor = vResult else {
let vMessage = vErr?[NSAppleScript.errorMessage] as? String
?? "Authorization cancelled or failed"
throw Error.scriptFailed(vMessage)
}
return vDescriptor.stringValue ?? ""
}
// POSIX-style single-quote escape so a string can be safely embedded
// inside the inScript argument of run(_:). Each embedded single
// quote becomes the escape sequence '\''. Use for any user-supplied
// path or argument; literal command names should not be quoted.
static func quote(_ inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
+17
View File
@@ -10,16 +10,22 @@ struct AllofitApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
// shared application state injected into the view tree
@StateObject private var model = AppModel()
// session-scoped store of sudo-staged user-readable copies. Sits
// alongside AppModel so both the Table (lock badge on rows) and
// the PreviewPane observe the same authorization state.
@StateObject private var access = AccessManager()
var body: some Scene {
WindowGroup("Allofit") {
ContentView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.environmentObject(access)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
}
.windowToolbarStyle(.unified)
.defaultSize(width: 1100, height: 640)
.commands {
// custom About panel with a clickable repo link in the credits
CommandGroup(replacing: .appInfo) {
@@ -46,6 +52,7 @@ struct AllofitApp: App {
SettingsView()
.environmentObject(model)
.environmentObject(Preferences.shared)
.environmentObject(access)
}
}
}
@@ -126,6 +133,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// wipe any elevated-access staging files left over from a previous
// run so a crash or hard-kill doesn't accumulate privileged copies
// in ~/Library/Caches across sessions
ElevatedAccess.cleanup()
// bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey {
@@ -136,6 +147,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// called on clean Cmd+Q quit; wipes the elevated-access staging dir
// so the user-readable copies of privileged files don't linger
func applicationWillTerminate(_ notification: Notification) {
ElevatedAccess.cleanup()
}
// keep the process alive when the user closes the last window: the index
// stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item.
+120 -18
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,10 +55,19 @@ 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 {
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 }
@@ -69,8 +79,10 @@ enum AllofitService {
vState.dirty = true
vState.lock.unlock()
}
}
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
}
}
// force one save right after the scan finishes, so the GUI sees a
// stable count even if no FSEvents come in for a while afterwards
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
@@ -91,14 +103,27 @@ 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 }
autoreleasepool {
if vMatcher.isExcluded(inPath: vChange.path) { return }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
continue
return
}
// skip paths we've already queued for removal in this batch
if vRemoved.contains(vChange.path) { return }
let vUrl = URL(fileURLWithPath: vChange.path)
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
@@ -110,18 +135,21 @@ enum AllofitService {
vState.records.append(vRec)
vAdded += 1
}
} else if let vIdx = vState.pathIndex[vChange.path] {
vState.records.remove(at: vIdx)
vState.pathIndex.removeAll(keepingCapacity: true)
for (vI, vR) in vState.records.enumerated() {
vState.pathIndex[vR.fullPath] = vI
}
vRemovedCount += 1
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
}
}
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)
autoreleasepool {
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)
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
}
}
+95 -20
View File
@@ -2,6 +2,27 @@ import Foundation
import SwiftUI
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
// Lets the DispatchSource timer compare a new stat() result against the
// previously seen value without touching any @MainActor state, so the
// poll only hops to main when the cache has actually changed on disk.
private final class BackgroundMtime: @unchecked Sendable {
private var mtime: Date?
private let lock = NSLock()
// returns true if the supplied mtime differs from the stored value
// (and updates the stored value), false otherwise
func updateIfChanged(_ inMtime: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
if mtime != inMtime {
mtime = inMtime
return true
}
return false
}
}
// AppModel is the central observable state for the application.
// It owns the in-memory file index, drives the background indexer and the
// FSEvents watcher, debounces the search query and exposes a filtered/sorted
@@ -80,8 +101,11 @@ final class AppModel: ObservableObject {
// last user-facing status string for the Settings buttons
@Published private(set) var workMessage: String = ""
// polling backup for reader-mode cache changes; FSEvents alone can miss
// updates if the watched directory didn't exist when the stream started
private var cachePollTimer: Timer?
// updates if the watched directory didn't exist when the stream started.
// Uses DispatchSourceTimer on a background queue (not Timer on the main
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// last mtime we observed on the cache file - skips needless reloads
private var lastSeenCacheMtime: Date?
// minimum gap between two reader-mode reloads. Prevents the Table from
@@ -124,8 +148,8 @@ final class AppModel: ObservableObject {
cacheWatcher.stop()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollTimer?.invalidate()
cachePollTimer = nil
cachePollSource?.cancel()
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
filterTask?.cancel()
@@ -217,6 +241,10 @@ final class AppModel: ObservableObject {
var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block
autoreleasepool {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
@@ -225,6 +253,7 @@ final class AppModel: ObservableObject {
}
vAccumulated.append(contentsOf: vList)
}
}
let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
// persist off-main before bouncing back so main never sees the
@@ -285,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 {
@@ -448,12 +499,17 @@ final class AppModel: ObservableObject {
return true
}
for vPath in inPaths {
// per-subtree autoreleasepool keeps the rescan's
// autoreleased URL/stat objects from accumulating across
// subtrees inside this long-running async block
autoreleasepool {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
}
let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
DispatchQueue.main.async {
@@ -482,29 +538,41 @@ final class AppModel: ObservableObject {
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
// FSEvents-based watcher for low-latency updates
// FSEvents-based watcher for low-latency updates. We watch the
// parent dir (FSEvents needs a real path) but only fire the reload
// when the cache file itself changed - other files in the dir
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
// trigger main.async hops, which contributed to dropped clicks.
cacheWatcher.start(inRoots: [vDir]) { vChanges in
if vChanges.contains(where: { $0.path == vTarget || $0.path == vDir }) {
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
guard vCacheChanged else { return }
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
DispatchQueue.main.async { [weak self] in
self?.reloadFromCache()
}
}
// Background-queue polling backup at 2s. The stat() runs off main,
// and a lock-guarded background-side mtime tracker means we ONLY
// hop to main when the cache actually changed - the steady state
// puts zero work on the main runloop, leaving NSTableView's click
// handling uninterrupted.
cachePollSource?.cancel()
let vPolledPath = vUrl.path
let vBgMtime = BackgroundMtime() // captured by the closure
let vSource = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
vSource.schedule(deadline: .now() + 2.0, repeating: 2.0)
vSource.setEventHandler { [weak self] in
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vPolledPath),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
// Background-side change detection; only proceeds when mtime moved
guard vBgMtime.updateIfChanged(vMtime) else { return }
DispatchQueue.main.async {
self?.reloadFromCache()
}
// Polling backup at 2s intervals. FSEvents can silently no-op when
// the watched dir didn't exist at stream creation, or under sandbox
// restrictions. The stat() is cheap; reload only fires on mtime change.
// Register on .common runloop mode so the timer keeps firing even
// when SwiftUI is mid-update or the user is interacting with the
// Table - the previous .default mode paused during those windows.
cachePollTimer?.invalidate()
let vTimer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
// Timer closures aren't statically @MainActor; the runtime
// invokes them on whatever runloop hosts the timer (here, main)
Task { @MainActor in self?.pollCacheForChanges() }
}
RunLoop.main.add(vTimer, forMode: .common)
cachePollTimer = vTimer
vSource.resume()
cachePollSource = vSource
}
// stat()'s the cache file and triggers a reload when mtime changes
@@ -626,7 +694,14 @@ final class AppModel: ObservableObject {
// on the next runloop tick, avoiding NSTableView reentrance when
// the search field is mid-edit
DispatchQueue.main.async {
self?.visibleRecords = vCapped
guard let vSelf = self else { return }
// Skip the @Published fire when the resulting list is byte-
// for-byte identical to what the Table is already showing.
// Full FileRecord equality catches mtime / size updates, so
// we only skip true no-op reassignments. Clicks landing on
// the Table during a no-op reload no longer get dropped.
if vSelf.visibleRecords == vCapped { return }
vSelf.visibleRecords = vCapped
}
}
}
+237 -127
View File
@@ -1,47 +1,133 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
// ContentView is the main window layout: search bar at the top, a results
// table that mirrors the look of Everything, and a status bar at the bottom.
// ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table on the left, a Quick Look preview pane on
// the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
@State private var selection: Set<FileRecord.ID> = []
// SwiftUI Table sort order - clicking a column header updates this
@State private var sortOrder: [KeyPathComparator<FileRecord>] = [
KeyPathComparator(\FileRecord.name, order: .forward)
]
// drives the Table's drag-to-reorder and column-visibility customization.
// Initial value is hydrated from UserDefaults so the user's column order
// survives app launches; subsequent changes flow back via .onChange.
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization()
// debounced background save task for columnCustomization changes.
// Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>?
// whether the right-hand preview pane is currently visible. Persisted
// across launches so the user's pane-visibility preference sticks.
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
// Computed binding for the Table's sortOrder: reads/writes
// model.sortDescriptor directly so the sort state survives any number
// of window closes / reopens.
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
set: { vNewOrder in
guard let vFirst = vNewOrder.first else { return }
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
// defer one runloop tick so we don't write back into the
// model while NSTableView is still in its sort delegate
// callback (avoids the reentrant-operation AppKit warning)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
}
}
)
}
var body: some View {
VStack(spacing: 0) {
searchBar
Divider()
resultsTable
Divider()
statusBar
Group {
if showPreviewPane {
HSplitView {
mainColumn
.layoutPriority(1)
.frame(minWidth: 460)
PreviewPane(selection: selection)
.frame(minWidth: 200, idealWidth: 360)
}
} else {
mainColumn
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showPreviewPane.toggle()
} label: {
Image(systemName: showPreviewPane
? "sidebar.right"
: "sidebar.squares.right")
}
.help(showPreviewPane ? "Hide preview" : "Show preview")
}
ToolbarItem(placement: .primaryAction) {
SettingsLink {
Image(systemName: "gearshape")
}
.help("Preferences (⌘,)")
}
}
.onAppear {
// align Table sort order with the persisted sort descriptor
sortOrder = [Self.comparatorFor(inDescriptor: model.sortDescriptor)]
model.start()
}
.onDisappear {
model.saveCache()
}
.onChange(of: sortOrder) { _, vNew in
if let vFirst = vNew.first {
// defer to the next runloop tick so we don't write back into
// the model while NSTableView is still in its sort delegate
// callback - that triggers the "reentrant operation in its
// NSTableView delegate" warning from AppKit
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
.onChange(of: columnCustomization) { _, vNew in
// Debounced + off-main save. SwiftUI fires onChange on every
// micro-update during a column drag - encoding synchronously
// on main here would freeze the drag delegate. Cancel any
// pending task and reschedule so we encode at most once per
// drag (300 ms after the user lets go).
columnSaveTask?.cancel()
columnSaveTask = Task.detached(priority: .utility) {
try? await Task.sleep(nanoseconds: Self.kColumnSaveDebounceNanos)
if Task.isCancelled { return }
ContentView.saveColumnCustomization(vNew)
}
}
}
// search bar + table + status bar - everything except the preview pane
private var mainColumn: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
}
}
// ===========================
// MARK: Column customization persistence
// ===========================
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode(
TableColumnCustomization<FileRecord>.self,
from: vData
)
else {
return TableColumnCustomization<FileRecord>()
}
return vCustom
}
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
}
// ===========================
@@ -49,21 +135,15 @@ struct ContentView: View {
// ===========================
private var searchBar: some View {
HStack(spacing: 8) {
SearchField(
text: $model.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
.frame(minHeight: 28)
SettingsLink {
Image(systemName: "gearshape")
}
.help("Preferences (⌘,)")
}
.frame(minHeight: 24)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
// ===========================
@@ -71,7 +151,15 @@ struct ContentView: View {
// ===========================
private var resultsTable: some View {
Table(model.visibleRecords, selection: $selection, sortOrder: $sortOrder) {
// Uses the explicit `rows:` form of Table so `.draggable` lives on
// TableRow rather than embedded in cell content. Cell-content
// draggable installs a SwiftUI drag-gesture recognizer that races
// with NSTableView's mouseDown→selection event on macOS 26 and
// occasionally eats left-clicks; row-level draggable doesn't.
Table(of: FileRecord.self,
selection: $selection,
sortOrder: sortOrderBinding,
columnCustomization: $columnCustomization) {
TableColumn("Name", value: \FileRecord.name) { vRecord in
HStack(spacing: 6) {
Image(nsImage: IconCache.icon(
@@ -82,10 +170,23 @@ struct ContentView: View {
.frame(width: 16, height: 16)
Text(vRecord.name)
.lineLimit(1)
// When the preview pane is closed, surface the
// elevate-permission affordance on the selected row
// itself so the user has a way to authorize without
// having to open the pane first. needsAuthorization
// is a stat() call so we only invoke it for the row
// that's actually selected.
if !showPreviewPane,
selection.count == 1,
selection.contains(vRecord.id),
access.needsAuthorization(for: vRecord) {
Spacer(minLength: 4)
AuthorizeBadge(record: vRecord)
}
}
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
.width(min: 200, ideal: 320)
.customizationID("name")
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
Text(vRecord.parentPath)
@@ -94,27 +195,36 @@ struct ContentView: View {
.lineLimit(1)
}
.width(min: 200, ideal: 380)
.customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Self.formatSize(inBytes: vRecord.size))
Text(vRecord.isDirectory ? "—" : Formatters.size(bytes: vRecord.size))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(90)
.customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateCreated))
Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateModified))
Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("modified")
} rows: {
ForEach(model.visibleRecords) { vRecord in
TableRow(vRecord)
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
}
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
Button("Open") { openSelection(inIds: vIds) }
@@ -125,13 +235,101 @@ struct ContentView: View {
} primaryAction: { vIds in
openSelection(inIds: vIds)
}
// Finder-style spacebar Quick Look. .onKeyPress only fires when the
// view (Table) has keyboard focus, so spaces typed into the search
// field still produce literal spaces in the query.
.onKeyPress(.space) {
guard !selection.isEmpty else { return .ignored }
let vUrls = recordsFor(inIds: selection)
.map { URL(fileURLWithPath: $0.fullPath) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
return .handled
}
}
// ===========================
// MARK: Status bar
// MARK: Selection actions
// ===========================
private var statusBar: some View {
private func revealSelection(inIds: Set<FileRecord.ID>) {
// reveal in Finder shows the *original* file (not the staged copy),
// since the user wants to navigate to the real location on disk
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls)
}
private func quickLookSelection(inIds: Set<FileRecord.ID>) {
// prefer the staged URL when one exists - QLPreviewPanel renders
// it without permission issues, whereas the original would fail
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
}
private func copyPaths(inIds: Set<FileRecord.ID>) {
// always copy the original path - the staged tmp path is an
// implementation detail that has no meaning outside this session
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
}
private func openSelection(inIds: Set<FileRecord.ID>) {
// open the staged copy when available so the default app can read
// it; falls back to the original path for files we can read directly
for vRecord in recordsFor(inIds: inIds) {
NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
}
}
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
return model.visibleRecords.filter { inIds.contains($0.id) }
}
// ===========================
// MARK: Sort mapping
// ===========================
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath
if vKp == \FileRecord.name { return vAsc ? .nameAscending : .nameDescending }
if vKp == \FileRecord.parentPath { return vAsc ? .pathAscending : .pathDescending }
if vKp == \FileRecord.size { return vAsc ? .sizeAscending : .sizeDescending }
if vKp == \FileRecord.dateCreated { return vAsc ? .createdAscending : .createdDescending }
if vKp == \FileRecord.dateModified { return vAsc ? .modifiedAscending : .modifiedDescending }
return .nameAscending
}
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
case .nameDescending: return KeyPathComparator(\FileRecord.name, order: .reverse)
case .pathAscending: return KeyPathComparator(\FileRecord.parentPath, order: .forward)
case .pathDescending: return KeyPathComparator(\FileRecord.parentPath, order: .reverse)
case .sizeAscending: return KeyPathComparator(\FileRecord.size, order: .forward)
case .sizeDescending: return KeyPathComparator(\FileRecord.size, order: .reverse)
case .createdAscending: return KeyPathComparator(\FileRecord.dateCreated, order: .forward)
case .createdDescending: return KeyPathComparator(\FileRecord.dateCreated, order: .reverse)
case .modifiedAscending: return KeyPathComparator(\FileRecord.dateModified, order: .forward)
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
}
}
}
// ===========================
// MARK: Status bar
// ===========================
// Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small leaf view rather than the ContentView body
// that contains the Table.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
var body: some View {
HStack(spacing: 8) {
if model.isIndexing {
ProgressView()
@@ -157,92 +355,4 @@ struct ContentView: View {
.font(.caption)
.foregroundColor(.secondary)
}
// ===========================
// MARK: Selection actions
// ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls)
}
private func quickLookSelection(inIds: Set<FileRecord.ID>) {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
}
private func copyPaths(inIds: Set<FileRecord.ID>) {
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
}
private func openSelection(inIds: Set<FileRecord.ID>) {
for vRecord in recordsFor(inIds: inIds) {
NSWorkspace.shared.open(URL(fileURLWithPath: vRecord.fullPath))
}
}
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
return model.visibleRecords.filter { inIds.contains($0.id) }
}
// ===========================
// MARK: Sort mapping
// ===========================
// converts a Table sort comparator into the model's FileSortDescriptor
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath
if vKp == \FileRecord.name { return vAsc ? .nameAscending : .nameDescending }
if vKp == \FileRecord.parentPath { return vAsc ? .pathAscending : .pathDescending }
if vKp == \FileRecord.size { return vAsc ? .sizeAscending : .sizeDescending }
if vKp == \FileRecord.dateCreated { return vAsc ? .createdAscending : .createdDescending }
if vKp == \FileRecord.dateModified { return vAsc ? .modifiedAscending : .modifiedDescending }
return .nameAscending
}
// returns the matching comparator for a given persisted sort descriptor
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
case .nameDescending: return KeyPathComparator(\FileRecord.name, order: .reverse)
case .pathAscending: return KeyPathComparator(\FileRecord.parentPath, order: .forward)
case .pathDescending: return KeyPathComparator(\FileRecord.parentPath, order: .reverse)
case .sizeAscending: return KeyPathComparator(\FileRecord.size, order: .forward)
case .sizeDescending: return KeyPathComparator(\FileRecord.size, order: .reverse)
case .createdAscending: return KeyPathComparator(\FileRecord.dateCreated, order: .forward)
case .createdDescending: return KeyPathComparator(\FileRecord.dateCreated, order: .reverse)
case .modifiedAscending: return KeyPathComparator(\FileRecord.dateModified, order: .forward)
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
}
}
// ===========================
// MARK: Formatting helpers
// ===========================
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
private static func formatSize(inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
private static func formatDate(inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
// ElevatedAccess provides on-demand sudo-backed access to files the GUI
// user can't read directly. Common case: the root LaunchDaemon indexed
// `/Users/<otheruser>/...` (it has Full Disk Access), the GUI runs as
// the current user, and trying to render an inline preview hits a
// permission denial. The user clicks "Authorize" in the preview pane,
// AdminShell prompts for the password once, sudo copies the file to
// the per-user staging directory and chowns it to the GUI user.
//
// The staged copy is owned by the GUI user, lives in
// ~/Library/Caches/Allofit/elevated/
// and is wiped at app launch and at app quit so privileged copies don't
// linger on disk across sessions.
enum ElevatedAccess {
// per-user staging directory; lives under Library/Caches so macOS
// itself may purge it under disk-pressure, and our own cleanup() at
// launch + terminate keeps it from accumulating
static var stagingDirectory: URL {
let vCaches = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!
return vCaches.appendingPathComponent("Allofit/elevated", isDirectory: true)
}
// true if the current user can read the file at inPath without elevation
static func canRead(path inPath: String) -> Bool {
return FileManager.default.isReadableFile(atPath: inPath)
}
// wipes anything in the staging directory. Called on app launch (so a
// previous session's elevated copies don't survive a relaunch) and on
// app terminate (so they don't survive a clean quit either). Failure
// is silent - if cleanup fails the next launch's cleanup will retry.
static func cleanup() {
try? FileManager.default.removeItem(at: stagingDirectory)
}
// copies inUrl into the staging directory via sudo, chowns it to the
// current user, and returns the staged URL. Caller is responsible for
// catching AdminShell.Error.scriptFailed (cancelled prompt etc).
//
// Throws if the parent staging directory can't be created or the
// admin script fails. Side effect: the system prompts for password
// the first time within the auth-cache window.
static func stage(_ inUrl: URL) throws -> URL {
let vDir = stagingDirectory
// create as the current user so the dir is owned by us; sudo
// only handles the file copy itself
try FileManager.default.createDirectory(
at: vDir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
var vDst = vDir.appendingPathComponent(UUID().uuidString)
let vExt = inUrl.pathExtension
if !vExt.isEmpty {
vDst.appendPathExtension(vExt)
}
// cp + chown to the current user. The chmod restores plain user
// rw / group+other r so the file is treated normally by QL etc.
let vScript = """
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \
chmod 0644 \(AdminShell.quote(vDst.path))
"""
_ = try AdminShell.run(vScript)
return vDst
}
}
+26 -4
View File
@@ -49,14 +49,21 @@ 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
return
}
if let vRecord = makeRecord(inURL: vURL) {
@@ -67,6 +74,7 @@ enum FileIndexer {
}
}
}
}
if !vBatch.isEmpty {
inBatch(vBatch)
}
@@ -96,9 +104,23 @@ enum FileIndexer {
return vRecords
}
// builds a FileRecord from a URL's pre-fetched resource values
// builds a FileRecord from a URL's pre-fetched resource values.
// Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise
// 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? {
guard let vValues = try? inURL.resourceValues(forKeys: Set(kPrefetchKeys)) else {
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 {
return nil
}
let vName = vValues.name ?? inURL.lastPathComponent
+32
View File
@@ -0,0 +1,32 @@
import Foundation
// Formatters bundles the byte-count and date formatting used by both
// the Table's columns and the right-hand preview pane footer. Keeping
// the formatter instances cached at file scope avoids reconstructing
// them per row render, which would be expensive at 5 000 rows.
enum Formatters {
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
// human-friendly byte count, e.g. "1.2 MB"
static func size(bytes inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
// short date+time, with em-dash for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
}
+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 }
+200
View File
@@ -0,0 +1,200 @@
import SwiftUI
import AppKit
import Quartz
// QuickLookPreviewView wraps Quartz's QLPreviewView so an inline Quick
// Look preview can be embedded inside a SwiftUI hierarchy. The same
// renderer powers the floating QLPreviewPanel (spacebar), so file-type
// coverage (PDFs, images, video, source files, plists, etc.) is
// identical between the inline pane and the floating panel.
struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
func makeNSView(context: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
// keep the view alive when the parent window closes - we own its
// lifetime via SwiftUI, not via QLPreviewPanel's modal behaviour
vView.shouldCloseWithWindow = false
vView.autostarts = true
return vView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let vQlView = nsView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// AuthorizeBadge is the small lock icon that appears either inside the
// preview pane (when the selected file isn't user-readable) or at the
// right of the selected row when the preview pane is closed. Clicking
// it kicks off the sudo cp + chown via AdminShell - the system prompts
// for the password the first time inside the admin-auth-cache window.
struct AuthorizeBadge: View {
@EnvironmentObject var access: AccessManager
let record: FileRecord
var body: some View {
Button {
Task { await access.authorize(record) }
} label: {
if access.isAuthorizing(record.id) {
ProgressView()
.controlSize(.small)
.frame(width: 16, height: 16)
} else {
Image(systemName: "lock.shield.fill")
.foregroundStyle(.orange)
.font(.system(size: 14, weight: .semibold))
}
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(record.id))
.help(access.isAuthorizing(record.id)
? "Authorizing…"
: "Authorize to read this file")
}
}
// PreviewPane is the right-hand side panel in the main window. When
// exactly one row is selected it renders a Quick Look preview plus a
// small metadata footer. If the file isn't user-readable the preview
// area becomes a single big tap-target showing a lock icon - clicking
// it (or the badge that appears on the selected row when the pane is
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var access: AccessManager
// passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes
let selection: Set<FileRecord.ID>
private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil }
return model.visibleRecords.first(where: { $0.id == vId })
}
var body: some View {
VStack(spacing: 0) {
if let vRecord = selectedRecord {
content(for: vRecord)
} else {
emptyState
}
}
.background(Color(NSColor.controlBackgroundColor))
}
// preview + metadata footer for one selected record
private func content(for inRecord: FileRecord) -> some View {
let vEffectiveUrl = access.effectiveURL(for: inRecord)
let vReadable = ElevatedAccess.canRead(path: vEffectiveUrl.path)
return VStack(spacing: 0) {
Group {
if vReadable {
QuickLookPreviewView(url: vEffectiveUrl)
} else {
authorizePrompt(for: inRecord)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
metadata(for: inRecord)
}
}
// full-area authorize hint shown when the selected file isn't
// user-readable. The whole area is the button target so users
// can click anywhere over the locked preview to authorize.
private func authorizePrompt(for inRecord: FileRecord) -> some View {
Button {
Task { await access.authorize(inRecord) }
} label: {
VStack(spacing: 10) {
if access.isAuthorizing(inRecord.id) {
ProgressView()
.controlSize(.regular)
} else {
Image(systemName: "lock.shield.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
}
Text(access.isAuthorizing(inRecord.id)
? "Authorizing…"
: "Click to authorize preview")
.font(.callout)
.foregroundColor(.secondary)
if let vErr = access.lastError, !access.isAuthorizing(inRecord.id) {
Text(vErr)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(inRecord.id))
}
// thin metadata bar at the bottom of the preview pane
private func metadata(for inRecord: FileRecord) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(inRecord.name)
.font(.headline)
.lineLimit(2)
.truncationMode(.middle)
Text(inRecord.parentPath)
.font(.caption)
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.textSelection(.enabled)
HStack(spacing: 6) {
if !inRecord.isDirectory {
Text(Formatters.size(bytes: inRecord.size))
.monospacedDigit()
Text("·")
}
Text("Modified \(Formatters.date(inRecord.dateModified))")
.monospacedDigit()
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
// placeholder shown when nothing or multiple rows are selected
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "eye.slash")
.font(.system(size: 32))
.foregroundColor(.secondary.opacity(0.6))
Text(placeholderText)
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var placeholderText: String {
if selection.isEmpty {
return "Select a file to preview"
}
return "\(selection.count) items selected"
}
}
+140 -19
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]
@@ -188,24 +313,20 @@ enum ServiceInstaller {
return (vProcess.terminationStatus, vOutStr, vErrStr)
}
// runs a shell script with administrator privileges through AppleScript;
// the system shows the standard password prompt the first time
// 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 {
let vEscaped = inScript
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let vAppleScriptSource = "do shell script \"\(vEscaped)\" with administrator privileges"
let vScript = NSAppleScript(source: vAppleScriptSource)
var vErr: NSDictionary?
let vResult = vScript?.executeAndReturnError(&vErr)
if vResult == nil {
let vMessage = vErr?[NSAppleScript.errorMessage] as? String ?? "unknown AppleScript error"
throw InstallError.authorizationFailed(vMessage)
do {
_ = try AdminShell.run(inScript)
} catch let vErr as AdminShell.Error {
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
}
}
// minimal POSIX-style single-quote escape
// shell-quote helper, delegating to the shared AdminShell quoter so
// both call sites use the same escaping rules
private static func shellQuote(inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'"
return AdminShell.quote(inString)
}
}
+152 -18
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()
installDescription
actionButtons
if !model.workMessage.isEmpty {
HStack(spacing: 6) {
if model.isWorking {
ProgressView().controlSize(.small)
}
if !model.workMessage.isEmpty {
Text(model.workMessage)
.font(.caption)
.foregroundColor(.secondary)
@@ -271,16 +280,141 @@ private struct ServiceTab: View {
Divider()
Text(currentStatusText())
statusBlock
}
.onAppear { statusTick &+= 1 }
.onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in
statusTick &+= 1
}
.onChange(of: model.isWorking) { _, _ in statusTick &+= 1 }
}
// ===========================
// MARK: Install explanation
// ===========================
@ViewBuilder
private var installDescription: some View {
if let vScope = scope {
GroupBox {
VStack(alignment: .leading, spacing: 4) {
Text("Install will:")
.font(.caption.bold())
Text("• Copy the running binary to \(daemonBinaryPath(for: vScope)) so the daemon has a stable on-disk path that won't break if you move Allofit.app.")
.font(.caption)
Text("• Write the launchd plist that runs it as \(vScope == .rootDaemon ? "root" : "your user").")
.font(.caption)
Text("• Start the daemon via launchctl bootstrap.")
.font(.caption)
if vScope == .rootDaemon {
Text("• Prompt once for your administrator password (steps run as one privileged script).")
.font(.caption)
}
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// ===========================
// MARK: Action buttons
// ===========================
private var actionButtons: some View {
let vInstalled = scope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = scope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
_ = statusTick // re-read the file-system status on each tick
return HStack {
Button("Install") {
Task { await model.performInstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
Button("Stop") {
Task { await model.performStopService() }
}
.disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") {
Task { await model.performStartService() }
}
.disabled(!vInstalled || vRunning || model.isWorking)
Spacer()
}
}
// ===========================
// MARK: Status block
// ===========================
private var statusBlock: some View {
_ = statusTick // ensure recomputation each tick
let vScope = scope
let vInstalled = vScope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = vScope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
let vInstalledVer = vScope.flatMap { ServiceInstaller.installedVersion(inScope: $0) }
let vBundleVer = ServiceInstaller.bundleVersion()
let vStale = vInstalled && vInstalledVer != nil && vInstalledVer != vBundleVer
return VStack(alignment: .leading, spacing: 4) {
LabeledContent("Service installed") {
Text(vInstalled ? "yes" : "no")
.foregroundColor(vInstalled ? .primary : .secondary)
.font(.callout)
}
LabeledContent("Currently running") {
Text(vRunning ? "yes" : "no")
.foregroundColor(vRunning ? .green : .secondary)
.font(.callout)
}
LabeledContent("Installed version") {
Text(vInstalledVer ?? "—")
.font(.callout)
.monospacedDigit()
}
LabeledContent("This app's version") {
HStack(spacing: 6) {
Text(vBundleVer)
.font(.callout)
.monospacedDigit()
if vStale {
Text("Reinstall to update")
.font(.caption)
.foregroundColor(.orange)
}
}
}
if let vScope = vScope {
LabeledContent("Other scope") {
Text(otherScopeStatusText(currentScope: vScope))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
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)"
// 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"
}
}
+53 -5
View File
@@ -3,7 +3,12 @@
# launched like any other macOS app (double-click in Finder, dragged into
# /Applications, etc.). Run from the project root:
# ./build-app.sh
# The produced bundle ends up at ./Allofit.app
# ./build-app.sh --version 1.0.0 # explicit version stamp
# ./build-app.sh -v 1.0.0 -b 42 # version + build number
# ALLOFIT_VERSION=1.0.0 ./build-app.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# The produced bundle ends up at ./Allofit.app.
#
# Icon support (optional, first match wins):
# icons/Allofit.icns - pre-built .icns, copied straight in
@@ -16,10 +21,53 @@ set -euo pipefail
kAppName="Allofit"
kBundleId="com.bitsycore.allofit"
# version + build can be overridden from CI so the release tag flows into
# the .app's Info.plist. Default to "0.0.0" / "0" for local dev builds.
kVersion="${ALLOFIT_VERSION:-0.0.0}"
kBuildNumber="${ALLOFIT_BUILD:-0}"
# ==================
# MARK: Args
# ==================
vVersionArg=""
vBuildArg=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version|-v)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vVersionArg="$2"
shift 2
;;
--build|-b)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vBuildArg="$2"
shift 2
;;
-h|--help)
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
# version / build precedence: --flag arg > env var > default
if [[ -n "$vVersionArg" ]]; then
kVersion="$vVersionArg"
else
kVersion="${ALLOFIT_VERSION:-0.0.0}"
fi
if [[ -n "$vBuildArg" ]]; then
kBuildNumber="$vBuildArg"
else
kBuildNumber="${ALLOFIT_BUILD:-0}"
fi
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
+44 -10
View File
@@ -4,39 +4,69 @@
# with the .app and an /Applications symlink so the user can drag-install.
#
# Usage:
# ./build-dmg.sh # rebuilds the .app first, then DMGs it
# ./build-dmg.sh --skip-build # assumes Allofit.app already exists
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh
# ./build-dmg.sh # default version (0.0.0)
# ./build-dmg.sh --version 1.0.0 # explicit version
# ./build-dmg.sh -v 1.0.0 --skip-build # version + skip rebuild
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# Output: Allofit-<version>.dmg in the project root.
set -euo pipefail
kAppName="Allofit"
kVersion="${ALLOFIT_VERSION:-0.0.0}"
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
vStagingDir="${vProjectRoot}/.build/dmg-staging"
# ==================
# MARK: Args
# ==================
vSkipBuild=0
for vArg in "$@"; do
case "$vArg" in
--skip-build) vSkipBuild=1 ;;
vVersionArg=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version|-v)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vVersionArg="$2"
shift 2
;;
--skip-build)
vSkipBuild=1
shift
;;
-h|--help)
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
exit 0
;;
*)
echo "Unknown argument: $vArg" >&2
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
# version precedence: --version arg > env var > default
if [[ -n "$vVersionArg" ]]; then
kVersion="$vVersionArg"
else
kVersion="${ALLOFIT_VERSION:-0.0.0}"
fi
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
# ==================
# MARK: Build .app
# ==================
if [[ "$vSkipBuild" -eq 0 ]]; then
echo "==> Rebuilding ${kAppName}.app first"
"${vProjectRoot}/build-app.sh"
# Propagate the resolved version into build-app.sh so the bundled
# Info.plist matches what we're naming the DMG.
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/build-app.sh"
fi
if [[ ! -d "${vAppBundle}" ]]; then
@@ -44,6 +74,10 @@ if [[ ! -d "${vAppBundle}" ]]; then
exit 1
fi
# ==================
# MARK: Stage + create DMG
# ==================
echo "==> Staging DMG contents"
rm -rf "${vStagingDir}"
mkdir -p "${vStagingDir}"
+19 -2
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
@@ -147,7 +151,20 @@ fi
if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
echo "==> Removing service log files"
vRun rm -f "$kServiceLogStdout" "$kServiceLogStderr"
# /tmp has the sticky bit, so only the file's owner can rm it. Logs
# left over from a previous *root* daemon run are owned by root - we
# need sudo to delete them. Plain rm for user-owned logs (user agent
# mode), sudo rm for root-owned ones.
for vLog in "$kServiceLogStdout" "$kServiceLogStderr"; do
if [[ -f "$vLog" ]]; then
if [[ -O "$vLog" ]]; then
vRun rm -f "$vLog"
else
vNeedsSudo
vRun sudo rm -f "$vLog"
fi
fi
done
fi
# ==================
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))")