1use serde::{Deserialize, Serialize};
10use wasm_bindgen::JsValue;
11use wasm_bindgen::prelude::*;
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[allow(
17 clippy::struct_excessive_bools,
18 reason = "Each bool maps to one of the four physical input channels (camera / screen / mic / system audio). Mirrors the Rust-side `SessionStreams` shape verbatim; a bitflag would diverge the IPC seam."
19)]
20pub struct SessionStreamsView {
21 pub camera: bool,
23 pub screen: bool,
25 pub microphone: bool,
27 pub system_audio: bool,
29}
30
31impl SessionStreamsView {
32 #[must_use]
34 pub fn any_enabled(self) -> bool {
35 self.camera || self.screen || self.microphone || self.system_audio
36 }
37}
38
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub struct RecordingConfigView {
44 pub streams: SessionStreamsView,
46 pub camera_id: String,
48 pub microphone_id: String,
50 pub screen_source_id: Option<String>,
53 pub output_path: Option<String>,
56 pub format: Option<String>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub struct RecordingStatusViewIpc {
66 pub session_id: Option<u64>,
68 pub state: String,
70 pub elapsed_ms: u64,
72 pub streams: Vec<StreamHealthView>,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub struct StreamHealthView {
80 pub kind: String,
82 pub lifecycle: String,
86 pub frame_count: u64,
88 pub last_frame_ms_ago: Option<u64>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub struct PendingExportView {
99 pub duration_ms: u64,
101 pub suggested_basename: String,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct RecordingSummaryView {
112 pub session_id: u64,
114 pub elapsed_ms: u64,
116 pub streams: Vec<StreamHealthView>,
118 pub output_path: Option<String>,
122 #[serde(default)]
125 pub pending_export: Option<PendingExportView>,
126}
127
128impl RecordingStatusViewIpc {
129 #[must_use]
131 pub fn idle() -> Self {
132 Self {
133 session_id: None,
134 state: "Idle".to_string(),
135 elapsed_ms: 0,
136 streams: Vec::new(),
137 }
138 }
139
140 #[must_use]
144 pub fn is_recording(&self) -> bool {
145 matches!(self.state.as_str(), "Starting" | "Running" | "Stopping")
146 }
147}
148
149#[wasm_bindgen]
150extern "C" {
151 #[wasm_bindgen(js_name = __screenStartRecording, catch)]
154 pub async fn start_recording_js(config: JsValue) -> Result<JsValue, JsValue>;
155
156 #[wasm_bindgen(js_name = __screenStopRecording, catch)]
158 pub async fn stop_recording_js() -> Result<JsValue, JsValue>;
159
160 #[wasm_bindgen(js_name = __screenRecordingStatus, catch)]
162 pub async fn recording_status_js() -> Result<JsValue, JsValue>;
163
164 #[wasm_bindgen(js_name = __screenDefaultRecordingOutputPath, catch)]
168 pub async fn default_output_path_js(format_slug: JsValue) -> Result<JsValue, JsValue>;
169
170 #[wasm_bindgen(js_name = __screenRevealRecordingInFileManager, catch)]
174 pub async fn reveal_in_file_manager_js(path: String) -> Result<JsValue, JsValue>;
175
176 #[wasm_bindgen(js_name = __screenRecordingPendingExport, catch)]
179 async fn recording_pending_export_js() -> Result<JsValue, JsValue>;
180
181 #[wasm_bindgen(js_name = __screenExportRecording, catch)]
184 async fn export_recording_js(format: JsValue, output_dir: JsValue) -> Result<JsValue, JsValue>;
185
186 #[wasm_bindgen(js_name = __screenDiscardRecording, catch)]
188 async fn discard_recording_js() -> Result<JsValue, JsValue>;
189}
190
191pub async fn recording_pending_export() -> Option<PendingExportView> {
194 match recording_pending_export_js().await {
195 Ok(value) => serde_wasm_bindgen::from_value(value).ok(),
196 Err(_) => None,
197 }
198}
199
200pub async fn export_recording(
209 format: Option<&str>,
210 output_dir: Option<&str>,
211) -> Result<String, String> {
212 let format_arg = format.map_or(JsValue::NULL, JsValue::from_str);
213 let dir_arg = output_dir.map_or(JsValue::NULL, JsValue::from_str);
214 match export_recording_js(format_arg, dir_arg).await {
215 Ok(value) => value
216 .as_string()
217 .ok_or_else(|| "export returned a non-string path".to_owned()),
218 Err(err) => Err(js_error_string(&err)),
219 }
220}
221
222pub async fn discard_recording() {
225 let _ = discard_recording_js().await;
226}
227
228pub async fn default_output_path(format_slug: Option<&str>) -> String {
233 let arg = match format_slug {
234 Some(s) => JsValue::from_str(s),
235 None => JsValue::NULL,
236 };
237 match default_output_path_js(arg).await {
238 Ok(value) => value.as_string().unwrap_or_default(),
239 Err(_) => String::new(),
240 }
241}
242
243pub async fn reveal_in_file_manager(path: &str) -> Result<(), String> {
245 reveal_in_file_manager_js(path.to_string())
246 .await
247 .map(|_| ())
248 .map_err(|err| js_error_string(&err))
249}
250
251pub async fn start_recording(config: RecordingConfigView) -> Result<u64, String> {
254 let arg = serde_wasm_bindgen::to_value(&config)
255 .map_err(|err| format!("encode config failed: {err}"))?;
256 match start_recording_js(arg).await {
257 Ok(value) => serde_wasm_bindgen::from_value(value)
258 .map_err(|err| format!("decode session_id failed: {err}")),
259 Err(err) => Err(js_error_string(&err)),
260 }
261}
262
263pub async fn stop_recording() -> Result<RecordingSummaryView, String> {
265 match stop_recording_js().await {
266 Ok(value) => serde_wasm_bindgen::from_value(value)
267 .map_err(|err| format!("decode summary failed: {err}")),
268 Err(err) => Err(js_error_string(&err)),
269 }
270}
271
272pub async fn recording_status() -> RecordingStatusViewIpc {
274 match recording_status_js().await {
275 Ok(value) => {
276 serde_wasm_bindgen::from_value(value).unwrap_or_else(|_| RecordingStatusViewIpc::idle())
277 }
278 Err(_) => RecordingStatusViewIpc::idle(),
279 }
280}
281
282fn js_error_string(err: &JsValue) -> String {
283 err.as_string().unwrap_or_else(|| format!("{err:?}"))
284}
285
286#[cfg(target_arch = "wasm32")]
298pub fn install_recording_lock_listener(lock: leptos::prelude::RwSignal<bool>) {
299 use js_sys::Reflect;
300 use leptos::prelude::*;
301 use leptos::task::spawn_local;
302
303 spawn_local(async move {
305 let view = recording_status().await;
306 lock.set(view.is_recording());
307 });
308
309 let Some(window) = web_sys::window() else {
310 return;
311 };
312 let Ok(tauri_obj) = Reflect::get(&window, &JsValue::from_str("__TAURI__")) else {
313 return;
314 };
315 let Ok(event_obj) = Reflect::get(&tauri_obj, &JsValue::from_str("event")) else {
316 return;
317 };
318 let Ok(listen_fn) = Reflect::get(&event_obj, &JsValue::from_str("listen")) else {
319 return;
320 };
321 if !listen_fn.is_function() {
322 return;
323 }
324 let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |evt: JsValue| {
325 let Ok(payload) = Reflect::get(&evt, &JsValue::from_str("payload")) else {
326 return;
327 };
328 if let Ok(parsed) = serde_wasm_bindgen::from_value::<RecordingStatusViewIpc>(payload) {
329 lock.set(parsed.is_recording());
330 }
331 }) as Box<dyn FnMut(JsValue)>);
332 let listen_fn: js_sys::Function = wasm_bindgen::JsCast::unchecked_into(listen_fn);
333 let _ = listen_fn.call2(
334 event_obj.as_ref(),
335 &JsValue::from_str("recording-status"),
336 callback.as_ref().unchecked_ref(),
337 );
338 callback.forget();
339}
340
341#[cfg(target_arch = "wasm32")]
352pub fn install_recording_status_listener(
353 status: leptos::prelude::RwSignal<RecordingStatusViewIpc>,
354) {
355 use js_sys::Reflect;
356 use leptos::prelude::*;
357 use leptos::task::spawn_local;
358
359 spawn_local(async move {
360 let view = recording_status().await;
361 status.set(view);
362 });
363
364 let Some(window) = web_sys::window() else {
365 return;
366 };
367 let Ok(tauri_obj) = Reflect::get(&window, &JsValue::from_str("__TAURI__")) else {
368 return;
369 };
370 let Ok(event_obj) = Reflect::get(&tauri_obj, &JsValue::from_str("event")) else {
371 return;
372 };
373 let Ok(listen_fn) = Reflect::get(&event_obj, &JsValue::from_str("listen")) else {
374 return;
375 };
376 if !listen_fn.is_function() {
377 return;
378 }
379 let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |evt: JsValue| {
380 let Ok(payload) = Reflect::get(&evt, &JsValue::from_str("payload")) else {
381 return;
382 };
383 if let Ok(parsed) = serde_wasm_bindgen::from_value::<RecordingStatusViewIpc>(payload) {
384 status.set(parsed);
385 }
386 }) as Box<dyn FnMut(JsValue)>);
387 let listen_fn: js_sys::Function = wasm_bindgen::JsCast::unchecked_into(listen_fn);
388 let _ = listen_fn.call2(
389 event_obj.as_ref(),
390 &JsValue::from_str("recording-status"),
391 callback.as_ref().unchecked_ref(),
392 );
393 callback.forget();
394}
395
396#[cfg(not(target_arch = "wasm32"))]
398pub fn install_recording_status_listener(
399 _status: leptos::prelude::RwSignal<RecordingStatusViewIpc>,
400) {
401}
402
403#[cfg(not(target_arch = "wasm32"))]
407pub fn install_recording_lock_listener(_lock: leptos::prelude::RwSignal<bool>) {}