1use 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";
19pub const NAME_DEFAULTS: &str = "defaults";
21pub const NAME_CONTAINER: &str = "container";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct Receipt {
28 #[serde(alias = "source")]
31 pub read_from: String,
32 pub nonce: u64,
34 pub ts: u64,
36}
37
38pub trait Transport: Send + Sync {
40 fn name(&self) -> &'static str;
42 fn available(&self) -> bool;
44 fn read(&self) -> Option<DataMap>;
46 fn write(&self, map: &DataMap) -> crate::Result<()>;
48 fn read_receipt(&self) -> Option<Receipt>;
50 fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()>;
52}
53
54pub 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
73pub fn build_driver(
75 kind: TransportKind,
76 cfg: &WidgetsPluginConfig,
77) -> crate::Result<Arc<dyn Transport>> {
78 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
116pub 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 return Ok(TransportKind::AppGroup);
125 }
126 #[cfg(not(target_os = "android"))]
127 {
128 match kind {
129 TransportKind::AppGroup | TransportKind::Auto => {
130 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 unsafe { std::env::set_var("WIDGET_EXTENSION_BUNDLE", bundle) };
167 }
168}
169
170#[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 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 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 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 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#[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
274pub fn pick_freshest_maps(maps: Vec<DataMap>) -> DataMap {
276 store::pick_freshest(maps)
277}
278
279pub struct FakeTransport {
283 name: &'static str,
284 available: AtomicBool,
285 map: Mutex<Option<DataMap>>,
286 receipt: Mutex<Option<Receipt>>,
287 pub writes: AtomicU64,
289}
290
291impl FakeTransport {
292 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 pub fn set_available(&self, v: bool) {
305 self.available.store(v, Ordering::Relaxed);
306 }
307
308 pub fn plant_receipt(&self, receipt: Receipt) {
310 *self.receipt.lock().unwrap() = Some(receipt);
311 }
312
313 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 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 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 } 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 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}