Skip to main content

rightkit_tauri_shell/
commands.rs

1//! Tauri commands the frontend bridge (`@rightkit/tauri-shell`) calls. Register them with
2//! [`crate::handler!`]. They are application commands, so they need no ACL capability entry.
3
4use 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
16/// Refuse a command whose module the app opted out of (see [`crate::modules`]).
17pub(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/// The frontend is listening: deliver queued deep links. Returns launch facts.
34#[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/// Diagnostics for tests and support: menu rows, tray, instance role, nav policy.
46#[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    // Where the OS placed the status item (screen points). Present only once AppKit/the shell
50    // gave the item real geometry, which makes this an OS-derived fact, not a registry entry.
51    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            // The guard decides: never-show makes this a silent no-op.
136            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/// `enabled = None` reads; `Some(b)` sets and returns the new status.
179#[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/// macOS: open System Settings > General > Login Items (`SMAppService.openSystemSettingsLoginItems`),
191/// where the user approves a login item whose autostart status is `requiresApproval`. Rejects off macOS
192/// and before macOS 13.
193#[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    /// PNG, base64.
204    pub png: Option<String>,
205    #[serde(default)]
206    pub files: Vec<String>,
207    /// format name -> base64.
208    #[serde(default)]
209    pub custom: BTreeMap<String, String>,
210    /// Private pasteboard name (macOS); omit for the general clipboard.
211    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/// The typed "open from clipboard" / paste result: files, then a bitmap (written to a granted temp
269/// file), then text, then `{"kind":"none"}`. Blocking clipboard work runs off the UI thread.
270#[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        // Engine calls block on the sidecar: keep them off the async executor threads.
295        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/// Engine lifecycle snapshot without marking the renderer ready.
316#[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/// Tagged engine call with structured errors. Registered even without `engine`.
333#[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/// The renderer finished draining after `rightkit-shell://quit-requested`: mark the app exiting,
390/// then exit. The `ExitRequested` this raises sees `exiting` and does not prompt again.
391#[tauri::command]
392pub fn rk_shell_finish_quit(app: AppHandle<Wry>) {
393    crate::lifecycle::finish_quit(&app);
394}
395
396/// What the host supports, for the renderer to probe once. Read-only.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
398#[serde(rename_all = "camelCase")]
399pub struct ShellCapabilities {
400    /// `rightkit-shell://quit-requested` plus `rk_shell_finish_quit`: the host opted into the drain.
401    pub quit_drain: bool,
402    /// `rk_shell_notify` honours `bypassDoNotDisturb`.
403    pub notify_bypass_dnd: bool,
404}
405
406/// The capabilities for a host whose quit drain is `quit_drain`.
407pub 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/// Mirror the app theme into native chrome (window appearance; tray template icons follow it).
420#[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
431// ---------------------------------------------------------------------------------------------
432// Notifications, presence / DnD, idle, path broker, recovery. These reject with the typed
433// `ShellError` envelope (`{code, message}`), unlike the older string-rejecting commands above.
434// ---------------------------------------------------------------------------------------------
435
436use 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/// Post a native notification. Resolves `{delivered:false, suppressed:"dnd"}` while Do-Not-Disturb
449/// (manual or an automatic meeting) is in effect. Runs off the UI thread: the first call may wait on
450/// the OS permission prompt.
451#[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    // A frontmost app's banner is suppressed by the OS; ask for the gentler attention cue instead.
467    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/// Authorization plus what the OS reports as delivered to this app (the QA read-back).
479#[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/// Remove delivered notifications by id; `None` or an empty list removes all of this app's.
490#[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/// Opt in or out of the automatic controls. Turning one off clears its stale automatic state.
527#[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/// Sample the real OS now: `{environment, windows}` (count of visible windows seen).
541#[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/// Run the detection policy over an injected observation. With `apply`, the result is reconciled
552/// with the current preferences and becomes the automatic state (what the poller would do), so a
553/// meeting can be simulated without a real conferencing app.
554#[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/// App-driven hide/show of the main window for the idle machine.
576#[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/// Canonicalise `path` and require a broker grant (`write: true` checks a save target).
590#[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/// Trade a one-shot recent-file id (minted in Rust) for a grant.
611#[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/// Re-grant a save-as target the app just wrote.
621#[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/// Mint the stream URL for a granted file. `scheme` picks one of the registered stream schemes
631/// (default: the first). Rejects `broker_denied` for anything not granted.
632#[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/// Grant the site root of a document the person already opened, and return the base URL the
666/// document's `<base href>` uses: `{token, root, baseUrl}`.
667#[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/// Revoke a site-root token when its tab closes. Its URLs stop serving at once and never revive.
687#[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}