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 @@
-
+
@@ -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.
+}