1use std::env;
2use std::fmt;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6
7use crate::signals::StructuralSignal;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Platform {
12 Linux,
13 MacOs,
14 Windows,
15 Ios,
16 Android,
17 Browser,
18 Unknown,
19}
20
21impl Platform {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Platform::Linux => "linux",
25 Platform::MacOs => "macos",
26 Platform::Windows => "windows",
27 Platform::Ios => "ios",
28 Platform::Android => "android",
29 Platform::Browser => "browser",
30 Platform::Unknown => "unknown",
31 }
32 }
33}
34
35impl fmt::Display for Platform {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.write_str(self.as_str())
38 }
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum DeviceClass {
44 Desktop,
45 Laptop,
46 Tablet,
47 Phone,
48 Server,
49 Browser,
50 Unknown,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct DeviceContext {
55 pub device_id: String,
56 pub device_name: String,
57 pub platform: Platform,
58 pub device_class: DeviceClass,
59 pub os_version: Option<String>,
60}
61
62impl Default for DeviceContext {
63 fn default() -> Self {
64 let platform = current_platform();
65 let device_name = env::var("HOSTNAME")
66 .or_else(|_| env::var("COMPUTERNAME"))
67 .unwrap_or_else(|_| "local-device".to_string());
68 let platform_label = platform.as_str();
69
70 Self {
71 device_id: format!("{platform_label}:{device_name}"),
72 device_name,
73 platform,
74 device_class: DeviceClass::Desktop,
75 os_version: None,
76 }
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ApplicationContext {
82 pub app_id: String,
83 pub app_name: String,
84 pub app_version: Option<String>,
85}
86
87impl Default for ApplicationContext {
88 fn default() -> Self {
89 Self {
90 app_id: "lcsa-client".to_string(),
91 app_name: "lcsa-client".to_string(),
92 app_version: None,
93 }
94 }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum SignalSource {
100 Clipboard,
101 Filesystem,
102 Selection,
103 Focus,
104 Terminal,
105 Browser,
106}
107
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct SignalEnvelope {
110 pub signal_id: String,
111 pub emitted_at: SystemTime,
112 pub source: SignalSource,
113 pub device: DeviceContext,
114 pub application: ApplicationContext,
115 pub payload: StructuralSignal,
116}
117
118impl SignalEnvelope {
119 pub fn new(
120 source: SignalSource,
121 device: DeviceContext,
122 application: ApplicationContext,
123 payload: StructuralSignal,
124 ) -> Self {
125 let micros = SystemTime::now()
126 .duration_since(UNIX_EPOCH)
127 .map(|duration| duration.as_micros())
128 .unwrap_or_default();
129
130 Self {
131 signal_id: format!("{source:?}-{micros}").to_ascii_lowercase(),
132 emitted_at: SystemTime::now(),
133 source,
134 device,
135 application,
136 payload,
137 }
138 }
139
140 pub fn from_signal(
141 device: DeviceContext,
142 application: ApplicationContext,
143 payload: StructuralSignal,
144 ) -> Self {
145 let source = payload.source();
146 Self::new(source, device, application, payload)
147 }
148}
149
150fn current_platform() -> Platform {
151 #[cfg(target_os = "linux")]
152 {
153 Platform::Linux
154 }
155 #[cfg(target_os = "macos")]
156 {
157 Platform::MacOs
158 }
159 #[cfg(target_os = "windows")]
160 {
161 Platform::Windows
162 }
163 #[cfg(target_os = "ios")]
164 {
165 Platform::Ios
166 }
167 #[cfg(target_os = "android")]
168 {
169 Platform::Android
170 }
171 #[cfg(not(any(
172 target_os = "linux",
173 target_os = "macos",
174 target_os = "windows",
175 target_os = "ios",
176 target_os = "android"
177 )))]
178 {
179 Platform::Unknown
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::signals::{ClipboardSignal, StructuralSignal};
187
188 #[test]
189 fn default_device_context_has_platform() {
190 let device = DeviceContext::default();
191 assert_ne!(device.platform, Platform::Unknown);
192 }
193
194 #[test]
195 fn envelope_wraps_payload_with_identity() {
196 let envelope = SignalEnvelope::from_signal(
197 DeviceContext::default(),
198 ApplicationContext::default(),
199 StructuralSignal::Clipboard(ClipboardSignal::text(
200 "cargo test",
201 "terminal".to_string(),
202 )),
203 );
204
205 assert_eq!(envelope.source, SignalSource::Clipboard);
206 assert!(matches!(envelope.payload, StructuralSignal::Clipboard(_)));
207 assert!(envelope.signal_id.starts_with("clipboard-"));
208 }
209
210 #[test]
211 fn envelope_infers_selection_source() {
212 let envelope = SignalEnvelope::from_signal(
213 DeviceContext::default(),
214 ApplicationContext::default(),
215 StructuralSignal::Selection(crate::signals::SelectionSignal::text(
216 "hello",
217 "editor".to_string(),
218 true,
219 )),
220 );
221
222 assert_eq!(envelope.source, SignalSource::Selection);
223 assert!(envelope.signal_id.starts_with("selection-"));
224 }
225}