tauri-plugin-widgets 0.7.0

Tauri plugin for App Widgets on Android, iOS, and macOS (WidgetKit); Windows Widgets Board (Adaptive Cards) + desktop webview; Linux desktop webview with X11 DESKTOP pin.
Documentation
use tauri::{AppHandle, Emitter, Runtime, State};

use crate::apply::ApplyOutcome;
use crate::error::Error;
use crate::group::{resolve_group, validate_widget_id};
use crate::models::{WidgetConfig, WidgetWindowConfig};

#[cfg(desktop)]
use crate::desktop::Widget;
#[cfg(mobile)]
use crate::mobile::Widget;

#[tauri::command]
pub async fn set_items<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    key: String,
    value: String,
    group: Option<String>,
) -> Result<bool, Error> {
    let group = resolve_group(group.as_deref())?;
    widget.set_items(&key, &value, &group)
}

#[tauri::command]
pub async fn get_items<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    key: String,
    group: Option<String>,
) -> Result<Option<String>, Error> {
    let group = resolve_group(group.as_deref())?;
    widget.get_items(&key, &group)
}

#[tauri::command]
pub fn set_register_widget<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    widgets: Vec<String>,
) -> Result<bool, Error> {
    widget.set_register_widget(widgets)
}

#[tauri::command]
pub fn reload_all_timelines<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
) -> Result<bool, Error> {
    widget.reload_all_timelines()
}

#[tauri::command]
pub fn reload_timelines<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    of_kind: String,
) -> Result<bool, Error> {
    widget.reload_timelines(&of_kind)
}

#[tauri::command]
pub fn request_widget<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
) -> Result<bool, Error> {
    widget.request_widget()
}

#[tauri::command]
pub async fn create_widget_window<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    mut config: WidgetWindowConfig,
) -> Result<bool, Error> {
    // Built-in renderer reads this group; default it like every other command.
    if config.url.as_deref().map_or(true, str::is_empty) || config.group.is_some() {
        config.group = Some(resolve_group(config.group.as_deref())?);
    }
    widget.create_widget_window(config)
}

#[tauri::command]
pub async fn close_widget_window<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    label: String,
) -> Result<bool, Error> {
    widget.close_widget_window(&label)
}

#[tauri::command]
pub async fn set_widget_config<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    config: WidgetConfig,
    group: Option<String>,
    widget_id: String,
    skip_reload: Option<bool>,
) -> Result<ApplyOutcome, Error> {
    let group = resolve_group(group.as_deref())?;
    validate_widget_id(&widget_id)?;
    widget.set_widget_config(&config, &group, &widget_id, skip_reload.unwrap_or(false))
}

/// Read and deserialize a [`WidgetConfig`] JSON file from disk.
///
/// `path` must be readable by the app process (absolute path or resolved resource /
/// app-data path). Network URLs are not supported.
pub(crate) fn load_widget_config_from_path(path: &str) -> Result<WidgetConfig, Error> {
    let path = path.trim();
    if path.is_empty() {
        return Err(Error::new(
            "set_widget_config_from_path: path must not be empty",
        ));
    }
    // Regular files only, bounded: `/dev/zero` or a FIFO would otherwise exhaust memory
    // or block forever.
    const MAX_BYTES: u64 = 4 * 1024 * 1024;
    let meta =
        std::fs::metadata(path).map_err(|e| Error::Io(format!("read config from {path}: {e}")))?;
    if !meta.is_file() {
        return Err(Error::Io(format!(
            "read config from {path}: not a regular file"
        )));
    }
    if meta.len() > MAX_BYTES {
        return Err(Error::Io(format!(
            "read config from {path}: {} bytes exceeds the {MAX_BYTES} byte limit",
            meta.len()
        )));
    }
    let raw = std::fs::read_to_string(path)
        .map_err(|e| Error::Io(format!("read config from {path}: {e}")))?;
    serde_json::from_str(&raw)
        .map_err(|e| Error::SerdeJson(format!("parse WidgetConfig from {path}: {e}")))
}

/// Load a [`WidgetConfig`] JSON file from disk, then apply it like [`set_widget_config`].
///
/// `path` must be readable by the app process (absolute path or resolved resource /
/// app-data path). Network URLs are not supported.
#[tauri::command]
pub async fn set_widget_config_from_path<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    path: String,
    group: Option<String>,
    widget_id: String,
    skip_reload: Option<bool>,
) -> Result<ApplyOutcome, Error> {
    let group = resolve_group(group.as_deref())?;
    validate_widget_id(&widget_id)?;
    widget.set_widget_config_from_path(&path, &group, &widget_id, skip_reload.unwrap_or(false))
}

