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(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            // 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() == 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/// `enabled = None` reads; `Some(b)` sets and returns the new status.
181#[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/// macOS: open System Settings > General > Login Items (`SMAppService.openSystemSettingsLoginItems`),
193/// where the user approves a login item whose autostart status is `requiresApproval`. Rejects off macOS
194/// and before macOS 13.
195#[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    /// PNG, base64.
206    pub png: Option<String>,
207    #[serde(default)]
208    pub files: Vec<String>,
209    /// format name -> base64.
210    #[serde(default)]
211    pub custom: BTreeMap<String, String>,
212    /// Private pasteboard name (macOS); omit for the general clipboard.
213    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/// The typed "open from clipboard" / paste result: files, then a bitmap (written to a granted temp
271/// file), then text, then `{"kind":"none"}`. Blocking clipboard work runs off the UI thread.
272#[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        // Engine calls block on the sidecar: keep them off the async executor threads.
297        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/// Engine lifecycle snapshot without marking the renderer ready.
318#[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/// Tagged engine call with structured errors. Registered even without `engine`.
335#[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/// The renderer finished draining after `rightkit-shell://quit-requested`: mark the app exiting,
392/// then exit. The `ExitRequested` this raises sees `exiting` and does not prompt again.
393#[tauri::command]
394pub fn rk_shell_finish_quit(app: AppHandle<Wry>) {
395    crate::lifecycle::finish_quit(&app);
396}
397
398/// What the host supports, for the renderer to probe once. Read-only.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
400#[serde(rename_all = "camelCase")]
401pub struct ShellCapabilities {
402    /// `rightkit-shell://quit-requested` plus `rk_shell_finish_quit`: the host opted into the drain.
403    pub quit_drain: bool,
404    /// `rk_shell_notify` honours `bypassDoNotDisturb`.
405    pub notify_bypass_dnd: bool,
406}
407
408/// The capabilities for a host whose quit drain is `quit_drain`.
409pub 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/// Mirror the app theme into native chrome (window appearance; tray template icons follow it).
422#[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
433// ---------------------------------------------------------------------------------------------
434// Notifications, presence / DnD, idle, path broker, recovery. These reject with the typed
435// `ShellError` envelope (`{code, message}`), unlike the older string-rejecting commands above.
436// ---------------------------------------------------------------------------------------------
437
438use 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/// Post a native notification. Resolves `{delivered:false, suppressed:"dnd"}` while Do-Not-Disturb
451/// (manual or an automatic meeting) is in effect. Runs off the UI thread: the first call may wait on
452/// the OS permission prompt.
453#[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    // A frontmost app's banner is suppressed by the OS; ask for the gentler attention cue instead.
469    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/// Authorization plus what the OS reports as delivered to this app (the QA read-back).
481#[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/// Remove delivered notifications by id; `None` or an empty list removes all of this app's.
492#[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/// Opt in or out of the automatic controls. Turning one off clears its stale automatic state.
529#[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/// Sample the real OS now: `{environment, windows}` (count of visible windows seen).
543#[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/// Run the detection policy over an injected observation. With `apply`, the result is reconciled
554/// with the current preferences and becomes the automatic state (what the poller would do), so a
555/// meeting can be simulated without a real conferencing app.
556#[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/// App-driven hide/show of the main window for the idle machine.
578#[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/// Canonicalise `path` and require a broker grant (`write: true` checks a save target).
592#[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/// Trade a one-shot recent-file id (minted in Rust) for a grant.
613#[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/// Re-grant a save-as target the app just wrote.
623#[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/// Mint the stream URL for a granted file. `scheme` picks one of the registered stream schemes
633/// (default: the first). Rejects `broker_denied` for anything not granted.
634#[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/// Grant the site root of a document the person already opened, and return the base URL the
668/// document's `<base href>` uses: `{token, root, baseUrl}`.
669#[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/// Revoke a site-root token when its tab closes. Its URLs stop serving at once and never revive.
689#[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}