mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e67da4634 | ||
|
|
bac2ca0287 | ||
|
|
9292c3ee29 | ||
|
|
cc386bef67 | ||
|
|
c8ba0a735c | ||
|
|
1a1235e215 |
+2
-1
@@ -10,8 +10,9 @@ xcuserdata/
|
|||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|
||||||
# Build output produced by ./build-app.sh
|
# Build output produced by ./build-app.sh and ./build-dmg.sh
|
||||||
Allofit.app/
|
Allofit.app/
|
||||||
|
*.dmg
|
||||||
|
|
||||||
# Service log files (also live in /tmp but local copies happen during dev)
|
# Service log files (also live in /tmp but local copies happen during dev)
|
||||||
allofit-service.log
|
allofit-service.log
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,10 +55,19 @@ 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 {
|
||||||
|
autoreleasepool {
|
||||||
NSLog("[Allofit] scanning %@", vRoot.path)
|
NSLog("[Allofit] scanning %@", vRoot.path)
|
||||||
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
||||||
|
autoreleasepool {
|
||||||
vState.lock.lock()
|
vState.lock.lock()
|
||||||
for vRec in vBatch {
|
for vRec in vBatch {
|
||||||
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
||||||
@@ -69,8 +79,10 @@ enum AllofitService {
|
|||||||
vState.dirty = true
|
vState.dirty = true
|
||||||
vState.lock.unlock()
|
vState.lock.unlock()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
|
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
|
||||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||||
@@ -91,14 +103,27 @@ 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 vMatcher.isExcluded(inPath: vChange.path) { return }
|
||||||
if vChange.mustScanSubDirs {
|
if vChange.mustScanSubDirs {
|
||||||
vRescanPrefixes.append(vChange.path)
|
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 vUrl = URL(fileURLWithPath: vChange.path)
|
||||||
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
||||||
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
||||||
@@ -110,18 +135,21 @@ enum AllofitService {
|
|||||||
vState.records.append(vRec)
|
vState.records.append(vRec)
|
||||||
vAdded += 1
|
vAdded += 1
|
||||||
}
|
}
|
||||||
} else if let vIdx = vState.pathIndex[vChange.path] {
|
} else if vState.pathIndex[vChange.path] != nil {
|
||||||
vState.records.remove(at: vIdx)
|
vRemoved.insert(vChange.path)
|
||||||
vState.pathIndex.removeAll(keepingCapacity: true)
|
|
||||||
for (vI, vR) in vState.records.enumerated() {
|
|
||||||
vState.pathIndex[vR.fullPath] = vI
|
|
||||||
}
|
|
||||||
vRemovedCount += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if vAdded + vUpdated + vRemovedCount > 0 {
|
}
|
||||||
|
|
||||||
|
if !vRemoved.isEmpty {
|
||||||
|
// single bulk removeAll + single pathIndex rebuild
|
||||||
|
vState.records.removeAll { vRemoved.contains($0.fullPath) }
|
||||||
|
rebuildPathIndex(vState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vAdded + vUpdated + vRemoved.count > 0 {
|
||||||
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
||||||
vAdded, vUpdated, vRemovedCount, vState.records.count)
|
vAdded, vUpdated, vRemoved.count, vState.records.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !vRescanPrefixes.isEmpty {
|
if !vRescanPrefixes.isEmpty {
|
||||||
@@ -141,10 +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)
|
||||||
|
autoreleasepool {
|
||||||
vState.lock.lock()
|
vState.lock.lock()
|
||||||
let vShouldSave = vState.dirty
|
let vShouldSave = vState.dirty
|
||||||
let vSnapshot = vState.records
|
let vSnapshot = vState.records
|
||||||
vState.dirty = false
|
vState.dirty = false
|
||||||
vState.lock.unlock()
|
vState.lock.unlock()
|
||||||
if vShouldSave {
|
if !vShouldSave { return }
|
||||||
NSLog("[Allofit] autosaving %d records", vSnapshot.count)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreServices
|
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.
|
// AppModel is the central observable state for the application.
|
||||||
// It owns the in-memory file index, drives the background indexer and the
|
// It owns the in-memory file index, drives the background indexer and the
|
||||||
// FSEvents watcher, debounces the search query and exposes a filtered/sorted
|
// 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
|
// last user-facing status string for the Settings buttons
|
||||||
@Published private(set) var workMessage: String = ""
|
@Published private(set) var workMessage: String = ""
|
||||||
// polling backup for reader-mode cache changes; FSEvents alone can miss
|
// polling backup for reader-mode cache changes; FSEvents alone can miss
|
||||||
// updates if the watched directory didn't exist when the stream started
|
// updates if the watched directory didn't exist when the stream started.
|
||||||
private var cachePollTimer: Timer?
|
// 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
|
// last mtime we observed on the cache file - skips needless reloads
|
||||||
private var lastSeenCacheMtime: Date?
|
private var lastSeenCacheMtime: Date?
|
||||||
// minimum gap between two reader-mode reloads. Prevents the Table from
|
// minimum gap between two reader-mode reloads. Prevents the Table from
|
||||||
@@ -124,8 +148,8 @@ final class AppModel: ObservableObject {
|
|||||||
cacheWatcher.stop()
|
cacheWatcher.stop()
|
||||||
autosaveTimer?.invalidate()
|
autosaveTimer?.invalidate()
|
||||||
autosaveTimer = nil
|
autosaveTimer = nil
|
||||||
cachePollTimer?.invalidate()
|
cachePollSource?.cancel()
|
||||||
cachePollTimer = nil
|
cachePollSource = nil
|
||||||
indexerLock?.unlock()
|
indexerLock?.unlock()
|
||||||
indexerLock = nil
|
indexerLock = nil
|
||||||
filterTask?.cancel()
|
filterTask?.cancel()
|
||||||
@@ -217,6 +241,10 @@ 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 {
|
||||||
|
// 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 vBaseline = vAccumulated.count
|
||||||
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -225,6 +253,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
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)
|
||||||
// persist off-main before bouncing back so main never sees the
|
// 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.
|
// 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 {
|
||||||
@@ -448,12 +499,17 @@ final class AppModel: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for vPath in inPaths {
|
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(
|
let vList = FileIndexer.indexRoot(
|
||||||
inRoot: URL(fileURLWithPath: vPath),
|
inRoot: URL(fileURLWithPath: vPath),
|
||||||
inExclusions: inExclusions
|
inExclusions: inExclusions
|
||||||
)
|
)
|
||||||
vKept.append(contentsOf: vList)
|
vKept.append(contentsOf: vList)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let vFinal = vKept
|
let vFinal = vKept
|
||||||
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -482,29 +538,41 @@ final class AppModel: ObservableObject {
|
|||||||
let vDir = vUrl.deletingLastPathComponent().path
|
let vDir = vUrl.deletingLastPathComponent().path
|
||||||
let vTarget = vUrl.path
|
let vTarget = vUrl.path
|
||||||
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
|
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
|
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")
|
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.reloadFromCache()
|
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)
|
vSource.resume()
|
||||||
cachePollTimer = vTimer
|
cachePollSource = vSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// stat()'s the cache file and triggers a reload when mtime changes
|
// 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
|
// on the next runloop tick, avoiding NSTableView reentrance when
|
||||||
// the search field is mid-edit
|
// the search field is mid-edit
|
||||||
DispatchQueue.main.async {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+236
-126
@@ -1,47 +1,133 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
// ContentView is the main window layout: search bar at the top, a results
|
// ContentView is the main window layout: a search bar bonded to the title
|
||||||
// table that mirrors the look of Everything, and a status bar at the bottom.
|
// 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 {
|
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> = []
|
||||||
// SwiftUI Table sort order - clicking a column header updates this
|
// drives the Table's drag-to-reorder and column-visibility customization.
|
||||||
@State private var sortOrder: [KeyPathComparator<FileRecord>] = [
|
// Initial value is hydrated from UserDefaults so the user's column order
|
||||||
KeyPathComparator(\FileRecord.name, order: .forward)
|
// 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
Group {
|
||||||
searchBar
|
if showPreviewPane {
|
||||||
Divider()
|
HSplitView {
|
||||||
resultsTable
|
mainColumn
|
||||||
Divider()
|
.layoutPriority(1)
|
||||||
statusBar
|
.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 {
|
.onAppear {
|
||||||
// align Table sort order with the persisted sort descriptor
|
|
||||||
sortOrder = [Self.comparatorFor(inDescriptor: model.sortDescriptor)]
|
|
||||||
model.start()
|
model.start()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
model.saveCache()
|
model.saveCache()
|
||||||
}
|
}
|
||||||
.onChange(of: sortOrder) { _, vNew in
|
.onChange(of: columnCustomization) { _, vNew in
|
||||||
if let vFirst = vNew.first {
|
// Debounced + off-main save. SwiftUI fires onChange on every
|
||||||
// defer to the next runloop tick so we don't write back into
|
// micro-update during a column drag - encoding synchronously
|
||||||
// the model while NSTableView is still in its sort delegate
|
// on main here would freeze the drag delegate. Cancel any
|
||||||
// callback - that triggers the "reentrant operation in its
|
// pending task and reschedule so we encode at most once per
|
||||||
// NSTableView delegate" warning from AppKit
|
// drag (300 ms after the user lets go).
|
||||||
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
|
columnSaveTask?.cancel()
|
||||||
DispatchQueue.main.async {
|
columnSaveTask = Task.detached(priority: .utility) {
|
||||||
model.sortDescriptor = vDescriptor
|
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 {
|
private var searchBar: some View {
|
||||||
HStack(spacing: 8) {
|
|
||||||
SearchField(
|
SearchField(
|
||||||
text: $model.query,
|
text: $model.query,
|
||||||
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
||||||
initiallyFirstResponder: true
|
initiallyFirstResponder: true
|
||||||
)
|
)
|
||||||
.frame(minHeight: 28)
|
.frame(minHeight: 24)
|
||||||
|
|
||||||
SettingsLink {
|
|
||||||
Image(systemName: "gearshape")
|
|
||||||
}
|
|
||||||
.help("Preferences (⌘,)")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================
|
// ===========================
|
||||||
@@ -71,7 +151,15 @@ struct ContentView: View {
|
|||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
private var resultsTable: some 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
|
TableColumn("Name", value: \FileRecord.name) { vRecord in
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(nsImage: IconCache.icon(
|
Image(nsImage: IconCache.icon(
|
||||||
@@ -82,10 +170,23 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
|
||||||
}
|
}
|
||||||
.width(min: 200, ideal: 320)
|
.width(min: 200, ideal: 320)
|
||||||
|
.customizationID("name")
|
||||||
|
|
||||||
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
|
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
|
||||||
Text(vRecord.parentPath)
|
Text(vRecord.parentPath)
|
||||||
@@ -94,27 +195,36 @@ struct ContentView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.width(min: 200, ideal: 380)
|
.width(min: 200, ideal: 380)
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
.width(90)
|
.width(90)
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
.width(140)
|
.width(140)
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
.width(140)
|
.width(140)
|
||||||
|
.customizationID("modified")
|
||||||
|
} rows: {
|
||||||
|
ForEach(model.visibleRecords) { vRecord in
|
||||||
|
TableRow(vRecord)
|
||||||
|
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
|
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
|
||||||
Button("Open") { openSelection(inIds: vIds) }
|
Button("Open") { openSelection(inIds: vIds) }
|
||||||
@@ -125,13 +235,101 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// MARK: Selection actions
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
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
|
// MARK: Status bar
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
private var statusBar: some View {
|
// 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) {
|
HStack(spacing: 8) {
|
||||||
if model.isIndexing {
|
if model.isIndexing {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -157,92 +355,4 @@ struct ContentView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,14 +49,21 @@ 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) {
|
||||||
@@ -67,6 +74,7 @@ enum FileIndexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !vBatch.isEmpty {
|
if !vBatch.isEmpty {
|
||||||
inBatch(vBatch)
|
inBatch(vBatch)
|
||||||
}
|
}
|
||||||
@@ -96,9 +104,23 @@ enum FileIndexer {
|
|||||||
return vRecords
|
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? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
let vName = vValues.name ?? inURL.lastPathComponent
|
let vName = vValues.name ?? inURL.lastPathComponent
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
.disabled(prefs.serviceMode == .none || model.isWorking)
|
|
||||||
Spacer()
|
|
||||||
if model.isWorking {
|
if model.isWorking {
|
||||||
ProgressView().controlSize(.small)
|
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
|
||||||
|
}
|
||||||
|
.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)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func currentStatusText() -> String {
|
// path of the daemon-renamed binary copy, used in the explanatory blurb
|
||||||
let vUser = ServiceInstaller.isInstalled(inScope: .userAgent) ? "installed" : "not installed"
|
private func daemonBinaryPath(for inScope: ServiceInstaller.Scope) -> String {
|
||||||
let vRoot = ServiceInstaller.isInstalled(inScope: .rootDaemon) ? "installed" : "not installed"
|
return ServiceInstaller.daemonBinaryPath(inScope: inScope)
|
||||||
return "User agent: \(vUser) · Root daemon: \(vRoot)"
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-3
@@ -3,7 +3,12 @@
|
|||||||
# launched like any other macOS app (double-click in Finder, dragged into
|
# launched like any other macOS app (double-click in Finder, dragged into
|
||||||
# /Applications, etc.). Run from the project root:
|
# /Applications, etc.). Run from the project root:
|
||||||
# ./build-app.sh
|
# ./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):
|
# Icon support (optional, first match wins):
|
||||||
# icons/Allofit.icns - pre-built .icns, copied straight in
|
# icons/Allofit.icns - pre-built .icns, copied straight in
|
||||||
@@ -16,10 +21,53 @@ set -euo pipefail
|
|||||||
|
|
||||||
kAppName="Allofit"
|
kAppName="Allofit"
|
||||||
kBundleId="com.bitsycore.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.
|
# ==================
|
||||||
|
# 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}"
|
kVersion="${ALLOFIT_VERSION:-0.0.0}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$vBuildArg" ]]; then
|
||||||
|
kBuildNumber="$vBuildArg"
|
||||||
|
else
|
||||||
kBuildNumber="${ALLOFIT_BUILD:-0}"
|
kBuildNumber="${ALLOFIT_BUILD:-0}"
|
||||||
|
fi
|
||||||
|
|
||||||
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
||||||
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
||||||
|
|||||||
+44
-10
@@ -4,39 +4,69 @@
|
|||||||
# with the .app and an /Applications symlink so the user can drag-install.
|
# with the .app and an /Applications symlink so the user can drag-install.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./build-dmg.sh # rebuilds the .app first, then DMGs it
|
# ./build-dmg.sh # default version (0.0.0)
|
||||||
# ./build-dmg.sh --skip-build # assumes Allofit.app already exists
|
# ./build-dmg.sh --version 1.0.0 # explicit version
|
||||||
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh
|
# ./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.
|
# Output: Allofit-<version>.dmg in the project root.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
kAppName="Allofit"
|
kAppName="Allofit"
|
||||||
kVersion="${ALLOFIT_VERSION:-0.0.0}"
|
|
||||||
|
|
||||||
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
||||||
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
||||||
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
|
|
||||||
vStagingDir="${vProjectRoot}/.build/dmg-staging"
|
vStagingDir="${vProjectRoot}/.build/dmg-staging"
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# MARK: Args
|
||||||
|
# ==================
|
||||||
|
|
||||||
vSkipBuild=0
|
vSkipBuild=0
|
||||||
for vArg in "$@"; do
|
vVersionArg=""
|
||||||
case "$vArg" in
|
while [[ $# -gt 0 ]]; do
|
||||||
--skip-build) vSkipBuild=1 ;;
|
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)
|
-h|--help)
|
||||||
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
|
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown argument: $vArg" >&2
|
echo "Unknown argument: $1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
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
|
if [[ "$vSkipBuild" -eq 0 ]]; then
|
||||||
echo "==> Rebuilding ${kAppName}.app first"
|
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
|
fi
|
||||||
|
|
||||||
if [[ ! -d "${vAppBundle}" ]]; then
|
if [[ ! -d "${vAppBundle}" ]]; then
|
||||||
@@ -44,6 +74,10 @@ if [[ ! -d "${vAppBundle}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# MARK: Stage + create DMG
|
||||||
|
# ==================
|
||||||
|
|
||||||
echo "==> Staging DMG contents"
|
echo "==> Staging DMG contents"
|
||||||
rm -rf "${vStagingDir}"
|
rm -rf "${vStagingDir}"
|
||||||
mkdir -p "${vStagingDir}"
|
mkdir -p "${vStagingDir}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -147,7 +151,20 @@ fi
|
|||||||
|
|
||||||
if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
|
if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
|
||||||
echo "==> Removing service log files"
|
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
|
fi
|
||||||
|
|
||||||
# ==================
|
# ==================
|
||||||
|
|||||||
@@ -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))")
|
|
||||||
Reference in New Issue
Block a user