4 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
16 changed files with 1085 additions and 321 deletions
+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 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
// shared application state injected into the view tree // shared application state injected into the view tree
@StateObject private var model = AppModel() @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 { var body: some Scene {
WindowGroup("Allofit") { WindowGroup("Allofit") {
ContentView() ContentView()
.environmentObject(model) .environmentObject(model)
.environmentObject(Preferences.shared) .environmentObject(Preferences.shared)
.environmentObject(access)
.frame(minWidth: 760, minHeight: 480) .frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker()) .background(MainWindowMarker())
} }
.windowToolbarStyle(.unified) .windowToolbarStyle(.unified)
.defaultSize(width: 1100, height: 640)
.commands { .commands {
// custom About panel with a clickable repo link in the credits // custom About panel with a clickable repo link in the credits
CommandGroup(replacing: .appInfo) { CommandGroup(replacing: .appInfo) {
@@ -46,6 +52,7 @@ struct AllofitApp: App {
SettingsView() SettingsView()
.environmentObject(model) .environmentObject(model)
.environmentObject(Preferences.shared) .environmentObject(Preferences.shared)
.environmentObject(access)
} }
} }
} }
@@ -126,6 +133,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// bundle - covers the SwiftPM "swift run" case // bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true) 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 // bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async { DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey { 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 // 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. // stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item. // Cmd+Q still quits via the standard Quit menu item.
+148 -46
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import CoreServices import CoreServices
import Darwin
// AllofitService is the headless runtime invoked by launchd when the binary // AllofitService is the headless runtime invoked by launchd when the binary
// is launched with the --service argument. It builds an initial index, then // is launched with the --service argument. It builds an initial index, then
@@ -54,22 +55,33 @@ enum AllofitService {
// the autosave thread (every 3s) can write partial progress while we // the autosave thread (every 3s) can write partial progress while we
// continue walking. Without this, large filesystems leave the cache // continue walking. Without this, large filesystems leave the cache
// empty for many minutes and the GUI shows nothing. // 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()) let vStartId = UInt64(FSEventsGetCurrentEventId())
for vRoot in vRoots { for vRoot in vRoots {
NSLog("[Allofit] scanning %@", vRoot.path) autoreleasepool {
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in NSLog("[Allofit] scanning %@", vRoot.path)
vState.lock.lock() FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
for vRec in vBatch { autoreleasepool {
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue } vState.lock.lock()
if vState.pathIndex[vRec.fullPath] == nil { for vRec in vBatch {
vState.pathIndex[vRec.fullPath] = vState.records.count if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
vState.records.append(vRec) if vState.pathIndex[vRec.fullPath] == nil {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
}
}
vState.dirty = true
vState.lock.unlock()
} }
} }
vState.dirty = true NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
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 // 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 // stable count even if no FSEvents come in for a while afterwards
@@ -91,37 +103,53 @@ enum AllofitService {
var vRescanPrefixes: [String] = [] var vRescanPrefixes: [String] = []
var vAdded = 0 var vAdded = 0
var vUpdated = 0 var vUpdated = 0
var vRemovedCount = 0 // batch removals into a set so we do one bulk allRecords pass
// and rebuild pathIndex once per FSEvents batch, instead of
// O(records) per individual removal - that nested rebuild was
// dominating CPU and turning into the daemon's memory churn
var vRemoved: Set<String> = []
// 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 { for vChange in vChanges {
if vMatcher.isExcluded(inPath: vChange.path) { continue } autoreleasepool {
if vChange.mustScanSubDirs { if vMatcher.isExcluded(inPath: vChange.path) { return }
vRescanPrefixes.append(vChange.path) if vChange.mustScanSubDirs {
continue vRescanPrefixes.append(vChange.path)
} return
let vUrl = URL(fileURLWithPath: vChange.path)
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
if let vIdx = vState.pathIndex[vRec.fullPath] {
vState.records[vIdx] = vRec
vUpdated += 1
} else {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
vAdded += 1
} }
} else if let vIdx = vState.pathIndex[vChange.path] { // skip paths we've already queued for removal in this batch
vState.records.remove(at: vIdx) if vRemoved.contains(vChange.path) { return }
vState.pathIndex.removeAll(keepingCapacity: true) let vUrl = URL(fileURLWithPath: vChange.path)
for (vI, vR) in vState.records.enumerated() { let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
vState.pathIndex[vR.fullPath] = vI if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
if let vIdx = vState.pathIndex[vRec.fullPath] {
vState.records[vIdx] = vRec
vUpdated += 1
} else {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
vAdded += 1
}
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
} }
vRemovedCount += 1
} }
} }
if vAdded + vUpdated + vRemovedCount > 0 {
if !vRemoved.isEmpty {
// single bulk removeAll + single pathIndex rebuild
vState.records.removeAll { vRemoved.contains($0.fullPath) }
rebuildPathIndex(vState)
}
if vAdded + vUpdated + vRemoved.count > 0 {
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)", NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
vAdded, vUpdated, vRemovedCount, vState.records.count) vAdded, vUpdated, vRemoved.count, vState.records.count)
} }
if !vRescanPrefixes.isEmpty { if !vRescanPrefixes.isEmpty {
@@ -141,10 +169,7 @@ enum AllofitService {
) )
vState.records.append(contentsOf: vList) vState.records.append(contentsOf: vList)
} }
vState.pathIndex.removeAll(keepingCapacity: true) rebuildPathIndex(vState)
for (vI, vR) in vState.records.enumerated() {
vState.pathIndex[vR.fullPath] = vI
}
} }
vState.dirty = true vState.dirty = true
@@ -152,20 +177,40 @@ enum AllofitService {
// periodic save loop (background thread). 3-second check interval so // periodic save loop (background thread). 3-second check interval so
// new files appear in the GUI within a few seconds of being created. // new files appear in the GUI within a few seconds of being created.
// Every N saves we also compact the in-memory containers so Swift's
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file
// activity. RSS is logged each save so the trajectory is visible.
//
// 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 { DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20
var vSavesSinceCompact = 0
while true { while true {
sleep(3) sleep(3)
vState.lock.lock() autoreleasepool {
let vShouldSave = vState.dirty vState.lock.lock()
let vSnapshot = vState.records let vShouldSave = vState.dirty
vState.dirty = false let vSnapshot = vState.records
vState.lock.unlock() vState.dirty = false
if vShouldSave { vState.lock.unlock()
NSLog("[Allofit] autosaving %d records", vSnapshot.count) if !vShouldSave { return }
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB())
IndexStore.save( IndexStore.save(
inRecords: vSnapshot, inRecords: vSnapshot,
inLastEventId: vWatcher.latestEventId inLastEventId: vWatcher.latestEventId
) )
vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0
compactContainers(vState)
}
} }
} }
} }
@@ -174,4 +219,61 @@ enum AllofitService {
RunLoop.current.run() RunLoop.current.run()
exit(0) exit(0)
} }
// rebuilds pathIndex from records. Used after a bulk allRecords mutation
// (batched removal or subtree rescan) - much cheaper than incrementally
// maintaining pathIndex during the mutation, and the reserveCapacity
// lets the dict size to its target without re-bucketing on each insert.
private static func rebuildPathIndex(_ inState: State) {
var vIndex: [String: Int] = [:]
vIndex.reserveCapacity(inState.records.count)
for (vI, vR) in inState.records.enumerated() {
vIndex[vR.fullPath] = vI
}
inState.pathIndex = vIndex
}
// recreates records and pathIndex with capacities matched to their
// actual element count. Swift Array/Dict only grow their backing
// allocations under churn, never auto-shrink, so a daemon that's
// been processing FSEvents for days can hold huge dead capacity
// (records.capacity >> records.count) that shows up as multi-GB
// RSS. Forcing fresh containers reclaims it.
private static func compactContainers(_ inState: State) {
inState.lock.lock()
defer { inState.lock.unlock() }
let vBefore = processFootprintMB()
// Array(_:) creates a fresh array sized exactly to the source -
// the old buffer's slack capacity is released
let vCompactRecords = Array(inState.records)
var vCompactIndex: [String: Int] = [:]
vCompactIndex.reserveCapacity(vCompactRecords.count)
for (vI, vR) in vCompactRecords.enumerated() {
vCompactIndex[vR.fullPath] = vI
}
inState.records = vCompactRecords
inState.pathIndex = vCompactIndex
let vAfter = processFootprintMB()
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
vCompactRecords.count, vBefore, vAfter)
}
// resident-memory size in MB matching Activity Monitor's "Memory" column
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
// of pages owned by the task minus shared/clean pages.
private static func processFootprintMB() -> Double {
var vInfo = task_vm_info_data_t()
var vCount = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
}
}
if vResult == KERN_SUCCESS {
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
}
return -1
}
} }
+42 -10
View File
@@ -241,13 +241,18 @@ final class AppModel: ObservableObject {
var vAccumulated: [FileRecord] = [] var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000) vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots { for vRoot in vRoots {
let vBaseline = vAccumulated.count // per-root autoreleasepool so the file-enumeration's
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in // autoreleased NSURL/NSDate/NSNumber objects don't pile
DispatchQueue.main.async { // up across roots inside this long-running async block
self?.indexedCount = vBaseline + vCount autoreleasepool {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
}
} }
vAccumulated.append(contentsOf: vList)
} }
vAccumulated.append(contentsOf: vList)
} }
let vFinal = vAccumulated let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal) let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
@@ -309,6 +314,28 @@ final class AppModel: ObservableObject {
} }
} }
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService() async {
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in
try ServiceInstaller.stop(inScope: vScope)
}
}
// starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write.
func performStartService() async {
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in
try ServiceInstaller.start(inScope: vScope)
}
if vOk {
try? await Task.sleep(nanoseconds: 1_500_000_000)
switchToCurrentMode()
}
}
// uninstalls the launchd service for the current serviceMode preference. // uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode. // On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService() async { func performUninstallService() async {
@@ -472,11 +499,16 @@ final class AppModel: ObservableObject {
return true return true
} }
for vPath in inPaths { for vPath in inPaths {
let vList = FileIndexer.indexRoot( // per-subtree autoreleasepool keeps the rescan's
inRoot: URL(fileURLWithPath: vPath), // autoreleased URL/stat objects from accumulating across
inExclusions: inExclusions // subtrees inside this long-running async block
) autoreleasepool {
vKept.append(contentsOf: vList) let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
} }
let vFinal = vKept let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal) let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
+83 -67
View File
@@ -3,12 +3,13 @@ import AppKit
// ContentView is the main window layout: a search bar bonded to the title // ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material // bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table that fills the body, and a status bar at // on macOS 15), a results table on the left, a Quick Look preview pane on
// the bottom. The Settings gear sits permanently in the window toolbar. // the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences @EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
@State private var selection: Set<FileRecord.ID> = [] @State private var selection: Set<FileRecord.ID> = []
// drives the Table's drag-to-reorder and column-visibility customization. // drives the Table's drag-to-reorder and column-visibility customization.
// Initial value is hydrated from UserDefaults so the user's column order // Initial value is hydrated from UserDefaults so the user's column order
@@ -18,15 +19,16 @@ struct ContentView: View {
// Cancelled+rescheduled per change so a drag (which fires onChange on // Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main. // every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>? @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 kColumnCustomizationKey = "Allofit.columnCustomization"
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000 private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
// Computed binding for the Table's sortOrder: reads/writes // Computed binding for the Table's sortOrder: reads/writes
// model.sortDescriptor directly so the sort state survives any number // model.sortDescriptor directly so the sort state survives any number
// of window closes / reopens (the previous `@State sortOrder` got // of window closes / reopens.
// reset whenever the view was recreated, and the onChange-syncing
// dance occasionally didn't re-wire properly after a window reopen).
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> { private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding( Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] }, get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
@@ -44,14 +46,30 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) { Group {
searchBar if showPreviewPane {
resultsTable HSplitView {
Divider() mainColumn
StatusBarView() // isolated so its @Published refresh .layoutPriority(1)
// doesn't re-evaluate the Table closure .frame(minWidth: 460)
PreviewPane(selection: selection)
.frame(minWidth: 200, idealWidth: 360)
}
} else {
mainColumn
}
} }
.toolbar { .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) { ToolbarItem(placement: .primaryAction) {
SettingsLink { SettingsLink {
Image(systemName: "gearshape") Image(systemName: "gearshape")
@@ -80,12 +98,21 @@ struct ContentView: View {
} }
} }
// 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 // MARK: Column customization persistence
// =========================== // ===========================
// loads the previously-saved column order/visibility from UserDefaults,
// or returns a fresh default if nothing was saved or decoding fails
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> { private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey), guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode( let vCustom = try? JSONDecoder().decode(
@@ -98,10 +125,6 @@ struct ContentView: View {
return vCustom return vCustom
} }
// persists the current column order/visibility to UserDefaults.
// nonisolated so the debounced background task can call it without an
// actor hop - the encode is the only non-trivial step and we want it
// genuinely off-main during column drags.
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) { private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return } guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey) UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
@@ -111,10 +134,6 @@ struct ContentView: View {
// MARK: Search bar // MARK: Search bar
// =========================== // ===========================
// Always-visible row at the top. `.background(.bar)` uses the system
// "bar" material, which sits right below the toolbar with the same
// vibrancy treatment - on macOS 26 this is the Liquid Glass surface,
// on macOS 15 it's the standard chrome material.
private var searchBar: some View { private var searchBar: some View {
SearchField( SearchField(
text: $model.query, text: $model.query,
@@ -132,16 +151,11 @@ struct ContentView: View {
// =========================== // ===========================
private var resultsTable: some View { private var resultsTable: some View {
// Uses the explicit `rows:` form of Table so we can attach `.draggable` // Uses the explicit `rows:` form of Table so `.draggable` lives on
// to TableRow rather than to cell content. Putting `.draggable` on // TableRow rather than embedded in cell content. Cell-content
// cell content installs a SwiftUI drag-gesture recognizer that // draggable installs a SwiftUI drag-gesture recognizer that races
// competes with NSTableView's mouseDown → selection event on // with NSTableView's mouseDown→selection event on macOS 26 and
// macOS 26 - the recognizer's "should this be a drag?" decision // occasionally eats left-clicks; row-level draggable doesn't.
// delays and occasionally eats the click, leaving the row never
// selected even though right-click (which bypasses the drag gesture
// entirely) still works. Row-level `.draggable` puts the drag at
// the same scope as NSTableView's own row-drag machinery and leaves
// the click path clean.
Table(of: FileRecord.self, Table(of: FileRecord.self,
selection: $selection, selection: $selection,
sortOrder: sortOrderBinding, sortOrder: sortOrderBinding,
@@ -156,6 +170,19 @@ struct ContentView: View {
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
Text(vRecord.name) Text(vRecord.name)
.lineLimit(1) .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)
}
} }
} }
.width(min: 200, ideal: 320) .width(min: 200, ideal: 320)
@@ -171,7 +198,7 @@ struct ContentView: View {
.customizationID("path") .customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in 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) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -179,7 +206,7 @@ struct ContentView: View {
.customizationID("size") .customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateCreated)) Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -187,7 +214,7 @@ struct ContentView: View {
.customizationID("created") .customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateModified)) Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -208,6 +235,16 @@ struct ContentView: View {
} primaryAction: { vIds in } primaryAction: { vIds in
openSelection(inIds: vIds) 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
}
} }
// =========================== // ===========================
@@ -215,24 +252,32 @@ struct ContentView: View {
// =========================== // ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) { 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) } let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls) NSWorkspace.shared.activateFileViewerSelecting(vUrls)
} }
private func quickLookSelection(inIds: Set<FileRecord.ID>) { private func quickLookSelection(inIds: Set<FileRecord.ID>) {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) } // 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) QuickLookCoordinator.shared.show(inUrls: vUrls)
} }
private func copyPaths(inIds: Set<FileRecord.ID>) { 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 } let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string) NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
} }
private func openSelection(inIds: Set<FileRecord.ID>) { 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) { for vRecord in recordsFor(inIds: inIds) {
NSWorkspace.shared.open(URL(fileURLWithPath: vRecord.fullPath)) NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
} }
} }
@@ -244,7 +289,6 @@ struct ContentView: View {
// MARK: Sort mapping // MARK: Sort mapping
// =========================== // ===========================
// converts a Table sort comparator into the model's FileSortDescriptor
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor { private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath let vKp = inComparator.keyPath
@@ -256,7 +300,6 @@ struct ContentView: View {
return .nameAscending return .nameAscending
} }
// returns the matching comparator for a given persisted sort descriptor
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> { private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor { switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward) case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
@@ -271,32 +314,6 @@ struct ContentView: View {
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse) case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
} }
} }
// ===========================
// MARK: Formatting helpers
// ===========================
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
fileprivate 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
}()
fileprivate static func formatDate(inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
} }
// =========================== // ===========================
@@ -305,9 +322,8 @@ struct ContentView: View {
// Extracted into its own View so its @Published-driven refreshes (cache // Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip) // load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small view rather than the ContentView body that // only re-evaluate this small leaf view rather than the ContentView body
// contains the Table. SwiftUI's dependency tracking is per-View, so an // that contains the Table.
// isolated leaf observer doesn't churn the Table's closure scope.
private struct StatusBarView: View { private struct StatusBarView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
+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
}
}
+30 -13
View File
@@ -49,21 +49,29 @@ enum FileIndexer {
vBatch.append(vRootRecord) 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 { 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) // short-circuit excluded entries (and don't descend into them)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) { if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() } if vIsDir { vEnumerator.skipDescendants() }
continue return
} }
if let vRecord = makeRecord(inURL: vURL) { if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord) vBatch.append(vRecord)
if vBatch.count >= inBatchSize { if vBatch.count >= inBatchSize {
inBatch(vBatch) inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true) vBatch.removeAll(keepingCapacity: true)
}
} }
} }
} }
@@ -99,8 +107,17 @@ enum FileIndexer {
// 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 // Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise // file - a file modified between two FSEvents batches would otherwise
// silently return the cached pre-edit mtime. // silently return the cached pre-edit mtime. The whole body is wrapped
// in autoreleasepool so the autoreleased NSDate / NSNumber / NSURL
// objects returned by resourceValues() are released at function exit
// rather than at the caller's pool drain.
static func makeRecord(inURL: URL) -> FileRecord? { static func makeRecord(inURL: URL) -> FileRecord? {
return autoreleasepool { () -> FileRecord? in
makeRecord_impl(inURL: inURL)
}
}
private static func makeRecord_impl(inURL: URL) -> FileRecord? {
var vUrl = inURL var vUrl = inURL
vUrl.removeAllCachedResourceValues() vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else { guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
+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) 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) { 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() var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80) vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId) writeU64(into: &vPayload, value: inLastEventId)
@@ -151,8 +161,16 @@ enum IndexStore {
return load(from: cacheURL) 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? { 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 } guard let vData = try? Data(contentsOf: inUrl) else { return nil }
var vOffset = 0 var vOffset = 0
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil } 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 { static func install(inScope: Scope) throws {
let vBinary = try resolveBinaryPath() let vBinary = try resolveBinaryPath()
let vPlistData = try makePlistData(inBinary: vBinary, inScope: inScope) let vDaemonBinary = daemonBinaryPath(inScope: inScope)
let vVersionFile = vDaemonBinary + ".version"
let vVersionString = bundleVersion()
let vPlistData = try makePlistData(inBinary: vDaemonBinary, inScope: inScope)
switch inScope { switch inScope {
case .userAgent: case .userAgent:
// user agent install: filesystem ops as the current user,
// no sudo needed for either the binary copy or the plist
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(
atPath: vDaemonDir,
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(atPath: vDaemonBinary)
try FileManager.default.copyItem(atPath: vBinary, toPath: vDaemonBinary)
// sidecar .version file - lets the GUI show which build is
// installed without having to run the copied binary
try? vVersionString.write(
toFile: vVersionFile,
atomically: true,
encoding: .utf8
)
let vTarget = userAgentPath() let vTarget = userAgentPath()
try? FileManager.default.createDirectory( try? FileManager.default.createDirectory(
at: vTarget.deletingLastPathComponent(), at: vTarget.deletingLastPathComponent(),
@@ -52,12 +75,23 @@ enum ServiceInstaller {
} }
case .rootDaemon: case .rootDaemon:
// root daemon install: binary copy + plist install + boot
// happen inside a single admin-priv script so the user
// gets ONE password prompt covering all of it
let vTmp = FileManager.default.temporaryDirectory let vTmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(kLabel).plist") .appendingPathComponent("\(kLabel).plist")
try vPlistData.write(to: vTmp, options: .atomic) try vPlistData.write(to: vTmp, options: .atomic)
let vTarget = rootDaemonPath() let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
let vScript = """ let vScript = """
cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \ mkdir -p \(shellQuote(inString: vDaemonDir)) \
&& rm -f \(shellQuote(inString: vDaemonBinary)) \
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \
&& chmod 644 \(shellQuote(inString: vVersionFile)) \
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \ && chown root:wheel \(shellQuote(inString: vTarget.path)) \
&& chmod 644 \(shellQuote(inString: vTarget.path)) \ && chmod 644 \(shellQuote(inString: vTarget.path)) \
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \ ; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
@@ -67,17 +101,25 @@ enum ServiceInstaller {
} }
} }
// removes the launchd plist for the given scope // removes the launchd plist AND the renamed binary copy AND the
// sidecar .version file for the given scope
static func uninstall(inScope: Scope) throws { static func uninstall(inScope: Scope) throws {
let vDaemonBinary = daemonBinaryPath(inScope: inScope)
let vVersionFile = vDaemonBinary + ".version"
switch inScope { switch inScope {
case .userAgent: case .userAgent:
let vTarget = userAgentPath() let vTarget = userAgentPath()
_ = runLaunchctl(inArgs: ["unload", vTarget.path]) _ = runLaunchctl(inArgs: ["unload", vTarget.path])
try? FileManager.default.removeItem(at: vTarget) try? FileManager.default.removeItem(at: vTarget)
try? FileManager.default.removeItem(atPath: vDaemonBinary)
try? FileManager.default.removeItem(atPath: vVersionFile)
case .rootDaemon: case .rootDaemon:
let vTarget = rootDaemonPath() let vTarget = rootDaemonPath()
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null ; rm -f \(shellQuote(inString: vTarget.path))" let vScript = """
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile))
"""
try runWithAdminPrivileges(inScript: vScript) try runWithAdminPrivileges(inScript: vScript)
} }
} }
@@ -90,6 +132,74 @@ enum ServiceInstaller {
} }
} }
// stops the running daemon for the given scope without uninstalling.
// The plist file stays on disk so a later start() (or app relaunch
// since RunAtLoad=true) brings it back. Use cases: temporary disable,
// freeing FSEvents resources, or pre-flight before a manual reindex.
static func stop(inScope: Scope) throws {
switch inScope {
case .userAgent:
let vPlist = userAgentPath().path
let vResult = runLaunchctl(inArgs: ["unload", vPlist])
if vResult.exitCode != 0 {
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
// starts a previously-installed but currently-stopped daemon.
static func start(inScope: Scope) throws {
switch inScope {
case .userAgent:
let vPlist = userAgentPath().path
let vResult = runLaunchctl(inArgs: ["load", "-w", vPlist])
if vResult.exitCode != 0 {
throw InstallError.launchctlFailed(vResult.stderr.isEmpty ? "exit \(vResult.exitCode)" : vResult.stderr)
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
// true if the daemon process for the given scope is currently alive.
// We check by reading the indexer lock file's PID and probing with
// kill(pid, 0) - cheaper and non-privileged compared to launchctl.
static func isRunning(inScope: Scope) -> Bool {
let vSystem = (inScope == .rootDaemon)
let vLockPath = IndexStore.lockURL(forSystem: vSystem).path
guard let vPid = IndexerLock.readHolderPid(path: vLockPath) else { return false }
// signal 0 = check-only; EPERM means "process exists but we lack
// permission to signal it", which is still "alive"
let vRc = kill(vPid, 0)
if vRc == 0 { return true }
if vRc == -1 && errno == EPERM { return true }
return false
}
// returns the version stamped into the installed daemon binary's
// sidecar .version file (written at install time). nil if the
// service isn't installed or the sidecar is missing/corrupt.
static func installedVersion(inScope: Scope) -> String? {
let vPath = daemonBinaryPath(inScope: inScope) + ".version"
guard let vText = try? String(contentsOfFile: vPath, encoding: .utf8) else {
return nil
}
let vTrimmed = vText.trimmingCharacters(in: .whitespacesAndNewlines)
return vTrimmed.isEmpty ? nil : vTrimmed
}
// returns the version of the running .app bundle (what would be
// installed if the user clicks Install right now)
static func bundleVersion() -> String {
return (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?"
}
// stops the service, removes the cache file, and starts the service again // stops the service, removes the cache file, and starts the service again
// as a single privileged operation. Necessary for the root daemon because // as a single privileged operation. Necessary for the root daemon because
// the cache file is owned by root and the daemon would otherwise just // the cache file is owned by root and the daemon would otherwise just
@@ -125,6 +235,21 @@ enum ServiceInstaller {
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist") return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
} }
// Filesystem path where the daemon's binary is installed. We use a
// renamed copy ("Allofit Service") so the daemon process is named
// differently from the GUI in Activity Monitor / ps - both used to
// be just "Allofit" because they shared the same on-disk binary.
// The filename embedded in argv[0] is what those tools display.
static func daemonBinaryPath(inScope: Scope) -> String {
switch inScope {
case .userAgent:
return NSHomeDirectory()
+ "/Library/Application Support/Allofit/Allofit Service"
case .rootDaemon:
return "/Library/Application Support/Allofit/Allofit Service"
}
}
// resolves an absolute path to the currently running binary // resolves an absolute path to the currently running binary
private static func resolveBinaryPath() throws -> String { private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0] let vArg0 = CommandLine.arguments[0]
@@ -188,24 +313,20 @@ enum ServiceInstaller {
return (vProcess.terminationStatus, vOutStr, vErrStr) return (vProcess.terminationStatus, vOutStr, vErrStr)
} }
// runs a shell script with administrator privileges through AppleScript; // runs a shell script with administrator privileges. Bridges
// the system shows the standard password prompt the first time // AdminShell.Error into ServiceInstaller.InstallError so the calling
// SettingsView UI gets a single error type to surface.
private static func runWithAdminPrivileges(inScript: String) throws { private static func runWithAdminPrivileges(inScript: String) throws {
let vEscaped = inScript do {
.replacingOccurrences(of: "\\", with: "\\\\") _ = try AdminShell.run(inScript)
.replacingOccurrences(of: "\"", with: "\\\"") } catch let vErr as AdminShell.Error {
let vAppleScriptSource = "do shell script \"\(vEscaped)\" with administrator privileges" throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
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)
} }
} }
// 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 { private static func shellQuote(inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'" return AdminShell.quote(inString)
} }
} }
+157 -23
View File
@@ -213,12 +213,26 @@ private struct VolumesTab: View {
// MARK: Service tab // MARK: Service tab
// =========================== // ===========================
// ServiceTab manages installation of the LaunchAgent or LaunchDaemon that // ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
// keeps the index up to date while the GUI is closed. // up to date while the GUI is closed. Surfaces install/uninstall, run-
// state (stop/start), and the gap between the version that's currently
// installed on disk vs the bundle the user is running right now.
private struct ServiceTab: View { private struct ServiceTab: View {
@EnvironmentObject var prefs: Preferences @EnvironmentObject var prefs: Preferences
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
// tick value to force the status block to recompute on a timer; the
// status doesn't observe @Published changes since it reads file
// system state directly, so we need an explicit refresh signal
@State private var statusTick: Int = 0
private var scope: ServiceInstaller.Scope? {
switch prefs.serviceMode {
case .none: return nil
case .userAgent: return .userAgent
case .rootDaemon: return .rootDaemon
}
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -249,20 +263,15 @@ private struct ServiceTab: View {
} }
} }
HStack { installDescription
Button("Install") {
Task { await model.performInstallService() } actionButtons
}
.disabled(prefs.serviceMode == .none || model.isWorking) if !model.workMessage.isEmpty {
Button("Uninstall") { HStack(spacing: 6) {
Task { await model.performUninstallService() } if model.isWorking {
} ProgressView().controlSize(.small)
.disabled(prefs.serviceMode == .none || model.isWorking) }
Spacer()
if model.isWorking {
ProgressView().controlSize(.small)
}
if !model.workMessage.isEmpty {
Text(model.workMessage) Text(model.workMessage)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -271,16 +280,141 @@ private struct ServiceTab: View {
Divider() Divider()
Text(currentStatusText()) statusBlock
.font(.caption) }
.foregroundColor(.secondary) .onAppear { statusTick &+= 1 }
.onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in
statusTick &+= 1
}
.onChange(of: model.isWorking) { _, _ in statusTick &+= 1 }
}
// ===========================
// MARK: Install explanation
// ===========================
@ViewBuilder
private var installDescription: some View {
if let vScope = scope {
GroupBox {
VStack(alignment: .leading, spacing: 4) {
Text("Install will:")
.font(.caption.bold())
Text("• Copy the running binary to \(daemonBinaryPath(for: vScope)) so the daemon has a stable on-disk path that won't break if you move Allofit.app.")
.font(.caption)
Text("• Write the launchd plist that runs it as \(vScope == .rootDaemon ? "root" : "your user").")
.font(.caption)
Text("• Start the daemon via launchctl bootstrap.")
.font(.caption)
if vScope == .rootDaemon {
Text("• Prompt once for your administrator password (steps run as one privileged script).")
.font(.caption)
}
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} }
} }
private func currentStatusText() -> String { // ===========================
let vUser = ServiceInstaller.isInstalled(inScope: .userAgent) ? "installed" : "not installed" // MARK: Action buttons
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed" // ===========================
return "User agent: \(vUser) · Root daemon: \(vRoot)"
private var actionButtons: some View {
let vInstalled = scope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = scope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
_ = statusTick // re-read the file-system status on each tick
return HStack {
Button("Install") {
Task { await model.performInstallService() }
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
Button("Stop") {
Task { await model.performStopService() }
}
.disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") {
Task { await model.performStartService() }
}
.disabled(!vInstalled || vRunning || model.isWorking)
Spacer()
}
}
// ===========================
// MARK: Status block
// ===========================
private var statusBlock: some View {
_ = statusTick // ensure recomputation each tick
let vScope = scope
let vInstalled = vScope.map { ServiceInstaller.isInstalled(inScope: $0) } ?? false
let vRunning = vScope.map { ServiceInstaller.isRunning(inScope: $0) } ?? false
let vInstalledVer = vScope.flatMap { ServiceInstaller.installedVersion(inScope: $0) }
let vBundleVer = ServiceInstaller.bundleVersion()
let vStale = vInstalled && vInstalledVer != nil && vInstalledVer != vBundleVer
return VStack(alignment: .leading, spacing: 4) {
LabeledContent("Service installed") {
Text(vInstalled ? "yes" : "no")
.foregroundColor(vInstalled ? .primary : .secondary)
.font(.callout)
}
LabeledContent("Currently running") {
Text(vRunning ? "yes" : "no")
.foregroundColor(vRunning ? .green : .secondary)
.font(.callout)
}
LabeledContent("Installed version") {
Text(vInstalledVer ?? "—")
.font(.callout)
.monospacedDigit()
}
LabeledContent("This app's version") {
HStack(spacing: 6) {
Text(vBundleVer)
.font(.callout)
.monospacedDigit()
if vStale {
Text("Reinstall to update")
.font(.caption)
.foregroundColor(.orange)
}
}
}
if let vScope = vScope {
LabeledContent("Other scope") {
Text(otherScopeStatusText(currentScope: vScope))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
// path of the daemon-renamed binary copy, used in the explanatory blurb
private func daemonBinaryPath(for inScope: ServiceInstaller.Scope) -> String {
return ServiceInstaller.daemonBinaryPath(inScope: inScope)
}
// summary of the *other* scope's install state so the user can see at
// a glance that they don't have a stray install on the unused side
private func otherScopeStatusText(currentScope inCurrent: ServiceInstaller.Scope) -> String {
let vOther: ServiceInstaller.Scope = (inCurrent == .userAgent) ? .rootDaemon : .userAgent
let vLabel = (vOther == .userAgent) ? "User agent" : "Root daemon"
let vInstalled = ServiceInstaller.isInstalled(inScope: vOther)
let vRunning = vInstalled && ServiceInstaller.isRunning(inScope: vOther)
if vRunning { return "\(vLabel): running" }
if vInstalled { return "\(vLabel): installed (stopped)" }
return "\(vLabel): not installed"
} }
} }
+5 -1
View File
@@ -104,7 +104,11 @@ if [[ -f "$kSystemDaemonPlist" ]]; then
fi fi
echo "==> Killing any leftover daemon processes" echo "==> Killing any leftover daemon processes"
vRun pkill -f "Allofit --service" >/dev/null 2>&1 || true # `pkill -f` matches against the full command line; the daemon binary is
# now installed as ".../Allofit Service" (renamed copy), so the literal
# "Allofit --service" no longer appears - use a regex that handles both
# the legacy and the renamed binary by anchoring on "Allofit...--service"
vRun pkill -f "Allofit.*--service" >/dev/null 2>&1 || true
# ================== # ==================
# MARK: Cache files # MARK: Cache files
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))")