use crate::config::{effective_transport, TransportKind, WidgetsPluginConfig};
use crate::error::Error;
#[cfg(any(target_os = "macos", test))]
use crate::store::map_nonce;
#[cfg(test)]
use crate::store::touch_meta;
use crate::store::{self, DataMap};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
pub const NAME_APPGROUP: &str = "appgroup";
pub const NAME_DEFAULTS: &str = "defaults";
pub const NAME_CONTAINER: &str = "container";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Receipt {
#[serde(alias = "source")]
pub read_from: String,
pub nonce: u64,
pub ts: u64,
}
pub trait Transport: Send + Sync {
fn name(&self) -> &'static str;
fn available(&self) -> bool;
fn read(&self) -> Option<DataMap>;
fn write(&self, map: &DataMap) -> crate::Result<()>;
fn read_receipt(&self) -> Option<Receipt>;
fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()>;
}
pub fn resolve_driver(cfg: &WidgetsPluginConfig) -> crate::Result<Arc<dyn Transport>> {
#[cfg(not(target_os = "macos"))]
{
let _ = cfg;
Err(Error::new(
"Apple transport drivers are only available on macOS host builds",
))
}
#[cfg(target_os = "macos")]
{
let kind = effective_transport(cfg);
match kind {
TransportKind::Auto => probe_once(cfg),
other => build_driver(other, cfg),
}
}
}
pub fn build_driver(
kind: TransportKind,
cfg: &WidgetsPluginConfig,
) -> crate::Result<Arc<dyn Transport>> {
if matches!(
kind,
TransportKind::AppGroup | TransportKind::UserDefaults | TransportKind::WidgetContainer
) {
let _ = require_app_group(cfg)?;
}
#[cfg(not(target_os = "macos"))]
{
let _ = kind;
Err(Error::new(
"Apple transport drivers are only available on macOS host builds",
))
}
#[cfg(target_os = "macos")]
{
if matches!(kind, TransportKind::Auto) {
return Err(Error::new(
"build_driver(Auto) is invalid — use resolve_driver / probe_once",
));
}
let group = require_app_group(cfg)?;
apply_extension_bundle_env(cfg);
match kind {
TransportKind::AppGroup => crate::macos_transport::app_group_transport(group),
TransportKind::UserDefaults => {
Ok(crate::macos_transport::user_defaults_transport(group))
}
TransportKind::WidgetContainer => {
Ok(crate::macos_transport::widget_container_transport(group))
}
TransportKind::Auto => unreachable!(),
}
}
}
pub fn validate_mobile_transport(cfg: &WidgetsPluginConfig) -> crate::Result<TransportKind> {
let kind = effective_transport(cfg);
#[cfg(target_os = "android")]
{
let _ = kind;
return Ok(TransportKind::AppGroup);
}
#[cfg(not(target_os = "android"))]
{
match kind {
TransportKind::AppGroup | TransportKind::Auto => {
let _ = require_app_group(cfg)?;
Ok(TransportKind::AppGroup)
}
TransportKind::UserDefaults | TransportKind::WidgetContainer => Err(Error::new(format!(
"iOS supports transport=appGroup only (got transport={}).\n\
UserDefaults suite and widget-container writes from the host are not supported on iOS.\n\
Set plugins.widgets.transport to \"appGroup\" in tauri.conf.json.",
kind.as_str()
))),
}
}
}
fn require_app_group(cfg: &WidgetsPluginConfig) -> crate::Result<&str> {
cfg.app_group
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
Error::new(
"plugins.widgets.appGroup is required in tauri.conf.json \
(e.g. \"group.com.example.app\").",
)
})
}
#[cfg(target_os = "macos")]
fn apply_extension_bundle_env(cfg: &WidgetsPluginConfig) {
if let Some(bundle) = cfg
.extension_bundle_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
unsafe { std::env::set_var("WIDGET_EXTENSION_BUNDLE", bundle) };
}
}
#[cfg(target_os = "macos")]
fn probe_once(cfg: &WidgetsPluginConfig) -> crate::Result<Arc<dyn Transport>> {
log::warn!(
"transport=auto — development only. Pin plugins.widgets.transport before release \
(appGroup with Team ID, or widgetContainer for ad-hoc)."
);
let group = require_app_group(cfg)?;
apply_extension_bundle_env(cfg);
let candidates = probe_candidates(group)?;
let mut probe = candidates
.iter()
.filter_map(|t| t.read())
.max_by_key(map_nonce)
.unwrap_or_default();
let floor = candidates
.iter()
.filter_map(|t| t.read_receipt().map(|r| r.nonce))
.max()
.unwrap_or(0)
.max(map_nonce(&probe));
probe.insert("__probe__".into(), "1".into());
store::touch_meta_above(&mut probe, floor);
let nonce = map_nonce(&probe);
for t in &candidates {
if t.available() {
let _ = t.write(&probe);
}
}
for t in &candidates {
if let Some(r) = t.read_receipt() {
if r.read_from == t.name() && r.nonce == nonce {
log::warn!(
"transport=auto latched {:?} — set plugins.widgets.transport = \"{}\"",
t.name(),
match t.name() {
NAME_APPGROUP => "appGroup",
NAME_DEFAULTS => "userDefaults",
NAME_CONTAINER => "widgetContainer",
other => other,
}
);
return Ok(Arc::clone(t));
}
}
}
let container = candidates
.into_iter()
.find(|t| t.name() == NAME_CONTAINER)
.ok_or_else(|| Error::new("transport=auto: widgetContainer candidate missing"))?;
log::warn!(
"transport=auto: no matching receipt yet — latching widgetContainer. \
After the widget paints once, set plugins.widgets.transport explicitly \
(appGroup if Team ID + App Groups work)."
);
Ok(container)
}
#[cfg(target_os = "macos")]
fn probe_candidates(group: &str) -> crate::Result<Vec<Arc<dyn Transport>>> {
let mut out = Vec::new();
match crate::macos_transport::app_group_transport(group) {
Ok(t) => out.push(t),
Err(e) => log::debug!("auto probe: appGroup unavailable: {e}"),
}
out.push(crate::macos_transport::user_defaults_transport(group));
out.push(crate::macos_transport::widget_container_transport(group));
Ok(out)
}
#[cfg(test)]
pub fn probe_once_among(
candidates: Vec<Arc<dyn Transport>>,
prefer_fallback: &'static str,
) -> crate::Result<Arc<dyn Transport>> {
let mut probe = DataMap::new();
probe.insert("__probe__".into(), "1".into());
touch_meta(&mut probe);
let nonce = map_nonce(&probe);
for t in &candidates {
t.write(&probe)?;
}
for t in &candidates {
if let Some(r) = t.read_receipt() {
if r.read_from == t.name() && r.nonce >= nonce {
return Ok(Arc::clone(t));
}
}
}
candidates
.into_iter()
.find(|t| t.name() == prefer_fallback)
.ok_or_else(|| Error::new("probe fallback missing"))
}
pub fn pick_freshest_maps(maps: Vec<DataMap>) -> DataMap {
store::pick_freshest(maps)
}
pub struct FakeTransport {
name: &'static str,
available: AtomicBool,
map: Mutex<Option<DataMap>>,
receipt: Mutex<Option<Receipt>>,
pub writes: AtomicU64,
}
impl FakeTransport {
pub fn new(name: &'static str) -> Arc<Self> {
Arc::new(Self {
name,
available: AtomicBool::new(true),
map: Mutex::new(None),
receipt: Mutex::new(None),
writes: AtomicU64::new(0),
})
}
pub fn set_available(&self, v: bool) {
self.available.store(v, Ordering::Relaxed);
}
pub fn plant_receipt(&self, receipt: Receipt) {
*self.receipt.lock().unwrap() = Some(receipt);
}
pub fn write_count(&self) -> u64 {
self.writes.load(Ordering::Relaxed)
}
}
impl Transport for FakeTransport {
fn name(&self) -> &'static str {
self.name
}
fn available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn read(&self) -> Option<DataMap> {
self.map.lock().unwrap().clone()
}
fn write(&self, map: &DataMap) -> crate::Result<()> {
self.writes.fetch_add(1, Ordering::Relaxed);
*self.map.lock().unwrap() = Some(map.clone());
Ok(())
}
fn read_receipt(&self) -> Option<Receipt> {
self.receipt.lock().unwrap().clone()
}
fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()> {
*self.receipt.lock().unwrap() = Some(receipt.clone());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::tests::ENV_LOCK;
use crate::config::WidgetsPluginConfig;
fn cfg(kind: Option<TransportKind>, group: Option<&str>) -> WidgetsPluginConfig {
WidgetsPluginConfig {
transport: kind,
app_group: group.map(Into::into),
..Default::default()
}
}
fn map(nonce: u64, widget: &str) -> DataMap {
let mut m = DataMap::new();
m.insert(format!("config:{widget}"), r#"{"version":1}"#.into());
store::touch_meta_above(&mut m, nonce.saturating_sub(1));
m
}
#[test]
fn driver_choice_from_config() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("WIDGET_TRANSPORT");
for kind in [
TransportKind::AppGroup,
TransportKind::UserDefaults,
TransportKind::WidgetContainer,
] {
for group in [None, Some(" ")] {
let err = build_driver(kind, &cfg(Some(kind), group))
.err()
.expect("no group");
assert!(err.to_string().contains("appGroup"), "{kind:?}: {err}");
}
}
#[cfg(not(target_os = "macos"))]
{
let c = cfg(Some(TransportKind::WidgetContainer), Some("group.t"));
for err in [
build_driver(TransportKind::WidgetContainer, &c)
.err()
.unwrap(),
resolve_driver(&c).err().unwrap(),
] {
assert!(err.to_string().contains("only available on macOS"), "{err}");
}
}
#[cfg(target_os = "macos")]
{
let err = build_driver(TransportKind::Auto, &cfg(None, Some("group.t")))
.err()
.unwrap();
assert!(err.to_string().contains("invalid"), "{err}");
}
#[cfg(not(target_os = "android"))]
{
for kind in [TransportKind::AppGroup, TransportKind::Auto] {
assert_eq!(
validate_mobile_transport(&cfg(Some(kind), Some("group.t"))).unwrap(),
TransportKind::AppGroup
);
}
let err =
validate_mobile_transport(&cfg(Some(TransportKind::AppGroup), None)).unwrap_err();
assert!(err.to_string().contains("appGroup is required"), "{err}");
for kind in [TransportKind::UserDefaults, TransportKind::WidgetContainer] {
let err = validate_mobile_transport(&cfg(Some(kind), Some("group.t"))).unwrap_err();
assert!(err.to_string().contains("appGroup only"), "{err}");
assert!(err.to_string().contains(kind.as_str()), "{err}");
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn file_driver_write_read_concurrent_and_recovery() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("WIDGET_TRANSPORT");
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("WIDGET_CONTAINER_ROOT", tmp.path());
std::env::remove_var("WIDGET_APP_GROUP_DATA_FILE");
let mut c = cfg(
Some(TransportKind::WidgetContainer),
Some("group.test.driver"),
);
c.extension_bundle_id = Some("test.driver.widgetkit".into());
let t = resolve_driver(&c).unwrap();
assert_eq!(t.name(), NAME_CONTAINER);
assert!(t.available());
let path = crate::macos_transport::sandbox_widget_data_path("group.test.driver");
assert!(
path.starts_with(tmp.path())
&& path.to_string_lossy().contains("test.driver.widgetkit")
);
assert!(t.read().is_none());
let v1 = map(1, "w");
t.write(&v1).unwrap();
assert_eq!(t.read(), Some(v1.clone()));
let r = Receipt {
read_from: NAME_CONTAINER.into(),
nonce: 1,
ts: 5,
};
t.write_receipt(&r).unwrap();
assert_eq!(t.read_receipt(), Some(r));
let v2 = map(2, "w");
t.write(&v2).unwrap();
assert_eq!(store::map_nonce(&t.read().unwrap()), 2);
let writers: Vec<_> = (10..18u64)
.map(|n| {
let t = Arc::clone(&t);
std::thread::spawn(move || {
for k in 0..5 {
t.write(&map(n * 10 + k, "w")).unwrap();
}
})
})
.collect();
writers.into_iter().for_each(|h| h.join().unwrap());
let last = t.read().expect("whole map after concurrent writes");
assert!((100..180).contains(&store::map_nonce(&last)), "{last:?}");
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
std::fs::write(&path, "{truncated").unwrap();
assert!(t.read().is_none());
t.write(&v1).unwrap();
assert_eq!(t.read(), Some(v1));
match build_driver(
TransportKind::AppGroup,
&cfg(None, Some("group.test.missing")),
) {
Err(err) => assert!(err.to_string().contains("App Group"), "{err}"),
Ok(_) => eprintln!("skip: App Group container available for group.test.missing"),
}
let file = tmp.path().join("ag/widget_data.json");
std::env::set_var("WIDGET_APP_GROUP_DATA_FILE", &file);
let ag = build_driver(
TransportKind::AppGroup,
&cfg(None, Some("group.test.driver")),
)
.unwrap();
assert_eq!(ag.name(), NAME_APPGROUP);
ag.write(&v2).unwrap();
assert!(file.exists());
assert_eq!(ag.read(), Some(v2));
for k in [
"WIDGET_CONTAINER_ROOT",
"WIDGET_APP_GROUP_DATA_FILE",
"WIDGET_EXTENSION_BUNDLE",
] {
std::env::remove_var(k);
}
}
#[test]
fn auto_probe_latches_on_matching_receipt_else_fallback() {
let pair = || {
(
FakeTransport::new(NAME_APPGROUP),
FakeTransport::new(NAME_CONTAINER),
)
};
let dyns = |a: &Arc<FakeTransport>, b: &Arc<FakeTransport>| {
vec![
a.clone() as Arc<dyn Transport>,
b.clone() as Arc<dyn Transport>,
]
};
let ack = |from: &str, nonce: u64| Receipt {
read_from: from.into(),
nonce,
ts: store::now_ms(),
};
let (ag, ct) = pair();
ag.plant_receipt(ack(NAME_APPGROUP, 1));
let latched = probe_once_among(dyns(&ag, &ct), NAME_CONTAINER).unwrap();
assert_eq!(latched.name(), NAME_APPGROUP);
assert_eq!((ag.write_count(), ct.write_count()), (1, 1));
assert_eq!(
ag.read().unwrap().get("__probe__").map(String::as_str),
Some("1")
);
ct.plant_receipt(ack(NAME_CONTAINER, 2));
assert_eq!(latched.name(), NAME_APPGROUP);
for planted in [ack(NAME_CONTAINER, 1), ack(NAME_APPGROUP, 0)] {
let (ag, ct) = pair();
ag.plant_receipt(planted);
assert_eq!(
probe_once_among(dyns(&ag, &ct), NAME_CONTAINER)
.unwrap()
.name(),
NAME_CONTAINER
);
}
let (ag, ct) = pair();
assert!(probe_once_among(dyns(&ag, &ct), NAME_DEFAULTS).is_err());
}
#[test]
fn fake_driver_contract_and_freshest_pick() {
let container = FakeTransport::new(NAME_CONTAINER);
let appgroup = FakeTransport::new(NAME_APPGROUP);
container.write(&map(1, "w")).unwrap();
assert_eq!((container.write_count(), appgroup.write_count()), (1, 0));
assert!(appgroup.read().is_none() && container.read().is_some());
container.set_available(false);
assert!(!container.available() && appgroup.available());
let r = Receipt {
read_from: NAME_CONTAINER.into(),
nonce: 1,
ts: 1,
};
appgroup.write_receipt(&r).unwrap();
assert_eq!(appgroup.read_receipt(), Some(r));
let picked = pick_freshest_maps(vec![map(3, "old"), map(7, "new"), map(5, "mid")]);
assert!(picked.contains_key("config:new"), "{picked:?}");
}
}