Skip to content

Commit 0b8549f

Browse files
committed
Release v1.4.0 - App Invasiveness Report & Real-time Monitoring
New Features: - App Invasiveness Report: Analyze apps by persistence mechanisms and installation footprint - Dual Scoring System: Separate persistence score (triggers, privileges, resilience) and junk score (bloat, orphans, spread) - Grade System: Apps rated A through F for quick assessment - Menu Bar Integration: Quick access to monitoring controls - Real-time Monitoring: FSEvents-based persistence change detection with noise suppression - Safe Containment Mode: Disable persistence items with network blocking Improvements: - On-demand size calculation for faster scans - Sortable app lists (by score, size, persistence, name) - Tabbed detail view with score breakdown - Build script (build.sh) for easy compilation Technical: - InvasivenessScorer for comprehensive scoring - AppInvasivenessAnalyzer for Library folder scanning - PersistenceMonitor with DirectoryWatcher (FSEvents) - NetworkBlocker with socketfilterfw/pfctl - MonitorBaseline for change detection
1 parent fc83871 commit 0b8549f

28 files changed

Lines changed: 9511 additions & 97 deletions

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Build
2+
.build/
3+
DerivedData/
4+
*.xcodeproj/xcuserdata/
5+
*.xcworkspace/xcuserdata/
6+
7+
# macOS
8+
.DS_Store
9+
*.swp
10+
*~
11+
12+
# Temporary files
13+
GenerateIcon.swift
14+
icona.png
15+
*.iconset/
16+
17+
# App bundle (built separately)
18+
*.app/
19+
20+
# Package manager
21+
.swiftpm/
22+
Packages/

MacPersistenceChecker/App/AppState.swift

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ import Foundation
22
import SwiftUI
33
import Combine
44

