use std::future::Future;
use std::pin::Pin;
use serde_json::{Value, json};
use plushie_core::ops::EffectRequest;
use plushie_widget_sdk::protocol::EffectResponse;
use super::EffectHandler;
pub struct NativeEffectHandler;
impl EffectHandler for NativeEffectHandler {
fn handle_sync(&self, id: &str, request: &EffectRequest) -> Option<EffectResponse> {
let (kind, payload) = plushie_core::ops::effect_request_to_wire(request);
Some(handle_effect(id.to_string(), kind, &payload))
}
fn handle_async(
&self,
id: String,
request: EffectRequest,
) -> Pin<Box<dyn Future<Output = EffectResponse> + Send>> {
let (kind, payload) = plushie_core::ops::effect_request_to_wire(&request);
let kind = kind.to_string();
Box::pin(async move { handle_async_effect(id, &kind, &payload).await })
}
fn is_async(&self, request: &EffectRequest) -> bool {
matches!(
request,
EffectRequest::FileOpen(_)
| EffectRequest::FileOpenMultiple(_)
| EffectRequest::FileSave(_)
| EffectRequest::DirectorySelect(_)
| EffectRequest::DirectorySelectMultiple(_)
)
}
}
fn path_to_json_string(path: &std::path::Path) -> String {
match path.to_str() {
Some(s) => s.to_string(),
None => {
log::warn!(
"file path contains non-UTF-8 bytes, using lossy conversion: {}",
path.display()
);
path.to_string_lossy().into_owned()
}
}
}
struct DialogParams<'a> {
title: &'a str,
filters: Vec<(&'a str, Vec<&'a str>)>,
directory: Option<&'a str>,
default_name: Option<&'a str>,
}
fn parse_dialog_params<'a>(payload: &'a Value, default_title: &'a str) -> DialogParams<'a> {
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(default_title);
let mut filters = Vec::new();
if let Some(arr) = payload.get("filters").and_then(|v| v.as_array()) {
for filter in arr {
if let Some(pair) = filter.as_array()
&& pair.len() >= 2
&& let (Some(name), Some(ext)) = (pair[0].as_str(), pair[1].as_str())
{
let extensions: Vec<&str> = ext
.split(';')
.map(|e| e.trim().trim_start_matches("*."))
.collect();
filters.push((name, extensions));
}
}
}
let directory = payload.get("directory").and_then(|v| v.as_str());
let default_name = payload.get("default_name").and_then(|v| v.as_str());
DialogParams {
title,
filters,
directory,
default_name,
}
}
macro_rules! apply_dialog_params {
($dialog_type:ty, $params:expr) => {{
let params = &$params;
let mut d = <$dialog_type>::new().set_title(params.title);
for (name, exts) in ¶ms.filters {
d = d.add_filter(*name, exts);
}
if let Some(dir) = params.directory {
d = d.set_directory(dir);
}
if let Some(name) = params.default_name {
d = d.set_file_name(name);
}
d
}};
}
pub fn is_async_effect(kind: &str) -> bool {
matches!(
kind,
"file_open"
| "file_open_multiple"
| "file_save"
| "directory_select"
| "directory_select_multiple"
)
}
pub fn handle_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
match kind {
"file_open" => handle_file_open(id, payload),
"file_open_multiple" => handle_file_open_multiple(id, payload),
"file_save" => handle_file_save(id, payload),
"directory_select" => handle_directory_select(id, payload),
"directory_select_multiple" => handle_directory_select_multiple(id, payload),
"clipboard_read" => handle_clipboard_read(id),
"clipboard_write" => handle_clipboard_write(id, payload),
"clipboard_read_html" => handle_clipboard_read_html(id),
"clipboard_write_html" => handle_clipboard_write_html(id, payload),
"clipboard_clear" => handle_clipboard_clear(id),
"clipboard_read_primary" => handle_clipboard_read_primary(id),
"clipboard_write_primary" => handle_clipboard_write_primary(id, payload),
"notification" => handle_notification(id, payload),
_ => EffectResponse::unsupported(id),
}
}
pub async fn handle_async_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
match kind {
"file_open" => {
let p = parse_dialog_params(payload, "Open File");
let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
match dialog.pick_file().await {
Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
None => EffectResponse::cancelled(id),
}
}
"file_open_multiple" => {
let p = parse_dialog_params(payload, "Open Files");
let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
match dialog.pick_files().await {
Some(handles) => {
let paths: Vec<String> = handles
.iter()
.map(|h| path_to_json_string(h.path()))
.collect();
EffectResponse::ok(id, json!({"paths": paths}))
}
None => EffectResponse::cancelled(id),
}
}
"file_save" => {
let p = parse_dialog_params(payload, "Save File");
let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
match dialog.save_file().await {
Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
None => EffectResponse::cancelled(id),
}
}
"directory_select" => {
let p = parse_dialog_params(payload, "Select Directory");
let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
match dialog.pick_folder().await {
Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
None => EffectResponse::cancelled(id),
}
}
"directory_select_multiple" => {
let p = parse_dialog_params(payload, "Select Directories");
let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
match dialog.pick_folders().await {
Some(handles) => {
let paths: Vec<String> = handles
.iter()
.map(|h| path_to_json_string(h.path()))
.collect();
EffectResponse::ok(id, json!({"paths": paths}))
}
None => EffectResponse::cancelled(id),
}
}
_ => EffectResponse::unsupported(id),
}
}
fn handle_file_open(id: String, payload: &Value) -> EffectResponse {
let p = parse_dialog_params(payload, "Open File");
let dialog = apply_dialog_params!(rfd::FileDialog, p);
match dialog.pick_file() {
Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
None => EffectResponse::cancelled(id),
}
}
fn handle_file_open_multiple(id: String, payload: &Value) -> EffectResponse {
let p = parse_dialog_params(payload, "Open Files");
let dialog = apply_dialog_params!(rfd::FileDialog, p);
match dialog.pick_files() {
Some(paths) => {
let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
EffectResponse::ok(id, json!({"paths": paths}))
}
None => EffectResponse::cancelled(id),
}
}
fn handle_file_save(id: String, payload: &Value) -> EffectResponse {
let p = parse_dialog_params(payload, "Save File");
let dialog = apply_dialog_params!(rfd::FileDialog, p);
match dialog.save_file() {
Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
None => EffectResponse::cancelled(id),
}
}
fn handle_directory_select(id: String, payload: &Value) -> EffectResponse {
let p = parse_dialog_params(payload, "Select Directory");
let dialog = apply_dialog_params!(rfd::FileDialog, p);
match dialog.pick_folder() {
Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
None => EffectResponse::cancelled(id),
}
}
fn handle_directory_select_multiple(id: String, payload: &Value) -> EffectResponse {
let p = parse_dialog_params(payload, "Select Directories");
let dialog = apply_dialog_params!(rfd::FileDialog, p);
match dialog.pick_folders() {
Some(paths) => {
let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
EffectResponse::ok(id, json!({"paths": paths}))
}
None => EffectResponse::cancelled(id),
}
}
fn with_clipboard(
id: &str,
f: impl FnOnce(&mut arboard::Clipboard, &str) -> EffectResponse,
) -> EffectResponse {
use std::sync::Mutex;
static CLIPBOARD: Mutex<Option<arboard::Clipboard>> = Mutex::new(None);
let mut guard = CLIPBOARD.lock().unwrap_or_else(|poisoned| {
log::warn!("clipboard mutex was poisoned, recovering");
poisoned.into_inner()
});
let clipboard = match guard.as_mut() {
Some(c) => c,
None => match arboard::Clipboard::new() {
Ok(c) => {
*guard = Some(c);
guard.as_mut().unwrap()
}
Err(e) => {
return EffectResponse::error(
id.to_string(),
format!("clipboard init failed: {e}"),
);
}
},
};
f(clipboard, id)
}
fn handle_clipboard_read(id: String) -> EffectResponse {
with_clipboard(&id, |clipboard, id| match clipboard.get_text() {
Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
Err(arboard::Error::ContentNotAvailable) => {
EffectResponse::ok(id.to_string(), json!({"text": ""}))
}
Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read failed: {e}")),
})
}
fn handle_clipboard_write(id: String, payload: &Value) -> EffectResponse {
let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
return EffectResponse::error(id, "missing required field: text".to_string());
};
let text = text.to_string();
with_clipboard(&id, |clipboard, id| match clipboard.set_text(text) {
Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
Err(e) => EffectResponse::error(id.to_string(), format!("clipboard write failed: {e}")),
})
}
fn handle_clipboard_read_html(id: String) -> EffectResponse {
with_clipboard(&id, |clipboard, id| match clipboard.get().html() {
Ok(html) => EffectResponse::ok(id.to_string(), json!({"html": html})),
Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read html failed: {e}")),
})
}
fn handle_clipboard_write_html(id: String, payload: &Value) -> EffectResponse {
let Some(html) = payload.get("html").and_then(|v| v.as_str()) else {
return EffectResponse::error(id, "missing required field: html".to_string());
};
let html = html.to_string();
let alt_text = payload
.get("alt_text")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
with_clipboard(&id, |clipboard, id| {
match clipboard.set_html(&html, alt_text.as_ref()) {
Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
Err(e) => {
EffectResponse::error(id.to_string(), format!("clipboard write html failed: {e}"))
}
}
})
}
fn handle_clipboard_clear(id: String) -> EffectResponse {
with_clipboard(&id, |clipboard, id| match clipboard.clear() {
Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
Err(e) => EffectResponse::error(id.to_string(), format!("clipboard clear failed: {e}")),
})
}
#[cfg(target_os = "linux")]
fn handle_clipboard_read_primary(id: String) -> EffectResponse {
use arboard::{GetExtLinux, LinuxClipboardKind};
with_clipboard(&id, |clipboard, id| {
match clipboard
.get()
.clipboard(LinuxClipboardKind::Primary)
.text()
{
Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
Err(e) => EffectResponse::error(
id.to_string(),
format!("primary clipboard read failed: {e}"),
),
}
})
}
#[cfg(target_os = "linux")]
fn handle_clipboard_write_primary(id: String, payload: &Value) -> EffectResponse {
use arboard::{LinuxClipboardKind, SetExtLinux};
let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
return EffectResponse::error(id, "missing required field: text".to_string());
};
let text = text.to_string();
with_clipboard(&id, |clipboard, id| {
match clipboard
.set()
.clipboard(LinuxClipboardKind::Primary)
.text(text)
{
Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
Err(e) => EffectResponse::error(
id.to_string(),
format!("primary clipboard write failed: {e}"),
),
}
})
}
#[cfg(not(target_os = "linux"))]
fn handle_clipboard_read_primary(id: String) -> EffectResponse {
EffectResponse::unsupported(id)
}
#[cfg(not(target_os = "linux"))]
fn handle_clipboard_write_primary(id: String, _payload: &Value) -> EffectResponse {
EffectResponse::unsupported(id)
}
fn handle_notification(id: String, payload: &Value) -> EffectResponse {
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Plushie");
let body = payload.get("body").and_then(|v| v.as_str()).unwrap_or("");
let mut notification = notify_rust::Notification::new();
notification.summary(title).body(body);
if let Some(icon) = payload.get("icon").and_then(|v| v.as_str()) {
notification.icon(icon);
}
if let Some(timeout_ms) = payload.get("timeout").and_then(|v| v.as_u64()) {
let clamped = timeout_ms.min(u32::MAX as u64) as u32;
notification.timeout(notify_rust::Timeout::Milliseconds(clamped));
}
#[cfg(target_os = "linux")]
if let Some(urgency) = payload.get("urgency").and_then(|v| v.as_str()) {
let u = match urgency {
"low" => notify_rust::Urgency::Low,
"critical" => notify_rust::Urgency::Critical,
_ => notify_rust::Urgency::Normal,
};
notification.urgency(u);
}
if let Some(sound) = payload.get("sound").and_then(|v| v.as_str()) {
notification.sound_name(sound);
}
match notification.show() {
Ok(_) => EffectResponse::ok(id, json!(null)),
Err(e) => EffectResponse::error(id, format!("notification failed: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unknown_effect_returns_unsupported() {
let resp = handle_effect("eff-1".to_string(), "teleport_sandwich", &json!({}));
assert_eq!(resp.status, "unsupported");
assert_eq!(resp.id, "eff-1");
}
#[test]
fn dispatch_routes_all_known_kinds_without_panic() {
let kinds_with_payloads: Vec<(&str, Value)> = vec![
#[cfg(target_os = "linux")]
("file_open", json!({"title": "Pick a file"})),
#[cfg(target_os = "linux")]
("file_open_multiple", json!({"title": "Pick files"})),
#[cfg(target_os = "linux")]
(
"file_save",
json!({"title": "Save", "default_name": "out.txt"}),
),
#[cfg(target_os = "linux")]
("directory_select", json!({"title": "Choose dir"})),
#[cfg(target_os = "linux")]
("directory_select_multiple", json!({"title": "Choose dirs"})),
("clipboard_read", json!({})),
("clipboard_write", json!({"text": "hello"})),
("clipboard_read_html", json!({})),
(
"clipboard_write_html",
json!({"html": "<b>hi</b>", "alt_text": "hi"}),
),
("clipboard_clear", json!({})),
("clipboard_read_primary", json!({})),
("clipboard_write_primary", json!({"text": "primary"})),
(
"notification",
json!({"title": "Test", "body": "body", "icon": "dialog-information", "timeout": 3000, "urgency": "low", "sound": "message-new-instant"}),
),
];
for (kind, payload) in &kinds_with_payloads {
let id = format!("test-{kind}");
let resp = handle_effect(id.clone(), kind, payload);
assert_eq!(resp.id, id, "id mismatch for kind {kind}");
assert_eq!(resp.message_type, "effect_response");
#[cfg(target_os = "linux")]
assert!(
resp.status == "ok" || resp.status == "error" || resp.status == "cancelled",
"unexpected status '{}' for kind {kind}",
resp.status
);
#[cfg(not(target_os = "linux"))]
assert!(
resp.status == "ok"
|| resp.status == "error"
|| resp.status == "cancelled"
|| (resp.status == "unsupported"
&& matches!(*kind, "clipboard_read_primary" | "clipboard_write_primary")),
"unexpected status '{}' for kind {kind}",
resp.status
);
}
}
#[test]
fn trait_impl_matches_free_function_for_all_sync_kinds() {
use plushie_core::ops::{EffectRequest, NotificationOpts};
let sync_requests: Vec<(&str, EffectRequest)> = vec![
("clipboard_read", EffectRequest::ClipboardRead),
(
"clipboard_write",
EffectRequest::ClipboardWrite("hello".to_string()),
),
("clipboard_read_html", EffectRequest::ClipboardReadHtml),
(
"clipboard_write_html",
EffectRequest::ClipboardWriteHtml {
html: "<b>hi</b>".to_string(),
alt_text: Some("hi".to_string()),
},
),
("clipboard_clear", EffectRequest::ClipboardClear),
(
"clipboard_read_primary",
EffectRequest::ClipboardReadPrimary,
),
(
"clipboard_write_primary",
EffectRequest::ClipboardWritePrimary("primary".to_string()),
),
(
"notification",
EffectRequest::Notification {
title: "Test".to_string(),
body: "body".to_string(),
opts: NotificationOpts::new()
.icon("dialog-information")
.timeout(std::time::Duration::from_millis(3000))
.sound("message-new-instant"),
},
),
];
let handler = NativeEffectHandler;
for (kind, request) in &sync_requests {
let id = format!("converge-{kind}");
let trait_resp = handler
.handle_sync(&id, request)
.expect("sync request must produce a response");
let (wire_kind, payload) = plushie_core::ops::effect_request_to_wire(request);
assert_eq!(
wire_kind, *kind,
"wire kind mismatch for {kind} (typed -> wire)"
);
let fn_resp = handle_effect(id.clone(), wire_kind, &payload);
assert_eq!(trait_resp.id, fn_resp.id, "id mismatch for {kind}");
assert_eq!(
trait_resp.message_type, fn_resp.message_type,
"message_type mismatch for {kind}"
);
assert_eq!(
trait_resp.status, fn_resp.status,
"status mismatch for {kind}"
);
assert_eq!(
trait_resp.result.is_some(),
fn_resp.result.is_some(),
"result presence mismatch for {kind}"
);
assert_eq!(
trait_resp.error.is_some(),
fn_resp.error.is_some(),
"error presence mismatch for {kind}"
);
}
}
#[test]
fn async_routing_agrees_between_trait_impl_and_free_function() {
use plushie_core::ops::EffectRequest;
let async_requests: Vec<(&str, EffectRequest)> = vec![
("file_open", EffectRequest::FileOpen(Default::default())),
(
"file_open_multiple",
EffectRequest::FileOpenMultiple(Default::default()),
),
("file_save", EffectRequest::FileSave(Default::default())),
(
"directory_select",
EffectRequest::DirectorySelect(Default::default()),
),
(
"directory_select_multiple",
EffectRequest::DirectorySelectMultiple(Default::default()),
),
];
let handler = NativeEffectHandler;
for (wire_kind, request) in &async_requests {
assert!(
handler.is_async(request),
"trait impl should route {wire_kind} async"
);
assert!(
is_async_effect(wire_kind),
"free function should route {wire_kind} async"
);
}
let sync_examples: Vec<(&str, EffectRequest)> = vec![
("clipboard_read", EffectRequest::ClipboardRead),
("clipboard_clear", EffectRequest::ClipboardClear),
(
"notification",
EffectRequest::Notification {
title: String::new(),
body: String::new(),
opts: plushie_core::ops::NotificationOpts::default(),
},
),
];
for (wire_kind, request) in &sync_examples {
assert!(
!handler.is_async(request),
"trait impl should route {wire_kind} sync"
);
assert!(
!is_async_effect(wire_kind),
"free function should route {wire_kind} sync"
);
}
}
#[test]
fn handlers_tolerate_empty_payloads() {
let kinds: &[&str] = &[
#[cfg(target_os = "linux")]
"file_open",
#[cfg(target_os = "linux")]
"file_open_multiple",
#[cfg(target_os = "linux")]
"file_save",
#[cfg(target_os = "linux")]
"directory_select",
#[cfg(target_os = "linux")]
"directory_select_multiple",
"clipboard_read",
"clipboard_write",
"clipboard_read_html",
"clipboard_write_html",
"clipboard_clear",
"clipboard_read_primary",
"clipboard_write_primary",
"notification",
];
for kind in kinds {
let resp = handle_effect(format!("empty-{kind}"), kind, &json!({}));
assert_eq!(resp.message_type, "effect_response");
}
}
#[test]
fn unknown_kinds_preserve_id() {
for i in 0..5 {
let id = format!("unk-{i}");
let resp = handle_effect(id.clone(), &format!("bogus_{i}"), &json!(null));
assert_eq!(resp.id, id);
assert_eq!(resp.status, "unsupported");
}
}
#[cfg(not(target_os = "linux"))]
#[test]
fn primary_clipboard_effects_are_unsupported() {
let read = handle_effect(
"read-primary".to_string(),
"clipboard_read_primary",
&json!({}),
);
assert_eq!(read.status, "unsupported");
assert_eq!(read.id, "read-primary");
let write = handle_effect(
"write-primary".to_string(),
"clipboard_write_primary",
&json!({"text": "primary"}),
);
assert_eq!(write.status, "unsupported");
assert_eq!(write.id, "write-primary");
}
#[test]
fn async_effects_recognized() {
assert!(is_async_effect("file_open"));
assert!(is_async_effect("file_open_multiple"));
assert!(is_async_effect("file_save"));
assert!(is_async_effect("directory_select"));
assert!(is_async_effect("directory_select_multiple"));
}
#[test]
fn sync_effects_not_async() {
assert!(!is_async_effect("clipboard_read"));
assert!(!is_async_effect("clipboard_write"));
assert!(!is_async_effect("notification"));
}
#[test]
fn unknown_effect_not_async() {
assert!(!is_async_effect("teleport_sandwich"));
assert!(!is_async_effect(""));
assert!(!is_async_effect("FILE_OPEN")); }
#[test]
fn parse_params_defaults() {
let payload = json!({});
let p = parse_dialog_params(&payload, "Default Title");
assert_eq!(p.title, "Default Title");
assert!(p.filters.is_empty());
assert!(p.directory.is_none());
assert!(p.default_name.is_none());
}
#[test]
fn parse_params_with_all_fields() {
let payload = json!({
"title": "Custom Title",
"filters": [["Images", "*.png;*.jpg"], ["All", "*.*"]],
"directory": "/home/user",
"default_name": "output.txt"
});
let p = parse_dialog_params(&payload, "Ignored");
assert_eq!(p.title, "Custom Title");
assert_eq!(p.filters.len(), 2);
assert_eq!(p.filters[0].0, "Images");
assert_eq!(p.filters[0].1, vec!["png", "jpg"]);
assert_eq!(p.filters[1].0, "All");
assert_eq!(p.directory, Some("/home/user"));
assert_eq!(p.default_name, Some("output.txt"));
}
#[test]
fn parse_params_malformed_filters_ignored() {
let payload = json!({
"filters": [
"not an array",
[],
["only one element"],
["Name", "*.txt"]
]
});
let p = parse_dialog_params(&payload, "T");
assert_eq!(p.filters.len(), 1);
assert_eq!(p.filters[0].0, "Name");
}
#[test]
fn path_normal() {
use std::path::Path;
assert_eq!(
path_to_json_string(Path::new("/home/user/file.txt")),
"/home/user/file.txt"
);
}
#[test]
fn path_empty() {
use std::path::Path;
assert_eq!(path_to_json_string(Path::new("")), "");
}
#[test]
fn path_with_spaces() {
use std::path::Path;
assert_eq!(
path_to_json_string(Path::new("/home/user/my documents/file.txt")),
"/home/user/my documents/file.txt"
);
}
#[test]
fn path_with_special_chars() {
use std::path::Path;
assert_eq!(
path_to_json_string(Path::new("/tmp/test-file_v2 (1).tar.gz")),
"/tmp/test-file_v2 (1).tar.gz"
);
}
#[test]
fn empty_clipboard_returns_ok_with_empty_text() {
let resp = handle_clipboard_read("read-empty".to_string());
assert_eq!(resp.message_type, "effect_response");
assert_eq!(resp.id, "read-empty");
assert!(resp.status == "ok" || resp.status == "error");
}
}