Skip to main content

lingxia_platform/traits/
clipboard.rs

1use crate::error::PlatformError;
2
3/// Representations the product clipboard can round-trip.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ClipboardKind {
6    Text,
7    Image,
8}
9
10impl ClipboardKind {
11    pub fn as_str(self) -> &'static str {
12        match self {
13            Self::Text => "text",
14            Self::Image => "image",
15        }
16    }
17
18    pub fn parse(value: &str) -> Option<Self> {
19        match value {
20            "text" => Some(Self::Text),
21            "image" => Some(Self::Image),
22            _ => None,
23        }
24    }
25}
26
27/// One write: text or a local image file, never both.
28#[derive(Debug, Clone)]
29pub enum ClipboardWrite {
30    Text(String),
31    Image { path: String },
32}
33
34/// What to surface from a read. `None` means every representation this host can.
35#[derive(Debug, Clone, Default)]
36pub struct ClipboardReadRequest {
37    pub kind: Option<ClipboardKind>,
38    /// Host writes a read image here as a regular file. Required when an image
39    /// may be returned; Logic allocates it under the lxapp temp dir.
40    pub image_output_path: Option<String>,
41}
42
43/// Completed clipboard read. `canceled` is only for a dismissed paste prompt.
44#[derive(Debug, Clone, Default)]
45pub struct ClipboardContents {
46    pub canceled: bool,
47    pub text: Option<String>,
48    pub image_path: Option<String>,
49    pub image_mime: Option<String>,
50}
51
52impl ClipboardContents {
53    pub fn empty() -> Self {
54        Self::default()
55    }
56
57    pub fn canceled() -> Self {
58        Self {
59            canceled: true,
60            ..Self::default()
61        }
62    }
63
64    pub fn is_empty(&self) -> bool {
65        !self.canceled && self.text.is_none() && self.image_path.is_none()
66    }
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct ClipboardTypes {
71    pub canceled: bool,
72    pub kinds: Vec<ClipboardKind>,
73}
74
75/// JSON envelope used by Apple / Android / Harmony native adapters.
76#[derive(Debug, Clone, Default, serde::Deserialize)]
77pub struct NativeClipboardReply {
78    #[serde(default)]
79    pub ok: Option<bool>,
80    #[serde(default)]
81    pub canceled: bool,
82    #[serde(default)]
83    pub text: Option<String>,
84    #[serde(default, rename = "imagePath")]
85    pub image_path: Option<String>,
86    #[serde(default, rename = "imageMime")]
87    pub image_mime: Option<String>,
88    #[serde(default)]
89    pub types: Vec<String>,
90    #[serde(default)]
91    pub error: Option<u32>,
92    #[serde(default)]
93    pub detail: Option<String>,
94}
95
96pub fn parse_native_reply(payload: &str) -> Result<NativeClipboardReply, PlatformError> {
97    let trimmed = payload.trim();
98    if trimmed.is_empty() {
99        return Ok(NativeClipboardReply {
100            ok: Some(true),
101            ..NativeClipboardReply::default()
102        });
103    }
104    serde_json::from_str(trimmed)
105        .map_err(|e| PlatformError::Platform(format!("clipboard returned invalid payload: {e}")))
106}
107
108impl NativeClipboardReply {
109    pub fn into_unit(self) -> Result<(), PlatformError> {
110        self.ensure_ok()?;
111        Ok(())
112    }
113
114    pub fn into_contents(self) -> Result<ClipboardContents, PlatformError> {
115        self.ensure_ok()?;
116        if self.canceled {
117            return Ok(ClipboardContents::canceled());
118        }
119        Ok(ClipboardContents {
120            canceled: false,
121            text: self.text,
122            image_path: self.image_path.filter(|value| !value.is_empty()),
123            image_mime: self.image_mime.filter(|value| !value.is_empty()),
124        })
125    }
126
127    pub fn into_types(self) -> Result<ClipboardTypes, PlatformError> {
128        self.ensure_ok()?;
129        if self.canceled {
130            return Ok(ClipboardTypes {
131                canceled: true,
132                kinds: Vec::new(),
133            });
134        }
135        let mut kinds = Vec::new();
136        for token in self.types {
137            if let Some(kind) = ClipboardKind::parse(&token)
138                && !kinds.contains(&kind)
139            {
140                kinds.push(kind);
141            }
142        }
143        Ok(ClipboardTypes {
144            canceled: false,
145            kinds,
146        })
147    }
148
149    fn ensure_ok(&self) -> Result<(), PlatformError> {
150        if let Some(code) = self.error {
151            return Err(PlatformError::BusinessError(code));
152        }
153        if self.ok == Some(false) {
154            return Err(PlatformError::Platform(
155                self.detail
156                    .clone()
157                    .unwrap_or_else(|| "clipboard operation failed".to_string()),
158            ));
159        }
160        Ok(())
161    }
162}
163
164pub trait ClipboardService: Send + Sync + 'static {
165    fn clipboard_write(
166        &self,
167        item: ClipboardWrite,
168    ) -> impl std::future::Future<Output = Result<(), PlatformError>> + Send;
169
170    fn clipboard_read(
171        &self,
172        request: ClipboardReadRequest,
173    ) -> impl std::future::Future<Output = Result<ClipboardContents, PlatformError>> + Send;
174
175    fn clipboard_clear(
176        &self,
177    ) -> impl std::future::Future<Output = Result<(), PlatformError>> + Send;
178
179    fn clipboard_types(
180        &self,
181    ) -> impl std::future::Future<Output = Result<ClipboardTypes, PlatformError>> + Send;
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{ClipboardKind, parse_native_reply};
187
188    #[test]
189    fn empty_payload_is_success() {
190        let reply = parse_native_reply("").unwrap();
191        assert_eq!(reply.ok, Some(true));
192        assert!(reply.into_unit().is_ok());
193    }
194
195    #[test]
196    fn permission_error_is_business_code() {
197        let reply = parse_native_reply(r#"{"ok":false,"error":3008,"detail":"denied"}"#).unwrap();
198        match reply.into_unit() {
199            Err(crate::error::PlatformError::BusinessError(3008)) => {}
200            other => panic!("expected business 3008, got {other:?}"),
201        }
202    }
203
204    #[test]
205    fn canceled_read_has_no_payload() {
206        let contents = parse_native_reply(r#"{"ok":true,"canceled":true}"#)
207            .unwrap()
208            .into_contents()
209            .unwrap();
210        assert!(contents.canceled);
211        assert!(contents.text.is_none());
212    }
213
214    #[test]
215    fn types_ignore_unknown_tokens() {
216        let peeked = parse_native_reply(r#"{"ok":true,"types":["text","html","image","text"]}"#)
217            .unwrap()
218            .into_types()
219            .unwrap();
220        assert_eq!(peeked.kinds, [ClipboardKind::Text, ClipboardKind::Image]);
221    }
222}