1 Commits
Author SHA1 Message Date
Bitsy cc386bef67 Add inline preview pane, spacebar Quick Look, sudo-elevated access
Inline preview pane

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

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

Spacebar -> Quick Look

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

Elevated access for root-only files

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

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

Other tidies

  * Formatters.swift consolidates byte-count + date helpers used by
    both Table columns and the preview pane footer.
  * StatusBarView extracted from ContentView so @Published refreshes
    update only the leaf view rather than the Table closure scope.
  * ServiceInstaller's runWithAdminPrivileges and shellQuote now
    delegate to AdminShell so admin escalation has one definition.
2026-06-15 17:02:53 +02:00
8 changed files with 553 additions and 81 deletions
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import SwiftUI
// AccessManager holds the in-memory mapping from FileRecord.ID to the
// staged user-readable copy produced by an "Authorize" tap. Both the
// Table (which shows a lock badge on the selected row when the preview
// pane is closed) and the PreviewPane (which gates the QLPreviewView
// behind the same badge) observe this so the badge disappears and the
// preview switches to the staged URL as soon as the sudo copy lands.
//
// All published mutations happen on the main actor; the actual sudo cp
// runs inside a Task.detached spawned by `authorize(_:)` so the AppleScript
// password prompt doesn't block the main runloop.
@MainActor
final class AccessManager: ObservableObject {
// id -> URL of the user-readable copy in ~/Library/Caches/Allofit/elevated
@Published private(set) var stagedURLs: [FileRecord.ID: URL] = [:]
// ids currently being authorized (admin script in flight); used to
// render a small spinner inside the lock badge
@Published private(set) var authorizingIds: Set<FileRecord.ID> = []
// most recent authorization error (cancelled prompt, sudo failure,
// etc); surfaced as the badge's accessibility / tooltip text
@Published private(set) var lastError: String?
// returns the URL to use when previewing / opening the file: the
// staged copy if we have one, otherwise the original path
func effectiveURL(for inRecord: FileRecord) -> URL {
if let vStaged = stagedURLs[inRecord.id] {
return vStaged
}
return URL(fileURLWithPath: inRecord.fullPath)
}
// true if the effective URL (staged or original) isn't readable by
// the current user - this is what drives the lock-badge visibility
func needsAuthorization(for inRecord: FileRecord) -> Bool {
let vUrl = effectiveURL(for: inRecord)
return !ElevatedAccess.canRead(path: vUrl.path)
}
// true while an authorize task is in flight for the given record id
func isAuthorizing(_ inId: FileRecord.ID) -> Bool {
return authorizingIds.contains(inId)
}
// runs the sudo cp + chown flow for one record. The first call inside
// the system's admin-auth-cache window (~5 min) prompts for the
// password; subsequent calls within that window are silent.
func authorize(_ inRecord: FileRecord) async {
let vId = inRecord.id
// idempotency: a double-tap on the badge shouldn't kick off two
// concurrent admin scripts for the same file
guard !authorizingIds.contains(vId) else { return }
authorizingIds.insert(vId)
lastError = nil
let vOriginalUrl = URL(fileURLWithPath: inRecord.fullPath)
do {
// Task.detached so the synchronous NSAppleScript admin prompt
// runs on a background thread; the prompt itself is shown on
// main by AppKit regardless of where we invoke it from
let vStaged = try await Task.detached(priority: .userInitiated) {
try ElevatedAccess.stage(vOriginalUrl)
}.value
stagedURLs[vId] = vStaged
} catch {
lastError = error.localizedDescription
}
authorizingIds.remove(vId)
}
// wipes the in-memory mapping. Called after ElevatedAccess.cleanup()
// removes the on-disk files so the two stay consistent.
func reset() {
stagedURLs.removeAll()
authorizingIds.removeAll()
lastError = nil
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
import AppKit
// AdminShell runs short shell commands with administrator privileges by
// wrapping them in `do shell script ... with administrator privileges`
// via NSAppleScript. The system shows its native password prompt the
// first time within a session; subsequent calls inside the auth-cache
// window (about 5 minutes) re-use the credential without re-prompting.
//
// Used by both ServiceInstaller (LaunchDaemon install/uninstall and
// cache-clear-and-restart) and ElevatedAccess (sudo cp of a single
// unreadable file into the per-user staging cache).
enum AdminShell {
// surfaces an AppleScript failure - typically the user clicked
// Cancel on the password prompt, or the embedded shell command
// returned a non-zero exit code
enum Error: Swift.Error, LocalizedError {
case scriptFailed(String)
var errorDescription: String? {
switch self {
case .scriptFailed(let vMsg): return vMsg
}
}
}
// runs inScript as root via NSAppleScript. Returns the script's
// stdout. Throws Error.scriptFailed if NSAppleScript reports an
// error (cancelled prompt, non-zero shell exit, etc).
@discardableResult
static func run(_ inScript: String) throws -> String {
// AppleScript string literal needs backslashes and double quotes
// escaped before we embed the shell command
let vEscaped = inScript
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let vSource = "do shell script \"\(vEscaped)\" with administrator privileges"
let vAppleScript = NSAppleScript(source: vSource)
var vErr: NSDictionary?
let vResult = vAppleScript?.executeAndReturnError(&vErr)
guard let vDescriptor = vResult else {
let vMessage = vErr?[NSAppleScript.errorMessage] as? String
?? "Authorization cancelled or failed"
throw Error.scriptFailed(vMessage)
}
return vDescriptor.stringValue ?? ""
}
// POSIX-style single-quote escape so a string can be safely embedded
// inside the inScript argument of run(_:). Each embedded single
// quote becomes the escape sequence '\''. Use for any user-supplied
// path or argument; literal command names should not be quoted.
static func quote(_ inString: String) -> String {
return "'" + inString.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
+17
View File
@@ -10,16 +10,22 @@ struct AllofitApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
// shared application state injected into the view tree // shared application state injected into the view tree
@StateObject private var model = AppModel() @StateObject private var model = AppModel()
// session-scoped store of sudo-staged user-readable copies. Sits
// alongside AppModel so both the Table (lock badge on rows) and
// the PreviewPane observe the same authorization state.
@StateObject private var access = AccessManager()
var body: some Scene { var body: some Scene {
WindowGroup("Allofit") { WindowGroup("Allofit") {
ContentView() ContentView()
.environmentObject(model) .environmentObject(model)
.environmentObject(Preferences.shared) .environmentObject(Preferences.shared)
.environmentObject(access)
.frame(minWidth: 760, minHeight: 480) .frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker()) .background(MainWindowMarker())
} }
.windowToolbarStyle(.unified) .windowToolbarStyle(.unified)
.defaultSize(width: 1100, height: 640)
.commands { .commands {
// custom About panel with a clickable repo link in the credits // custom About panel with a clickable repo link in the credits
CommandGroup(replacing: .appInfo) { CommandGroup(replacing: .appInfo) {
@@ -46,6 +52,7 @@ struct AllofitApp: App {
SettingsView() SettingsView()
.environmentObject(model) .environmentObject(model)
.environmentObject(Preferences.shared) .environmentObject(Preferences.shared)
.environmentObject(access)
} }
} }
} }
@@ -126,6 +133,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// bundle - covers the SwiftPM "swift run" case // bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
// wipe any elevated-access staging files left over from a previous
// run so a crash or hard-kill doesn't accumulate privileged copies
// in ~/Library/Caches across sessions
ElevatedAccess.cleanup()
// bring the main window to the front so it accepts keystrokes // bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async { DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey { for vWindow in NSApp.windows where vWindow.canBecomeKey {
@@ -136,6 +147,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
// called on clean Cmd+Q quit; wipes the elevated-access staging dir
// so the user-readable copies of privileged files don't linger
func applicationWillTerminate(_ notification: Notification) {
ElevatedAccess.cleanup()
}
// keep the process alive when the user closes the last window: the index // keep the process alive when the user closes the last window: the index
// stays in RAM and clicking the dock icon snaps a new window up instantly. // stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item. // Cmd+Q still quits via the standard Quit menu item.
+83 -67
View File
@@ -3,12 +3,13 @@ import AppKit
// ContentView is the main window layout: a search bar bonded to the title // ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material // bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table that fills the body, and a status bar at // on macOS 15), a results table on the left, a Quick Look preview pane on
// the bottom. The Settings gear sits permanently in the window toolbar. // the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences @EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager
@State private var selection: Set<FileRecord.ID> = [] @State private var selection: Set<FileRecord.ID> = []
// drives the Table's drag-to-reorder and column-visibility customization. // drives the Table's drag-to-reorder and column-visibility customization.
// Initial value is hydrated from UserDefaults so the user's column order // Initial value is hydrated from UserDefaults so the user's column order
@@ -18,15 +19,16 @@ struct ContentView: View {
// Cancelled+rescheduled per change so a drag (which fires onChange on // Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main. // every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>? @State private var columnSaveTask: Task<Void, Never>?
// whether the right-hand preview pane is currently visible. Persisted
// across launches so the user's pane-visibility preference sticks.
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization" private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000 private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
// Computed binding for the Table's sortOrder: reads/writes // Computed binding for the Table's sortOrder: reads/writes
// model.sortDescriptor directly so the sort state survives any number // model.sortDescriptor directly so the sort state survives any number
// of window closes / reopens (the previous `@State sortOrder` got // of window closes / reopens.
// reset whenever the view was recreated, and the onChange-syncing
// dance occasionally didn't re-wire properly after a window reopen).
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> { private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding( Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] }, get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
@@ -44,14 +46,30 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) { Group {
searchBar if showPreviewPane {
resultsTable HSplitView {
Divider() mainColumn
StatusBarView() // isolated so its @Published refresh .layoutPriority(1)
// doesn't re-evaluate the Table closure .frame(minWidth: 460)
PreviewPane(selection: selection)
.frame(minWidth: 200, idealWidth: 360)
}
} else {
mainColumn
}
} }
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showPreviewPane.toggle()
} label: {
Image(systemName: showPreviewPane
? "sidebar.right"
: "sidebar.squares.right")
}
.help(showPreviewPane ? "Hide preview" : "Show preview")
}
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
SettingsLink { SettingsLink {
Image(systemName: "gearshape") Image(systemName: "gearshape")
@@ -80,12 +98,21 @@ struct ContentView: View {
} }
} }
// search bar + table + status bar - everything except the preview pane
private var mainColumn: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
}
}
// =========================== // ===========================
// MARK: Column customization persistence // MARK: Column customization persistence
// =========================== // ===========================
// loads the previously-saved column order/visibility from UserDefaults,
// or returns a fresh default if nothing was saved or decoding fails
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> { private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey), guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode( let vCustom = try? JSONDecoder().decode(
@@ -98,10 +125,6 @@ struct ContentView: View {
return vCustom return vCustom
} }
// persists the current column order/visibility to UserDefaults.
// nonisolated so the debounced background task can call it without an
// actor hop - the encode is the only non-trivial step and we want it
// genuinely off-main during column drags.
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) { private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return } guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey) UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
@@ -111,10 +134,6 @@ struct ContentView: View {
// MARK: Search bar // MARK: Search bar
// =========================== // ===========================
// Always-visible row at the top. `.background(.bar)` uses the system
// "bar" material, which sits right below the toolbar with the same
// vibrancy treatment - on macOS 26 this is the Liquid Glass surface,
// on macOS 15 it's the standard chrome material.
private var searchBar: some View { private var searchBar: some View {
SearchField( SearchField(
text: $model.query, text: $model.query,
@@ -132,16 +151,11 @@ struct ContentView: View {
// =========================== // ===========================
private var resultsTable: some View { private var resultsTable: some View {
// Uses the explicit `rows:` form of Table so we can attach `.draggable` // Uses the explicit `rows:` form of Table so `.draggable` lives on
// to TableRow rather than to cell content. Putting `.draggable` on // TableRow rather than embedded in cell content. Cell-content
// cell content installs a SwiftUI drag-gesture recognizer that // draggable installs a SwiftUI drag-gesture recognizer that races
// competes with NSTableView's mouseDown → selection event on // with NSTableView's mouseDown→selection event on macOS 26 and
// macOS 26 - the recognizer's "should this be a drag?" decision // occasionally eats left-clicks; row-level draggable doesn't.
// delays and occasionally eats the click, leaving the row never
// selected even though right-click (which bypasses the drag gesture
// entirely) still works. Row-level `.draggable` puts the drag at
// the same scope as NSTableView's own row-drag machinery and leaves
// the click path clean.
Table(of: FileRecord.self, Table(of: FileRecord.self,
selection: $selection, selection: $selection,
sortOrder: sortOrderBinding, sortOrder: sortOrderBinding,
@@ -156,6 +170,19 @@ struct ContentView: View {
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
Text(vRecord.name) Text(vRecord.name)
.lineLimit(1) .lineLimit(1)
// When the preview pane is closed, surface the
// elevate-permission affordance on the selected row
// itself so the user has a way to authorize without
// having to open the pane first. needsAuthorization
// is a stat() call so we only invoke it for the row
// that's actually selected.
if !showPreviewPane,
selection.count == 1,
selection.contains(vRecord.id),
access.needsAuthorization(for: vRecord) {
Spacer(minLength: 4)
AuthorizeBadge(record: vRecord)
}
} }
} }
.width(min: 200, ideal: 320) .width(min: 200, ideal: 320)
@@ -171,7 +198,7 @@ struct ContentView: View {
.customizationID("path") .customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Self.formatSize(inBytes: vRecord.size)) Text(vRecord.isDirectory ? "—" : Formatters.size(bytes: vRecord.size))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -179,7 +206,7 @@ struct ContentView: View {
.customizationID("size") .customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateCreated)) Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -187,7 +214,7 @@ struct ContentView: View {
.customizationID("created") .customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateModified)) Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit() .monospacedDigit()
} }
@@ -208,6 +235,16 @@ struct ContentView: View {
} primaryAction: { vIds in } primaryAction: { vIds in
openSelection(inIds: vIds) openSelection(inIds: vIds)
} }
// Finder-style spacebar Quick Look. .onKeyPress only fires when the
// view (Table) has keyboard focus, so spaces typed into the search
// field still produce literal spaces in the query.
.onKeyPress(.space) {
guard !selection.isEmpty else { return .ignored }
let vUrls = recordsFor(inIds: selection)
.map { URL(fileURLWithPath: $0.fullPath) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
return .handled
}
} }
// =========================== // ===========================
@@ -215,24 +252,32 @@ struct ContentView: View {
// =========================== // ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) { private func revealSelection(inIds: Set<FileRecord.ID>) {
// reveal in Finder shows the *original* file (not the staged copy),
// since the user wants to navigate to the real location on disk
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) } let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls) NSWorkspace.shared.activateFileViewerSelecting(vUrls)
} }
private func quickLookSelection(inIds: Set<FileRecord.ID>) { private func quickLookSelection(inIds: Set<FileRecord.ID>) {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) } // prefer the staged URL when one exists - QLPreviewPanel renders
// it without permission issues, whereas the original would fail
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) }
QuickLookCoordinator.shared.show(inUrls: vUrls) QuickLookCoordinator.shared.show(inUrls: vUrls)
} }
private func copyPaths(inIds: Set<FileRecord.ID>) { private func copyPaths(inIds: Set<FileRecord.ID>) {
// always copy the original path - the staged tmp path is an
// implementation detail that has no meaning outside this session
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath } let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string) NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
} }
private func openSelection(inIds: Set<FileRecord.ID>) { private func openSelection(inIds: Set<FileRecord.ID>) {
// open the staged copy when available so the default app can read
// it; falls back to the original path for files we can read directly
for vRecord in recordsFor(inIds: inIds) { for vRecord in recordsFor(inIds: inIds) {
NSWorkspace.shared.open(URL(fileURLWithPath: vRecord.fullPath)) NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
} }
} }
@@ -244,7 +289,6 @@ struct ContentView: View {
// MARK: Sort mapping // MARK: Sort mapping
// =========================== // ===========================
// converts a Table sort comparator into the model's FileSortDescriptor
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor { private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath let vKp = inComparator.keyPath
@@ -256,7 +300,6 @@ struct ContentView: View {
return .nameAscending return .nameAscending
} }
// returns the matching comparator for a given persisted sort descriptor
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> { private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor { switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward) case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
@@ -271,32 +314,6 @@ struct ContentView: View {
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse) case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
} }
} }
// ===========================
// MARK: Formatting helpers
// ===========================
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
fileprivate static func formatSize(inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
fileprivate static func formatDate(inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
} }
// =========================== // ===========================
@@ -305,9 +322,8 @@ struct ContentView: View {
// Extracted into its own View so its @Published-driven refreshes (cache // Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip) // load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small view rather than the ContentView body that // only re-evaluate this small leaf view rather than the ContentView body
// contains the Table. SwiftUI's dependency tracking is per-View, so an // that contains the Table.
// isolated leaf observer doesn't churn the Table's closure scope.
private struct StatusBarView: View { private struct StatusBarView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
+76
View File
@@ -0,0 +1,76 @@
import Foundation
// ElevatedAccess provides on-demand sudo-backed access to files the GUI
// user can't read directly. Common case: the root LaunchDaemon indexed
// `/Users/<otheruser>/...` (it has Full Disk Access), the GUI runs as
// the current user, and trying to render an inline preview hits a
// permission denial. The user clicks "Authorize" in the preview pane,
// AdminShell prompts for the password once, sudo copies the file to
// the per-user staging directory and chowns it to the GUI user.
//
// The staged copy is owned by the GUI user, lives in
// ~/Library/Caches/Allofit/elevated/
// and is wiped at app launch and at app quit so privileged copies don't
// linger on disk across sessions.
enum ElevatedAccess {
// per-user staging directory; lives under Library/Caches so macOS
// itself may purge it under disk-pressure, and our own cleanup() at
// launch + terminate keeps it from accumulating
static var stagingDirectory: URL {
let vCaches = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!
return vCaches.appendingPathComponent("Allofit/elevated", isDirectory: true)
}
// true if the current user can read the file at inPath without elevation
static func canRead(path inPath: String) -> Bool {
return FileManager.default.isReadableFile(atPath: inPath)
}
// wipes anything in the staging directory. Called on app launch (so a
// previous session's elevated copies don't survive a relaunch) and on
// app terminate (so they don't survive a clean quit either). Failure
// is silent - if cleanup fails the next launch's cleanup will retry.
static func cleanup() {
try? FileManager.default.removeItem(at: stagingDirectory)
}
// copies inUrl into the staging directory via sudo, chowns it to the
// current user, and returns the staged URL. Caller is responsible for
// catching AdminShell.Error.scriptFailed (cancelled prompt etc).
//
// Throws if the parent staging directory can't be created or the
// admin script fails. Side effect: the system prompts for password
// the first time within the auth-cache window.
static func stage(_ inUrl: URL) throws -> URL {
let vDir = stagingDirectory
// create as the current user so the dir is owned by us; sudo
// only handles the file copy itself
try FileManager.default.createDirectory(
at: vDir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
var vDst = vDir.appendingPathComponent(UUID().uuidString)
let vExt = inUrl.pathExtension
if !vExt.isEmpty {
vDst.appendPathExtension(vExt)
}
// cp + chown to the current user. The chmod restores plain user
// rw / group+other r so the file is treated normally by QL etc.
let vScript = """
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \
chmod 0644 \(AdminShell.quote(vDst.path))
"""
_ = try AdminShell.run(vScript)
return vDst
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
// Formatters bundles the byte-count and date formatting used by both
// the Table's columns and the right-hand preview pane footer. Keeping
// the formatter instances cached at file scope avoids reconstructing
// them per row render, which would be expensive at 5 000 rows.
enum Formatters {
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF
}()
// human-friendly byte count, e.g. "1.2 MB"
static func size(bytes inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
vF.timeStyle = .short
return vF
}()
// short date+time, with em-dash for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
}
+200
View File
@@ -0,0 +1,200 @@
import SwiftUI
import AppKit
import Quartz
// QuickLookPreviewView wraps Quartz's QLPreviewView so an inline Quick
// Look preview can be embedded inside a SwiftUI hierarchy. The same
// renderer powers the floating QLPreviewPanel (spacebar), so file-type
// coverage (PDFs, images, video, source files, plists, etc.) is
// identical between the inline pane and the floating panel.
struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
func makeNSView(context: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
// keep the view alive when the parent window closes - we own its
// lifetime via SwiftUI, not via QLPreviewPanel's modal behaviour
vView.shouldCloseWithWindow = false
vView.autostarts = true
return vView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let vQlView = nsView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// AuthorizeBadge is the small lock icon that appears either inside the
// preview pane (when the selected file isn't user-readable) or at the
// right of the selected row when the preview pane is closed. Clicking
// it kicks off the sudo cp + chown via AdminShell - the system prompts
// for the password the first time inside the admin-auth-cache window.
struct AuthorizeBadge: View {
@EnvironmentObject var access: AccessManager
let record: FileRecord
var body: some View {
Button {
Task { await access.authorize(record) }
} label: {
if access.isAuthorizing(record.id) {
ProgressView()
.controlSize(.small)
.frame(width: 16, height: 16)
} else {
Image(systemName: "lock.shield.fill")
.foregroundStyle(.orange)
.font(.system(size: 14, weight: .semibold))
}
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(record.id))
.help(access.isAuthorizing(record.id)
? "Authorizing…"
: "Authorize to read this file")
}
}
// PreviewPane is the right-hand side panel in the main window. When
// exactly one row is selected it renders a Quick Look preview plus a
// small metadata footer. If the file isn't user-readable the preview
// area becomes a single big tap-target showing a lock icon - clicking
// it (or the badge that appears on the selected row when the pane is
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var access: AccessManager
// passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes
let selection: Set<FileRecord.ID>
private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil }
return model.visibleRecords.first(where: { $0.id == vId })
}
var body: some View {
VStack(spacing: 0) {
if let vRecord = selectedRecord {
content(for: vRecord)
} else {
emptyState
}
}
.background(Color(NSColor.controlBackgroundColor))
}
// preview + metadata footer for one selected record
private func content(for inRecord: FileRecord) -> some View {
let vEffectiveUrl = access.effectiveURL(for: inRecord)
let vReadable = ElevatedAccess.canRead(path: vEffectiveUrl.path)
return VStack(spacing: 0) {
Group {
if vReadable {
QuickLookPreviewView(url: vEffectiveUrl)
} else {
authorizePrompt(for: inRecord)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
metadata(for: inRecord)
}
}
// full-area authorize hint shown when the selected file isn't
// user-readable. The whole area is the button target so users
// can click anywhere over the locked preview to authorize.
private func authorizePrompt(for inRecord: FileRecord) -> some View {
Button {
Task { await access.authorize(inRecord) }
} label: {
VStack(spacing: 10) {
if access.isAuthorizing(inRecord.id) {
ProgressView()
.controlSize(.regular)
} else {
Image(systemName: "lock.shield.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
}
Text(access.isAuthorizing(inRecord.id)
? "Authorizing…"
: "Click to authorize preview")
.font(.callout)
.foregroundColor(.secondary)
if let vErr = access.lastError, !access.isAuthorizing(inRecord.id) {
Text(vErr)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(inRecord.id))
}
// thin metadata bar at the bottom of the preview pane
private func metadata(for inRecord: FileRecord) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(inRecord.name)
.font(.headline)
.lineLimit(2)
.truncationMode(.middle)
Text(inRecord.parentPath)
.font(.caption)
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.textSelection(.enabled)
HStack(spacing: 6) {
if !inRecord.isDirectory {
Text(Formatters.size(bytes: inRecord.size))
.monospacedDigit()
Text("·")
}
Text("Modified \(Formatters.date(inRecord.dateModified))")
.monospacedDigit()
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
// placeholder shown when nothing or multiple rows are selected
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "eye.slash")
.font(.system(size: 32))
.foregroundColor(.secondary.opacity(0.6))
Text(placeholderText)
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var placeholderText: String {
if selection.isEmpty {
return "Select a file to preview"
}
return "\(selection.count) items selected"
}
}
+10 -14
View File
@@ -188,24 +188,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)
} }
} }