1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4#[cfg(target_os = "macos")]
5use std::sync::Arc;
6use std::sync::Mutex;
7use tauri::{
8 plugin::PluginApi, AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder,
9};
10
11use crate::apply::{config_content_hash, ApplyOutcome, ReloadOutcome, SkipReason};
12use crate::config::WidgetsPluginConfig;
13use crate::error::Error;
14use crate::models::{WidgetConfig, WidgetWindowConfig};
15use crate::receipt::{receipts_path, ReceiptStore, WidgetRenderReceipt};
16use crate::store::{self, config_key, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY};
17use crate::trace::{trace_path, TraceEvent, TraceStore, WidgetTrace};
18
19#[cfg(target_os = "macos")]
20use crate::transport::Transport;
21#[cfg(target_os = "macos")]
22use std::ffi::CString;
23
24pub(crate) const BUILTIN_PROTOCOL: &str = "widgetview";
26
27fn encode_query_component(s: &str) -> String {
29 let mut out = String::with_capacity(s.len());
30 for &b in s.as_bytes() {
31 match b {
32 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
33 out.push(b as char);
34 }
35 _ => {
36 use std::fmt::Write as _;
37 let _ = write!(out, "%{b:02X}");
38 }
39 }
40 }
41 out
42}
43
44fn builtin_widget_url(group: &str, size: &str, widget_id: &str) -> WebviewUrl {
45 let group = encode_query_component(group);
46 let size = encode_query_component(size);
47 let widget_id = encode_query_component(widget_id);
48 #[cfg(target_os = "windows")]
49 let url_str = format!(
50 "https://{}.localhost/?group={}&size={}&widgetId={}",
51 BUILTIN_PROTOCOL, group, size, widget_id
52 );
53 #[cfg(not(target_os = "windows"))]
54 let url_str = format!(
55 "{}://localhost/?group={}&size={}&widgetId={}",
56 BUILTIN_PROTOCOL, group, size, widget_id
57 );
58 WebviewUrl::External(url_str.parse().expect("invalid built-in widget URL"))
59}
60
61fn on_main_thread() -> bool {
63 std::thread::current().name() == Some("main")
64}
65
66pub fn init<R: Runtime>(
67 app: &AppHandle<R>,
68 api: PluginApi<R, Option<WidgetsPluginConfig>>,
69) -> crate::Result<Widget<R>> {
70 let cfg = api
71 .config()
72 .clone()
73 .unwrap_or_default()
74 .with_defaults(&app.config().identifier);
75 init_with_config(app, cfg)
76}
77
78pub(crate) fn init_with_config<R: Runtime>(
79 app: &AppHandle<R>,
80 cfg: WidgetsPluginConfig,
81) -> crate::Result<Widget<R>> {
82 let receipts = ReceiptStore::new();
83 let trace = TraceStore::new();
84 if let Ok(dir) = app.path().app_data_dir() {
85 receipts.load_from_path(&receipts_path(&dir));
86 trace.load_from_path(&trace_path(&dir));
87 }
88
89 #[cfg(target_os = "macos")]
92 let macos_driver = match crate::transport::resolve_driver(&cfg) {
93 Ok(driver) => driver,
94 Err(e) => {
95 log::warn!(
96 "widgets: {e}\nFalling back to transport=widgetContainer (works for ad-hoc signed \
97 builds). Sign with a Team ID and App Groups for transport=appGroup."
98 );
99 crate::transport::build_driver(crate::config::TransportKind::WidgetContainer, &cfg)?
100 }
101 };
102
103 let widget = Widget {
104 app: app.clone(),
105 cfg,
106 store: Mutex::new(HashMap::new()),
107 known_groups: Mutex::new(Vec::new()),
108 receipts,
109 trace,
110 #[cfg(target_os = "macos")]
111 poller_started: Mutex::new(false),
112 #[cfg(target_os = "macos")]
113 macos_driver,
114 };
115 if let Some(g) = widget.cfg.app_group.clone() {
117 widget.remember_group(&g);
118 }
119 #[cfg(target_os = "macos")]
120 widget.ensure_action_poller();
121
122 Ok(widget)
123}
124
125pub struct Widget<R: Runtime> {
126 app: AppHandle<R>,
127 #[allow(dead_code)]
128 cfg: WidgetsPluginConfig,
129 store: Mutex<HashMap<String, DataMap>>,
131 known_groups: Mutex<Vec<String>>,
132 receipts: ReceiptStore,
134 trace: TraceStore,
136 #[cfg(target_os = "macos")]
137 poller_started: Mutex<bool>,
138 #[cfg(target_os = "macos")]
140 macos_driver: Arc<dyn Transport>,
141}
142
143impl<R: Runtime> Widget<R> {
144 fn remember_group(&self, group: &str) {
145 let mut groups = self.known_groups.lock().unwrap();
146 if !groups.iter().any(|g| g == group) {
147 groups.push(group.to_string());
148 }
149 }
150
151 fn storage_path(&self, group: &str) -> crate::Result<PathBuf> {
152 #[cfg(target_os = "macos")]
153 {
154 if let Some(path) = crate::macos_transport::app_group_data_override() {
155 if let Some(parent) = path.parent() {
156 if !parent.exists() {
157 fs::create_dir_all(parent)?;
158 }
159 }
160 return Ok(path);
161 }
162 if let Some(dir) = macos_shared_container(group) {
163 if !dir.exists() {
164 fs::create_dir_all(&dir)?;
165 }
166 return Ok(dir.join("widget_data.json"));
167 }
168 Ok(crate::macos_transport::sandbox_widget_data_path(group))
169 }
170
171 #[cfg(target_os = "windows")]
172 {
173 for key in ["TAURI_WIDGETS_DATA", "WIDGET_DATA_DIR"] {
175 if let Ok(env_path) = std::env::var(key) {
176 let p = env_path.trim();
177 if !p.is_empty() {
178 let path = if p.to_ascii_lowercase().ends_with(".json") {
179 PathBuf::from(p)
180 } else {
181 PathBuf::from(p).join("widget_data.json")
182 };
183 if let Some(parent) = path.parent() {
184 if !parent.exists() {
185 fs::create_dir_all(parent)?;
186 }
187 }
188 return Ok(path);
189 }
190 }
191 }
192 let local = std::env::var("LOCALAPPDATA")
193 .map(PathBuf::from)
194 .or_else(|_| {
195 self.app
196 .path()
197 .app_data_dir()
198 .map_err(|e| Error::Io(e.to_string()))
199 })?;
200 let dir = local.join("tauri-plugin-widgets");
201 if !dir.exists() {
202 fs::create_dir_all(&dir)?;
203 }
204 let _ = group; Ok(dir.join("widget_data.json"))
206 }
207
208 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
209 {
210 let base = self
211 .app
212 .path()
213 .app_data_dir()
214 .map_err(|e| Error::Io(e.to_string()))?;
215 let dir = base.join("widgets");
216 if !dir.exists() {
217 fs::create_dir_all(&dir)?;
218 }
219 let safe: String = group
221 .chars()
222 .map(|c| {
223 if c.is_alphanumeric() || c == '.' || c == '-' {
224 c
225 } else {
226 '_'
227 }
228 })
229 .collect();
230 Ok(dir.join(format!("{safe}.json")))
231 }
232 }
233
234 fn load_map_locked<'a>(
235 store: &'a mut HashMap<String, DataMap>,
236 path: &PathBuf,
237 group: &str,
238 ) -> &'a mut DataMap {
239 store.entry(group.to_string()).or_insert_with(|| {
240 let mut maps = Vec::new();
241 if path.exists() {
242 if let Some(m) = fs::read_to_string(path)
243 .ok()
244 .and_then(|s| serde_json::from_str(&s).ok())
245 {
246 maps.push(m);
247 }
248 }
249 #[cfg(target_os = "macos")]
250 {
251 for t in crate::macos_transport::all_transports(group) {
252 if let Some(m) = t.read() {
253 maps.push(m);
254 }
255 }
256 }
257 store::pick_freshest(maps)
258 })
259 }
260
261 fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
268 #[cfg(target_os = "macos")]
269 crate::macos_transport::with_store_lock(group, || {
270 let mut out = map.clone();
271 crate::macos_transport::merge_pending_into_map(&mut out, group);
272 crate::macos_transport::clear_sibling_transports(
273 group,
274 self.macos_driver.name(),
275 &mut out,
276 );
277 self.macos_driver.write(&out)
278 })?;
279 #[cfg(target_os = "windows")]
280 {
281 let path = self.storage_path(group)?;
282 persist_windows_shared_map(&path, map, &[])?;
283 }
284 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
285 {
286 let path = self.storage_path(group)?;
287 let json = serde_json::to_string_pretty(map)?;
288 atomic_write(&path, json.as_bytes())?;
289 }
290
291 let _ = self.app.emit("widget-update", group);
292 Ok(())
293 }
294
295 pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
296 self.remember_group(group);
297 let path = self.storage_path(group)?;
298 let mut store = self.store.lock().unwrap();
299 let map = Self::load_map_locked(&mut store, &path, group);
300 if map.get(key).map(String::as_str) == Some(value) {
301 return Ok(true);
302 }
303 map.insert(key.into(), value.into());
304 #[cfg(target_os = "macos")]
305 {
306 let floor = crate::macos_transport::max_nonce_across(group);
307 store::touch_meta_above(map, floor);
308 }
309 #[cfg(not(target_os = "macos"))]
310 {
311 store::touch_meta(map);
312 }
313 let snapshot = map.clone();
314 drop(store);
315 self.persist_map(group, &snapshot)?;
316 Ok(true)
317 }
318
319 pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
320 #[cfg(target_os = "macos")]
321 {
322 let freshest = self.macos_driver_map(group)?;
323 Ok(freshest.get(key).cloned())
324 }
325 #[cfg(not(target_os = "macos"))]
326 {
327 let path = self.storage_path(group)?;
328 let mut store = self.store.lock().unwrap();
329 let map = Self::load_map_locked(&mut store, &path, group);
330 Ok(map.get(key).cloned())
331 }
332 }
333
334 pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
335 let app = self.app.clone();
336 #[cfg(all(target_os = "linux", feature = "linux"))]
339 {
340 if gtk::glib::MainContext::default().is_owner() {
341 return Self::create_widget_window_on_main(&app, config);
342 }
343 }
344
345 if on_main_thread() {
348 self.app
349 .run_on_main_thread(move || {
350 if let Err(e) = Self::create_widget_window_on_main(&app, config) {
351 log::error!("{e}");
352 }
353 })
354 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
355 return Ok(true);
356 }
357
358 let (tx, rx) = std::sync::mpsc::sync_channel(1);
359 self.app
360 .run_on_main_thread(move || {
361 let _ = tx.send(Self::create_widget_window_on_main(&app, config));
362 })
363 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
364 rx.recv()
365 .map_err(|_| Error::new("create_widget_window: main thread dropped"))?
366 }
367
368 fn create_widget_window_on_main(
369 app: &AppHandle<R>,
370 config: WidgetWindowConfig,
371 ) -> crate::Result<bool> {
372 let label_log = config.label.clone();
373 let url = match config.url.as_deref() {
374 Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
375 _ => {
376 let group = config
377 .group
378 .clone()
379 .or_else(|| crate::group::default_group().map(String::from))
380 .unwrap_or_default();
381 let group = group.as_str();
382 let size = config.size.as_deref().unwrap_or("small");
383 let widget_id = config.widget_id.as_deref().unwrap_or("default");
384 builtin_widget_url(group, size, widget_id)
385 }
386 };
387 let skip_taskbar = config.skip_taskbar;
388 if let Some(prev) = app.get_webview_window(&config.label) {
391 let url = match url {
392 WebviewUrl::External(u) | WebviewUrl::CustomProtocol(u) => u,
393 WebviewUrl::App(path) => prev
394 .url()
395 .map_err(|e| Error::new(e.to_string()))?
396 .join(&path.to_string_lossy())
397 .map_err(|e| Error::new(e.to_string()))?,
398 _ => return Err(Error::new("create_widget_window: unsupported url")),
399 };
400 prev.navigate(url).map_err(|e| Error::new(e.to_string()))?;
401 prev.set_size(tauri::LogicalSize::new(config.width, config.height))
402 .map_err(|e| Error::new(e.to_string()))?;
403 if let (Some(x), Some(y)) = (config.x, config.y) {
404 prev.set_position(tauri::LogicalPosition::new(x, y))
405 .map_err(|e| Error::new(e.to_string()))?;
406 }
407 prev.set_always_on_top(config.always_on_top)
408 .map_err(|e| Error::new(e.to_string()))?;
409 prev.show().map_err(|e| Error::new(e.to_string()))?;
410 log::debug!("reused widget window '{label_log}'");
411 return Ok(true);
412 }
413 let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
414 .title(&config.label)
416 .inner_size(config.width, config.height)
417 .decorations(false)
418 .skip_taskbar(skip_taskbar)
419 .always_on_top(config.always_on_top)
420 .resizable(false)
421 .visible(true);
422 #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
424 {
425 builder = builder.transparent(true);
426 }
427
428 if let (Some(x), Some(y)) = (config.x, config.y) {
429 builder = builder.position(x, y);
430 }
431
432 let win = builder
433 .build()
434 .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
435 #[cfg(all(target_os = "linux", feature = "linux"))]
436 crate::linux::pin_widget_window(&win, skip_taskbar);
437 #[cfg(not(all(target_os = "linux", feature = "linux")))]
438 let _ = win;
439 log::debug!("created widget window '{label_log}'");
440 Ok(true)
441 }
442
443 pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
444 let app = self.app.clone();
445 let label = label.to_string();
446
447 #[cfg(all(target_os = "linux", feature = "linux"))]
448 {
449 if gtk::glib::MainContext::default().is_owner() {
450 return Self::close_widget_window_on_main(&app, &label);
451 }
452 }
453
454 if on_main_thread() {
455 return Self::close_widget_window_on_main(&app, &label);
456 }
457 let (tx, rx) = std::sync::mpsc::sync_channel(1);
458 self.app
459 .run_on_main_thread(move || {
460 let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
461 })
462 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
463
464 rx.recv()
465 .map_err(|_| Error::new("close_widget_window: main thread dropped"))?
466 }
467
468 fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
469 if let Some(win) = app.get_webview_window(label) {
470 win.close().map_err(|e| Error::new(e.to_string()))?;
471 Ok(true)
472 } else {
473 Ok(false)
474 }
475 }
476
477 pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
485 Ok(true)
487 }
488
489 pub fn reload_all_timelines(&self) -> crate::Result<bool> {
490 let _ = self.app.emit("widget-reload", "all");
491 #[cfg(target_os = "macos")]
492 if !unsafe { macos_widget_reload_all() } {
493 return Err(Error::Unsupported(
494 "WidgetKit reload needs macOS 11+".into(),
495 ));
496 }
497 Ok(true)
498 }
499
500 pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
501 let _ = self.app.emit("widget-reload", of_kind);
502 #[cfg(target_os = "macos")]
503 {
504 let c = CString::new(of_kind)
505 .map_err(|_| Error::new("reload_timelines: kind contains a NUL byte"))?;
506 if !unsafe { macos_widget_reload_kind(c.as_ptr()) } {
507 return Err(Error::Unsupported(
508 "WidgetKit reload needs macOS 11+".into(),
509 ));
510 }
511 }
512 Ok(true)
513 }
514
515 pub fn request_widget(&self) -> crate::Result<bool> {
523 Err(Error::Unsupported(
524 "Use create_widget_window on desktop".into(),
525 ))
526 }
527
528 pub fn set_widget_config_from_path(
530 &self,
531 path: impl AsRef<std::path::Path>,
532 group: &str,
533 widget_id: &str,
534 skip_reload: bool,
535 ) -> crate::Result<ApplyOutcome> {
536 if widget_id.is_empty() {
537 return Err(Error::new("widget_id must not be empty"));
538 }
539 let path = path.as_ref();
540 let path_str = path
541 .to_str()
542 .ok_or_else(|| Error::new(format!("invalid UTF-8 path: {}", path.display())))?;
543 let config = crate::commands::load_widget_config_from_path(path_str)?;
544 self.set_widget_config(&config, group, widget_id, skip_reload)
545 }
546
547 pub fn set_widget_config(
548 &self,
549 config: &WidgetConfig,
550 group: &str,
551 widget_id: &str,
552 skip_reload: bool,
553 ) -> crate::Result<ApplyOutcome> {
554 if widget_id.is_empty() {
555 return Err(Error::new("widget_id must not be empty"));
556 }
557 self.remember_group(group);
558
559 let mut config =
560 crate::normalize::normalize(config, crate::capabilities::WidgetPlatform::current())
561 .config;
562 crate::image_prefetch::prefetch_remote_images(&mut config);
563
564 let json = serde_json::to_string(&config)
565 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
566 let compact: serde_json::Value = serde_json::from_str(&json)
567 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
568 let hash = config_content_hash(&json);
569 let key = config_key(widget_id);
570
571 let existing = self.get_items(&key, group)?;
572 let changed = existing.as_deref() != Some(json.as_str());
573
574 let _ = self.app.emit(
577 "widget-config-push",
578 serde_json::json!({
579 "group": group,
580 "widgetId": widget_id,
581 "config": compact,
582 }),
583 );
584
585 if !changed {
586 let outcome = ApplyOutcome::unchanged(hash);
587 self.trace.push(TraceEvent::ConfigSet {
588 widget_id: widget_id.into(),
589 nonce: 0,
590 bytes: json.len(),
591 changed: false,
592 skip: Some(SkipReason::Unchanged { hash }),
593 });
594 self.trace.push(TraceEvent::Reload {
595 performed: false,
596 reason: outcome.reload.clone(),
597 });
598 self.maybe_flush_trace();
599 return Ok(outcome);
600 }
601
602 crate::capabilities::log_capabilities(&config);
603 let t0 = std::time::Instant::now();
604 self.set_items(&key, &json, group)?;
605 let write_ms = t0.elapsed().as_millis() as u32;
606 let transports = self.written_transport_names(group);
607 let nonce = self
608 .get_items("__meta_nonce__", group)
609 .ok()
610 .flatten()
611 .and_then(|s| s.parse().ok())
612 .unwrap_or(0);
613
614 self.trace.push(TraceEvent::ConfigSet {
615 widget_id: widget_id.into(),
616 nonce,
617 bytes: json.len(),
618 changed: true,
619 skip: None,
620 });
621 for name in &transports {
622 self.trace.push(TraceEvent::Write {
623 transport: name.clone(),
624 ok: true,
625 duration_ms: write_ms,
626 error: None,
627 });
628 }
629
630 #[cfg(target_os = "windows")]
631 {
632 if let Some(result) = crate::adaptive_card::to_adaptive_card_for_size(&config, "medium")
636 {
637 let template = serde_json::to_string(&result.card)
638 .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
639 self.set_items(
640 &crate::adaptive_card::ac_template_key(widget_id),
641 &template,
642 group,
643 )?;
644 self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
645 }
646 }
647
648 let reload = if skip_reload {
649 ReloadOutcome::Skipped {
650 why: "skip_reload".into(),
651 }
652 } else {
653 match self.reload_all_timelines() {
654 Ok(_) => ReloadOutcome::Ok,
655 Err(e) => ReloadOutcome::Failed {
656 error: e.to_string(),
657 },
658 }
659 };
660 self.trace.push(TraceEvent::Reload {
661 performed: matches!(reload, ReloadOutcome::Ok),
662 reason: reload.clone(),
663 });
664
665 #[cfg(target_os = "macos")]
666 self.ensure_action_poller();
667
668 self.maybe_flush_trace();
669
670 Ok(ApplyOutcome {
671 written: true,
672 reload,
673 transports,
674 skip: None,
675 })
676 }
677
678 fn written_transport_names(&self, group: &str) -> Vec<String> {
680 #[cfg(target_os = "macos")]
681 {
682 let _ = group;
683 vec![self.macos_driver.name().to_string()]
684 }
685 #[cfg(not(target_os = "macos"))]
686 {
687 let _ = group;
688 vec!["file".into()]
689 }
690 }
691
692 pub fn get_widget_config(
693 &self,
694 group: &str,
695 widget_id: &str,
696 ) -> crate::Result<Option<WidgetConfig>> {
697 if widget_id.is_empty() {
698 return Err(Error::new("widget_id must not be empty"));
699 }
700 let raw = self.get_items(&config_key(widget_id), group)?;
701 match raw {
702 Some(json) => {
703 let config: WidgetConfig = serde_json::from_str(&json)
704 .map_err(|e| Error::new(format!("parse config: {e}")))?;
705 Ok(Some(config))
706 }
707 None => Ok(None),
708 }
709 }
710
711 #[cfg(target_os = "macos")]
713 fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
714 let mut maps = Vec::new();
715 if let Some(disk) = self.macos_driver.read() {
716 maps.push(disk);
717 }
718 let path = self.storage_path(group)?;
719 let mut store = self.store.lock().unwrap();
720 let map = Self::load_map_locked(&mut store, &path, group);
721 maps.push(map.clone());
722 let freshest = store::pick_freshest(maps);
723 if store::map_nonce(&freshest) > store::map_nonce(map) {
724 *map = freshest.clone();
725 }
726 Ok(freshest)
727 }
728
729 pub fn poll_pending_actions(
732 &self,
733 group: &str,
734 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
735 #[cfg(target_os = "macos")]
736 return crate::macos_transport::with_store_lock(group, || {
737 self.drain_pending_actions(group)
738 });
739 #[cfg(not(target_os = "macos"))]
740 self.drain_pending_actions(group)
741 }
742
743 fn drain_pending_actions(
744 &self,
745 group: &str,
746 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
747 self.remember_group(group);
748
749 #[cfg(target_os = "macos")]
750 let disk_maps: Vec<DataMap> = {
751 let mut maps = Vec::new();
752 for t in crate::macos_transport::all_transports(group) {
753 if let Some(m) = t.read() {
754 maps.push(m);
755 }
756 }
757 maps
758 };
759 #[cfg(not(target_os = "macos"))]
760 let disk_maps: Vec<DataMap> = {
761 let path = self.storage_path(group)?;
762 if path.exists() {
763 fs::read_to_string(&path)
764 .ok()
765 .and_then(|s| serde_json::from_str(&s).ok())
766 .into_iter()
767 .collect()
768 } else {
769 Vec::new()
770 }
771 };
772
773 let freshest = store::pick_freshest(disk_maps);
774
775 let mut store = self.store.lock().unwrap();
776 let path = self.storage_path(group)?;
777 let map = Self::load_map_locked(&mut store, &path, group);
778
779 if store::map_nonce(&freshest) > store::map_nonce(map) {
780 *map = freshest;
781 }
782
783 #[cfg(target_os = "macos")]
784 let actions = {
785 let harvested = crate::macos_transport::harvest_pending_actions(group);
786 if !harvested.is_empty() {
787 harvested
788 } else {
789 parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()))
790 }
791 };
792 #[cfg(not(target_os = "macos"))]
793 let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
794
795 if actions.is_empty() {
796 return Ok(Vec::new());
797 }
798
799 map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
800 #[cfg(target_os = "macos")]
801 {
802 let floor = crate::macos_transport::max_nonce_across(group);
803 store::touch_meta_above(map, floor);
804 }
805 #[cfg(not(target_os = "macos"))]
806 {
807 store::touch_meta(map);
808 }
809 let snapshot = map.clone();
810 drop(store);
811
812 #[cfg(target_os = "windows")]
813 persist_windows_shared_map(&self.storage_path(group)?, &snapshot, &actions)?;
814 #[cfg(not(target_os = "windows"))]
815 self.persist_map(group, &snapshot)?;
816 #[cfg(target_os = "macos")]
817 {
818 crate::macos_transport::clear_pending_actions_everywhere(group, &actions);
819 }
820
821 Ok(actions)
822 }
823
824 pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
825 self.remember_group(&receipt.group);
826 let trigger = receipt.trigger.clone().unwrap_or_else(|| "timeline".into());
827 let lag_ms = {
828 let since = self.trace.list_since(None);
829 since
830 .iter()
831 .rev()
832 .find_map(|e| match &e.event {
833 TraceEvent::ConfigSet { nonce, .. }
834 if *nonce == receipt.nonce && *nonce > 0 =>
835 {
836 Some(receipt.ts.saturating_sub(e.ts))
837 }
838 _ => None,
839 })
840 .unwrap_or(0)
841 };
842 self.trace.push(TraceEvent::Render {
843 instance: receipt.instance.clone(),
844 nonce: receipt.nonce,
845 source: receipt.source.clone(),
846 trigger,
847 lag_ms,
848 skipped: receipt.skipped.clone(),
849 });
850 self.receipts.upsert(receipt);
851 if let Ok(dir) = self.app.path().app_data_dir() {
852 let _ = self.receipts.save_to_path(&receipts_path(&dir));
853 }
854 self.maybe_flush_trace();
855 Ok(true)
856 }
857
858 pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
859 Ok(self.receipts.list(group))
860 }
861
862 pub fn get_widget_trace(
863 &self,
864 group: &str,
865 since_ms: Option<u64>,
866 ) -> crate::Result<WidgetTrace> {
867 self.maybe_flush_trace();
868 Ok(WidgetTrace {
869 enabled: crate::trace::trace_enabled(),
870 events: self.trace.list_since(since_ms),
871 receipts: self.receipts.history(group),
872 })
873 }
874
875 pub fn flush_widget_trace(&self) -> crate::Result<bool> {
876 if let Ok(dir) = self.app.path().app_data_dir() {
877 self.trace.flush_to_path(&trace_path(&dir))?;
878 }
879 Ok(true)
880 }
881
882 fn maybe_flush_trace(&self) {
883 if !self.trace.needs_timed_flush() {
884 return;
885 }
886 if let Ok(dir) = self.app.path().app_data_dir() {
887 let _ = self.trace.flush_to_path(&trace_path(&dir));
888 }
889 }
890
891 #[cfg(target_os = "macos")]
892 fn ensure_action_poller(&self) {
893 let mut started = self.poller_started.lock().unwrap();
894 if *started {
895 return;
896 }
897 *started = true;
898
899 let groups_handle = self.app.clone();
900 std::thread::spawn(move || loop {
901 std::thread::sleep(std::time::Duration::from_millis(500));
902 let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
903 continue;
904 };
905 widget.inner().maybe_flush_trace();
906 let groups = widget.inner().known_groups.lock().unwrap().clone();
907 for group in groups {
908 match widget.inner().poll_pending_actions(&group) {
909 Ok(actions) if !actions.is_empty() => {
910 widget.inner().trace.push(TraceEvent::Poll {
911 count: actions.len(),
912 });
913 for action in actions {
914 let _ = groups_handle.emit("widget-action", action);
915 }
916 }
917 _ => {}
918 }
919 }
920 });
921 }
922}
923
924#[cfg(target_os = "macos")]
927extern "C" {
928 fn macos_widget_reload_all() -> bool;
929 fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
930 fn macos_widget_container_path(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
931 fn macos_widget_free_string(ptr: *mut std::ffi::c_char);
932}
933
934#[cfg(target_os = "macos")]
935fn macos_shared_container(group: &str) -> Option<PathBuf> {
936 use std::ffi::CStr;
937 let c_group = CString::new(group).ok()?;
938 let ptr = unsafe { macos_widget_container_path(c_group.as_ptr()) };
939 if ptr.is_null() {
940 return None;
941 }
942 let path = unsafe { CStr::from_ptr(ptr) }
943 .to_string_lossy()
944 .into_owned();
945 unsafe { macos_widget_free_string(ptr) };
946 Some(PathBuf::from(path))
947}
948
949#[cfg(not(target_os = "macos"))]
950fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
951 let nanos = std::time::SystemTime::now()
952 .duration_since(std::time::UNIX_EPOCH)
953 .map(|d| d.as_nanos())
954 .unwrap_or(0);
955 let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
956 fs::write(&tmp, data)?;
957 match fs::rename(&tmp, path) {
958 Ok(()) => Ok(()),
959 Err(e) => {
960 let _ = fs::remove_file(&tmp);
961 Err(e)
962 }
963 }
964}
965
966#[cfg(target_os = "windows")]
970fn persist_windows_shared_map(
971 path: &PathBuf,
972 map: &DataMap,
973 drained: &[crate::WidgetActionEnvelope],
974) -> crate::Result<()> {
975 use std::fs::OpenOptions;
976 use std::os::windows::fs::OpenOptionsExt;
977 use std::thread;
978 use std::time::Duration;
979
980 if let Some(parent) = path.parent() {
981 fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
982 }
983
984 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
986 let _lock = {
987 let mut last_err = None;
988 let mut held = None;
989 for _ in 0..100 {
990 let mut opts = OpenOptions::new();
991 opts.read(true).write(true).create(true).share_mode(0); match opts.open(&lock_path) {
993 Ok(f) => {
994 held = Some(f);
995 break;
996 }
997 Err(e) => {
998 if e.raw_os_error() == Some(32) {
1000 last_err = Some(e);
1001 thread::sleep(Duration::from_millis(20));
1002 continue;
1003 }
1004 return Err(Error::Io(e.to_string()));
1005 }
1006 }
1007 }
1008 held.ok_or_else(|| {
1009 Error::Io(
1010 last_err
1011 .map(|e| e.to_string())
1012 .unwrap_or_else(|| "widget_data.json.lock busy".into()),
1013 )
1014 })?
1015 };
1016
1017 let mut merged: DataMap = if path.exists() {
1018 fs::read_to_string(path)
1019 .ok()
1020 .and_then(|s| serde_json::from_str(&s).ok())
1021 .unwrap_or_default()
1022 } else {
1023 DataMap::new()
1024 };
1025
1026 for (k, v) in map {
1027 if k == PENDING_ACTIONS_KEY {
1028 let rest = store::pending_minus_drained(merged.get(k).map(String::as_str), drained)?;
1030 merged.insert(k.clone(), rest);
1031 continue;
1032 }
1033 merged.insert(k.clone(), v.clone());
1034 }
1035
1036 if let Some(n) = map.get(store::META_NONCE_KEY) {
1038 merged.insert(store::META_NONCE_KEY.into(), n.clone());
1039 }
1040 if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
1041 merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
1042 }
1043
1044 let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
1045 atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
1046 Ok(())
1047}
1048
1049#[cfg(test)]
1050mod builtin_url_tests {
1051 use super::encode_query_component;
1052
1053 #[test]
1054 fn encodes_ampersand_and_equals() {
1055 assert_eq!(
1056 encode_query_component("small&widgetId=hijack"),
1057 "small%26widgetId%3Dhijack"
1058 );
1059 assert_eq!(encode_query_component("a=b&c"), "a%3Db%26c");
1060 }
1061
1062 #[test]
1063 fn leaves_unreserved_alone() {
1064 assert_eq!(
1065 encode_query_component("group.com.example.app"),
1066 "group.com.example.app"
1067 );
1068 assert_eq!(encode_query_component("weather_1"), "weather_1");
1069 }
1070}