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(
69 debug_assertions,
70 feature = "content-devtools",
71 feature = "dev-inspect"
72 ))]
73 let devtools_open = app
74 .get_webview_window(&state.main_label)
75 .map(|w| w.is_devtools_open())
76 .unwrap_or(false);
77 #[cfg(not(any(
78 debug_assertions,
79 feature = "content-devtools",
80 feature = "dev-inspect"
81 )))]
82 let devtools_open = false;
83 let windows: Vec<Value> = app
84 .webview_windows()
85 .iter()
86 .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}))}))
87 .collect();
88 let menu_enabled: BTreeMap<String, bool> = state
89 .menu_items
90 .lock()
91 .unwrap_or_else(|e| e.into_inner())
92 .iter()
93 .map(|(id, i)| (id.clone(), i.is_enabled().unwrap_or(false)))
94 .collect();
95 json!({
96 "appId": state.app_id,
97 "instance": *state.single_instance.lock().unwrap_or_else(|e| e.into_inner()),
98 "tray": tray,
99 "trayRect": tray_rect,
100 "menu": menu,
101 "windows": windows,
102 "menuEnabled": menu_enabled,
103 "stateDir": state.windows.dir(),
104 "devtoolsOpen": devtools_open,
105 "streamScheme": state.stream_scheme,
106 "streamSchemes": state.stream_schemes,
107 "siteScheme": state.site_scheme,
108 "modules": state.modules.names(),
109 "clipboardDir": state.clipboard_dir,
110 "cacheRepair": state.cache_repair.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(p, o)| json!({"dir": p, "outcome": o})),
111 })
112}
113
114#[tauri::command]
115pub fn rk_shell_window(action: String, window: WebviewWindow<Wry>) -> Result<(), String> {
116 let r: Result<(), String> = match action.as_str() {
117 "minimize" => window.minimize().map_err(|e| e.to_string()),
118 "maximize" => window.maximize().map_err(|e| e.to_string()),
119 "unmaximize" => window.unmaximize().map_err(|e| e.to_string()),
120 "toggle-maximize" => if window.is_maximized().unwrap_or(false) {
121 window.unmaximize()
122 } else {
123 window.maximize()
124 }
125 .map_err(|e| e.to_string()),
126 "fullscreen" => window
127 .set_fullscreen(!window.is_fullscreen().unwrap_or(false))
128 .map_err(|e| e.to_string()),
129 "hide" => {
130 let r = window.hide();
131 if window.label() == "main" {
132 crate::idle::note_hidden(window.app_handle(), false);
133 }
134 if r.is_ok() && window.label() == window.app_handle().state::<ShellState>().main_label {
135 crate::lifecycle::note_visibility(
136 window.app_handle(),
137 crate::lifecycle::Visibility::Hidden,
138 );
139 }
140 r.map_err(|e| e.to_string())
141 }
142 "show" => {
143 let r = crate::windows::show_window(
145 window.app_handle(),
146 &window,
147 crate::lifecycle::RevealSource::WindowCommand,
148 crate::windows::RevealStyle::ShowFocus,
149 );
150 if window.label() == "main" && matches!(r, Ok(true)) {
151 crate::idle::note_visible(window.app_handle());
152 }
153 r.map(|_| ())
154 }
155 "close" => window.close().map_err(|e| e.to_string()),
156 "start-drag" => window.start_dragging().map_err(|e| e.to_string()),
157 other => return Err(format!("unknown window action {other}")),
158 };
159 r
160}
161
162#[tauri::command]
163pub fn rk_shell_open_window(spec: WindowSpec, app: AppHandle<Wry>) -> Result<String, String> {
164 crate::windows::open(&app, &spec)
165}
166
167#[tauri::command]
168pub fn rk_shell_set_tray(update: TrayUpdate, app: AppHandle<Wry>) -> Result<(), String> {
169 crate::tray::update(&app, &update)
170}
171
172#[tauri::command]
173pub fn rk_shell_set_menu_item_enabled(
174 id: String,
175 enabled: bool,
176 state: State<'_, ShellState>,
177) -> Result<(), String> {
178 let items = state.menu_items.lock().unwrap_or_else(|e| e.into_inner());
179 items
180 .get(&id)
181 .ok_or_else(|| format!("no menu item {id}"))?
182 .set_enabled(enabled)
183 .map_err(|e| e.to_string())
184}
185
186#[tauri::command]
188pub fn rk_shell_autostart(
189 enabled: Option<bool>,
190 state: State<'_, ShellState>,
191) -> Result<crate::autostart::AutostartStatus, String> {
192 match enabled {
193 Some(on) => crate::autostart::set_enabled(&state.autostart, on),
194 None => Ok(crate::autostart::status(&state.autostart)),
195 }
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]
316pub fn rk_shell_traffic_lights(
317 dx: f64,
318 dy: f64,
319 window: WebviewWindow<Wry>,
320 state: State<'_, ShellState>,
321) {
322 state.traffic.set_offset(&window, dx, dy);
323}
324
325#[tauri::command]
326pub fn rk_shell_quit(app: AppHandle<Wry>) {
327 crate::lifecycle::quit(&app);
328}
329
330#[tauri::command]
333pub fn rk_shell_finish_quit(app: AppHandle<Wry>) {
334 crate::lifecycle::finish_quit(&app);
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
339#[serde(rename_all = "camelCase")]
340pub struct ShellCapabilities {
341 pub quit_drain: bool,
343 pub notify_bypass_dnd: bool,
345}
346
347pub fn capabilities_for(quit_drain: bool) -> ShellCapabilities {
349 ShellCapabilities {
350 quit_drain,
351 notify_bypass_dnd: true,
352 }
353}
354
355#[tauri::command]
356pub fn rk_shell_capabilities(state: State<'_, ShellState>) -> ShellCapabilities {
357 capabilities_for(state.quit.enabled())
358}
359
360#[tauri::command]
362pub fn rk_shell_set_theme(theme: String, window: WebviewWindow<Wry>) -> Result<(), String> {
363 let t = match theme.as_str() {
364 "light" => Some(tauri::Theme::Light),
365 "dark" => Some(tauri::Theme::Dark),
366 "system" => None,
367 other => return Err(format!("unknown theme {other}")),
368 };
369 window.set_theme(t).map_err(|e| e.to_string())
370}
371
372use crate::error::ShellError;
378use crate::notify::{NotificationRequest, NotifyOutcome, NotifyStatus};
379use crate::presence::{AutoPrefs, Environment, PresenceState, Rect, WindowObservation};
380
381async fn blocking<T: Send + 'static>(
382 f: impl FnOnce() -> Result<T, ShellError> + Send + 'static,
383) -> Result<T, ShellError> {
384 tauri::async_runtime::spawn_blocking(f)
385 .await
386 .map_err(|e| ShellError::Other(e.to_string()))?
387}
388
389#[tauri::command]
393pub async fn rk_shell_notify(
394 request: NotificationRequest,
395 app: AppHandle<Wry>,
396) -> Result<NotifyOutcome, ShellError> {
397 let handle = app.clone();
398 let outcome = blocking(move || {
399 let state = handle.state::<ShellState>();
400 crate::notify::post(
401 &request,
402 &state.notifications,
403 state.presence.effective_dnd(),
404 )
405 })
406 .await?;
407 if outcome.delivered {
409 let main = app.state::<ShellState>().main_label.clone();
410 if let Some(w) = app.get_webview_window(&main) {
411 if w.is_focused().unwrap_or(false) {
412 let _ = w.request_user_attention(Some(tauri::UserAttentionType::Informational));
413 }
414 }
415 }
416 Ok(outcome)
417}
418
419#[tauri::command]
421pub async fn rk_shell_notify_status(app: AppHandle<Wry>) -> Result<NotifyStatus, ShellError> {
422 blocking(move || {
423 Ok(crate::notify::status(
424 &app.state::<ShellState>().notifications,
425 ))
426 })
427 .await
428}
429
430#[tauri::command]
432pub async fn rk_shell_notify_clear(ids: Option<Vec<String>>) -> Result<(), ShellError> {
433 blocking(move || crate::notify::clear_delivered(&ids.unwrap_or_default())).await
434}
435
436#[tauri::command]
437pub fn rk_shell_badge(
438 count: i64,
439 app: AppHandle<Wry>,
440 state: State<'_, ShellState>,
441) -> Result<u32, ShellError> {
442 let n = state.notifications.set_badge(count);
443 if let Some(w) = app.get_webview_window(&state.main_label) {
444 w.set_badge_count((n > 0).then_some(i64::from(n)))
445 .map_err(|e| ShellError::Other(format!("set badge count: {e}")))?;
446 }
447 Ok(n)
448}
449
450#[tauri::command]
451pub fn rk_shell_set_dnd(
452 enabled: bool,
453 app: AppHandle<Wry>,
454 state: State<'_, ShellState>,
455) -> PresenceState {
456 state.presence.set_manual_dnd(enabled);
457 let s = state.presence.snapshot();
458 let _ = tauri::Emitter::emit(&app, crate::EVENT_PRESENCE, s);
459 s
460}
461
462#[tauri::command]
463pub fn rk_shell_presence(state: State<'_, ShellState>) -> PresenceState {
464 state.presence.snapshot()
465}
466
467#[tauri::command]
469pub fn rk_shell_presence_prefs(prefs: AutoPrefs, state: State<'_, ShellState>) -> PresenceState {
470 state.presence.set_prefs(prefs);
471 if !prefs.auto_hide_fullscreen || !prefs.auto_dnd_meetings {
472 let cur = state.presence.snapshot();
473 state.presence.set_auto(crate::presence::AutoState {
474 hidden_for_fullscreen: cur.auto_hidden && prefs.auto_hide_fullscreen,
475 dnd_for_meeting: cur.auto_dnd && prefs.auto_dnd_meetings,
476 });
477 }
478 state.presence.snapshot()
479}
480
481#[tauri::command]
483pub async fn rk_shell_presence_probe(app: AppHandle<Wry>) -> Result<Value, ShellError> {
484 blocking(move || {
485 let name = app.state::<ShellState>().app_name.clone();
486 let (env, windows) = crate::presence::detect_live(&name).map_err(ShellError::Unsupported)?;
487 Ok(json!({"environment": env, "windows": windows, "supported": crate::presence::controls_supported()}))
488 })
489 .await
490}
491
492#[tauri::command]
496pub fn rk_shell_presence_evaluate(
497 monitor: Rect,
498 windows: Vec<WindowObservation>,
499 apply: Option<bool>,
500 state: State<'_, ShellState>,
501) -> Value {
502 let env: Environment = crate::presence::detect(monitor, &windows, &state.app_name);
503 if apply.unwrap_or(false) {
504 state
505 .presence
506 .apply_observation(crate::presence::reconcile(state.presence.prefs(), env));
507 }
508 json!({"environment": env, "state": state.presence.snapshot()})
509}
510
511#[tauri::command]
512pub fn rk_shell_idle_status(app: AppHandle<Wry>) -> crate::idle::IdleStatus {
513 crate::idle::status(&app)
514}
515
516#[tauri::command]
518pub fn rk_shell_idle_note(hidden: bool, suspend_after_ms: Option<u64>, app: AppHandle<Wry>) {
519 if hidden {
520 crate::idle::note_hidden_after(
521 &app,
522 false,
523 suspend_after_ms.map(std::time::Duration::from_millis),
524 );
525 } else {
526 crate::idle::note_visible(&app);
527 }
528}
529
530#[tauri::command]
532pub fn rk_shell_path_require(
533 path: String,
534 write: Option<bool>,
535 state: State<'_, ShellState>,
536) -> Result<String, ShellError> {
537 require(&state, Module::Broker)?;
538 if write.unwrap_or(false) {
539 state.broker.require_write_target(&path)
540 } else {
541 state.broker.require_file(&path)
542 }
543}
544
545#[tauri::command]
546pub fn rk_shell_path_revoke(path: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
547 require(&state, Module::Broker)?;
548 state.broker.revoke_path(&path)
549}
550
551#[tauri::command]
553pub fn rk_shell_path_grant_recent(
554 id: String,
555 state: State<'_, ShellState>,
556) -> Result<String, ShellError> {
557 require(&state, Module::Broker)?;
558 state.broker.grant_known(&id)
559}
560
561#[tauri::command]
563pub fn rk_shell_path_regrant_saved(
564 path: String,
565 state: State<'_, ShellState>,
566) -> Result<String, ShellError> {
567 require(&state, Module::Broker)?;
568 state.broker.regrant_saved_target(&path)
569}
570
571#[tauri::command]
574pub fn rk_shell_stream_grant(
575 path: String,
576 scheme: Option<String>,
577 state: State<'_, ShellState>,
578) -> Result<Value, ShellError> {
579 require(&state, Module::Broker)?;
580 require(&state, Module::Stream)?;
581 let scheme = match scheme {
582 Some(requested) => {
583 let requested = requested.to_ascii_lowercase();
584 if state.stream_schemes.contains(&requested) {
585 requested
586 } else {
587 return Err(ShellError::Invalid(format!(
588 "stream scheme {requested} is not registered"
589 )));
590 }
591 }
592 None => state
593 .stream_scheme
594 .clone()
595 .ok_or_else(|| ShellError::Unsupported("no stream protocol configured".into()))?,
596 };
597 let id = state.broker.grant_stream(&path)?;
598 let url = if cfg!(windows) {
599 format!("http://{scheme}.localhost/{id}")
600 } else {
601 format!("{scheme}://localhost/{id}")
602 };
603 Ok(json!({"id": id, "url": url}))
604}
605
606#[tauri::command]
609pub fn rk_shell_site_grant(
610 document: String,
611 state: State<'_, ShellState>,
612) -> Result<Value, ShellError> {
613 require(&state, Module::Broker)?;
614 require(&state, Module::Stream)?;
615 let scheme = state
616 .site_scheme
617 .clone()
618 .ok_or_else(|| ShellError::Unsupported("no site protocol configured".into()))?;
619 let grant = crate::site::grant_document_site(&state.broker, &document, &state.site_policy)?;
620 Ok(json!({
621 "token": grant.token,
622 "root": crate::broker::path_to_string(&grant.root),
623 "baseUrl": crate::site::site_base_url(&scheme, &grant.token),
624 }))
625}
626
627#[tauri::command]
629pub fn rk_shell_site_revoke(token: String, state: State<'_, ShellState>) -> Result<(), ShellError> {
630 require(&state, Module::Broker)?;
631 state.broker.revoke_site_root(&token)
632}
633
634fn recovery_of(state: &ShellState) -> Result<&crate::recovery::RecoveryStore, ShellError> {
635 require(state, Module::Recovery)?;
636 require(state, Module::Broker)?;
637 state
638 .recovery
639 .as_ref()
640 .ok_or_else(|| ShellError::Unsupported("setup recovery is not configured".into()))
641}
642
643#[tauri::command]
644pub fn rk_shell_recovery_save(
645 snapshot: String,
646 state: State<'_, ShellState>,
647) -> Result<(), ShellError> {
648 recovery_of(&state)?.save(&snapshot, &state.broker)
649}
650
651#[tauri::command]
652pub fn rk_shell_recovery_load(state: State<'_, ShellState>) -> Result<Option<String>, ShellError> {
653 recovery_of(&state)?.load(&state.broker)
654}
655
656#[tauri::command]
657pub fn rk_shell_recovery_clear(state: State<'_, ShellState>) -> Result<(), ShellError> {
658 recovery_of(&state)?.clear()
659}