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(any(feature = "content-devtools", feature = "dev-inspect"))]
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(any(feature = "content-devtools", feature = "dev-inspect")))]
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() == "main" {
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() == "main" && matches!(r, Ok(true)) {
143 crate::idle::note_visible(window.app_handle());
144 }
145 r.map(|_| ())
146 }
147 "close" => window.close().map_err(|e| e.to_string()),
148 "start-drag" => window.start_dragging().map_err(|e| e.to_string()),
149 other => return Err(format!("unknown window action {other}")),
150 };
151 r
152}
153
154#[tauri::command]
155pub fn rk_shell_open_window(spec: WindowSpec, app: AppHandle<Wry>) -> Result<String, String> {
156 crate::windows::open(&app, &spec)
157}
158
159#[tauri::command]
160pub fn rk_shell_set_tray(update: TrayUpdate, app: AppHandle<Wry>) -> Result<(), String> {
161 crate::tray::update(&app, &update)
162}
163
164#[tauri::command]
165pub fn rk_shell_set_menu_item_enabled(
166 id: String,
167 enabled: bool,
168 state: State<'_, ShellState>,
169) -> Result<(), String> {
170 let items = state.menu_items.lock().unwrap_or_else(|e| e.into_inner());
171 items
172 .get(&id)
173 .ok_or_else(|| format!("no menu item {id}"))?
174 .set_enabled(enabled)
175 .map_err(|e| e.to_string())
176}
177
178#[tauri::command]
180pub fn rk_shell_autostart(
181 enabled: Option<bool>,
182 state: State<'_, ShellState>,
183) -> Result<crate::autostart::AutostartStatus, String> {
184 match enabled {
185 Some(on) => crate::autostart::set_enabled(&state.autostart, on),
186 None => Ok(crate::autostart::status(&state.autostart)),
187 }
188}
189
190#[tauri::command]
194pub fn rk_shell_open_login_items_settings() -> Result<(), String> {
195 crate::login_item::open_login_items_settings()
196}
197
198#[derive(Debug, Default, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct ClipboardPayload {
201 pub text: Option<String>,
202 pub html: Option<String>,
203 pub png: Option<String>,
205 #[serde(default)]
206 pub files: Vec<String>,
207 #[serde(default)]
209 pub custom: BTreeMap<String, String>,
210 pub board: Option<String>,
212}
213
214fn board_of(p: &Option<String>) -> Board {
215 p.clone().map(Board::Named).unwrap_or(Board::General)
216}
217
218#[tauri::command]
219pub fn rk_shell_clipboard_read(
220 board: Option<String>,
221 state: State<'_, ShellState>,
222) -> Result<ClipboardPayload, String> {
223 require(&state, Module::Clipboard).map_err(|e| e.to_string())?;
224 let c = crate::clipboard::read_with(&board_of(&board), &state.clipboard_options)?;
225 Ok(ClipboardPayload {
226 text: c.text,
227 html: c.html,
228 png: c.png.map(|p| b64::encode(&p)),
229 files: c.files,
230 custom: c
231 .custom
232 .into_iter()
233 .map(|(k, v)| (k, b64::encode(&v)))
234 .collect(),
235 board,
236 })
237}
238
239#[tauri::command]
240pub fn rk_shell_clipboard_write(
241 payload: ClipboardPayload,
242 state: State<'_, ShellState>,
243) -> Result<(), String> {
244 require(&state, Module::Clipboard).map_err(|e| e.to_string())?;
245 let mut custom = BTreeMap::new();
246 for (k, v) in &payload.custom {
247 custom.insert(
248 k.clone(),
249 b64::decode(v).ok_or_else(|| format!("custom format {k} is not base64"))?,
250 );
251 }
252 let png = match &payload.png {
253 Some(s) => Some(b64::decode(s).ok_or("png is not base64")?),
254 None => None,
255 };
256 crate::clipboard::write(
257 &board_of(&payload.board),
258 &ClipboardContent {
259 text: payload.text,
260 html: payload.html,
261 png,
262 files: payload.files,
263 custom,
264 },
265 )
266}
267
268#[tauri::command]
271pub async fn rk_shell_clipboard_item(app: AppHandle<Wry>) -> Result<ClipboardItem, ShellError> {
272 blocking(move || {
273 let state = app.state::<ShellState>();
274 require(&state, Module::Clipboard)?;
275 require(&state, Module::Broker)?;
276 crate::clipboard::take_item_with(
277 &state.broker,
278 &state.clipboard_dir,
279 state.clipboard,
280 &state.clipboard_options,
281 )
282 })
283 .await
284}
285
286#[tauri::command]
287pub async fn rk_shell_engine_call(
288 method: String,
289 params: Option<Value>,
290 app: AppHandle<Wry>,
291) -> Result<Value, String> {
292 #[cfg(feature = "engine")]
293 {
294 tauri::async_runtime::spawn_blocking(move || {
296 let state = app.state::<ShellState>();
297 let engine = state
298 .engine
299 .clone()
300 .ok_or_else(|| "no engine configured".to_string())?;
301 engine
302 .call(&app, &method, params.unwrap_or(Value::Null))
303 .map_err(|e| format!("{}: {}", e.code, e.message))
304 })
305 .await
306 .map_err(|e| e.to_string())?
307 }
308 #[cfg(not(feature = "engine"))]
309 {
310 let _ = (method, params, app);
311 Err("engine feature disabled".into())
312 }
313}
314
315#[tauri::command]
317pub fn rk_shell_engine_status(app: AppHandle<Wry>) -> Option<Value> {
318 #[cfg(feature = "engine")]
319 {
320 app.state::<ShellState>()
321 .engine
322 .as_ref()
323 .map(|e| e.lifecycle())
324 }
325 #[cfg(not(feature = "engine"))]
326 {
327 let _ = app;
328 None
329 }
330}
331
332#[tauri::command]
334pub async fn rk_shell_engine_call_tagged(
335 method: String,
336 params: Option<Value>,
337 tag: Option<String>,
338 app: AppHandle<Wry>,
339) -> Result<Value, Value> {
340 #[cfg(feature = "engine")]
341 {
342 tauri::async_runtime::spawn_blocking(move || {
343 use crate::engine::EngineCallError;
344 use tauri::Emitter;
345 let engine = app.state::<ShellState>().engine.clone().ok_or_else(
346 || json!({"code": "engine_not_running", "message": "no engine configured", "origin": "supervisor"}),
347 )?;
348 let handle = app.clone();
349 engine
350 .call_tagged(
351 &method,
352 params.unwrap_or(Value::Null),
353 tag,
354 std::sync::Arc::new(move |event| {
355 let _ = handle.emit(crate::EVENT_ENGINE, event);
356 }),
357 )
358 .map_err(|e| {
359 serde_json::to_value(EngineCallError::from(e)).expect("serializable error")
360 })
361 })
362 .await
363 .map_err(|e| json!({"code": "internal", "message": e.to_string(), "origin": "supervisor"}))?
364 }
365 #[cfg(not(feature = "engine"))]
366 {
367 let _ = (method, params, tag, app);
368 Err(
369 json!({"code": "unsupported", "message": "engine feature disabled", "origin": "supervisor"}),
370 )
371 }
372}
373
374#[tauri::command]
375pub fn rk_shell_traffic_lights(
376 dx: f64,
377 dy: f64,
378 window: WebviewWindow<Wry>,
379 state: State<'_, ShellState>,
380) {
381 state.traffic.set_offset(&window, dx, dy);
382}
383
384#[tauri::command]
385pub fn rk_shell_quit(app: AppHandle<Wry>) {
386 crate::lifecycle::quit(&app);
387}
388
389#[tauri::command]
392pub fn rk_shell_finish_quit(app: AppHandle<Wry>) {
393 crate::lifecycle::finish_quit(&app);
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
398#[serde(rename_all = "camelCase")]
399pub struct ShellCapabilities {
400 pub quit_drain: bool,
402 pub notify_bypass_dnd: bool,
404}
405
406pub fn capabilities_for(quit_drain: bool) -> ShellCapabilities {
408 ShellCapabilities {
409 quit_drain,
410 notify_bypass_dnd: true,
411 }
412}
413
414#[tauri::command]
415pub fn rk_shell_capabilities(state: State<'_, ShellState>) -> ShellCapabilities {
416 capabilities_for(state.quit.enabled())
417}
418
419#[tauri::command]
421pub fn rk_shell_set_theme(theme: String, window: WebviewWindow<Wry>) -> Result<(), String> {
422 let t = match theme.as_str() {
423 "light" => Some(tauri::Theme::Light),
424 "dark" => Some(tauri::Theme::Dark),
425 "system" => None,
426 other => return Err(format!("unknown theme {other}")),
427 };
428 window.set_theme(t).map_err(|e| e.to_string())
429}
430
431use crate::error::ShellError;
437use crate::notify::{NotificationRequest, NotifyOutcome, NotifyStatus};
438use crate::presence::{AutoPrefs, Environment, PresenceState, Rect, WindowObservation};
439
440async fn blocking<T: Send + 'static>(
441 f: impl FnOnce() -> Result<T, ShellError> + Send + 'static,
442) -> Result<T, ShellError> {
443 tauri::async_runtime::spawn_blocking(f)
444 .await
445 .map_err(|e| ShellError::Other(e.to_string()))?
446}
447
448#[tauri::command]
452pub async fn rk_shell_notify(
453 request: NotificationRequest,
454 app: AppHandle<Wry>,
455) -> Result<NotifyOutcome, ShellError> {
456 let handle = app.clone();
457 let outcome = blocking(move || {
458 let state = handle.state::<ShellState>();
459 crate::notify::post(
460 &request,
461 &state.notifications,
462 state.presence.effective_dnd(),
463 )
464 })
465 .await?;
466 if outcome.delivered {
468 let main = app.state::<ShellState>().main_label.clone();
469 if let Some(w) = app.get_webview_window(&main) {
470 if w.is_focused().unwrap_or(false) {
471 let _ = w.request_user_attention(Some(tauri::UserAttentionType::Informational));
472 }
473 }
474 }
475 Ok(outcome)
476}
477
478#[tauri::command]
480pub async fn rk_shell_notify_status(app: AppHandle<Wry>) -> Result<NotifyStatus, ShellError> {
481 blocking(move || {
482 Ok(crate::notify::status(
483 &app.state::<ShellState>().notifications,
484 ))
485 })
486 .await
487}
488
489#[tauri::command]
491pub async fn rk_shell_notify_clear(ids: Option<Vec<String>>) -> Result<(), ShellError> {
492 blocking(move || crate::notify::clear_delivered(&ids.unwrap_or_default())).await
493}
494
495#[tauri::command]
496pub fn rk_shell_badge(
497 count: i64,
498 app: AppHandle<Wry>,
499 state: State<'_, ShellState>,
500) -> Result<u32, ShellError> {
501 let n = state.notifications.set_badge(count);
502 if let Some(w) = app.get_webview_window(&state.main_label) {
503 w.set_badge_count((n > 0).then_some(i64::from(n)))
504 .map_err(|e| ShellError::Other(format!("set badge count: {e}")))?;
505 }
506 Ok(n)
507}
508
509#[tauri::command]
510pub fn rk_shell_set_dnd(
511 enabled: bool,
512 app: AppHandle<Wry>,
513 state: State<'_, ShellState>,
514) -> PresenceState {
515 state.presence.set_manual_dnd(enabled);
516 let s = state.presence.snapshot();
517 let _ = tauri::Emitter::emit(&app, crate::EVENT_PRESENCE, s);
518 s
519}
520
521#[tauri::command]
522pub fn rk_shell_presence(state: State<'_, ShellState>) -> PresenceState {
523 state.presence.snapshot()
524}
525
526#[tauri::command]
528pub fn rk_shell_presence_prefs(prefs: AutoPrefs, state: State<'_, ShellState>) -> PresenceState {
529 state.presence.set_prefs(prefs);
530 if !prefs.auto_hide_fullscreen || !prefs.auto_dnd_meetings {
531 let cur = state.presence.snapshot();
532 state.presence.set_auto(crate::presence::AutoState {
533 hidden_for_fullscreen: cur.auto_hidden && prefs.auto_hide_fullscreen,
534 dnd_for_meeting: cur.auto_dnd && prefs.auto_dnd_meetings,
535 });
536 }
537 state.presence.snapshot()
538}
539
540#[tauri::command]
542pub async fn rk_shell_presence_probe(app: AppHandle<Wry>) -> Result<Value, ShellError> {
543 blocking(move || {
544 let name = app.state::<ShellState>().app_name.clone();
545 let (env, windows) = crate::presence::detect_live(&name).map_err(ShellError::Unsupported)?;
546 Ok(json!({"environment": env, "windows": windows, "supported": crate::presence::controls_supported()}))
547 })
548 .await
549}
550
551#[tauri::command]
555pub fn rk_shell_presence_evaluate(
556 monitor: Rect,
557 windows: Vec<WindowObservation>,
558 apply: Option<bool>,
559 state: State<'_, ShellState>,
560) -> Value {
561 let env: Environment = crate::presence::detect(monitor, &windows, &state.app_name);
562 if apply.unwrap_or(false) {
563 state
564 .presence
565 .apply_observation(crate::presence::reconcile(state.presence.prefs(), env));
566 }
567 json!({"environment": env, "state": state.presence.snapshot()})
568}
569
570#[tauri::command]
571pub fn rk_shell_idle_status(app: AppHandle<Wry>) -> crate::idle::IdleStatus {
572 crate::idle::status(&app)
573}
574
575#[tauri::command]
577pub fn rk_shell_idle_note(hidden: bool, suspend_after_ms: Option<u64>, app: AppHandle<Wry>) {
578 if hidden {
579 crate::idle::note_hidden_after(
580 &app,
581 false,
582 suspend_after_ms.map(std::time::Duration::from_millis),
583 );
584 } else {
585 crate::idle::note_visible(&app);
586 }
587}
588
589#[tauri::command]
591pub fn rk_shell_path_require(
592 path: String,
593 write: Option<bool>,
594 state: State<'_, ShellState>,
595) -> Result<String, ShellError> {
596 require(&state, Module::Broker)?;
597 if write.unwrap_or(false) {
598 state.broker.require_write_target(&path)
599 } else {
600 state.broker.require_file(&path)
601 }
602}
603
604#[tauri::command]
605pub fn rk_shell_path_revoke(path: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
606 require(&state, Module::Broker)?;
607 state.broker.revoke_path(&path)
608}
609
610#[tauri::command]
612pub fn rk_shell_path_grant_recent(
613 id: String,
614 state: State<'_, ShellState>,
615) -> Result<String, ShellError> {
616 require(&state, Module::Broker)?;
617 state.broker.grant_known(&id)
618}
619
620#[tauri::command]
622pub fn rk_shell_path_regrant_saved(
623 path: String,
624 state: State<'_, ShellState>,
625) -> Result<String, ShellError> {
626 require(&state, Module::Broker)?;
627 state.broker.regrant_saved_target(&path)
628}
629
630#[tauri::command]
633pub fn rk_shell_stream_grant(
634 path: String,
635 scheme: Option<String>,
636 state: State<'_, ShellState>,
637) -> Result<Value, ShellError> {
638 require(&state, Module::Broker)?;
639 require(&state, Module::Stream)?;
640 let scheme = match scheme {
641 Some(requested) => {
642 let requested = requested.to_ascii_lowercase();
643 if state.stream_schemes.contains(&requested) {
644 requested
645 } else {
646 return Err(ShellError::Invalid(format!(
647 "stream scheme {requested} is not registered"
648 )));
649 }
650 }
651 None => state
652 .stream_scheme
653 .clone()
654 .ok_or_else(|| ShellError::Unsupported("no stream protocol configured".into()))?,
655 };
656 let id = state.broker.grant_stream(&path)?;
657 let url = if cfg!(windows) {
658 format!("http://{scheme}.localhost/{id}")
659 } else {
660 format!("{scheme}://localhost/{id}")
661 };
662 Ok(json!({"id": id, "url": url}))
663}
664
665#[tauri::command]
668pub fn rk_shell_site_grant(
669 document: String,
670 state: State<'_, ShellState>,
671) -> Result<Value, ShellError> {
672 require(&state, Module::Broker)?;
673 require(&state, Module::Stream)?;
674 let scheme = state
675 .site_scheme
676 .clone()
677 .ok_or_else(|| ShellError::Unsupported("no site protocol configured".into()))?;
678 let grant = crate::site::grant_document_site(&state.broker, &document, &state.site_policy)?;
679 Ok(json!({
680 "token": grant.token,
681 "root": crate::broker::path_to_string(&grant.root),
682 "baseUrl": crate::site::site_base_url(&scheme, &grant.token),
683 }))
684}
685
686#[tauri::command]
688pub fn rk_shell_site_revoke(token: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
689 require(&state, Module::Broker)?;
690 state.broker.revoke_site_root(&token)
691}
692
693fn recovery_of(state: &ShellState) -> Result<&crate::recovery::RecoveryStore, ShellError> {
694 require(state, Module::Recovery)?;
695 require(state, Module::Broker)?;
696 state
697 .recovery
698 .as_ref()
699 .ok_or_else(|| ShellError::Unsupported("setup recovery is not configured".into()))
700}
701
702#[tauri::command]
703pub fn rk_shell_recovery_save(
704 snapshot: String,
705 state: State<'_, ShellState>,
706) -> Result<(), ShellError> {
707 recovery_of(&state)?.save(&snapshot, &state.broker)
708}
709
710#[tauri::command]
711pub fn rk_shell_recovery_load(state: State<'_, ShellState>) -> Result<Option<String>, ShellError> {
712 recovery_of(&state)?.load(&state.broker)
713}
714
715#[tauri::command]
716pub fn rk_shell_recovery_clear(state: State<'_, ShellState>) -> Result<(), ShellError> {
717 recovery_of(&state)?.clear()
718}