#[tauri::command]
pub async fn get_widget_config<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    group: Option<String>,
    widget_id: String,
) -> Result<Option<WidgetConfig>, Error> {
    let group = resolve_group(group.as_deref())?;
    validate_widget_id(&widget_id)?;
    widget.get_widget_config(&group, &widget_id)
}

#[tauri::command]
pub fn widget_action<R: Runtime>(
    app: AppHandle<R>,
    action: String,
    payload: Option<String>,
    widget_id: Option<String>,
    group: Option<String>,
) -> Result<bool, Error> {
    let group = resolve_group(group.as_deref()).unwrap_or_default();
    let data = serde_json::json!({
        "action": action,
        "payload": payload,
        "ts": crate::store::now_ms(),
        "widgetId": widget_id.unwrap_or_default(),
        "group": group,
    });
    app.emit("widget-action", data)
        .map_err(|e| Error::new(format!("emit widget-action: {e}")))?;
    Ok(true)
}

#[tauri::command]
pub async fn poll_pending_actions<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    group: Option<String>,
) -> Result<Vec<crate::WidgetActionEnvelope>, Error> {
    let group = resolve_group(group.as_deref())?;
    widget.poll_pending_actions(&group)
}

#[tauri::command]
pub fn report_receipt<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    mut receipt: crate::receipt::WidgetRenderReceipt,
) -> Result<bool, Error> {
    // The group feeds the macOS action poller: only the app's own group is accepted.
    receipt.group = resolve_group(Some(&receipt.group))?;
    widget.report_receipt(receipt)
}

#[tauri::command]
pub fn get_widget_diagnostics<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    group: Option<String>,
) -> Result<Vec<crate::receipt::WidgetRenderReceipt>, Error> {
    let group = resolve_group(group.as_deref())?;
    widget.get_widget_diagnostics(&group)
}

#[tauri::command]
pub fn get_widget_trace<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
    group: Option<String>,
    since_ms: Option<u64>,
) -> Result<crate::trace::WidgetTrace, Error> {
    let group = resolve_group(group.as_deref())?;
    widget.get_widget_trace(&group, since_ms)
}

#[tauri::command]
pub fn flush_widget_trace<R: Runtime>(
    _app: AppHandle<R>,
    widget: State<'_, Widget<R>>,
) -> Result<bool, Error> {
    widget.flush_widget_trace()
}

#[cfg(test)]
mod from_path_tests {
    use super::load_widget_config_from_path;
    use std::path::PathBuf;

    fn fixture(name: &str) -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/presets")
            .join(name)
    }

    #[test]
    fn loads_weather_fixture() {
        let cfg = load_widget_config_from_path(fixture("weather.json").to_str().unwrap())
            .expect("weather.json parses");
        assert!(cfg.small.is_some());
    }

    #[test]
    fn loads_stress_nest_keeps_list_children() {
        use crate::models::WidgetElement;

        fn find_list_children(el: &WidgetElement) -> Option<usize> {
            match el {
                WidgetElement::List(list) => Some(list.children.len()),
                WidgetElement::VStack(v) => v.children.iter().find_map(find_list_children),
                WidgetElement::HStack(v) => v.children.iter().find_map(find_list_children),
                WidgetElement::ZStack(v) => v.children.iter().find_map(find_list_children),
                WidgetElement::Grid(v) => v.children.iter().find_map(find_list_children),
                WidgetElement::Container(v) => v.children.iter().find_map(find_list_children),
                WidgetElement::Link(v) => v.children.iter().find_map(find_list_children),
                _ => None,
            }
        }

        let cfg = load_widget_config_from_path(fixture("stress-nest.json").to_str().unwrap())
            .expect("stress-nest.json parses");
        let root = cfg.large.expect("large root");
        let n = find_list_children(&root).expect("list node present");
        assert_eq!(
            n, 3,
            "rich list children must survive from_path deserialize"
        );
    }

    #[test]
    fn rejects_empty_path() {
        assert!(load_widget_config_from_path("").is_err());
        assert!(load_widget_config_from_path("   ").is_err());
    }

    #[test]
    fn rejects_non_regular_files() {
        assert!(load_widget_config_from_path("/dev/zero").is_err());
        assert!(load_widget_config_from_path(env!("CARGO_MANIFEST_DIR")).is_err());
    }

    #[test]
    fn rejects_missing_file() {
        let err = load_widget_config_from_path("/no/such/widget-config-xyz.json").unwrap_err();
        assert!(err.to_string().contains("IO error") || err.to_string().contains("read config"));
    }
}