mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc386bef67 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user