selection-capture 0.1.3

Sync, cancellable selected-text capture engine with strategy-aware fallbacks
Documentation
#![cfg(feature = "linux-alpha")]

use selection_capture::{
    capture, ActiveApp, AppAdapter, AppProfile, AppProfileStore, AppProfileUpdate, CancelSignal,
    CaptureFailureContext, CaptureMethod, CaptureOptions, CaptureOutcome, CapturePlatform,
    CleanupStatus, PlatformAttemptResult, TraceEvent, UserHint,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;

struct NeverCancel;

impl CancelSignal for NeverCancel {
    fn is_cancelled(&self) -> bool {
        false
    }
}

struct StubStore;

impl AppProfileStore for StubStore {
    fn load(&self, app: &ActiveApp) -> AppProfile {
        AppProfile::unknown(app.bundle_id.clone())
    }

    fn merge_update(&self, _app: &ActiveApp, _update: AppProfileUpdate) {}
}

struct NoAdapters;

impl AppAdapter for NoAdapters {
    fn matches(&self, _app: &ActiveApp) -> bool {
        false
    }

    fn strategy_override(&self, _app: &ActiveApp) -> Option<Vec<CaptureMethod>> {
        None
    }

    fn hint_override(&self, _context: &CaptureFailureContext) -> Option<UserHint> {
        None
    }
}

#[derive(Clone)]
struct StubPlatform {
    app: Option<ActiveApp>,
    responses: Arc<Mutex<Vec<PlatformAttemptResult>>>,
    cleanup: CleanupStatus,
}

impl StubPlatform {
    fn with_app_and_responses(app: ActiveApp, responses: Vec<PlatformAttemptResult>) -> Self {
        Self {
            app: Some(app),
            responses: Arc::new(Mutex::new(responses)),
            cleanup: CleanupStatus::Clean,
        }
    }

    fn new(responses: Vec<PlatformAttemptResult>) -> Self {
        Self::with_app_and_responses(
            ActiveApp {
                bundle_id: "org.example.linux".into(),
                name: "Linux Test App".into(),
            },
            responses,
        )
    }
}

impl CapturePlatform for StubPlatform {
    fn active_app(&self) -> Option<ActiveApp> {
        self.app.clone()
    }

    fn attempt(&self, _method: CaptureMethod, _app: Option<&ActiveApp>) -> PlatformAttemptResult {
        let mut responses = self.responses.lock().unwrap();
        if responses.is_empty() {
            PlatformAttemptResult::Unavailable
        } else {
            responses.remove(0)
        }
    }

    fn cleanup(&self) -> CleanupStatus {
        self.cleanup
    }
}

fn smoke_options() -> CaptureOptions {
    let mut options = CaptureOptions {
        collect_trace: true,
        ..CaptureOptions::default()
    };
    options.retry_policy.primary_accessibility = vec![Duration::from_millis(0)];
    options.retry_policy.range_accessibility = vec![Duration::from_millis(0)];
    options.retry_policy.clipboard = vec![Duration::from_millis(0)];
    options
}

fn assert_capture_success(
    out: CaptureOutcome,
    expected_method: CaptureMethod,
    expected_text: &str,
    expected_started_methods: &[CaptureMethod],
) {
    match out {
        CaptureOutcome::Success(success) => {
            assert_eq!(success.method, expected_method);
            assert_eq!(success.text, expected_text);
            let trace = success.trace.expect("trace should be collected");
            let started_methods: Vec<_> = trace
                .events
                .iter()
                .filter_map(|event| match event {
                    TraceEvent::MethodStarted(method) => Some(*method),
                    _ => None,
                })
                .collect();
            assert_eq!(started_methods, expected_started_methods);
        }
        CaptureOutcome::Failure(failure) => {
            panic!("expected success, got {:?}", failure.status);
        }
    }
}

#[test]
fn linux_alpha_falls_back_from_accessibility_to_clipboard() {
    let platform = StubPlatform::with_app_and_responses(
        ActiveApp {
            bundle_id: "org.example.fallback-isolation-linux".into(),
            name: "Linux Test App".into(),
        },
        vec![
            PlatformAttemptResult::Unavailable,
            PlatformAttemptResult::EmptySelection,
            PlatformAttemptResult::Success("clipboard capture".into()),
        ],
    );
    let store = StubStore;
    let cancel = NeverCancel;
    let adapter = NoAdapters;

    let out = capture(&platform, &store, &cancel, &[&adapter], &smoke_options());

    assert_capture_success(
        out,
        CaptureMethod::ClipboardBorrow,
        "clipboard capture",
        &[
            CaptureMethod::AccessibilityPrimary,
            CaptureMethod::AccessibilityRange,
            CaptureMethod::ClipboardBorrow,
        ],
    );
}

#[test]
fn linux_alpha_respects_strategy_override_order() {
    let platform = StubPlatform::new(vec![
        PlatformAttemptResult::Unavailable,
        PlatformAttemptResult::Success("synthetic copy capture".into()),
    ]);
    let store = StubStore;
    let cancel = NeverCancel;
    let adapter = NoAdapters;
    let mut options = smoke_options();
    options.strategy_override = Some(vec![
        CaptureMethod::AccessibilityPrimary,
        CaptureMethod::SyntheticCopy,
    ]);

    let out = capture(&platform, &store, &cancel, &[&adapter], &options);

    assert_capture_success(
        out,
        CaptureMethod::SyntheticCopy,
        "synthetic copy capture",
        &[
            CaptureMethod::AccessibilityPrimary,
            CaptureMethod::SyntheticCopy,
        ],
    );
}

#[test]
fn linux_alpha_desktop_matrix_paths() {
    struct Case {
        app: ActiveApp,
        strategy_override: Option<Vec<CaptureMethod>>,
        responses: Vec<PlatformAttemptResult>,
        expected_method: CaptureMethod,
        expected_text: &'static str,
        expected_started_methods: Vec<CaptureMethod>,
    }

    let cases = vec![
        Case {
            app: ActiveApp {
                bundle_id: "process://gnome-shell".into(),
                name: "GNOME Shell".into(),
            },
            strategy_override: None,
            responses: vec![PlatformAttemptResult::Success("gnome atspi text".into())],
            expected_method: CaptureMethod::AccessibilityPrimary,
            expected_text: "gnome atspi text",
            expected_started_methods: vec![CaptureMethod::AccessibilityPrimary],
        },
        Case {
            app: ActiveApp {
                bundle_id: "process://plasmashell".into(),
                name: "KDE Plasma".into(),
            },
            strategy_override: None,
            responses: vec![
                PlatformAttemptResult::Unavailable,
                PlatformAttemptResult::Success("kde primary selection text".into()),
            ],
            expected_method: CaptureMethod::AccessibilityRange,
            expected_text: "kde primary selection text",
            expected_started_methods: vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
            ],
        },
        Case {
            app: ActiveApp {
                bundle_id: "process://sway".into(),
                name: "Sway".into(),
            },
            strategy_override: None,
            responses: vec![
                PlatformAttemptResult::EmptySelection,
                PlatformAttemptResult::Unavailable,
                PlatformAttemptResult::Success("sway clipboard text".into()),
            ],
            expected_method: CaptureMethod::ClipboardBorrow,
            expected_text: "sway clipboard text",
            expected_started_methods: vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
        },
        Case {
            app: ActiveApp {
                bundle_id: "process://x11-session".into(),
                name: "X11 Session".into(),
            },
            strategy_override: Some(vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::SyntheticCopy,
            ]),
            responses: vec![
                PlatformAttemptResult::Unavailable,
                PlatformAttemptResult::Success("x11 synthetic copy text".into()),
            ],
            expected_method: CaptureMethod::SyntheticCopy,
            expected_text: "x11 synthetic copy text",
            expected_started_methods: vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::SyntheticCopy,
            ],
        },
    ];

    let store = StubStore;
    let cancel = NeverCancel;
    let adapter = NoAdapters;

    for case in cases {
        let platform = StubPlatform::with_app_and_responses(case.app, case.responses);
        let mut options = smoke_options();
        options.strategy_override = case.strategy_override;

        let out = capture(&platform, &store, &cancel, &[&adapter], &options);
        assert_capture_success(
            out,
            case.expected_method,
            case.expected_text,
            &case.expected_started_methods,
        );
    }
}