Skip to content
Merged
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
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ let package = Package(
),

// MARK: - Tests
.testTarget(
name: "AccessibilityExtractionTests",
dependencies: ["AccessibilityExtraction"],
path: "Tests/AccessibilityExtractionTests"
),
.testTarget(
name: "SharedTests",
dependencies: ["Shared"],
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/platform-macOS%2014%2B-blue?style=flat-square&logo=apple" alt="macOS 14+" />
<img src="https://img.shields.io/badge/swift-5.10-orange?style=flat-square&logo=swift" alt="Swift 5.10" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License" />
<img src="https://img.shields.io/badge/tests-285%20passing-brightgreen?style=flat-square" alt="285 Tests Passing" />
<img src="https://img.shields.io/badge/tests-299%20passing-brightgreen?style=flat-square" alt="299 Tests Passing" />
<img src="https://img.shields.io/badge/obsidian-compatible-7C3AED?style=flat-square" alt="Obsidian Compatible" />
<img src="https://img.shields.io/badge/AI-multi--provider-FF6B6B?style=flat-square" alt="Multi-Provider AI" />
</p>
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 = ""
Expand Down
9 changes: 8 additions & 1 deletion Sources/PipelineCore/PipelineCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion Sources/ScreenMindApp/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.0</string>
<key>CFBundleVersion</key>
<string>21</string>
<string>22</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
31 changes: 27 additions & 4 deletions Sources/SystemIntegration/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)")
}
Expand All @@ -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"
Expand All @@ -61,7 +84,7 @@ public final class NotificationManager: NSObject, Sendable, UNUserNotificationCe
trigger: nil
)

UNUserNotificationCenter.current().add(request)
center.add(request)
}

// MARK: - UNUserNotificationCenterDelegate
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
27 changes: 27 additions & 0 deletions Tests/PipelineCoreTests/PipelineCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
33 changes: 33 additions & 0 deletions Tests/SystemIntegrationTests/SystemIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}