diff --git a/Package.swift b/Package.swift index 8480da5..4e0fd7d 100644 --- a/Package.swift +++ b/Package.swift @@ -137,6 +137,11 @@ let package = Package( ), // MARK: - Tests + .testTarget( + name: "AccessibilityExtractionTests", + dependencies: ["AccessibilityExtraction"], + path: "Tests/AccessibilityExtractionTests" + ), .testTarget( name: "SharedTests", dependencies: ["Shared"], diff --git a/README.md b/README.md index 1677b7f..0b0732b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ macOS 14+ Swift 5.10 MIT License - 285 Tests Passing + 299 Tests Passing Obsidian Compatible Multi-Provider AI

@@ -306,7 +306,7 @@ cp .build/release/screenmind-cli /usr/local/bin/ ## Architecture -ScreenMind is built as a clean Swift Package Manager project with 13 independent modules and 285 unit tests: +ScreenMind is built as a clean Swift Package Manager project with 13 independent modules and 299 unit tests: ``` ScreenMindApp <- Main app (SwiftUI menu bar + windows) diff --git a/Sources/AccessibilityExtraction/AccessibilityTreeExtractor.swift b/Sources/AccessibilityExtraction/AccessibilityTreeExtractor.swift index b7e2d61..9c64d38 100644 --- a/Sources/AccessibilityExtraction/AccessibilityTreeExtractor.swift +++ b/Sources/AccessibilityExtraction/AccessibilityTreeExtractor.swift @@ -12,6 +12,13 @@ public actor AccessibilityTreeExtractor { /// This is 10x faster than OCR for most native and Electron apps. /// Returns nil if extraction fails or times out. public nonisolated func extractText(from pid: pid_t) -> ExtractedText? { + // Skip extraction if target is ScreenMind itself — querying our own + // SwiftUI accessibility tree from a background actor causes MainActor + // re-entry (EXC_BREAKPOINT via _dispatch_assert_queue_fail). + guard pid != ProcessInfo.processInfo.processIdentifier else { + return nil + } + let start = CFAbsoluteTimeGetCurrent() let app = AXUIElementCreateApplication(pid) @@ -23,7 +30,7 @@ public actor AccessibilityTreeExtractor { guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success else { return nil } - // Safe cast — windowRef is an AXUIElement from the system + // CFTypeRef → AXUIElement cast always succeeds for AX API results let window = windowRef as! AXUIElement var text = "" diff --git a/Sources/PipelineCore/PipelineCoordinator.swift b/Sources/PipelineCore/PipelineCoordinator.swift index 92c6c11..fea6499 100644 --- a/Sources/PipelineCore/PipelineCoordinator.swift +++ b/Sources/PipelineCore/PipelineCoordinator.swift @@ -245,7 +245,14 @@ public actor PipelineCoordinator { private func processFrame(_ frame: CapturedFrame) async { await resourceMonitor.recordFrameCaptured() - // Stage 0: Excluded apps filter + // Stage 0a: Skip our own frames (PID-based, works in bare binary mode) + if let pid = frame.processIdentifier, + pid == ProcessInfo.processInfo.processIdentifier { + SMLogger.pipeline.debug("Skipping self-capture: ScreenMind's own window") + return + } + + // Stage 0b: Excluded apps filter if let bundleID = frame.bundleIdentifier, captureConfig.excludedBundleIDs.contains(bundleID) { SMLogger.pipeline.debug("Skipping excluded app: \(bundleID, privacy: .public)") diff --git a/Sources/ScreenMindApp/Resources/Info.plist b/Sources/ScreenMindApp/Resources/Info.plist index 0acb5a7..6ef1511 100644 --- a/Sources/ScreenMindApp/Resources/Info.plist +++ b/Sources/ScreenMindApp/Resources/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 2.1.0 CFBundleVersion - 21 + 22 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/SystemIntegration/NotificationManager.swift b/Sources/SystemIntegration/NotificationManager.swift index b502aef..49cacf1 100644 --- a/Sources/SystemIntegration/NotificationManager.swift +++ b/Sources/SystemIntegration/NotificationManager.swift @@ -11,10 +11,32 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe super.init() } + /// Whether notifications are available (requires app bundle with CFBundleIdentifier). + private var isAvailable: Bool { Bundle.main.bundleIdentifier != nil } + + /// Safe accessor for UNUserNotificationCenter — returns nil in bare binary mode. + /// Centralizes the guard so every call site is inherently safe. + private var notificationCenter: UNUserNotificationCenter? { + guard isAvailable else { return nil } + return UNUserNotificationCenter.current() + } + /// Request notification permissions. + /// Safe for non-bundle execution (SPM debug builds, Conductor). public func requestAuthorization() async -> Bool { + // UNUserNotificationCenter.current() throws NSInternalInconsistencyException + // when running as a bare binary without an app bundle. This ObjC exception + // bypasses Swift do/catch and calls abort(), killing the entire process. + guard let center = notificationCenter else { + if Bundle.main.bundleURL.pathExtension == "app" { + SMLogger.system.error("Notification center unavailable despite .app bundle — CFBundleIdentifier missing from Info.plist") + } else { + SMLogger.system.warning("Skipping notification auth — no bundle identifier (bare binary)") + } + return false + } do { - let granted = try await UNUserNotificationCenter.current() + let granted = try await center .requestAuthorization(options: [.alert, .sound, .badge]) SMLogger.system.info("Notification authorization: \(granted)") return granted @@ -26,6 +48,7 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe /// Post a notification when a new note is created. public func notifyNoteCreated(title: String, category: String) { + guard let center = notificationCenter else { return } let content = UNMutableNotificationContent() content.title = "New Note" content.subtitle = title @@ -39,7 +62,7 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe trigger: nil ) - UNUserNotificationCenter.current().add(request) { error in + center.add(request) { error in if let error { SMLogger.system.error("Notification failed: \(error.localizedDescription)") } @@ -48,7 +71,7 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe /// Post a notification for daily summary. public func notifyDailySummary(noteCount: Int) { - guard noteCount > 0 else { return } + guard let center = notificationCenter, noteCount > 0 else { return } let content = UNMutableNotificationContent() content.title = "Daily Summary" @@ -61,7 +84,7 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe trigger: nil ) - UNUserNotificationCenter.current().add(request) + center.add(request) } // MARK: - UNUserNotificationCenterDelegate diff --git a/Tests/AccessibilityExtractionTests/AccessibilityExtractionTests.swift b/Tests/AccessibilityExtractionTests/AccessibilityExtractionTests.swift new file mode 100644 index 0000000..93003d5 --- /dev/null +++ b/Tests/AccessibilityExtractionTests/AccessibilityExtractionTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import AccessibilityExtraction + +// MARK: - AccessibilityTreeExtractor Self-PID Guard Tests + +@Test func extractTextReturnNilForSelfPID() { + let extractor = AccessibilityTreeExtractor() + let selfPID = ProcessInfo.processInfo.processIdentifier + let result = extractor.extractText(from: selfPID) + #expect(result == nil, "extractText should return nil for ScreenMind's own PID to prevent MainActor re-entry") +} + +@Test func extractTextDoesNotCrashForInvalidPID() { + let extractor = AccessibilityTreeExtractor() + // PID 0 is the kernel — AX API will fail gracefully, but we verify no crash + let result = extractor.extractText(from: 0) + // Result may be nil (no accessibility access or no window), but must not crash + _ = result +} + +@Test func extractTextAllowsNonSelfPID() { + let extractor = AccessibilityTreeExtractor() + // PID 1 (launchd) is always running — verify the guard doesn't block non-self PIDs. + // The call may return nil (no focused window or no AX permission), but it should + // attempt extraction rather than short-circuit via the self-PID guard. + let result = extractor.extractText(from: 1) + // We can't assert non-nil (depends on AX permissions), but the guard path + // is verified: PID 1 != self PID, so the function proceeds past the guard. + _ = result +} + +// MARK: - ExtractedText Model Tests + +@Test func extractedTextInitializes() { + let text = ExtractedText( + text: "Hello world", + source: .accessibility, + nodeCount: 5, + browserURL: "https://example.com", + extractionTime: 0.05 + ) + #expect(text.text == "Hello world") + #expect(text.source == .accessibility) + #expect(text.nodeCount == 5) + #expect(text.browserURL == "https://example.com") + #expect(text.extractionTime == 0.05) +} + +@Test func extractedTextNilBrowserURL() { + let text = ExtractedText( + text: "Some text", + source: .ocr, + nodeCount: 0, + extractionTime: 0.1 + ) + #expect(text.browserURL == nil) + #expect(text.source == .ocr) +} + +@Test func extractionSourceRawValues() { + #expect(ExtractionSource.accessibility.rawValue == "accessibility") + #expect(ExtractionSource.ocr.rawValue == "ocr") +} diff --git a/Tests/PipelineCoreTests/PipelineCoreTests.swift b/Tests/PipelineCoreTests/PipelineCoreTests.swift index fe40696..98ed8c8 100644 --- a/Tests/PipelineCoreTests/PipelineCoreTests.swift +++ b/Tests/PipelineCoreTests/PipelineCoreTests.swift @@ -627,3 +627,30 @@ import Testing #expect(summary["stage-A"] == 1) #expect(summary["stage-B"] == 2) } + +// MARK: - Self-Exclusion Logic Tests (PipelineCoordinator concept) + +@Test func selfPIDExclusionDetectsOwnProcess() { + // Verify the PID-based self-exclusion pattern used in PipelineCoordinator.processFrame + let selfPID = ProcessInfo.processInfo.processIdentifier + #expect(selfPID > 0, "Self PID should be a valid positive integer") + + // Simulate the guard from processFrame: + // if let pid = frame.processIdentifier, pid == ProcessInfo.processInfo.processIdentifier + let framePID: pid_t? = selfPID + let shouldSkip = framePID.map { $0 == ProcessInfo.processInfo.processIdentifier } ?? false + #expect(shouldSkip == true, "Frame with self PID should be skipped") +} + +@Test func selfPIDExclusionAllowsOtherProcesses() { + let otherPID: pid_t = 1 // launchd + let shouldSkip = otherPID == ProcessInfo.processInfo.processIdentifier + #expect(shouldSkip == false, "Frame from other process should not be skipped") +} + +@Test func selfPIDExclusionHandlesNilPID() { + // When frame.processIdentifier is nil, the optional binding fails and we don't skip + let framePID: pid_t? = nil + let shouldSkip = framePID.map { $0 == ProcessInfo.processInfo.processIdentifier } ?? false + #expect(shouldSkip == false, "Frame with nil PID should not be skipped") +} diff --git a/Tests/SystemIntegrationTests/SystemIntegrationTests.swift b/Tests/SystemIntegrationTests/SystemIntegrationTests.swift index 7d73c6a..38bb050 100644 --- a/Tests/SystemIntegrationTests/SystemIntegrationTests.swift +++ b/Tests/SystemIntegrationTests/SystemIntegrationTests.swift @@ -241,3 +241,36 @@ import Testing #expect(isNewerVersion("1.0", than: "1.0.0") == false) #expect(isNewerVersion("1.0.1", than: "1.0") == true) } + +// MARK: - NotificationManager Bare Binary Guard Tests + +@Test func notificationManagerSingletonExists() { + let manager = NotificationManager.shared + _ = manager +} + +@Test func notificationManagerRequestAuthDoesNotCrash() async { + // In SPM test context, Bundle.main.bundleIdentifier may or may not exist. + // The key assertion: this call must NOT crash with NSInternalInconsistencyException. + let result = await NotificationManager.shared.requestAuthorization() + // Result depends on environment — may be true (Xcode), false (bare binary), or + // false (denied). The important thing is we didn't crash. + _ = result +} + +@Test func notificationManagerNotifyNoteCreatedDoesNotCrash() { + // Must not crash in any execution environment (bundled or bare binary) + NotificationManager.shared.notifyNoteCreated(title: "Test Note", category: "testing") +} + +@Test func notificationManagerNotifyDailySummaryDoesNotCrash() { + // Must not crash — guard should handle both bare binary and zero-count cases + NotificationManager.shared.notifyDailySummary(noteCount: 0) + NotificationManager.shared.notifyDailySummary(noteCount: 5) +} + +@Test func notificationManagerDailySummaryZeroCountNoOp() { + // noteCount == 0 should early-return without attempting notification + NotificationManager.shared.notifyDailySummary(noteCount: 0) + // No crash = pass. The guard `noteCount > 0` ensures early return. +}