5+
// Debug helper
6+
extension String {
7+
func appendToFile(_ path: String) throws {
8+
if let handle = FileHandle(forWritingAtPath: path) {
9+
handle.seekToEndOfFile()
10+
handle.write(self.data(using: .utf8)!)
11+
handle.closeFile()
12+
} else {
13+
try self.write(toFile: path, atomically: true, encoding: .utf8)
14+
}
15+
}
16+
}
17+
518
/// Global application state
619
@MainActor
720
final class AppState: ObservableObject {
@@ -65,6 +78,23 @@ final class AppState: ObservableObject {
6578
/// Item to show in focused graph view (nil = show full graph)
6679
@Published var focusedGraphItem: PersistenceItem? = nil
6780

81+
// MARK: - Graph Detail Window
82+
83+
/// Node to show in detail window
84+
@Published var graphDetailNode: GraphNode? = nil
85+
86+
/// Edge to show in detail window
87+
@Published var graphDetailEdge: GraphEdge? = nil
88+
89+
/// Source node for edge detail
90+
@Published var graphDetailSourceNode: GraphNode? = nil
91+
92+
/// Target node for edge detail
93+
@Published var graphDetailTargetNode: GraphNode? = nil
94+
95+
/// Persistence item for detailed view (linked from node)
96+
@Published var graphDetailPersistenceItem: PersistenceItem? = nil
97+
6898
// MARK: - Snapshots
6999

70100
/// Available snapshots
@@ -73,14 +103,59 @@ final class AppState: ObservableObject {
73103
/// Current snapshot being viewed
74104
@Published var currentSnapshot: Snapshot? = nil
75105

106+
// MARK: - Monitoring State
107+
108+
/// Whether real-time monitoring is active
109+
@Published var isMonitoring: Bool = false
110+
111+
/// Last detected change from monitor
112+
@Published var lastMonitorChange: MonitorChange? = nil
113+
114+
/// Count of unacknowledged changes
115+
@Published var unacknowledgedChangeCount: Int = 0
116+
76117
// MARK: - Private
77118

78119
private let scanner = ScannerOrchestrator()
79120
private var cancellables = Set<AnyCancellable>()
80121

81122
private init() {
123+
// Ensure database is initialized before we try to load from it
124+
ensureDatabaseInitialized()
82125
setupBindings()
83126
loadSnapshots()
127+
loadCachedScan()
128+
// Note: Containment and Monitor are initialized lazily to avoid permission prompts on launch
129+
}
130+
131+
/// Ensure database is initialized (idempotent)
132+
private func ensureDatabaseInitialized() {
133+
if DatabaseManager.shared.dbQueue == nil {
134+
do {
135+
try DatabaseManager.shared.initialize()
136+
NSLog("[AppState] Database initialized")
137+
} catch {
138+
NSLog("[AppState] Failed to initialize database: %@", error.localizedDescription)
139+
}
140+
} else {
141+
NSLog("[AppState] Database already initialized")
142+
}
143+
}
144+
145+
/// Load cached scan results from database
146+
private func loadCachedScan() {
147+
NSLog("[AppState] Loading cached scan...")
148+
do {
149+
if let cached = try DatabaseManager.shared.loadLastScan() {
150+
items = cached.items
151+
lastScanDate = cached.scanDate
152+
NSLog("[AppState] Loaded %d items from cache", cached.items.count)
153+
} else {
154+
NSLog("[AppState] No cached scan found")
155+
}
156+
} catch {
157+
NSLog("[AppState] Failed to load cached scan: %@", error.localizedDescription)
158+
}
84159
}
85160

86161
private func setupBindings() {
@@ -187,12 +262,29 @@ final class AppState: ObservableObject {
187262

188263
/// Scan all categories
189264
func scanAll() async {
265+
let debugFile = "/tmp/mpc_scan_debug.log"
266+
try? "Scan started at \(Date())\n".write(toFile: debugFile, atomically: true, encoding: .utf8)
267+
268+
// Clear previous results to show we're starting fresh
269+
items = []
270+
selectedItem = nil
271+
190272
let startTime = CFAbsoluteTimeGetCurrent()
191273
items = await scanner.scanAll()
192274
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
193-
print("⚡️ Scan completed in \(String(format: "%.2f", elapsed)) seconds - Found \(items.count) items")
194275
lastScanDate = Date()
195276

277+
try? "Scan completed: \(items.count) items in \(elapsed)s\n".appendToFile(debugFile)
278+
279+
// Save to cache for next app launch
280+
do {
281+
try? "Saving to cache...\n".appendToFile(debugFile)
282+
try DatabaseManager.shared.saveLastScan(items: items, scanDate: lastScanDate!)
283+
try? "Cache saved OK!\n".appendToFile(debugFile)
284+
} catch {
285+
try? "Cache save FAILED: \(error)\n".appendToFile(debugFile)
286+
}
287+
196288
// Create automatic snapshot if first scan
197289
if snapshots.isEmpty {
198290
await createSnapshot(trigger: .firstLaunch)
@@ -259,6 +351,71 @@ final class AppState: ObservableObject {
259351
var totalCount: Int {
260352
items.count
261353
}
354+
355+
// MARK: - Monitoring Methods
356+
357+
/// Whether monitor bindings have been setup
358+
private var monitorBindingsSetup = false
359+
360+
/// Setup bindings to PersistenceMonitor (lazy - called on first monitoring access)
361+
private func ensureMonitorBindings() {
362+
guard !monitorBindingsSetup else { return }
363+
monitorBindingsSetup = true
364+
365+
let monitor = PersistenceMonitor.shared
366+
367+
// Sync monitoring state
368+
monitor.$state
369+
.map { $0 == .running }
370+
.receive(on: RunLoop.main)
371+
.assign(to: &$isMonitoring)
372+
373+
// Sync last change
374+
monitor.$lastChange
375+
.receive(on: RunLoop.main)
376+
.assign(to: &$lastMonitorChange)
377+
378+
// Sync unacknowledged count
379+
monitor.$unacknowledgedCount
380+
.receive(on: RunLoop.main)
381+
.assign(to: &$unacknowledgedChangeCount)
382+
}
383+
384+
/// Toggle real-time monitoring
385+
func toggleMonitoring() async {
386+
ensureMonitorBindings()
387+
await PersistenceMonitor.shared.toggleMonitoring()
388+
}
389+
390+
/// Start monitoring
391+
func startMonitoring() async {
392+
ensureMonitorBindings()
393+
await PersistenceMonitor.shared.startMonitoring()
394+
}
395+
396+
/// Stop monitoring
397+
func stopMonitoring() {
398+
ensureMonitorBindings()
399+
PersistenceMonitor.shared.stopMonitoring()
400+
}
401+
402+
/// Update monitoring baseline with current items
403+
func updateMonitorBaseline() async throws {
404+
ensureMonitorBindings()
405+
try await PersistenceMonitor.shared.updateBaseline()
406+
}
407+
408+
/// Acknowledge all pending changes
409+
func acknowledgeAllChanges() {
410+
ensureMonitorBindings()
411+
PersistenceMonitor.shared.acknowledgeAllChanges()
412+
}
413+
414+
/// Get change history
415+
func getChangeHistory(limit: Int = 100) -> [ChangeHistoryEntry] {
416+
ensureMonitorBindings()
417+
return PersistenceMonitor.shared.getChangeHistory(limit: limit)
418+
}
262419
}
263420

264421
// MARK: - Sort Order

MacPersistenceChecker/App/MacPersistenceCheckerApp.swift

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@ import SwiftUI
22

33
@main
44
struct MacPersistenceCheckerApp: App {
5-
@StateObject private var appState = AppState.shared
6-
@StateObject private var fdaChecker = FullDiskAccessChecker.shared
7-
8-
init() {
9-
// Initialize database
5+
// IMPORTANT: Database must be initialized before AppState.shared is accessed
6+
// This static initializer runs before @StateObject property initializers
7+
private static let _initializeDatabase: Void = {
108
do {
119
try DatabaseManager.shared.initialize()
10+
print("[App] Database initialized successfully")
1211
} catch {
13-
print("Failed to initialize database: \(error)")
12+
print("[App] Failed to initialize database: \(error)")
1413
}
14+
}()
15+
16+
@StateObject private var appState = AppState.shared
17+
@StateObject private var fdaChecker = FullDiskAccessChecker.shared
18+
@StateObject private var monitor = PersistenceMonitor.shared
19+
20+
init() {
21+
// Ensure database initialization runs (static property is lazy)
22+
_ = Self._initializeDatabase
23+
// Note: Monitor is NOT auto-started here
24+
// User must explicitly enable monitoring in Settings → Monitoring
1525
}
1626

1727
var body: some Scene {
@@ -41,10 +51,41 @@ struct MacPersistenceCheckerApp: App {
4151
.windowStyle(.automatic)
4252
.defaultSize(width: 1000, height: 700)
4353

54+
// Graph Detail Window (opens on double-click)
55+
Window("Graph Detail", id: "graph-detail-window") {
56+
GraphDetailWindowView()
57+
.environmentObject(appState)
58+
}
59+
.windowStyle(.automatic)
60+
.defaultSize(width: 900, height: 650)
61+
62+
// App Invasiveness Report Window
63+
Window("App Invasiveness Report", id: "invasiveness-window") {
64+
AppInvasivenessView()
65+
.environmentObject(appState)
66+
}
67+
.windowStyle(.automatic)
68+
.defaultSize(width: 1000, height: 700)
69+
4470
Settings {
4571
SettingsView()
4672
.environmentObject(appState)
4773
}
74+
75+
// Menu Bar Extra for quick monitoring control
76+
MenuBarExtra {
77+
MenuBarView(monitor: monitor, appState: appState)
78+
} label: {
79+
HStack(spacing: 4) {
80+
Image(systemName: monitor.isMonitoring ? "shield.checkered" : "shield")
81+
.symbolRenderingMode(.hierarchical)
82+
if monitor.unacknowledgedCount > 0 {
83+
Text("\(monitor.unacknowledgedCount)")
84+
.font(.caption2)
85+
}
86+
}
87+
}
88+
.menuBarExtraStyle(.window)
4889
}
4990
}
5091

@@ -82,12 +123,17 @@ struct SettingsView: View {
82123
Label("Extended Scanners", systemImage: "plus.circle")
83124
}
84125

126+
MonitoringSettingsView()
127+
.tabItem {
128+
Label("Monitoring", systemImage: "eye")
129+
}
130+
85131
AboutView()
86132
.tabItem {
87133
Label("About", systemImage: "info.circle")
88134
}
89135
}
90-
.frame(width: 550, height: 500)
136+
.frame(width: 550, height: 600)
91137
}
92138
}
93139

@@ -268,7 +314,7 @@ struct AboutView: View {
268314
.font(.title)
269315
.fontWeight(.bold)
270316

271-
Text("Version 1.3.0")
317+
Text("Version 1.4.0")
272318
.foregroundColor(.secondary)
273319

274320
Text("A comprehensive tool for monitoring macOS persistence mechanisms.")

0 commit comments

Comments
 (0)