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