Skip to main content

tauri_plugin_widgets/
transport.rs

1//! Apple host→widget transport: one driver, chosen by config (not runtime fan-out).
2//!
3//! Availability (`containerURL`, suite exists) is **not** delivery proof — the
4//! developer picks [`crate::config::TransportKind`] from signing knowledge.
5
6use crate::config::{effective_transport, TransportKind, WidgetsPluginConfig};
7use crate::error::Error;
8use crate::store::{self, DataMap};
9#[cfg(any(target_os = "macos", test))]
10use crate::store::map_nonce;
11#[cfg(test)]
12use crate::store::touch_meta;
13use serde::{Deserialize, Serialize};
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex};
16
17pub const NAME_APPGROUP: &str = "appgroup";
18pub const NAME_DEFAULTS: &str = "defaults";
19pub const NAME_CONTAINER: &str = "container";
20
21/// Widget-side ack that it rendered a config from a specific transport.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Receipt {
25    /// Transport that won freshest pick on the widget.
26    /// Also accepts `source` from the richer render-receipt schema.
27    #[serde(alias = "source")]
28    pub read_from: String,
29    /// Config-map nonce the widget observed.
30    pub nonce: u64,
31    /// Unix ms when the receipt was written.
32    pub ts: u64,
33}
34
35pub trait Transport: Send + Sync {
36    fn name(&self) -> &'static str;
37    /// Cheap local probe — must NOT be treated as delivery proof by itself.
38    fn available(&self) -> bool;
39    fn read(&self) -> Option<DataMap>;
40    fn write(&self, map: &DataMap) -> crate::Result<()>;
41    fn read_receipt(&self) -> Option<Receipt>;
42    fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()>;
43}
44
45/// Resolve a single host transport from plugin config (+ `WIDGET_TRANSPORT`).
46pub fn resolve_driver(cfg: &WidgetsPluginConfig) -> crate::Result<Arc<dyn Transport>> {
47    #[cfg(not(target_os = "macos"))]
48    {
49        let _ = cfg;
50        return Err(Error::new(
51            "Apple transport drivers are only available on macOS host builds",
52        ));
53    }
54    #[cfg(target_os = "macos")]
55    {
56        let kind = effective_transport(cfg);
57        match kind {
58            TransportKind::Auto => probe_once(cfg),
59            other => build_driver(other, cfg),
60        }
61    }
62}
63
64/// Fail loud when the chosen transport cannot be constructed.
65pub fn build_driver(
66    kind: TransportKind,
67    cfg: &WidgetsPluginConfig,
68) -> crate::Result<Arc<dyn Transport>> {
69    // Validate appGroup before the platform gate so CI/non-macOS tests see the
70    // configuration error rather than a generic "Apple-only" message.
71    if matches!(
72        kind,
73        TransportKind::AppGroup | TransportKind::UserDefaults | TransportKind::WidgetContainer
74    ) {
75        let _ = require_app_group(cfg)?;
76    }
77    #[cfg(not(target_os = "macos"))]
78    {
79        let _ = kind;
80        return Err(Error::new(
81            "Apple transport drivers are only available on macOS host builds",
82        ));
83    }
84    #[cfg(target_os = "macos")]
85    {
86        if matches!(kind, TransportKind::Auto) {
87            return Err(Error::new(
88                "build_driver(Auto) is invalid — use resolve_driver / probe_once",
89            ));
90        }
91        let group = require_app_group(cfg)?;
92        apply_extension_bundle_env(cfg);
93
94        match kind {
95            TransportKind::AppGroup => crate::macos_transport::app_group_transport(group),
96            TransportKind::UserDefaults => {
97                Ok(crate::macos_transport::user_defaults_transport(group))
98            }
99            TransportKind::WidgetContainer => {
100                Ok(crate::macos_transport::widget_container_transport(group))
101            }
102            TransportKind::Auto => unreachable!(),
103        }
104    }
105}
106
107/// iOS: only `appGroup` (or `auto` → appGroup). Other kinds fail at init.
108/// Android: Apple-only transports are ignored (treated as AppGroup / SharedPreferences).
109pub fn validate_mobile_transport(cfg: &WidgetsPluginConfig) -> crate::Result<TransportKind> {
110    let kind = effective_transport(cfg);
111    #[cfg(target_os = "android")]
112    {
113        let _ = kind;
114        // SharedPreferences path — Apple transport enums are not meaningful here.
115        return Ok(TransportKind::AppGroup);
116    }
117    #[cfg(not(target_os = "android"))]
118    {
119        match kind {
120            TransportKind::AppGroup | TransportKind::Auto => {
121                // Fail closed: App Group id is required on iOS for a shared container.
122                let _ = require_app_group(cfg)?;
123                Ok(TransportKind::AppGroup)
124            }
125            TransportKind::UserDefaults | TransportKind::WidgetContainer => Err(Error::new(format!(
126                "iOS supports transport=appGroup only (got transport={}).\n\
127                 UserDefaults suite and widget-container writes from the host are not supported on iOS.\n\
128                 Set plugins.widgets.transport to \"appGroup\" in tauri.conf.json.",
129                kind.as_str()
130            ))),
131        }
132    }
133}
134
135fn require_app_group(cfg: &WidgetsPluginConfig) -> crate::Result<&str> {
136    cfg.app_group
137        .as_deref()
138        .map(str::trim)
139        .filter(|s| !s.is_empty())
140        .ok_or_else(|| {
141            Error::new(
142                "plugins.widgets.appGroup is required in tauri.conf.json \
143                 (e.g. \"group.com.example.app\").",
144            )
145        })
146}
147
148#[cfg(target_os = "macos")]
149fn apply_extension_bundle_env(cfg: &WidgetsPluginConfig) {
150    if let Some(bundle) = cfg
151        .extension_bundle_id
152        .as_deref()
153        .map(str::trim)
154        .filter(|s| !s.is_empty())
155    {
156        // SAFETY: called once during plugin init before other threads use the path helpers.
157        unsafe { std::env::set_var("WIDGET_EXTENSION_BUNDLE", bundle) };
158    }
159}
160
161/// Dev-only: try candidates once, latch the first with a matching receipt, else container.
162#[cfg(target_os = "macos")]
163fn probe_once(cfg: &WidgetsPluginConfig) -> crate::Result<Arc<dyn Transport>> {
164    log::warn!(
165        "transport=auto — development only. Pin plugins.widgets.transport before release \
166         (appGroup with Team ID, or widgetContainer for ad-hoc)."
167    );
168    let group = require_app_group(cfg)?;
169    apply_extension_bundle_env(cfg);
170
171    let candidates = probe_candidates(group)?;
172    // Preserve the freshest existing map so probing does not wipe live configs/actions.
173    let mut probe = candidates
174        .iter()
175        .filter_map(|t| t.read())
176        .max_by_key(|m| map_nonce(m))
177        .unwrap_or_default();
178    let floor = candidates
179        .iter()
180        .filter_map(|t| t.read_receipt().map(|r| r.nonce))
181        .max()
182        .unwrap_or(0)
183        .max(map_nonce(&probe));
184    probe.insert("__probe__".into(), "1".into());
185    // Unique nonce above any existing map/receipt — avoid latching on stale receipts.
186    store::touch_meta_above(&mut probe, floor);
187    let nonce = map_nonce(&probe);
188
189    for t in &candidates {
190        if t.available() {
191            let _ = t.write(&probe);
192        }
193    }
194
195    // Prefer an exact post-write receipt match for this probe nonce + transport name.
196    for t in &candidates {
197        if let Some(r) = t.read_receipt() {
198            if r.read_from == t.name() && r.nonce == nonce {
199                log::warn!(
200                    "transport=auto latched {:?} — set plugins.widgets.transport = \"{}\"",
201                    t.name(),
202                    match t.name() {
203                        NAME_APPGROUP => "appGroup",
204                        NAME_DEFAULTS => "userDefaults",
205                        NAME_CONTAINER => "widgetContainer",
206                        other => other,
207                    }
208                );
209                return Ok(Arc::clone(t));
210            }
211        }
212    }
213
214    // No widget receipt yet: latch container (works for ad-hoc) and tell the developer.
215    let container = candidates
216        .into_iter()
217        .find(|t| t.name() == NAME_CONTAINER)
218        .ok_or_else(|| Error::new("transport=auto: widgetContainer candidate missing"))?;
219    log::warn!(
220        "transport=auto: no matching receipt yet — latching widgetContainer. \
221         After the widget paints once, set plugins.widgets.transport explicitly \
222         (appGroup if Team ID + App Groups work)."
223    );
224    Ok(container)
225}
226
227#[cfg(target_os = "macos")]
228fn probe_candidates(group: &str) -> crate::Result<Vec<Arc<dyn Transport>>> {
229    let mut out = Vec::new();
230    match crate::macos_transport::app_group_transport(group) {
231        Ok(t) => out.push(t),
232        Err(e) => log::debug!("auto probe: appGroup unavailable: {e}"),
233    }
234    out.push(crate::macos_transport::user_defaults_transport(group));
235    out.push(crate::macos_transport::widget_container_transport(group));
236    Ok(out)
237}
238
239/// Test helper: probe among provided fakes (no FS).
240#[cfg(test)]
241pub fn probe_once_among(
242    candidates: Vec<Arc<dyn Transport>>,
243    prefer_fallback: &'static str,
244) -> crate::Result<Arc<dyn Transport>> {
245    let mut probe = DataMap::new();
246    probe.insert("__probe__".into(), "1".into());
247    touch_meta(&mut probe);
248    let nonce = map_nonce(&probe);
249    for t in &candidates {
250        t.write(&probe)?;
251    }
252    for t in &candidates {
253        if let Some(r) = t.read_receipt() {
254            if r.read_from == t.name() && r.nonce >= nonce {
255                return Ok(Arc::clone(t));
256            }
257        }
258    }
259    candidates
260        .into_iter()
261        .find(|t| t.name() == prefer_fallback)
262        .ok_or_else(|| Error::new("probe fallback missing"))
263}
264
265pub fn pick_freshest_maps(maps: Vec<DataMap>) -> DataMap {
266    store::pick_freshest(maps)
267}
268
269// ─── Fake transport (unit tests) ─────────────────────────────────────────────
270
271pub struct FakeTransport {
272    name: &'static str,
273    available: AtomicBool,
274    map: Mutex<Option<DataMap>>,
275    receipt: Mutex<Option<Receipt>>,
276    pub writes: AtomicU64,
277}
278
279impl FakeTransport {
280    pub fn new(name: &'static str) -> Arc<Self> {
281        Arc::new(Self {
282            name,
283            available: AtomicBool::new(true),
284            map: Mutex::new(None),
285            receipt: Mutex::new(None),
286            writes: AtomicU64::new(0),
287        })
288    }
289
290    pub fn set_available(&self, v: bool) {
291        self.available.store(v, Ordering::Relaxed);
292    }
293
294    pub fn plant_receipt(&self, receipt: Receipt) {
295        *self.receipt.lock().unwrap() = Some(receipt);
296    }
297
298    pub fn write_count(&self) -> u64 {
299        self.writes.load(Ordering::Relaxed)
300    }
301}
302
303impl Transport for FakeTransport {
304    fn name(&self) -> &'static str {
305        self.name
306    }
307
308    fn available(&self) -> bool {
309        self.available.load(Ordering::Relaxed)
310    }
311
312    fn read(&self) -> Option<DataMap> {
313        self.map.lock().unwrap().clone()
314    }
315
316    fn write(&self, map: &DataMap) -> crate::Result<()> {
317        self.writes.fetch_add(1, Ordering::Relaxed);
318        *self.map.lock().unwrap() = Some(map.clone());
319        Ok(())
320    }
321
322    fn read_receipt(&self) -> Option<Receipt> {
323        self.receipt.lock().unwrap().clone()
324    }
325
326    fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()> {
327        *self.receipt.lock().unwrap() = Some(receipt.clone());
328        Ok(())
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::config::WidgetsPluginConfig;
336
337    fn map_v1() -> DataMap {
338        let mut m = DataMap::new();
339        m.insert("config:probe".into(), r#"{"version":1}"#.into());
340        touch_meta(&mut m);
341        m
342    }
343
344    #[cfg(target_os = "macos")]
345    #[test]
346    fn explicit_appgroup_fails_loudly_when_container_missing() {
347        let cfg = WidgetsPluginConfig {
348            transport: TransportKind::AppGroup,
349            app_group: Some("group.test.missing".into()),
350            extension_bundle_id: None,
351        };
352        unsafe {
353            std::env::remove_var("WIDGET_APP_GROUP_DATA_FILE");
354        }
355        match build_driver(TransportKind::AppGroup, &cfg) {
356            Err(err) => {
357                let msg = err.to_string();
358                assert!(
359                    msg.contains("App Group")
360                        || msg.contains("appGroup")
361                        || msg.contains("widgetContainer"),
362                    "expected loud appGroup error, got: {msg}"
363                );
364            }
365            Ok(_) => {
366                // Some macOS installs return a container URL for arbitrary group ids.
367                eprintln!(
368                    "skip: App Group container unexpectedly available for group.test.missing"
369                );
370            }
371        }
372    }
373
374    #[test]
375    fn explicit_container_writes_only_container() {
376        let container = FakeTransport::new(NAME_CONTAINER);
377        let appgroup = FakeTransport::new(NAME_APPGROUP);
378        let m = map_v1();
379        container.write(&m).unwrap();
380        assert_eq!(container.write_count(), 1);
381        assert_eq!(appgroup.write_count(), 0);
382        assert!(appgroup.read().is_none());
383        assert!(container.read().is_some());
384    }
385
386    #[test]
387    fn unchanged_value_skips_second_write_semantics() {
388        // Mirrors desktop set_items dedup: same bytes → no second transport write.
389        let t = FakeTransport::new(NAME_CONTAINER);
390        let mut map = DataMap::new();
391        map.insert("k".into(), "v".into());
392        touch_meta(&mut map);
393        t.write(&map).unwrap();
394        let before = t.write_count();
395        if map.get("k").map(String::as_str) == Some("v") {
396            // no write
397        } else {
398            t.write(&map).unwrap();
399        }
400        assert_eq!(t.write_count(), before);
401    }
402
403    #[test]
404    fn auto_latches_after_first_receipt_and_never_switches() {
405        let appgroup = FakeTransport::new(NAME_APPGROUP);
406        let container = FakeTransport::new(NAME_CONTAINER);
407
408        let mut probe = DataMap::new();
409        probe.insert("__probe__".into(), "1".into());
410        touch_meta(&mut probe);
411        let nonce = map_nonce(&probe);
412        appgroup.write(&probe).unwrap();
413        container.write(&probe).unwrap();
414        appgroup.plant_receipt(Receipt {
415            read_from: NAME_APPGROUP.into(),
416            nonce,
417            ts: store::now_ms(),
418        });
419
420        let latched = probe_once_among(
421            vec![
422                appgroup.clone() as Arc<dyn Transport>,
423                container.clone() as Arc<dyn Transport>,
424            ],
425            NAME_CONTAINER,
426        )
427        .unwrap();
428        assert_eq!(latched.name(), NAME_APPGROUP);
429
430        // Later container receipts must not switch the already-chosen driver in production;
431        // resolve_driver is one-shot. Here we only assert latch picked appgroup once.
432        container.plant_receipt(Receipt {
433            read_from: NAME_CONTAINER.into(),
434            nonce: nonce + 1,
435            ts: store::now_ms(),
436        });
437        assert_eq!(latched.name(), NAME_APPGROUP);
438    }
439
440    #[test]
441    fn require_app_group_message() {
442        let cfg = WidgetsPluginConfig::default();
443        match build_driver(TransportKind::WidgetContainer, &cfg) {
444            Err(err) => assert!(err.to_string().contains("appGroup")),
445            Ok(_) => panic!("expected missing appGroup error"),
446        }
447    }
448}