Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions MacPersistenceChecker/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ final class AppState: ObservableObject {
/// Whether to show snapshots sheet
@Published var showSnapshotsSheet: Bool = false

/// Whether to skip FDA check (persisted - survives app restarts)
@AppStorage("skipFDACheck") var skipFDACheck: Bool = false
/// Whether to skip FDA check for the current app session only.
@Published var skipFDACheckForCurrentSession: Bool = false

/// Sidebar collapsed state
@Published var sidebarCollapsed: Bool = false
Expand Down Expand Up @@ -130,24 +130,46 @@ final class AppState: ObservableObject {
private let scanner = ScannerOrchestrator()
private var cancellables = Set<AnyCancellable>()

private init() {
static let legacySkipFDACheckKey = "skipFDACheck"

init(
defaults: UserDefaults = .standard,
initializeDatabase: Bool = true,
setupBindings: Bool = true,
startBackgroundLoading: Bool = true,
preloadCaches: Bool = true
) {
Self.clearLegacySkipFDACheck(defaults: defaults)

// Ensure database is initialized before we try to load from it
ensureDatabaseInitialized()
setupBindings()
if initializeDatabase {
ensureDatabaseInitialized()
}
if setupBindings {
self.setupBindings()
}
// Heavy DB reads (snapshots, cached scan with 6800+ JSON-decoded items)
// run off the main thread so the UI can show the chrome immediately.
// The @Published assignments hop back to main when the data is ready.
Task.detached(priority: .userInitiated) { [weak self] in
await self?.loadSnapshotsAsync()
await self?.loadCachedScanAsync()
if startBackgroundLoading {
Task.detached(priority: .userInitiated) { [weak self] in
await self?.loadSnapshotsAsync()
await self?.loadCachedScanAsync()
}
}
// Pre-warm the knowledge-graph rule cache + concept cache off the main
// thread so the first filter / eye-toggle never blocks on SQLite.
RuleMatcher.shared.preload()
ConceptStore.shared.preload()
if preloadCaches {
RuleMatcher.shared.preload()
ConceptStore.shared.preload()
}
// Note: Containment and Monitor are initialized lazily to avoid permission prompts on launch
}

static func clearLegacySkipFDACheck(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: legacySkipFDACheckKey)
}

/// Ensure database is initialized (idempotent)
private func ensureDatabaseInitialized() {
if DatabaseManager.shared.dbQueue == nil {
Expand Down
78 changes: 75 additions & 3 deletions MacPersistenceChecker/App/MacPersistenceCheckerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct MacPersistenceCheckerApp: App {
@StateObject private var appState = AppState.shared
@StateObject private var fdaChecker = FullDiskAccessChecker.shared
@StateObject private var monitor = PersistenceMonitor.shared
@Environment(\.scenePhase) private var scenePhase

init() {
// Ensure database initialization runs (static property is lazy)
Expand All @@ -27,16 +28,27 @@ struct MacPersistenceCheckerApp: App {
var body: some Scene {
WindowGroup {
Group {
if fdaChecker.hasFullDiskAccess || appState.skipFDACheck {
ContentView()
.environmentObject(appState)
if fdaChecker.hasFullDiskAccess || appState.skipFDACheckForCurrentSession {
VStack(spacing: 0) {
if appState.skipFDACheckForCurrentSession && !fdaChecker.hasFullDiskAccess {
ReducedVisibilityBanner(appState: appState, fdaChecker: fdaChecker)
}

ContentView()
.environmentObject(appState)
}
} else {
PermissionGuideView()
.environmentObject(fdaChecker)
.environmentObject(appState)
}
}
.frame(minWidth: 1000, minHeight: 600)
.onChange(of: scenePhase) { phase in
if phase == .active {
fdaChecker.refreshAccess(reason: .sceneBecameActive)
}
}
}
.windowStyle(.automatic)
.commands {
Expand Down Expand Up @@ -183,18 +195,78 @@ struct SettingsView: View {
}

struct GeneralSettingsView: View {
@EnvironmentObject var appState: AppState
@AppStorage("autoScanOnLaunch") private var autoScanOnLaunch = true
@AppStorage("showNotifications") private var showNotifications = true

var body: some View {
Form {
Toggle("Scan automatically on launch", isOn: $autoScanOnLaunch)
Toggle("Show notifications for new items", isOn: $showNotifications)

Section {
Button {
FullDiskAccessChecker.shared.refreshAccess(reason: .manual)
} label: {
Label("Check Full Disk Access Again", systemImage: "arrow.clockwise")
}

if appState.skipFDACheckForCurrentSession {
Button {
appState.skipFDACheckForCurrentSession = false
} label: {
Label("Show Full Disk Access Guide", systemImage: "shield")
}
}
} header: {
Text("Full Disk Access")
} footer: {
Text("Run another permission check after changing Full Disk Access in System Settings.")
}
}
.padding()
}
}

private struct ReducedVisibilityBanner: View {
@ObservedObject var appState: AppState
@ObservedObject var fdaChecker: FullDiskAccessChecker

var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)

VStack(alignment: .leading, spacing: 2) {
Text("Running without Full Disk Access")
.font(.subheadline)
.fontWeight(.semibold)
Text("Some persistence locations may be hidden until permission is granted.")
.font(.caption)
.foregroundColor(.secondary)
}

Spacer()

Button {
fdaChecker.refreshAccess(reason: .manual)
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}

Button {
appState.skipFDACheckForCurrentSession = false
} label: {
Label("Show Guide", systemImage: "shield")
}
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.orange.opacity(0.12))
}
}

struct ScanSettingsView: View {
@AppStorage("includeSystemItems") private var includeSystemItems = true
@AppStorage("verifySignatures") private var verifySignatures = true
Expand Down
Loading