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";
18pub const NAME_DEFAULTS: &str = "defaults";
19pub const NAME_CONTAINER: &str = "container";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Receipt {
25 #[serde(alias = "source")]
28 pub read_from: String,
29 pub nonce: u64,
31 pub ts: u64,
33}
34
35pub trait Transport: Send + Sync {
36 fn name(&self) -> &'static str;
37 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
45pub 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
64pub fn build_driver(
66 kind: TransportKind,
67 cfg: &WidgetsPluginConfig,
68) -> crate::Result<Arc<dyn Transport>> {
69 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
107pub 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 return Ok(TransportKind::AppGroup);
116 }
117 #[cfg(not(target_os = "android"))]
118 {
119 match kind {
120 TransportKind::AppGroup | TransportKind::Auto => {
121 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 unsafe { std::env::set_var("WIDGET_EXTENSION_BUNDLE", bundle) };
158 }
159}
160
161#[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 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 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 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 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#[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
269pub 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 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 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 } 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 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}