Skip to main content

tauri_plugin_widgets/
config.rs

1//! Plugin config from `tauri.conf.json` → `plugins.widgets`.
2
3use serde::Deserialize;
4
5#[cfg(feature = "schema")]
6use schemars::JsonSchema;
7
8/// Which Apple host→widget channel to use.
9///
10/// Pick this at build time (you know your signing). Do not rely on runtime fan-out.
11#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "schema", derive(JsonSchema))]
13#[serde(rename_all = "camelCase")]
14pub enum TransportKind {
15    /// App Group shared container file (`widget_data.json`). Needs a real Team ID.
16    /// Production-safe default; use `auto` explicitly for ad-hoc probing.
17    #[default]
18    AppGroup,
19    /// App Group `UserDefaults` suite. Same signing requirements as [`AppGroup`].
20    UserDefaults,
21    /// Widget extension container file. Works with ad-hoc signing; macOS host must not be sandboxed.
22    WidgetContainer,
23    /// One-shot probe at startup (dev only). Prefer pinning an explicit driver in conf.
24    Auto,
25}
26
27impl TransportKind {
28    /// Parse `WIDGET_TRANSPORT` / conf string.
29    pub fn parse(s: &str) -> Option<Self> {
30        match s.trim().to_ascii_lowercase().as_str() {
31            "appgroup" | "app_group" | "app-group" => Some(Self::AppGroup),
32            "userdefaults" | "user_defaults" | "user-defaults" | "defaults" => {
33                Some(Self::UserDefaults)
34            }
35            "widgetcontainer" | "widget_container" | "widget-container" | "container" => {
36                Some(Self::WidgetContainer)
37            }
38            "auto" => Some(Self::Auto),
39            _ => None,
40        }
41    }
42
43    /// Wire / conf string for this kind (`appGroup`, `auto`, …).
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::AppGroup => "appGroup",
47            Self::UserDefaults => "userDefaults",
48            Self::WidgetContainer => "widgetContainer",
49            Self::Auto => "auto",
50        }
51    }
52}
53
54/// `plugins.widgets` object from `tauri.conf.json`.
55#[derive(Debug, Clone, Deserialize, Default)]
56#[cfg_attr(feature = "schema", derive(JsonSchema))]
57#[serde(rename_all = "camelCase")]
58pub struct WidgetsPluginConfig {
59    /// Host→widget transport driver. Unset: `widgetContainer` on macOS (a Developer ID
60    /// extension without a provisioning profile cannot open a `group.*` container),
61    /// `appGroup` elsewhere.
62    #[serde(default)]
63    pub transport: Option<TransportKind>,
64    /// App Group id, e.g. `group.com.example.app`.
65    pub app_group: Option<String>,
66    /// Widget extension bundle id (macOS container path). Default: `{app_id}.widgetkit`.
67    pub extension_bundle_id: Option<String>,
68    /// Desktop: reopen the widget windows that were open when the app last quit.
69    #[serde(default)]
70    pub restore_windows_on_start: bool,
71    /// Desktop surface for live activities: `"island"` (default) a Dynamic-Island pill at
72    /// the top centre of the primary display (around the notch on notched Macs, expands on
73    /// hover); `"window"` a small always-on-top window in the top-right corner;
74    /// `"notification"` a system notification (feature `notify`, Linux / Windows).
75    #[serde(default)]
76    pub activity_surface: Option<String>,
77}
78
79impl WidgetsPluginConfig {
80    /// Fill `appGroup` with `group.<identifier>` when the app did not set it, and make it
81    /// the default group for commands that omit `group`.
82    pub fn with_defaults(mut self, identifier: &str) -> Self {
83        let missing = self
84            .app_group
85            .as_deref()
86            .map(str::trim)
87            .unwrap_or("")
88            .is_empty();
89        if missing && !identifier.trim().is_empty() {
90            self.app_group = Some(format!("group.{}", identifier.trim()));
91        }
92        if let Some(group) = self.app_group.as_deref() {
93            crate::group::set_default_group(group);
94        }
95        self
96    }
97}
98
99/// `WIDGET_TRANSPORT` overrides `plugins.widgets.transport` when set.
100pub fn env_transport_override() -> Option<TransportKind> {
101    std::env::var("WIDGET_TRANSPORT")
102        .ok()
103        .filter(|s| !s.is_empty())
104        .and_then(|s| TransportKind::parse(&s))
105}
106
107/// Merge conf + env into the effective kind.
108pub fn effective_transport(cfg: &WidgetsPluginConfig) -> TransportKind {
109    env_transport_override()
110        .or(cfg.transport)
111        .unwrap_or(if cfg!(target_os = "macos") {
112            TransportKind::WidgetContainer
113        } else {
114            TransportKind::AppGroup
115        })
116}
117
118#[cfg(test)]
119pub(crate) mod tests {
120    use super::*;
121
122    /// Serializes lib tests that set process env (`WIDGET_TRANSPORT`, `WIDGET_*` paths).
123    pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
124
125    const KINDS: [TransportKind; 4] = [
126        TransportKind::AppGroup,
127        TransportKind::UserDefaults,
128        TransportKind::WidgetContainer,
129        TransportKind::Auto,
130    ];
131
132    /// `transport` string lifecycle: every accepted spelling parses → unknown ones are
133    /// rejected → the canonical `as_str` spelling parses back to the same kind.
134    #[test]
135    fn transport_kind_spellings_parse_reject_and_roundtrip() {
136        // 1. Common spellings (case, `_` / `-`, short aliases, surrounding blanks).
137        let accepted = [
138            ("appGroup", TransportKind::AppGroup),
139            ("appgroup", TransportKind::AppGroup),
140            ("app_group", TransportKind::AppGroup),
141            ("app-group", TransportKind::AppGroup),
142            (" App-Group ", TransportKind::AppGroup),
143            ("userDefaults", TransportKind::UserDefaults),
144            ("user_defaults", TransportKind::UserDefaults),
145            ("user-defaults", TransportKind::UserDefaults),
146            ("defaults", TransportKind::UserDefaults),
147            ("widgetContainer", TransportKind::WidgetContainer),
148            ("widget_container", TransportKind::WidgetContainer),
149            ("widget-container", TransportKind::WidgetContainer),
150            ("container", TransportKind::WidgetContainer),
151            ("auto", TransportKind::Auto),
152            ("AUTO", TransportKind::Auto),
153        ];
154        for (raw, want) in accepted {
155            assert_eq!(TransportKind::parse(raw), Some(want), "parse({raw:?})");
156        }
157        // 2. Anything else is not a transport (no fuzzy match, inner blanks are not separators).
158        for raw in ["", "app group", "fanout"] {
159            assert_eq!(TransportKind::parse(raw), None, "parse({raw:?})");
160        }
161        // 3. The conf spelling we print round-trips, and the default is the production one.
162        for kind in KINDS {
163            assert_eq!(TransportKind::parse(kind.as_str()), Some(kind));
164        }
165        assert_eq!(TransportKind::default(), TransportKind::AppGroup);
166    }
167
168    /// `plugins.widgets` from tauri.conf → `with_defaults(identifier)` → effective transport
169    /// (unset → OS default, conf value, `WIDGET_TRANSPORT` override, empty env ignored).
170    #[test]
171    fn plugin_config_defaults_then_effective_transport() {
172        // 1. Conf with a blank appGroup: filled from the identifier (trimmed).
173        let conf: WidgetsPluginConfig =
174            serde_json::from_value(serde_json::json!({"appGroup": "  "})).unwrap();
175        assert_eq!(conf.transport, None);
176        assert!(!conf.restore_windows_on_start);
177        let filled = conf.with_defaults(" com.ex.app ");
178        assert_eq!(filled.app_group.as_deref(), Some("group.com.ex.app"));
179        assert_eq!(
180            WidgetsPluginConfig::default()
181                .with_defaults("com.ex.app")
182                .app_group
183                .as_deref(),
184            Some("group.com.ex.app")
185        );
186        // 2. An explicit group and transport are kept as written.
187        let kept: WidgetsPluginConfig = serde_json::from_value(
188            serde_json::json!({"appGroup": "group.custom", "transport": "appGroup"}),
189        )
190        .unwrap();
191        let kept = kept.with_defaults("com.ex.app");
192        assert_eq!(kept.app_group.as_deref(), Some("group.custom"));
193        assert_eq!(kept.transport, Some(TransportKind::AppGroup));
194        // 3. No identifier → nothing to derive from.
195        assert_eq!(
196            WidgetsPluginConfig::default().with_defaults("  ").app_group,
197            None
198        );
199
200        // 4. Effective transport: conf wins over the OS default, env wins over conf.
201        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
202        let prev = std::env::var_os("WIDGET_TRANSPORT");
203        std::env::remove_var("WIDGET_TRANSPORT");
204        let os_default = if cfg!(target_os = "macos") {
205            TransportKind::WidgetContainer
206        } else {
207            TransportKind::AppGroup
208        };
209        assert_eq!(effective_transport(&filled), os_default);
210        assert_eq!(effective_transport(&kept), TransportKind::AppGroup);
211        std::env::set_var("WIDGET_TRANSPORT", "widget-container");
212        assert_eq!(
213            env_transport_override(),
214            Some(TransportKind::WidgetContainer)
215        );
216        assert_eq!(effective_transport(&kept), TransportKind::WidgetContainer);
217        std::env::set_var("WIDGET_TRANSPORT", "");
218        assert_eq!(
219            effective_transport(&kept),
220            TransportKind::AppGroup,
221            "empty env must not override"
222        );
223        std::env::set_var("WIDGET_TRANSPORT", "bogus");
224        assert_eq!(effective_transport(&kept), TransportKind::AppGroup);
225        match prev {
226            Some(v) => std::env::set_var("WIDGET_TRANSPORT", v),
227            None => std::env::remove_var("WIDGET_TRANSPORT"),
228        }
229    }
230}