Skip to main content

plushie_renderer_lib/effects/
native.rs

1//! Native platform effect handler.
2//!
3//! Effects are side-effectful operations requested by the host that
4//! interact with OS resources. Each effect has an `id` for correlating
5//! the response, a `kind` string for dispatch, and a JSON `payload`
6//! with kind-specific parameters.
7//!
8//! This module is the single source of truth for native effect
9//! implementations. The renderer binary and the in-process direct mode
10//! of the `plushie` SDK both delegate here. See the by-design entry
11//! "Platform effect implementations live in `plushie-renderer-lib`"
12//! for the rationale.
13//!
14//! File dialog effects run asynchronously via [`handle_async_effect`]
15//! when a tokio runtime is available (the normal iced daemon path).
16//! The sync [`handle_effect`] fallback exists for headless/blocking
17//! contexts. Clipboard and notification effects are always synchronous.
18//!
19//! # Platform notes
20//!
21//! **File paths:** Returned paths use OS-native separators (`/` on Unix,
22//! `\` on Windows). On macOS, paths may arrive in NFD (decomposed Unicode)
23//! form. Hosts should normalize paths for comparison if needed.
24//!
25//! **File dialogs (rfd):** On Wayland, rfd uses the xdg-desktop-portal.
26//! On compositors without a portal service, dialogs return None (cancelled).
27//! On X11 without a portal, rfd falls back to GTK dialogs which may block
28//! a tokio worker thread. Filter extensions should be simple (e.g. "png",
29//! "jpg") without wildcards for best cross-platform compatibility.
30//!
31//! **Clipboard (arboard):** The clipboard instance is lazily initialized
32//! in a static Mutex and may be created from a worker thread. On Wayland,
33//! arboard spawns a background thread for clipboard serving. Dropping the
34//! Clipboard would lose served data, so it persists for process lifetime.
35//! On Linux, primary selection is routed via `LinuxClipboardKind::Primary`.
36//!
37//! **Notifications (notify-rust):** On macOS, notifications require an app
38//! bundle identifier (bare binaries may fail). The `icon` field only works
39//! on Linux (freedesktop icon name); on macOS it is ignored, on Windows
40//! the app icon is used instead. The `urgency` field is Linux-only
41//! (freedesktop notification spec).
42
43use std::future::Future;
44use std::pin::Pin;
45
46use serde_json::{Value, json};
47
48use plushie_core::ops::EffectRequest;
49use plushie_widget_sdk::protocol::EffectResponse;
50
51use super::EffectHandler;
52
53/// Native effect handler wrapping rfd (file dialogs), arboard (clipboard),
54/// and notify-rust (notifications).
55///
56/// Both the `plushie-renderer` binary and direct mode in the `plushie`
57/// SDK use this handler. The behaviour is byte-for-byte identical
58/// across the two paths.
59pub struct NativeEffectHandler;
60
61impl EffectHandler for NativeEffectHandler {
62    fn handle_sync(&self, id: &str, request: &EffectRequest) -> Option<EffectResponse> {
63        let (kind, payload) = plushie_core::ops::effect_request_to_wire(request);
64        Some(handle_effect(id.to_string(), kind, &payload))
65    }
66
67    fn handle_async(
68        &self,
69        id: String,
70        request: EffectRequest,
71    ) -> Pin<Box<dyn Future<Output = EffectResponse> + Send>> {
72        let (kind, payload) = plushie_core::ops::effect_request_to_wire(&request);
73        let kind = kind.to_string();
74        Box::pin(async move { handle_async_effect(id, &kind, &payload).await })
75    }
76
77    fn is_async(&self, request: &EffectRequest) -> bool {
78        matches!(
79            request,
80            EffectRequest::FileOpen(_)
81                | EffectRequest::FileOpenMultiple(_)
82                | EffectRequest::FileSave(_)
83                | EffectRequest::DirectorySelect(_)
84                | EffectRequest::DirectorySelectMultiple(_)
85        )
86    }
87}
88
89/// Convert a file path to a JSON string value, logging a warning if the path
90/// contains non-UTF-8 bytes and lossy conversion is required.
91///
92/// **Platform notes:** Windows UNC paths (`\\?\C:\...`) are valid UTF-8 and
93/// pass through cleanly. macOS HFS+ paths may arrive in NFD (decomposed
94/// Unicode) form. This is valid UTF-8 but the host should normalize for
95/// comparison. Non-UTF-8 filenames are rare on modern systems (NTFS is
96/// UTF-16, HFS+ is UTF-8, ext4 allows arbitrary bytes but tooling
97/// discourages it).
98fn path_to_json_string(path: &std::path::Path) -> String {
99    match path.to_str() {
100        Some(s) => s.to_string(),
101        None => {
102            log::warn!(
103                "file path contains non-UTF-8 bytes, using lossy conversion: {}",
104                path.display()
105            );
106            path.to_string_lossy().into_owned()
107        }
108    }
109}
110
111// -- Dialog parameter parsing ------------------------------------------------
112
113/// Parsed file dialog parameters extracted from the JSON payload.
114struct DialogParams<'a> {
115    title: &'a str,
116    filters: Vec<(&'a str, Vec<&'a str>)>,
117    directory: Option<&'a str>,
118    default_name: Option<&'a str>,
119}
120
121/// Parse common dialog parameters from a JSON payload.
122fn parse_dialog_params<'a>(payload: &'a Value, default_title: &'a str) -> DialogParams<'a> {
123    let title = payload
124        .get("title")
125        .and_then(|v| v.as_str())
126        .unwrap_or(default_title);
127
128    let mut filters = Vec::new();
129    if let Some(arr) = payload.get("filters").and_then(|v| v.as_array()) {
130        for filter in arr {
131            if let Some(pair) = filter.as_array()
132                && pair.len() >= 2
133                && let (Some(name), Some(ext)) = (pair[0].as_str(), pair[1].as_str())
134            {
135                let extensions: Vec<&str> = ext
136                    .split(';')
137                    .map(|e| e.trim().trim_start_matches("*."))
138                    .collect();
139                filters.push((name, extensions));
140            }
141        }
142    }
143
144    let directory = payload.get("directory").and_then(|v| v.as_str());
145    let default_name = payload.get("default_name").and_then(|v| v.as_str());
146
147    DialogParams {
148        title,
149        filters,
150        directory,
151        default_name,
152    }
153}
154
155/// Apply parsed parameters to an `rfd::FileDialog` or `rfd::AsyncFileDialog`.
156/// Both types share identical builder methods but no common trait.
157macro_rules! apply_dialog_params {
158    ($dialog_type:ty, $params:expr) => {{
159        let params = &$params;
160        let mut d = <$dialog_type>::new().set_title(params.title);
161        for (name, exts) in &params.filters {
162            d = d.add_filter(*name, exts);
163        }
164        if let Some(dir) = params.directory {
165            d = d.set_directory(dir);
166        }
167        if let Some(name) = params.default_name {
168            d = d.set_file_name(name);
169        }
170        d
171    }};
172}
173
174// -- Effect dispatch ---------------------------------------------------------
175
176/// Returns true for effect kinds that should run asynchronously (file dialogs).
177pub fn is_async_effect(kind: &str) -> bool {
178    matches!(
179        kind,
180        "file_open"
181            | "file_open_multiple"
182            | "file_save"
183            | "directory_select"
184            | "directory_select_multiple"
185    )
186}
187
188/// Dispatch an effect synchronously and return the response.
189///
190/// File dialog effects use `rfd::FileDialog` (blocking). On macOS, sync
191/// dialogs may deadlock if called on the main thread; prefer
192/// [`handle_async_effect`] when a tokio runtime is available.
193///
194/// Clipboard and notification effects are always synchronous regardless
195/// of which dispatch function is used.
196pub fn handle_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
197    match kind {
198        "file_open" => handle_file_open(id, payload),
199        "file_open_multiple" => handle_file_open_multiple(id, payload),
200        "file_save" => handle_file_save(id, payload),
201        "directory_select" => handle_directory_select(id, payload),
202        "directory_select_multiple" => handle_directory_select_multiple(id, payload),
203        "clipboard_read" => handle_clipboard_read(id),
204        "clipboard_write" => handle_clipboard_write(id, payload),
205        "clipboard_read_html" => handle_clipboard_read_html(id),
206        "clipboard_write_html" => handle_clipboard_write_html(id, payload),
207        "clipboard_clear" => handle_clipboard_clear(id),
208        "clipboard_read_primary" => handle_clipboard_read_primary(id),
209        "clipboard_write_primary" => handle_clipboard_write_primary(id, payload),
210        "notification" => handle_notification(id, payload),
211        _ => EffectResponse::unsupported(id),
212    }
213}
214
215/// Dispatch an async effect and return the response. The response format
216/// matches [`handle_effect`] exactly so the host can deserialize uniformly.
217///
218/// Only file dialog effects have async implementations (via
219/// `rfd::AsyncFileDialog`). Other kinds are not routed here; see
220/// [`is_async_effect`].
221///
222/// Note: on X11-only Linux desktops without a portal (e.g. minimal WMs),
223/// rfd falls back to a GTK dialog which may block a tokio worker thread.
224/// This is a known rfd limitation, not specific to plushie.
225pub async fn handle_async_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
226    match kind {
227        "file_open" => {
228            let p = parse_dialog_params(payload, "Open File");
229            let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
230            match dialog.pick_file().await {
231                Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
232                None => EffectResponse::cancelled(id),
233            }
234        }
235        "file_open_multiple" => {
236            let p = parse_dialog_params(payload, "Open Files");
237            let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
238            match dialog.pick_files().await {
239                Some(handles) => {
240                    let paths: Vec<String> = handles
241                        .iter()
242                        .map(|h| path_to_json_string(h.path()))
243                        .collect();
244                    EffectResponse::ok(id, json!({"paths": paths}))
245                }
246                None => EffectResponse::cancelled(id),
247            }
248        }
249        "file_save" => {
250            let p = parse_dialog_params(payload, "Save File");
251            let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
252            match dialog.save_file().await {
253                Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
254                None => EffectResponse::cancelled(id),
255            }
256        }
257        "directory_select" => {
258            let p = parse_dialog_params(payload, "Select Directory");
259            let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
260            match dialog.pick_folder().await {
261                Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
262                None => EffectResponse::cancelled(id),
263            }
264        }
265        "directory_select_multiple" => {
266            let p = parse_dialog_params(payload, "Select Directories");
267            let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
268            match dialog.pick_folders().await {
269                Some(handles) => {
270                    let paths: Vec<String> = handles
271                        .iter()
272                        .map(|h| path_to_json_string(h.path()))
273                        .collect();
274                    EffectResponse::ok(id, json!({"paths": paths}))
275                }
276                None => EffectResponse::cancelled(id),
277            }
278        }
279        _ => EffectResponse::unsupported(id),
280    }
281}
282
283// -- Sync file dialog handlers ----------------------------------------------
284//
285// These use rfd::FileDialog (blocking). The async counterparts above use
286// rfd::AsyncFileDialog. Both coexist: sync for headless/blocking contexts,
287// async for the normal iced daemon event loop.
288
289fn handle_file_open(id: String, payload: &Value) -> EffectResponse {
290    let p = parse_dialog_params(payload, "Open File");
291    let dialog = apply_dialog_params!(rfd::FileDialog, p);
292    match dialog.pick_file() {
293        Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
294        None => EffectResponse::cancelled(id),
295    }
296}
297
298fn handle_file_open_multiple(id: String, payload: &Value) -> EffectResponse {
299    let p = parse_dialog_params(payload, "Open Files");
300    let dialog = apply_dialog_params!(rfd::FileDialog, p);
301    match dialog.pick_files() {
302        Some(paths) => {
303            let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
304            EffectResponse::ok(id, json!({"paths": paths}))
305        }
306        None => EffectResponse::cancelled(id),
307    }
308}
309
310fn handle_file_save(id: String, payload: &Value) -> EffectResponse {
311    let p = parse_dialog_params(payload, "Save File");
312    let dialog = apply_dialog_params!(rfd::FileDialog, p);
313    match dialog.save_file() {
314        Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
315        None => EffectResponse::cancelled(id),
316    }
317}
318
319fn handle_directory_select(id: String, payload: &Value) -> EffectResponse {
320    let p = parse_dialog_params(payload, "Select Directory");
321    let dialog = apply_dialog_params!(rfd::FileDialog, p);
322    match dialog.pick_folder() {
323        Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
324        None => EffectResponse::cancelled(id),
325    }
326}
327
328fn handle_directory_select_multiple(id: String, payload: &Value) -> EffectResponse {
329    let p = parse_dialog_params(payload, "Select Directories");
330    let dialog = apply_dialog_params!(rfd::FileDialog, p);
331    match dialog.pick_folders() {
332        Some(paths) => {
333            let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
334            EffectResponse::ok(id, json!({"paths": paths}))
335        }
336        None => EffectResponse::cancelled(id),
337    }
338}
339
340// -- Clipboard (arboard crate) ----------------------------------------------
341//
342// A single Clipboard instance is kept alive for the process lifetime.
343// On Wayland, arboard serves clipboard data from a background thread
344// tied to the Clipboard instance; dropping it loses the data.
345
346fn with_clipboard(
347    id: &str,
348    f: impl FnOnce(&mut arboard::Clipboard, &str) -> EffectResponse,
349) -> EffectResponse {
350    use std::sync::Mutex;
351
352    static CLIPBOARD: Mutex<Option<arboard::Clipboard>> = Mutex::new(None);
353
354    let mut guard = CLIPBOARD.lock().unwrap_or_else(|poisoned| {
355        log::warn!("clipboard mutex was poisoned, recovering");
356        poisoned.into_inner()
357    });
358
359    let clipboard = match guard.as_mut() {
360        Some(c) => c,
361        None => match arboard::Clipboard::new() {
362            Ok(c) => {
363                *guard = Some(c);
364                guard.as_mut().unwrap()
365            }
366            Err(e) => {
367                return EffectResponse::error(
368                    id.to_string(),
369                    format!("clipboard init failed: {e}"),
370                );
371            }
372        },
373    };
374
375    f(clipboard, id)
376}
377
378fn handle_clipboard_read(id: String) -> EffectResponse {
379    // Normalise platform variance: some backends return
380    // `Err(ContentNotAvailable)` for an empty clipboard while others
381    // return `Ok("")`. Map both to `{"text": ""}` so apps see a
382    // consistent "empty-is-empty" semantic.
383    with_clipboard(&id, |clipboard, id| match clipboard.get_text() {
384        Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
385        Err(arboard::Error::ContentNotAvailable) => {
386            EffectResponse::ok(id.to_string(), json!({"text": ""}))
387        }
388        Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read failed: {e}")),
389    })
390}
391
392fn handle_clipboard_write(id: String, payload: &Value) -> EffectResponse {
393    let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
394        return EffectResponse::error(id, "missing required field: text".to_string());
395    };
396    let text = text.to_string();
397
398    with_clipboard(&id, |clipboard, id| match clipboard.set_text(text) {
399        Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
400        Err(e) => EffectResponse::error(id.to_string(), format!("clipboard write failed: {e}")),
401    })
402}
403
404fn handle_clipboard_read_html(id: String) -> EffectResponse {
405    with_clipboard(&id, |clipboard, id| match clipboard.get().html() {
406        Ok(html) => EffectResponse::ok(id.to_string(), json!({"html": html})),
407        Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read html failed: {e}")),
408    })
409}
410
411fn handle_clipboard_write_html(id: String, payload: &Value) -> EffectResponse {
412    let Some(html) = payload.get("html").and_then(|v| v.as_str()) else {
413        return EffectResponse::error(id, "missing required field: html".to_string());
414    };
415    let html = html.to_string();
416
417    let alt_text = payload
418        .get("alt_text")
419        .and_then(|v| v.as_str())
420        .map(|s| s.to_string());
421
422    with_clipboard(&id, |clipboard, id| {
423        match clipboard.set_html(&html, alt_text.as_ref()) {
424            Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
425            Err(e) => {
426                EffectResponse::error(id.to_string(), format!("clipboard write html failed: {e}"))
427            }
428        }
429    })
430}
431
432fn handle_clipboard_clear(id: String) -> EffectResponse {
433    with_clipboard(&id, |clipboard, id| match clipboard.clear() {
434        Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
435        Err(e) => EffectResponse::error(id.to_string(), format!("clipboard clear failed: {e}")),
436    })
437}
438
439// Primary clipboard: uses the X11/Wayland primary selection on Linux.
440// On other platforms, the protocol reports it as unsupported.
441
442#[cfg(target_os = "linux")]
443fn handle_clipboard_read_primary(id: String) -> EffectResponse {
444    use arboard::{GetExtLinux, LinuxClipboardKind};
445
446    with_clipboard(&id, |clipboard, id| {
447        match clipboard
448            .get()
449            .clipboard(LinuxClipboardKind::Primary)
450            .text()
451        {
452            Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
453            Err(e) => EffectResponse::error(
454                id.to_string(),
455                format!("primary clipboard read failed: {e}"),
456            ),
457        }
458    })
459}
460
461#[cfg(target_os = "linux")]
462fn handle_clipboard_write_primary(id: String, payload: &Value) -> EffectResponse {
463    use arboard::{LinuxClipboardKind, SetExtLinux};
464    let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
465        return EffectResponse::error(id, "missing required field: text".to_string());
466    };
467    let text = text.to_string();
468
469    with_clipboard(&id, |clipboard, id| {
470        match clipboard
471            .set()
472            .clipboard(LinuxClipboardKind::Primary)
473            .text(text)
474        {
475            Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
476            Err(e) => EffectResponse::error(
477                id.to_string(),
478                format!("primary clipboard write failed: {e}"),
479            ),
480        }
481    })
482}
483
484#[cfg(not(target_os = "linux"))]
485fn handle_clipboard_read_primary(id: String) -> EffectResponse {
486    EffectResponse::unsupported(id)
487}
488
489#[cfg(not(target_os = "linux"))]
490fn handle_clipboard_write_primary(id: String, _payload: &Value) -> EffectResponse {
491    EffectResponse::unsupported(id)
492}
493
494// -- Notifications (notify-rust crate) --------------------------------------
495
496/// Send an OS notification.
497///
498/// **Platform quirks:**
499/// - **macOS:** Requires the app to be signed or have an Info.plist for
500///   notifications to appear. The `icon` field is ignored (macOS uses the
501///   app icon). Notifications go to macOS Notification Center.
502/// - **Linux:** Depends on the desktop environment's notification daemon
503///   (e.g. dunst, mako, GNOME notifications). The `icon` field is a
504///   freedesktop icon name (e.g. "dialog-information"). `urgency` is
505///   Linux-only.
506/// - **Windows:** Uses the Windows toast notification system. The `icon`
507///   field is ignored (Windows uses the app icon).
508fn handle_notification(id: String, payload: &Value) -> EffectResponse {
509    let title = payload
510        .get("title")
511        .and_then(|v| v.as_str())
512        .unwrap_or("Plushie");
513
514    let body = payload.get("body").and_then(|v| v.as_str()).unwrap_or("");
515
516    let mut notification = notify_rust::Notification::new();
517    notification.summary(title).body(body);
518
519    if let Some(icon) = payload.get("icon").and_then(|v| v.as_str()) {
520        notification.icon(icon);
521    }
522
523    if let Some(timeout_ms) = payload.get("timeout").and_then(|v| v.as_u64()) {
524        let clamped = timeout_ms.min(u32::MAX as u64) as u32;
525        notification.timeout(notify_rust::Timeout::Milliseconds(clamped));
526    }
527
528    #[cfg(target_os = "linux")]
529    if let Some(urgency) = payload.get("urgency").and_then(|v| v.as_str()) {
530        let u = match urgency {
531            "low" => notify_rust::Urgency::Low,
532            "critical" => notify_rust::Urgency::Critical,
533            _ => notify_rust::Urgency::Normal,
534        };
535        notification.urgency(u);
536    }
537
538    if let Some(sound) = payload.get("sound").and_then(|v| v.as_str()) {
539        notification.sound_name(sound);
540    }
541
542    match notification.show() {
543        Ok(_) => EffectResponse::ok(id, json!(null)),
544        Err(e) => EffectResponse::error(id, format!("notification failed: {e}")),
545    }
546}
547
548// -- Tests -------------------------------------------------------------------
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554
555    #[test]
556    fn unknown_effect_returns_unsupported() {
557        let resp = handle_effect("eff-1".to_string(), "teleport_sandwich", &json!({}));
558        assert_eq!(resp.status, "unsupported");
559        assert_eq!(resp.id, "eff-1");
560    }
561
562    /// Dispatch every known effect kind with a minimal payload and verify
563    /// none of them panic. The handlers may return "error" when the OS
564    /// resource (clipboard, display server, notification daemon) is
565    /// unavailable in the test environment. That's fine: we're testing
566    /// that the routing reaches the right handler and returns cleanly.
567    ///
568    /// File dialog kinds are gated to Linux: rfd-0.15 panics on macOS
569    /// when invoked from a non-main thread in a NonWindowed environment
570    /// (CI runners), and blocks indefinitely on headless Windows. Those
571    /// are rfd-internal preconditions, not routing-layer concerns. The
572    /// async file-dialog routing is covered separately by
573    /// `async_kinds_recognised_on_both_paths`.
574    #[test]
575    fn dispatch_routes_all_known_kinds_without_panic() {
576        let kinds_with_payloads: Vec<(&str, Value)> = vec![
577            #[cfg(target_os = "linux")]
578            ("file_open", json!({"title": "Pick a file"})),
579            #[cfg(target_os = "linux")]
580            ("file_open_multiple", json!({"title": "Pick files"})),
581            #[cfg(target_os = "linux")]
582            (
583                "file_save",
584                json!({"title": "Save", "default_name": "out.txt"}),
585            ),
586            #[cfg(target_os = "linux")]
587            ("directory_select", json!({"title": "Choose dir"})),
588            #[cfg(target_os = "linux")]
589            ("directory_select_multiple", json!({"title": "Choose dirs"})),
590            ("clipboard_read", json!({})),
591            ("clipboard_write", json!({"text": "hello"})),
592            ("clipboard_read_html", json!({})),
593            (
594                "clipboard_write_html",
595                json!({"html": "<b>hi</b>", "alt_text": "hi"}),
596            ),
597            ("clipboard_clear", json!({})),
598            ("clipboard_read_primary", json!({})),
599            ("clipboard_write_primary", json!({"text": "primary"})),
600            (
601                "notification",
602                json!({"title": "Test", "body": "body", "icon": "dialog-information", "timeout": 3000, "urgency": "low", "sound": "message-new-instant"}),
603            ),
604        ];
605
606        for (kind, payload) in &kinds_with_payloads {
607            let id = format!("test-{kind}");
608            let resp = handle_effect(id.clone(), kind, payload);
609
610            assert_eq!(resp.id, id, "id mismatch for kind {kind}");
611            assert_eq!(resp.message_type, "effect_response");
612            #[cfg(target_os = "linux")]
613            assert!(
614                resp.status == "ok" || resp.status == "error" || resp.status == "cancelled",
615                "unexpected status '{}' for kind {kind}",
616                resp.status
617            );
618
619            #[cfg(not(target_os = "linux"))]
620            assert!(
621                resp.status == "ok"
622                    || resp.status == "error"
623                    || resp.status == "cancelled"
624                    || (resp.status == "unsupported"
625                        && matches!(*kind, "clipboard_read_primary" | "clipboard_write_primary")),
626                "unexpected status '{}' for kind {kind}",
627                resp.status
628            );
629        }
630    }
631
632    /// Convergence: the trait impl path (`NativeEffectHandler::handle_sync`,
633    /// used by the SDK in direct mode and by the renderer daemon for sync
634    /// effects) and the free-function path (`handle_effect`, used by the
635    /// renderer's headless dispatcher) must produce identical responses
636    /// for the same input.
637    ///
638    /// Before consolidation, the SDK's `DirectEffectHandler` and the
639    /// renderer's `NativeEffectHandler` each carried their own copy of
640    /// these handlers and quietly drifted (the clipboard
641    /// `ContentNotAvailable` handling diverged for a while). With one
642    /// shared implementation, the two entry points can only diverge if
643    /// the trait impl wraps the free function differently. This test
644    /// pins the wrapper down.
645    #[test]
646    fn trait_impl_matches_free_function_for_all_sync_kinds() {
647        use plushie_core::ops::{EffectRequest, NotificationOpts};
648
649        // One typed EffectRequest per sync kind. Async (file dialog)
650        // requests have a separate convergence path covered below.
651        let sync_requests: Vec<(&str, EffectRequest)> = vec![
652            ("clipboard_read", EffectRequest::ClipboardRead),
653            (
654                "clipboard_write",
655                EffectRequest::ClipboardWrite("hello".to_string()),
656            ),
657            ("clipboard_read_html", EffectRequest::ClipboardReadHtml),
658            (
659                "clipboard_write_html",
660                EffectRequest::ClipboardWriteHtml {
661                    html: "<b>hi</b>".to_string(),
662                    alt_text: Some("hi".to_string()),
663                },
664            ),
665            ("clipboard_clear", EffectRequest::ClipboardClear),
666            (
667                "clipboard_read_primary",
668                EffectRequest::ClipboardReadPrimary,
669            ),
670            (
671                "clipboard_write_primary",
672                EffectRequest::ClipboardWritePrimary("primary".to_string()),
673            ),
674            (
675                "notification",
676                EffectRequest::Notification {
677                    title: "Test".to_string(),
678                    body: "body".to_string(),
679                    opts: NotificationOpts::new()
680                        .icon("dialog-information")
681                        .timeout(std::time::Duration::from_millis(3000))
682                        .sound("message-new-instant"),
683                },
684            ),
685        ];
686
687        let handler = NativeEffectHandler;
688        for (kind, request) in &sync_requests {
689            let id = format!("converge-{kind}");
690
691            // Path A: SDK / renderer-daemon path through the trait impl.
692            let trait_resp = handler
693                .handle_sync(&id, request)
694                .expect("sync request must produce a response");
695
696            // Path B: Renderer headless path through the free function.
697            // Synthesise the same wire (kind, payload) the trait impl
698            // produces internally so the two paths see identical input.
699            let (wire_kind, payload) = plushie_core::ops::effect_request_to_wire(request);
700            assert_eq!(
701                wire_kind, *kind,
702                "wire kind mismatch for {kind} (typed -> wire)"
703            );
704            let fn_resp = handle_effect(id.clone(), wire_kind, &payload);
705
706            // Identity envelope: id, message_type, and status must agree.
707            assert_eq!(trait_resp.id, fn_resp.id, "id mismatch for {kind}");
708            assert_eq!(
709                trait_resp.message_type, fn_resp.message_type,
710                "message_type mismatch for {kind}"
711            );
712            assert_eq!(
713                trait_resp.status, fn_resp.status,
714                "status mismatch for {kind}"
715            );
716
717            // Shape of the optional payload fields must agree (presence
718            // and structure). We don't assert byte-equal values because
719            // the OS clipboard / notification daemon is shared static
720            // state and the second call may observe a transient change
721            // (e.g. a cursor in the test text). Status agreement plus
722            // the same field being populated is the guarantee.
723            assert_eq!(
724                trait_resp.result.is_some(),
725                fn_resp.result.is_some(),
726                "result presence mismatch for {kind}"
727            );
728            assert_eq!(
729                trait_resp.error.is_some(),
730                fn_resp.error.is_some(),
731                "error presence mismatch for {kind}"
732            );
733        }
734    }
735
736    /// Convergence for async (file dialog) effects. Async paths route
737    /// through `NativeEffectHandler::is_async` to decide whether to
738    /// dispatch via tokio. We don't actually await the dialog futures
739    /// (they would spin up a real file picker), but we do confirm that
740    /// every async effect kind is recognised by both `is_async_effect`
741    /// (the headless path) and `NativeEffectHandler::is_async` (the
742    /// daemon path). A divergence here would route effects down the
743    /// wrong path silently, the same class of bug the consolidation is
744    /// meant to prevent.
745    #[test]
746    fn async_routing_agrees_between_trait_impl_and_free_function() {
747        use plushie_core::ops::EffectRequest;
748
749        let async_requests: Vec<(&str, EffectRequest)> = vec![
750            ("file_open", EffectRequest::FileOpen(Default::default())),
751            (
752                "file_open_multiple",
753                EffectRequest::FileOpenMultiple(Default::default()),
754            ),
755            ("file_save", EffectRequest::FileSave(Default::default())),
756            (
757                "directory_select",
758                EffectRequest::DirectorySelect(Default::default()),
759            ),
760            (
761                "directory_select_multiple",
762                EffectRequest::DirectorySelectMultiple(Default::default()),
763            ),
764        ];
765
766        let handler = NativeEffectHandler;
767        for (wire_kind, request) in &async_requests {
768            assert!(
769                handler.is_async(request),
770                "trait impl should route {wire_kind} async"
771            );
772            assert!(
773                is_async_effect(wire_kind),
774                "free function should route {wire_kind} async"
775            );
776        }
777
778        // Sync requests must NOT be routed async by either side.
779        let sync_examples: Vec<(&str, EffectRequest)> = vec![
780            ("clipboard_read", EffectRequest::ClipboardRead),
781            ("clipboard_clear", EffectRequest::ClipboardClear),
782            (
783                "notification",
784                EffectRequest::Notification {
785                    title: String::new(),
786                    body: String::new(),
787                    opts: plushie_core::ops::NotificationOpts::default(),
788                },
789            ),
790        ];
791        for (wire_kind, request) in &sync_examples {
792            assert!(
793                !handler.is_async(request),
794                "trait impl should route {wire_kind} sync"
795            );
796            assert!(
797                !is_async_effect(wire_kind),
798                "free function should route {wire_kind} sync"
799            );
800        }
801    }
802
803    /// Verify that empty payloads don't cause panics; handlers should
804    /// defensively unwrap_or on missing fields.
805    ///
806    /// File dialog kinds are gated to Linux for the same reason as
807    /// `dispatch_routes_all_known_kinds_without_panic`.
808    #[test]
809    fn handlers_tolerate_empty_payloads() {
810        let kinds: &[&str] = &[
811            #[cfg(target_os = "linux")]
812            "file_open",
813            #[cfg(target_os = "linux")]
814            "file_open_multiple",
815            #[cfg(target_os = "linux")]
816            "file_save",
817            #[cfg(target_os = "linux")]
818            "directory_select",
819            #[cfg(target_os = "linux")]
820            "directory_select_multiple",
821            "clipboard_read",
822            "clipboard_write",
823            "clipboard_read_html",
824            "clipboard_write_html",
825            "clipboard_clear",
826            "clipboard_read_primary",
827            "clipboard_write_primary",
828            "notification",
829        ];
830
831        for kind in kinds {
832            let resp = handle_effect(format!("empty-{kind}"), kind, &json!({}));
833            assert_eq!(resp.message_type, "effect_response");
834        }
835    }
836
837    #[test]
838    fn unknown_kinds_preserve_id() {
839        for i in 0..5 {
840            let id = format!("unk-{i}");
841            let resp = handle_effect(id.clone(), &format!("bogus_{i}"), &json!(null));
842            assert_eq!(resp.id, id);
843            assert_eq!(resp.status, "unsupported");
844        }
845    }
846
847    #[cfg(not(target_os = "linux"))]
848    #[test]
849    fn primary_clipboard_effects_are_unsupported() {
850        let read = handle_effect(
851            "read-primary".to_string(),
852            "clipboard_read_primary",
853            &json!({}),
854        );
855        assert_eq!(read.status, "unsupported");
856        assert_eq!(read.id, "read-primary");
857
858        let write = handle_effect(
859            "write-primary".to_string(),
860            "clipboard_write_primary",
861            &json!({"text": "primary"}),
862        );
863        assert_eq!(write.status, "unsupported");
864        assert_eq!(write.id, "write-primary");
865    }
866
867    // -- is_async_effect -----------------------------------------------------
868
869    #[test]
870    fn async_effects_recognized() {
871        assert!(is_async_effect("file_open"));
872        assert!(is_async_effect("file_open_multiple"));
873        assert!(is_async_effect("file_save"));
874        assert!(is_async_effect("directory_select"));
875        assert!(is_async_effect("directory_select_multiple"));
876    }
877
878    #[test]
879    fn sync_effects_not_async() {
880        assert!(!is_async_effect("clipboard_read"));
881        assert!(!is_async_effect("clipboard_write"));
882        assert!(!is_async_effect("notification"));
883    }
884
885    #[test]
886    fn unknown_effect_not_async() {
887        assert!(!is_async_effect("teleport_sandwich"));
888        assert!(!is_async_effect(""));
889        assert!(!is_async_effect("FILE_OPEN")); // case-sensitive
890    }
891
892    // -- parse_dialog_params -------------------------------------------------
893
894    #[test]
895    fn parse_params_defaults() {
896        let payload = json!({});
897        let p = parse_dialog_params(&payload, "Default Title");
898        assert_eq!(p.title, "Default Title");
899        assert!(p.filters.is_empty());
900        assert!(p.directory.is_none());
901        assert!(p.default_name.is_none());
902    }
903
904    #[test]
905    fn parse_params_with_all_fields() {
906        let payload = json!({
907            "title": "Custom Title",
908            "filters": [["Images", "*.png;*.jpg"], ["All", "*.*"]],
909            "directory": "/home/user",
910            "default_name": "output.txt"
911        });
912        let p = parse_dialog_params(&payload, "Ignored");
913        assert_eq!(p.title, "Custom Title");
914        assert_eq!(p.filters.len(), 2);
915        assert_eq!(p.filters[0].0, "Images");
916        assert_eq!(p.filters[0].1, vec!["png", "jpg"]);
917        assert_eq!(p.filters[1].0, "All");
918        assert_eq!(p.directory, Some("/home/user"));
919        assert_eq!(p.default_name, Some("output.txt"));
920    }
921
922    #[test]
923    fn parse_params_malformed_filters_ignored() {
924        let payload = json!({
925            "filters": [
926                "not an array",
927                [],
928                ["only one element"],
929                ["Name", "*.txt"]
930            ]
931        });
932        let p = parse_dialog_params(&payload, "T");
933        // Only the last filter is valid
934        assert_eq!(p.filters.len(), 1);
935        assert_eq!(p.filters[0].0, "Name");
936    }
937
938    // -- path_to_json_string -------------------------------------------------
939
940    #[test]
941    fn path_normal() {
942        use std::path::Path;
943        assert_eq!(
944            path_to_json_string(Path::new("/home/user/file.txt")),
945            "/home/user/file.txt"
946        );
947    }
948
949    #[test]
950    fn path_empty() {
951        use std::path::Path;
952        assert_eq!(path_to_json_string(Path::new("")), "");
953    }
954
955    #[test]
956    fn path_with_spaces() {
957        use std::path::Path;
958        assert_eq!(
959            path_to_json_string(Path::new("/home/user/my documents/file.txt")),
960            "/home/user/my documents/file.txt"
961        );
962    }
963
964    #[test]
965    fn path_with_special_chars() {
966        use std::path::Path;
967        assert_eq!(
968            path_to_json_string(Path::new("/tmp/test-file_v2 (1).tar.gz")),
969            "/tmp/test-file_v2 (1).tar.gz"
970        );
971    }
972
973    #[test]
974    fn empty_clipboard_returns_ok_with_empty_text() {
975        // ContentNotAvailable -> empty text shape; verifies the
976        // platform-variance normalisation in handle_clipboard_read.
977        // Drives the full handler: we don't assert the exact result
978        // (a real clipboard server may fill it during the test), but
979        // the response shape must be consistent.
980        let resp = handle_clipboard_read("read-empty".to_string());
981        assert_eq!(resp.message_type, "effect_response");
982        assert_eq!(resp.id, "read-empty");
983        // Either ok or error is acceptable depending on whether a
984        // clipboard daemon is reachable in the test env.
985        assert!(resp.status == "ok" || resp.status == "error");
986    }
987}