use crate::error::Error;
use crate::models::WidgetConfig;
use crate::store::{
self, config_key, encode_pending_actions, parse_pending_actions, touch_meta, DataMap,
WidgetActionEnvelope, META_NONCE_KEY, META_UPDATED_AT_KEY, PENDING_ACTIONS_KEY,
};
use crate::transport::{Receipt, Transport, NAME_APPGROUP, NAME_CONTAINER, NAME_DEFAULTS};
use std::ffi::{CStr, CString};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub fn container_root() -> PathBuf {
if let Ok(p) = std::env::var("WIDGET_CONTAINER_ROOT") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
PathBuf::from(std::env::var("HOME").unwrap_or_default())
}
pub fn extension_bundle_id(group: &str) -> String {
if let Ok(b) = std::env::var("WIDGET_EXTENSION_BUNDLE") {
if !b.is_empty() {
return b;
}
}
let app_id = group.strip_prefix("group.").unwrap_or(group);
format!("{app_id}.widgetkit")
}
pub fn sandbox_widget_data_path(group: &str) -> PathBuf {
container_root()
.join("Library/Containers")
.join(extension_bundle_id(group))
.join("Data")
.join("widget_data.json")
}
pub fn sandbox_receipt_path(group: &str) -> PathBuf {
sandbox_widget_data_path(group).with_file_name("widget_receipt.json")
}
pub fn app_group_data_override() -> Option<PathBuf> {
std::env::var("WIDGET_APP_GROUP_DATA_FILE")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub fn app_group_receipt_path(data_file: &Path) -> PathBuf {
data_file.with_file_name("widget_receipt.json")
}
pub fn read_map_file(path: &Path) -> Option<DataMap> {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
}
fn unique_tmp_path(path: &Path) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos))
}
fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> crate::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
}
let mut tmp = unique_tmp_path(path);
let mut file = loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(f) => break f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
tmp = unique_tmp_path(path);
continue;
}
Err(e) => return Err(Error::Io(e.to_string())),
}
};
if let Err(e) = file.write_all(bytes).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(&tmp);
return Err(Error::Io(e.to_string()));
}
drop(file);
fs::rename(&tmp, path).map_err(|e| {
let _ = fs::remove_file(&tmp);
Error::Io(e.to_string())
})?;
Ok(())
}
pub fn write_map_file(path: &Path, map: &DataMap) -> crate::Result<()> {
let json = serde_json::to_string_pretty(map).map_err(|e| Error::new(e.to_string()))?;
write_bytes_atomic(path, json.as_bytes())
}
fn read_receipt_file(path: &Path) -> Option<Receipt> {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
}
fn write_receipt_file(path: &Path, receipt: &Receipt) -> crate::Result<()> {
let json = serde_json::to_string(receipt).map_err(|e| Error::new(e.to_string()))?;
write_bytes_atomic(path, json.as_bytes())
}
pub fn write_sandbox_map(group: &str, map: &DataMap) -> crate::Result<()> {
write_map_file(&sandbox_widget_data_path(group), map)
}
extern "C" {
fn macos_widget_container_path(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
fn macos_widget_free_string(ptr: *mut std::ffi::c_char);
fn macos_widget_set_defaults_map(
group: *const std::ffi::c_char,
json_map: *const std::ffi::c_char,
) -> bool;
fn macos_widget_get_defaults_map(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
fn macos_widget_set_defaults_string(
group: *const std::ffi::c_char,
key: *const std::ffi::c_char,
value: *const std::ffi::c_char,
) -> bool;
fn macos_widget_get_defaults_string(
group: *const std::ffi::c_char,
key: *const std::ffi::c_char,
) -> *mut std::ffi::c_char;
}
fn shared_container_dir(group: &str) -> Option<PathBuf> {
let c_group = CString::new(group).ok()?;
let ptr = unsafe { macos_widget_container_path(c_group.as_ptr()) };
if ptr.is_null() {
return None;
}
let path = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { macos_widget_free_string(ptr) };
Some(PathBuf::from(path))
}
fn resolve_app_group_data_file(group: &str) -> Option<PathBuf> {
app_group_data_override()
.or_else(|| shared_container_dir(group).map(|d| d.join("widget_data.json")))
}
struct FileTransport {
name: &'static str,
data_path: PathBuf,
receipt_path: PathBuf,
available: bool,
}
impl Transport for FileTransport {
fn name(&self) -> &'static str {
self.name
}
fn available(&self) -> bool {
self.available
}
fn read(&self) -> Option<DataMap> {
read_map_file(&self.data_path)
}
fn write(&self, map: &DataMap) -> crate::Result<()> {
write_map_file(&self.data_path, map)
}
fn read_receipt(&self) -> Option<Receipt> {
read_receipt_file(&self.receipt_path)
}
fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()> {
write_receipt_file(&self.receipt_path, receipt)
}
}
struct UserDefaultsTransport {
group: String,
}
impl Transport for UserDefaultsTransport {
fn name(&self) -> &'static str {
NAME_DEFAULTS
}
fn available(&self) -> bool {
CString::new(self.group.as_str()).is_ok()
}
fn read(&self) -> Option<DataMap> {
let c_group = CString::new(self.group.as_str()).ok()?;
let ptr = unsafe { macos_widget_get_defaults_map(c_group.as_ptr()) };
if ptr.is_null() {
return None;
}
let json = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { macos_widget_free_string(ptr) };
serde_json::from_str(&json).ok()
}
fn write(&self, map: &DataMap) -> crate::Result<()> {
let compact = serde_json::to_string(map).map_err(|e| Error::new(e.to_string()))?;
let c_group = CString::new(self.group.as_str()).map_err(|e| Error::new(e.to_string()))?;
let c_json = CString::new(compact).map_err(|e| Error::new(e.to_string()))?;
let ok = unsafe { macos_widget_set_defaults_map(c_group.as_ptr(), c_json.as_ptr()) };
if ok {
Ok(())
} else {
Err(Error::new("UserDefaults map write failed"))
}
}
fn read_receipt(&self) -> Option<Receipt> {
let c_group = CString::new(self.group.as_str()).ok()?;
let c_key = CString::new("widget_receipt").ok()?;
let ptr = unsafe { macos_widget_get_defaults_string(c_group.as_ptr(), c_key.as_ptr()) };
if ptr.is_null() {
return None;
}
let json = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { macos_widget_free_string(ptr) };
serde_json::from_str(&json).ok()
}
fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()> {
let json = serde_json::to_string(receipt).map_err(|e| Error::new(e.to_string()))?;
let c_group = CString::new(self.group.as_str()).map_err(|e| Error::new(e.to_string()))?;
let c_key = CString::new("widget_receipt").map_err(|e| Error::new(e.to_string()))?;
let c_val = CString::new(json).map_err(|e| Error::new(e.to_string()))?;
let ok = unsafe {
macos_widget_set_defaults_string(c_group.as_ptr(), c_key.as_ptr(), c_val.as_ptr())
};
if ok {
Ok(())
} else {
Err(Error::new("UserDefaults receipt write failed"))
}
}
}
pub fn app_group_transport(group: &str) -> crate::Result<Arc<dyn Transport>> {
let path = resolve_app_group_data_file(group).ok_or_else(|| {
Error::new(format!(
"App Group '{group}' is unavailable (containerURL returned nil).\n\
• Enable App Groups on App + Widget Extension targets\n\
• Sign with a real Team ID — ad-hoc does not share the App Group container\n\
• For local/ad-hoc development set plugins.widgets.transport = \"widgetContainer\"\n\
• Tests may set WIDGET_APP_GROUP_DATA_FILE to a writable path"
))
})?;
let receipt = app_group_receipt_path(&path);
Ok(Arc::new(FileTransport {
name: NAME_APPGROUP,
data_path: path,
receipt_path: receipt,
available: true,
}))
}
pub fn user_defaults_transport(group: &str) -> Arc<dyn Transport> {
Arc::new(UserDefaultsTransport {
group: group.to_string(),
})
}
pub fn widget_container_transport(group: &str) -> Arc<dyn Transport> {
let sandbox = sandbox_widget_data_path(group);
Arc::new(FileTransport {
name: NAME_CONTAINER,
receipt_path: sandbox_receipt_path(group),
available: true,
data_path: sandbox,
})
}
pub fn all_transports(group: &str) -> Vec<Arc<dyn Transport>> {
let mut out = Vec::new();
match app_group_transport(group) {
Ok(t) => out.push(t),
Err(e) => log::debug!("all_transports: appGroup skipped: {e}"),
}
out.push(user_defaults_transport(group));
out.push(widget_container_transport(group));
out
}
pub fn clear_sibling_transports(group: &str, keep: &str, into: &mut DataMap) {
for t in all_transports(group) {
if t.name() == keep || !t.available() {
continue;
}
for _ in 0..5 {
let Some(existing) = t.read() else { break };
if existing.is_empty() {
break;
}
merge_pending_from_map(into, &existing);
if let Err(e) = t.write(&DataMap::new()) {
log::debug!("clear_sibling_transports({}): {e}", t.name());
break;
}
match t.read() {
None => break,
Some(after) if after.is_empty() => break,
Some(after) => {
merge_pending_from_map(into, &after);
}
}
}
if let Some(left) = t.read() {
if !left.is_empty() {
merge_pending_from_map(into, &left);
}
}
}
}
fn pending_action_key(a: &crate::store::WidgetActionEnvelope) -> String {
serde_json::to_string(&(&a.action, &a.widget_id, &a.payload, a.ts))
.unwrap_or_else(|_| format!("{}:{}:{:?}", a.action, a.widget_id, a.ts))
}
fn merge_pending_from_map(into: &mut DataMap, src: &DataMap) {
use crate::store::{encode_pending_actions, parse_pending_actions, PENDING_ACTIONS_KEY};
let incoming = parse_pending_actions(src.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
if incoming.is_empty() {
return;
}
let mut merged = parse_pending_actions(into.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
let mut seen: std::collections::HashSet<String> =
merged.iter().map(pending_action_key).collect();
for a in incoming {
if seen.insert(pending_action_key(&a)) {
merged.push(a);
}
}
match encode_pending_actions(&merged) {
Ok(s) => {
into.insert(PENDING_ACTIONS_KEY.into(), s);
}
Err(e) => log::debug!("merge_pending_from_map encode: {e}"),
}
}
pub fn merge_pending_into_map(map: &mut DataMap, group: &str) {
for t in all_transports(group) {
if !t.available() {
continue;
}
if let Some(m) = t.read() {
merge_pending_from_map(map, &m);
}
}
}
pub fn max_nonce_across(group: &str) -> u64 {
all_transports(group)
.into_iter()
.filter_map(|t| t.read())
.map(|m| store::map_nonce(&m))
.max()
.unwrap_or(0)
}
pub fn harvest_pending_actions(group: &str) -> Vec<crate::store::WidgetActionEnvelope> {
use crate::store::{parse_pending_actions, PENDING_ACTIONS_KEY};
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for t in all_transports(group) {
if !t.available() {
continue;
}
let Some(m) = t.read() else { continue };
for a in parse_pending_actions(m.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())) {
if seen.insert(pending_action_key(&a)) {
out.push(a);
}
}
}
out.sort_by_key(|a| a.ts);
out
}
pub fn clear_pending_actions_everywhere(
group: &str,
drained: &[crate::store::WidgetActionEnvelope],
) {
use crate::store::{
encode_pending_actions, map_nonce, parse_pending_actions, PENDING_ACTIONS_KEY,
};
use std::collections::HashMap;
fn filter_pending(
current: Vec<crate::store::WidgetActionEnvelope>,
drained_counts: &HashMap<String, usize>,
) -> Vec<crate::store::WidgetActionEnvelope> {
let mut counts = drained_counts.clone();
current
.into_iter()
.filter(|a| {
let key = pending_action_key(a);
if let Some(c) = counts.get_mut(&key) {
if *c > 0 {
*c -= 1;
return false;
}
}
true
})
.collect()
}
let mut drained_counts: HashMap<String, usize> = HashMap::new();
for a in drained {
*drained_counts.entry(pending_action_key(a)).or_default() += 1;
}
for t in all_transports(group) {
if !t.available() {
continue;
}
for attempt in 0..5 {
let Some(m_at_read) = t.read() else {
break;
};
let nonce_at_read = map_nonce(&m_at_read);
let pending_snapshot = m_at_read.get(PENDING_ACTIONS_KEY).cloned();
let current = parse_pending_actions(pending_snapshot.as_deref());
if current.is_empty() {
break;
}
let Some(m_disk) = t.read() else {
break;
};
let disk_nonce = map_nonce(&m_disk);
let disk_pending = m_disk.get(PENDING_ACTIONS_KEY).cloned();
if (disk_nonce > nonce_at_read || disk_pending != pending_snapshot) && attempt + 1 < 5
{
continue;
}
let fresh = parse_pending_actions(m_disk.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
if fresh.is_empty() {
break;
}
let remaining = filter_pending(fresh, &drained_counts);
let encoded = match encode_pending_actions(&remaining) {
Ok(s) => s,
Err(e) => {
log::debug!("clear_pending_actions_everywhere encode({}): {e}", t.name());
break;
}
};
let mut m = m_disk;
m.insert(PENDING_ACTIONS_KEY.into(), encoded);
if let Err(e) = t.write(&m) {
log::debug!("clear_pending_actions_everywhere({}): {e}", t.name());
}
break;
}
}
}
pub fn read_file_transports(group: &str, app_group_file: Option<&Path>) -> Vec<DataMap> {
let mut maps = Vec::new();
if let Some(m) = read_map_file(&sandbox_widget_data_path(group)) {
maps.push(m);
}
if let Some(p) = app_group_file {
if let Some(m) = read_map_file(p) {
maps.push(m);
}
} else if let Some(p) = app_group_data_override() {
if let Some(m) = read_map_file(&p) {
maps.push(m);
}
}
maps
}
pub fn get_config_freshest(
group: &str,
widget_id: &str,
app_group_file: Option<&Path>,
extra: impl IntoIterator<Item = DataMap>,
) -> crate::Result<Option<WidgetConfig>> {
let mut maps = read_file_transports(group, app_group_file);
maps.extend(extra);
let freshest = store::pick_freshest(maps);
let Some(raw) = freshest.get(&config_key(widget_id)) else {
return Ok(None);
};
let config: WidgetConfig =
serde_json::from_str(raw).map_err(|e| Error::new(format!("parse config: {e}")))?;
Ok(Some(config))
}
pub fn put_config_in_map(
map: &mut DataMap,
widget_id: &str,
config: &WidgetConfig,
) -> crate::Result<()> {
let json = serde_json::to_string(config).map_err(|e| Error::new(e.to_string()))?;
map.insert(config_key(widget_id), json);
touch_meta(map);
Ok(())
}
pub fn enqueue_action_like_extension(
action: &str,
payload: Option<&str>,
widget_id: &str,
group: &str,
) -> crate::Result<()> {
let path = sandbox_widget_data_path(group);
let mut map = read_map_file(&path).unwrap_or_default();
let mut actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
actions.push(WidgetActionEnvelope::new(
action,
payload.map(|s| s.to_string()),
widget_id,
group,
));
map.insert(
PENDING_ACTIONS_KEY.into(),
encode_pending_actions(&actions)?,
);
touch_meta(&mut map);
write_sandbox_map(group, &map)
}
pub fn poll_pending_actions_files(
group: &str,
app_group_file: Option<&Path>,
) -> crate::Result<Vec<WidgetActionEnvelope>> {
let maps = read_file_transports(group, app_group_file);
let mut freshest = store::pick_freshest(maps);
let actions = parse_pending_actions(freshest.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
if actions.is_empty() {
return Ok(Vec::new());
}
freshest.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
touch_meta(&mut freshest);
write_sandbox_map(group, &freshest)?;
if let Some(p) = app_group_file {
let _ = write_map_file(p, &freshest);
} else if let Some(p) = app_group_data_override() {
let _ = write_map_file(&p, &freshest);
}
Ok(actions)
}
pub fn map_with_nonce(
nonce: u64,
widget_id: &str,
config: &WidgetConfig,
) -> crate::Result<DataMap> {
let mut map = DataMap::new();
put_config_in_map(&mut map, widget_id, config)?;
map.insert(META_NONCE_KEY.into(), nonce.to_string());
map.insert(META_UPDATED_AT_KEY.into(), store::now_ms().to_string());
Ok(map)
}