lingxia_platform/traits/
clipboard.rs1use crate::error::PlatformError;
2
3#[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#[derive(Debug, Clone)]
29pub enum ClipboardWrite {
30 Text(String),
31 Image { path: String },
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct ClipboardReadRequest {
37 pub kind: Option<ClipboardKind>,
38 pub image_output_path: Option<String>,
41}
42
43#[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#[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}