1use std::collections::BTreeMap;
5use std::sync::atomic::Ordering;
6
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tauri::{AppHandle, Manager, State, WebviewWindow, Wry};
10
11use crate::clipboard::{Board, ClipboardContent, ClipboardItem};
12use crate::tray::TrayUpdate;
13use crate::windows::WindowSpec;
14use crate::{b64, Module, ShellState};
15
16pub(crate) fn require(state: &ShellState, module: Module) -> Result<(), ShellError> {
18 if state.modules.contains(module) {
19 Ok(())
20 } else {
21 Err(ShellError::Unsupported(format!(
22 "module {} is disabled for this app",
23 module.name()
24 )))
25 }
26}
27
28#[tauri::command]
29pub fn rk_shell_open_external(url: String, state: State<'_, ShellState>) -> Result<(), String> {
30 crate::opener::open_checked(state.opener.as_ref(), &url)
31}
32
33#[tauri::command]
35pub fn rk_shell_ready(app: AppHandle<Wry>, state: State<'_, ShellState>) -> Value {
36 state.frontend_ready.store(true, Ordering::SeqCst);
37 crate::lifecycle::flush_links(&app);
38 #[cfg(feature = "engine")]
39 let engine = state.engine.as_ref().map(|e| e.lifecycle());
40 #[cfg(not(feature = "engine"))]
41 let engine: Option<Value> = None;
42 json!({"background": crate::lifecycle::launched_in_background(), "appId": state.app_id, "appName": state.app_name, "engine": engine})
43}
44
45#[tauri::command]
47pub fn rk_shell_status(app: AppHandle<Wry>, state: State<'_, ShellState>) -> Value {
48 let tray = app.tray_by_id(crate::tray::TRAY_ID).is_some();
49 let tray_rect = app
52 .tray_by_id(crate::tray::TRAY_ID)
53 .and_then(|t| t.rect().ok().flatten())
54 .map(|r| {
55 let (p, z) = (
56 r.position.to_physical::<f64>(1.0),
57 r.size.to_physical::<f64>(1.0),
58 );
59 json!({"x": p.x, "y": p.y, "w": z.width, "h": z.height})
60 });
61 let menu: Vec<Value> = state
62 .menu_rows
63 .lock()
64 .unwrap_or_else(|e| e.into_inner())
65 .iter()
66 .map(|(p, l)| json!({"path": p, "label": l}))
67 .collect();
68 #[cfg(feature = "content-devtools")]
69 let devtools_open = app
70 .get_webview_window(&state.main_label)
71 .map(|w| w.is_devtools_open())
72 .unwrap_or(false);
73 #[cfg(not(feature = "content-devtools"))]
74 let devtools_open = false;
75 let windows: Vec<Value> = app
76 .webview_windows()
77 .iter()
78 .map(|(label, w)| json!({"label": label, "visible": w.is_visible().unwrap_or(false), "title": w.title().unwrap_or_default(), "native": crate::windows::readback(w).map(|native| json!(native)).unwrap_or_else(|e| json!({"error": e}))}))
79 .collect();
80 let menu_enabled: BTreeMap<String, bool> = state
81 .menu_items
82 .lock()
83 .unwrap_or_else(|e| e.into_inner())
84 .iter()
85 .map(|(id, i)| (id.clone(), i.is_enabled().unwrap_or(false)))
86 .collect();
87 json!({
88 "appId": state.app_id,
89 "instance": *state.single_instance.lock().unwrap_or_else(|e| e.into_inner()),
90 "tray": tray,
91 "trayRect": tray_rect,
92 "menu": menu,
93 "windows": windows,
94 "menuEnabled": menu_enabled,
95 "stateDir": state.windows.dir(),
96 "devtoolsOpen": devtools_open,
97 "streamScheme": state.stream_scheme,
98 "streamSchemes": state.stream_schemes,
99 "siteScheme": state.site_scheme,
100 "modules": state.modules.names(),
101 "clipboardDir": state.clipboard_dir,
102 "cacheRepair": state.cache_repair.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(p, o)| json!({"dir": p, "outcome": o})),
103 })
104}
105
106#[tauri::command]
107pub fn rk_shell_window(action: String, window: WebviewWindow<Wry>) -> Result<(), String> {
108 let r: Result<(), String> = match action.as_str() {
109 "minimize" => window.minimize().map_err(|e| e.to_string()),
110 "maximize" => window.maximize().map_err(|e| e.to_string()),
111 "unmaximize" => window.unmaximize().map_err(|e| e.to_string()),
112 "toggle-maximize" => if window.is_maximized().unwrap_or(false) {
113 window.unmaximize()
114 } else {
115 window.maximize()
116 }
117 .map_err(|e| e.to_string()),
118 "fullscreen" => window
119 .set_fullscreen(!window.is_fullscreen().unwrap_or(false))
120 .map_err(|e| e.to_string()),
121 "hide" => {
122 let r = window.hide();
123 if window.label() == window.app_handle().state::<ShellState>().main_label {
124 crate::idle::note_hidden(window.app_handle(), false);
125 }
126 if r.is_ok() && window.label() == window.app_handle().state::<ShellState>().main_label {
127 crate::lifecycle::note_visibility(
128 window.app_handle(),
129 crate::lifecycle::Visibility::Hidden,
130 );
131 }
132 r.map_err(|e| e.to_string())
133 }
134 "show" => {
135 let r = crate::windows::show_window(
137 window.app_handle(),
138 &window,
139 crate::lifecycle::RevealSource::WindowCommand,
140 crate::windows::RevealStyle::ShowFocus,
141 );
142 if window.label() == window.app_handle().state::<ShellState>().main_label
143 && matches!(r, Ok(true))
144 {
145 crate::idle::note_visible(window.app_handle());
146 }
147 r.map(|_| ())
148 }
149 "close" => window.close().map_err(|e| e.to_string()),
150 "start-drag" => window.start_dragging().map_err(|e| e.to_string()),
151 other => return Err(format!("unknown window action {other}")),
152 };
153 r
154}
155
156#[tauri::command]
157pub fn rk_shell_open_window(spec: WindowSpec, app: AppHandle<Wry>) -> Result<String, String> {
158 crate::windows::open(&app, &spec)
159}
160
161#[tauri::command]
162pub fn rk_shell_set_tray(update: TrayUpdate, app: AppHandle<Wry>) -> Result<(), String> {
163 crate::tray::update(&app, &update)
164}
165
166#[tauri::command]
167pub fn rk_shell_set_menu_item_enabled(
168 id: String,
169 enabled: bool,
170 state: State<'_, ShellState>,
171) -> Result<(), String> {
172 let items = state.menu_items.lock().unwrap_or_else(|e| e.into_inner());
173 items
174 .get(&id)
175 .ok_or_else(|| format!("no menu item {id}"))?
176 .set_enabled(enabled)
177 .map_err(|e| e.to_string())
178}
179
180#[tauri::command]
182pub fn rk_shell_autostart(
183 enabled: Option<bool>,
184 state: State<'_, ShellState>,
185) -> Result<crate::autostart::AutostartStatus, String> {
186 match enabled {
187 Some(on) => crate::autostart::set_enabled(&state.autostart, on),
188 None => Ok(crate::autostart::status(&state.autostart)),
189 }
190}
191
192#[tauri::command]
196pub fn rk_shell_open_login_items_settings() -> Result<(), String> {
197 crate::login_item::open_login_items_settings()
198}
199
200#[derive(Debug, Default, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct ClipboardPayload {
203 pub text: Option<String>,
204 pub html: Option<String>,
205 pub png: Option<String>,
207 #[serde(default)]
208 pub files: Vec<String>,
209 #[serde(default)]
211 pub custom: BTreeMap<String, String>,
212 pub board: Option<String>,
214}
215
216fn board_of(p: &Option<String>) -> Board {
217 p.clone().map(Board::Named).unwrap_or(Board::General)
218}
219
220#[tauri::command]
221pub fn rk_shell_clipboard_read(
222 board: Option<String>,
223 state: State<'_, ShellState>,
224) -> Result<ClipboardPayload, String> {
225 require(&state, Module::Clipboard).map_err(|e| e.to_string())?;
226 let c = crate::clipboard::read_with(&board_of(&board), &state.clipboard_options)?;
227 Ok(ClipboardPayload {
228 text: c.text,
229 html: c.html,
230 png: c.png.map(|p| b64::encode(&p)),
231 files: c.files,
232 custom: c
233 .custom
234 .into_iter()
235 .map(|(k, v)| (k, b64::encode(&v)))
236 .collect(),
237 board,
238 })
239}
240
241#[tauri::command]
242pub fn rk_shell_clipboard_write(
243 payload: ClipboardPayload,
244 state: State<'_, ShellState>,
245) -> Result<(), String> {
246 require(&state, Module::Clipboard).map_err(|e| e.to_string())?;
247 let mut custom = BTreeMap::new();
248 for (k, v) in &payload.custom {
249 custom.insert(
250 k.clone(),
251 b64::decode(v).ok_or_else(|| format!("custom format {k} is not base64"))?,
252 );
253 }
254 let png = match &payload.png {
255 Some(s) => Some(b64::decode(s).ok_or("png is not base64")?),
256 None => None,
257 };
258 crate::clipboard::write(
259 &board_of(&payload.board),
260 &ClipboardContent {
261 text: payload.text,
262 html: payload.html,
263 png,
264 files: payload.files,
265 custom,
266 },
267 )
268}
269
270#[tauri::command]
273pub async fn rk_shell_clipboard_item(app: AppHandle<Wry>) -> Result<ClipboardItem, ShellError> {
274 blocking(move || {
275 let state = app.state::<ShellState>();
276 require(&state, Module::Clipboard)?;
277 require(&state, Module::Broker)?;
278 crate::clipboard::take_item_with(
279 &state.broker,
280 &state.clipboard_dir,
281 state.clipboard,
282 &state.clipboard_options,
283 )
284 })
285 .await
286}
287
288#[tauri::command]
289pub async fn rk_shell_engine_call(
290 method: String,
291 params: Option<Value>,
292 app: AppHandle<Wry>,
293) -> Result<Value, String> {
294 #[cfg(feature = "engine")]
295 {
296 tauri::async_runtime::spawn_blocking(move || {
298 let state = app.state::<ShellState>();
299 let engine = state
300 .engine
301 .clone()
302 .ok_or_else(|| "no engine configured".to_string())?;
303 engine
304 .call(&app, &method, params.unwrap_or(Value::Null))
305 .map_err(|e| format!("{}: {}", e.code, e.message))
306 })
307 .await
308 .map_err(|e| e.to_string())?
309 }
310 #[cfg(not(feature = "engine"))]
311 {
312 let _ = (method, params, app);
313 Err("engine feature disabled".into())
314 }
315}
316
317#[tauri::command]
319pub fn rk_shell_engine_status(app: AppHandle<Wry>) -> Option<Value> {
320 #[cfg(feature = "engine")]
321 {
322 app.state::<ShellState>()
323 .engine
324 .as_ref()
325 .map(|e| e.lifecycle())
326 }
327 #[cfg(not(feature = "engine"))]
328 {
329 let _ = app;
330 None
331 }
332}
333
334#[tauri::command]
336pub async fn rk_shell_engine_call_tagged(
337 method: String,
338 params: Option<Value>,
339 tag: Option<String>,
340 app: AppHandle<Wry>,
341) -> Result<Value, Value> {
342 #[cfg(feature = "engine")]
343 {
344 tauri::async_runtime::spawn_blocking(move || {
345 use crate::engine::EngineCallError;
346 use tauri::Emitter;
347 let engine = app.state::<ShellState>().engine.clone().ok_or_else(
348 || json!({"code": "engine_not_running", "message": "no engine configured", "origin": "supervisor"}),
349 )?;
350 let handle = app.clone();
351 engine
352 .call_tagged(
353 &method,
354 params.unwrap_or(Value::Null),
355 tag,
356 std::sync::Arc::new(move |event| {
357 let _ = handle.emit(crate::EVENT_ENGINE, event);
358 }),
359 )
360 .map_err(|e| {
361 serde_json::to_value(EngineCallError::from(e)).expect("serializable error")
362 })
363 })
364 .await
365 .map_err(|e| json!({"code": "internal", "message": e.to_string(), "origin": "supervisor"}))?
366 }
367 #[cfg(not(feature = "engine"))]
368 {
369 let _ = (method, params, tag, app);
370 Err(
371 json!({"code": "unsupported", "message": "engine feature disabled", "origin": "supervisor"}),
372 )
373 }
374}
375
376#[tauri::command]
377pub fn rk_shell_traffic_lights(
378 dx: f64,
379 dy: f64,
380 window: WebviewWindow<Wry>,
381 state: State<'_, ShellState>,
382) {
383 state.traffic.set_offset(&window, dx, dy);
384}
385
386#[tauri::command]
387pub fn rk_shell_quit(app: AppHandle<Wry>) {
388 crate::lifecycle::quit(&app);
389}
390
391#[tauri::command]
394pub fn rk_shell_finish_quit(app: AppHandle<Wry>) {
395 crate::lifecycle::finish_quit(&app);
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
400#[serde(rename_all = "camelCase")]
401pub struct ShellCapabilities {
402 pub quit_drain: bool,
404 pub notify_bypass_dnd: bool,
406}
407
408pub fn capabilities_for(quit_drain: bool) -> ShellCapabilities {
410 ShellCapabilities {
411 quit_drain,
412 notify_bypass_dnd: true,
413 }
414}
415
416#[tauri::command]
417pub fn rk_shell_capabilities(state: State<'_, ShellState>) -> ShellCapabilities {
418 capabilities_for(state.quit.enabled())
419}
420
421#[tauri::command]
423pub fn rk_shell_set_theme(theme: String, window: WebviewWindow<Wry>) -> Result<(), String> {
424 let t = match theme.as_str() {
425 "light" => Some(tauri::Theme::Light),
426 "dark" => Some(tauri::Theme::Dark),
427 "system" => None,
428 other => return Err(format!("unknown theme {other}")),
429 };
430 window.set_theme(t).map_err(|e| e.to_string())
431}
432
433use crate::error::ShellError;
439use crate::notify::{NotificationRequest, NotifyOutcome, NotifyStatus};
440use crate::presence::{AutoPrefs, Environment, PresenceState, Rect, WindowObservation};
441
442async fn blocking<T: Send + 'static>(
443 f: impl FnOnce() -> Result<T, ShellError> + Send + 'static,
444) -> Result<T, ShellError> {
445 tauri::async_runtime::spawn_blocking(f)
446 .await
447 .map_err(|e| ShellError::Other(e.to_string()))?
448}
449
450#[tauri::command]
454pub async fn rk_shell_notify(
455 request: NotificationRequest,
456 app: AppHandle<Wry>,
457) -> Result<NotifyOutcome, ShellError> {
458 let handle = app.clone();
459 let outcome = blocking(move || {
460 let state = handle.state::<ShellState>();
461 crate::notify::post(
462 &request,
463 &state.notifications,
464 state.presence.effective_dnd(),
465 )
466 })
467 .await?;
468 if outcome.delivered {
470 let main = app.state::<ShellState>().main_label.clone();
471 if let Some(w) = app.get_webview_window(&main) {
472 if w.is_focused().unwrap_or(false) {
473 let _ = w.request_user_attention(Some(tauri::UserAttentionType::Informational));
474 }
475 }
476 }
477 Ok(outcome)
478}
479
480#[tauri::command]
482pub async fn rk_shell_notify_status(app: AppHandle<Wry>) -> Result<NotifyStatus, ShellError> {
483 blocking(move || {
484 Ok(crate::notify::status(
485 &app.state::<ShellState>().notifications,
486 ))
487 })
488 .await
489}
490
491#[tauri::command]
493pub async fn rk_shell_notify_clear(ids: Option<Vec<String>>) -> Result<(), ShellError> {
494 blocking(move || crate::notify::clear_delivered(&ids.unwrap_or_default())).await
495}
496
497#[tauri::command]
498pub fn rk_shell_badge(
499 count: i64,
500 app: AppHandle<Wry>,
501 state: State<'_, ShellState>,
502) -> Result<u32, ShellError> {
503 let n = state.notifications.set_badge(count);
504 if let Some(w) = app.get_webview_window(&state.main_label) {
505 w.set_badge_count((n > 0).then_some(i64::from(n)))
506 .map_err(|e| ShellError::Other(format!("set badge count: {e}")))?;
507 }
508 Ok(n)
509}
510
511#[tauri::command]
512pub fn rk_shell_set_dnd(
513 enabled: bool,
514 app: AppHandle<Wry>,
515 state: State<'_, ShellState>,
516) -> PresenceState {
517 state.presence.set_manual_dnd(enabled);
518 let s = state.presence.snapshot();
519 let _ = tauri::Emitter::emit(&app, crate::EVENT_PRESENCE, s);
520 s
521}
522
523#[tauri::command]
524pub fn rk_shell_presence(state: State<'_, ShellState>) -> PresenceState {
525 state.presence.snapshot()
526}
527
528#[tauri::command]
530pub fn rk_shell_presence_prefs(prefs: AutoPrefs, state: State<'_, ShellState>) -> PresenceState {
531 state.presence.set_prefs(prefs);
532 if !prefs.auto_hide_fullscreen || !prefs.auto_dnd_meetings {
533 let cur = state.presence.snapshot();
534 state.presence.set_auto(crate::presence::AutoState {
535 hidden_for_fullscreen: cur.auto_hidden && prefs.auto_hide_fullscreen,
536 dnd_for_meeting: cur.auto_dnd && prefs.auto_dnd_meetings,
537 });
538 }
539 state.presence.snapshot()
540}
541
542#[tauri::command]
544pub async fn rk_shell_presence_probe(app: AppHandle<Wry>) -> Result<Value, ShellError> {
545 blocking(move || {
546 let name = app.state::<ShellState>().app_name.clone();
547 let (env, windows) = crate::presence::detect_live(&name).map_err(ShellError::Unsupported)?;
548 Ok(json!({"environment": env, "windows": windows, "supported": crate::presence::controls_supported()}))
549 })
550 .await
551}
552
553#[tauri::command]
557pub fn rk_shell_presence_evaluate(
558 monitor: Rect,
559 windows: Vec<WindowObservation>,
560 apply: Option<bool>,
561 state: State<'_, ShellState>,
562) -> Value {
563 let env: Environment = crate::presence::detect(monitor, &windows, &state.app_name);
564 if apply.unwrap_or(false) {
565 state
566 .presence
567 .apply_observation(crate::presence::reconcile(state.presence.prefs(), env));
568 }
569 json!({"environment": env, "state": state.presence.snapshot()})
570}
571
572#[tauri::command]
573pub fn rk_shell_idle_status(app: AppHandle<Wry>) -> crate::idle::IdleStatus {
574 crate::idle::status(&app)
575}
576
577#[tauri::command]
579pub fn rk_shell_idle_note(hidden: bool, suspend_after_ms: Option<u64>, app: AppHandle<Wry>) {
580 if hidden {
581 crate::idle::note_hidden_after(
582 &app,
583 false,
584 suspend_after_ms.map(std::time::Duration::from_millis),
585 );
586 } else {
587 crate::idle::note_visible(&app);
588 }
589}
590
591#[tauri::command]
593pub fn rk_shell_path_require(
594 path: String,
595 write: Option<bool>,
596 state: State<'_, ShellState>,
597) -> Result<String, ShellError> {
598 require(&state, Module::Broker)?;
599 if write.unwrap_or(false) {
600 state.broker.require_write_target(&path)
601 } else {
602 state.broker.require_file(&path)
603 }
604}
605
606#[tauri::command]
607pub fn rk_shell_path_revoke(path: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
608 require(&state, Module::Broker)?;
609 state.broker.revoke_path(&path)
610}
611
612#[tauri::command]
614pub fn rk_shell_path_grant_recent(
615 id: String,
616 state: State<'_, ShellState>,
617) -> Result<String, ShellError> {
618 require(&state, Module::Broker)?;
619 state.broker.grant_known(&id)
620}
621
622#[tauri::command]
624pub fn rk_shell_path_regrant_saved(
625 path: String,
626 state: State<'_, ShellState>,
627) -> Result<String, ShellError> {
628 require(&state, Module::Broker)?;
629 state.broker.regrant_saved_target(&path)
630}
631
632#[tauri::command]
635pub fn rk_shell_stream_grant(
636 path: String,
637 scheme: Option<String>,
638 state: State<'_, ShellState>,
639) -> Result<Value, ShellError> {
640 require(&state, Module::Broker)?;
641 require(&state, Module::Stream)?;
642 let scheme = match scheme {
643 Some(requested) => {
644 let requested = requested.to_ascii_lowercase();
645 if state.stream_schemes.contains(&requested) {
646 requested
647 } else {
648 return Err(ShellError::Invalid(format!(
649 "stream scheme {requested} is not registered"
650 )));
651 }
652 }
653 None => state
654 .stream_scheme
655 .clone()
656 .ok_or_else(|| ShellError::Unsupported("no stream protocol configured".into()))?,
657 };
658 let id = state.broker.grant_stream(&path)?;
659 let url = if cfg!(windows) {
660 format!("http://{scheme}.localhost/{id}")
661 } else {
662 format!("{scheme}://localhost/{id}")
663 };
664 Ok(json!({"id": id, "url": url}))
665}
666
667#[tauri::command]
670pub fn rk_shell_site_grant(
671 document: String,
672 state: State<'_, ShellState>,
673) -> Result<Value, ShellError> {
674 require(&state, Module::Broker)?;
675 require(&state, Module::Stream)?;
676 let scheme = state
677 .site_scheme
678 .clone()
679 .ok_or_else(|| ShellError::Unsupported("no site protocol configured".into()))?;
680 let grant = crate::site::grant_document_site(&state.broker, &document, &state.site_policy)?;
681 Ok(json!({
682 "token": grant.token,
683 "root": crate::broker::path_to_string(&grant.root),
684 "baseUrl": crate::site::site_base_url(&scheme, &grant.token),
685 }))
686}
687
688#[tauri::command]
690pub fn rk_shell_site_revoke(token: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
691 require(&state, Module::Broker)?;
692 state.broker.revoke_site_root(&token)
693}
694
695fn recovery_of(state: &ShellState) -> Result<&crate::recovery::RecoveryStore, ShellError> {
696 require(state, Module::Recovery)?;
697 require(state, Module::Broker)?;
698 state
699 .recovery
700 .as_ref()
701 .ok_or_else(|| ShellError::Unsupported("setup recovery is not configured".into()))
702}
703
704#[tauri::command]
705pub fn rk_shell_recovery_save(
706 snapshot: String,
707 state: State<'_, ShellState>,
708) -> Result<(), ShellError> {
709 recovery_of(&state)?.save(&snapshot, &state.broker)
710}
711
712#[tauri::command]
713pub fn rk_shell_recovery_load(state: State<'_, ShellState>) -> Result<Option<String>, ShellError> {
714 recovery_of(&state)?.load(&state.broker)
715}
716
717#[tauri::command]
718pub fn rk_shell_recovery_clear(state: State<'_, ShellState>) -> Result<(), ShellError> {
719 recovery_of(&state)?.clear()
720}