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) mod island;
25mod notify;
26pub(crate) mod placement;
27mod sources;
28mod window;
29
30pub(crate) const BUILTIN_PROTOCOL: &str = "widgetview";
32
33fn encode_query_component(s: &str) -> String {
35 let mut out = String::with_capacity(s.len());
36 for &b in s.as_bytes() {
37 match b {
38 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
39 out.push(b as char);
40 }
41 _ => {
42 use std::fmt::Write as _;
43 let _ = write!(out, "%{b:02X}");
44 }
45 }
46 }
47 out
48}
49
50fn builtin_widget_url(group: &str, size: &str, widget_id: &str, instance: &str) -> WebviewUrl {
51 let group = encode_query_component(group);
52 let size = encode_query_component(size);
53 let widget_id = encode_query_component(widget_id);
54 let instance = encode_query_component(instance);
55 #[cfg(target_os = "windows")]
56 let url_str = format!(
57 "https://{}.localhost/?group={}&size={}&widgetId={}&instance={}",
58 BUILTIN_PROTOCOL, group, size, widget_id, instance
59 );
60 #[cfg(not(target_os = "windows"))]
61 let url_str = format!(
62 "{}://localhost/?group={}&size={}&widgetId={}&instance={}",
63 BUILTIN_PROTOCOL, group, size, widget_id, instance
64 );
65 WebviewUrl::External(url_str.parse().expect("invalid built-in widget URL"))
66}
67
68#[cfg(target_os = "windows")]
71fn windows_locale() -> String {
72 if let Some(v) = ["LC_ALL", "LC_MESSAGES", "LANG"]
73 .iter()
74 .find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty()))
75 {
76 return v.split('.').next().unwrap_or_default().to_string();
77 }
78 #[link(name = "kernel32")]
79 unsafe extern "system" {
80 fn GetUserDefaultLocaleName(name: *mut u16, len: i32) -> i32;
81 }
82 let mut buf = [0u16; 85]; let n = unsafe { GetUserDefaultLocaleName(buf.as_mut_ptr(), buf.len() as i32) };
85 if n > 1 {
86 String::from_utf16_lossy(&buf[..(n - 1) as usize])
87 } else {
88 String::new()
89 }
90}
91
92#[cfg(all(target_os = "windows", feature = "image-prefetch"))]
94fn fetch_source_text(req: &crate::resolve::SourceRequest) -> Option<String> {
95 use std::io::Read;
96 let mut r = ureq::request(&req.method, &req.url).timeout(std::time::Duration::from_secs(10));
97 for (k, v) in &req.headers {
98 r = r.set(k, v);
99 }
100 let resp = match &req.body {
101 Some(b) => r.send_string(b),
102 None => r.call(),
103 }
104 .ok()?;
105 let mut buf = Vec::new();
106 resp.into_reader()
107 .take(crate::resolve::MAX_SOURCE_BYTES as u64 + 1)
108 .read_to_end(&mut buf)
109 .ok()?;
110 if buf.len() > crate::resolve::MAX_SOURCE_BYTES {
111 return None;
112 }
113 let text = String::from_utf8(buf).ok()?;
114 serde_json::from_str::<serde_json::Value>(&text).ok()?;
115 Some(text)
116}
117
118const ACTIVITY_WINDOW_SIZE: (f64, f64) = (340.0, 128.0);
120
121fn activity_slot(area_x: f64, area_y: f64, area_w: f64, n: usize) -> (f64, f64) {
125 const MARGIN: f64 = 16.0;
126 let (w, h) = ACTIVITY_WINDOW_SIZE;
127 (
128 area_x + area_w - w - MARGIN,
129 area_y + MARGIN + n as f64 * (h + 8.0),
130 )
131}
132
133fn activity_window_label(id: &str) -> String {
134 format!("activity-{}", id.replace('_', "__").replace('.', "_d"))
135}
136
137fn on_main_thread() -> bool {
139 std::thread::current().name() == Some("main")
140}
141
142pub fn init<R: Runtime>(
143 app: &AppHandle<R>,
144 api: PluginApi<R, Option<WidgetsPluginConfig>>,
145) -> crate::Result<Widget<R>> {
146 let cfg = api
147 .config()
148 .clone()
149 .unwrap_or_default()
150 .with_defaults(&app.config().identifier);
151 init_with_config(app, cfg)
152}
153
154pub(crate) fn init_with_config<R: Runtime>(
155 app: &AppHandle<R>,
156 cfg: WidgetsPluginConfig,
157) -> crate::Result<Widget<R>> {
158 let receipts = ReceiptStore::new();
159 let trace = TraceStore::new();
160 if let Ok(dir) = app.path().app_data_dir() {
161 receipts.load_from_path(&receipts_path(&dir));
162 trace.load_from_path(&trace_path(&dir));
163 }
164
165 #[cfg(target_os = "macos")]
168 let macos_driver = match crate::transport::resolve_driver(&cfg) {
169 Ok(driver) => driver,
170 Err(e) => {
171 log::warn!(
172 "widgets: {e}\nFalling back to transport=widgetContainer (works for ad-hoc signed \
173 builds). Sign with a Team ID and App Groups for transport=appGroup."
174 );
175 crate::transport::build_driver(crate::config::TransportKind::WidgetContainer, &cfg)?
176 }
177 };
178
179 let widget = Widget {
180 app: app.clone(),
181 cfg,
182 store: Mutex::new(HashMap::new()),
183 known_groups: Mutex::new(Vec::new()),
184 receipts,
185 trace,
186 windows: Mutex::new(HashMap::new()),
187 activities: Mutex::new(HashMap::new()),
188 activity_ts: Mutex::new(HashMap::new()),
189 island: Mutex::new(island::Island::default()),
190 listener_gate: store::ListenerGate::new(
191 store::now_ms(),
192 store::ListenerGate::DEFAULT_FALLBACK_MS,
193 ),
194 #[cfg(target_os = "windows")]
195 board_timers: Mutex::new(HashMap::new()),
196 #[cfg(target_os = "windows")]
197 board_fetches: Mutex::new(HashMap::new()),
198 #[cfg(target_os = "macos")]
199 poller_started: Mutex::new(false),
200 #[cfg(target_os = "macos")]
201 persist: Arc::new(PersistQueue::default()),
202 #[cfg(target_os = "macos")]
203 sibling_sweeps: Mutex::new(HashMap::new()),
204 #[cfg(target_os = "macos")]
205 macos_driver,
206 window_store: placement::WindowStore::load(
207 app.path()
208 .app_data_dir()
209 .ok()
210 .map(|d| d.join("widget-windows.json")),
211 ),
212 notifier: notify::Notifier::default(),
213 sources: Mutex::new(HashMap::new()),
214 };
215 if let Some(g) = widget.cfg.app_group.clone() {
217 widget.remember_group(&g);
218 }
219 #[cfg(target_os = "macos")]
220 widget.ensure_action_poller();
221
222 if widget.cfg.restore_windows_on_start {
224 let handle = app.clone();
225 let _ = app.run_on_main_thread(move || {
226 if let Some(w) = handle.try_state::<Widget<R>>() {
227 w.restore_windows();
228 }
229 });
230 }
231
232 Ok(widget)
233}
234
235pub struct Widget<R: Runtime> {
236 app: AppHandle<R>,
237 #[allow(dead_code)]
238 cfg: WidgetsPluginConfig,
239 store: Mutex<HashMap<String, DataMap>>,
241 known_groups: Mutex<Vec<String>>,
242 receipts: ReceiptStore,
244 trace: TraceStore,
246 windows: Mutex<HashMap<String, crate::store::ActiveWidget>>,
248 activities: Mutex<HashMap<String, String>>,
250 activity_ts: Mutex<HashMap<String, u64>>,
252 island: Mutex<island::Island>,
254 #[allow(dead_code)]
256 listener_gate: store::ListenerGate,
257 #[cfg(target_os = "windows")]
259 board_timers: Mutex<HashMap<String, u64>>,
260 #[cfg(target_os = "windows")]
262 board_fetches: Mutex<HashMap<String, u64>>,
263 #[cfg(target_os = "macos")]
264 poller_started: Mutex<bool>,
265 #[cfg(target_os = "macos")]
267 persist: Arc<PersistQueue>,
268 #[cfg(target_os = "macos")]
270 sibling_sweeps: Mutex<HashMap<String, std::time::Instant>>,
271 #[cfg(target_os = "macos")]
273 macos_driver: Arc<dyn Transport>,
274 window_store: placement::WindowStore,
276 notifier: notify::Notifier,
278 sources: Mutex<HashMap<String, u64>>,
280}
281
282impl<R: Runtime> Widget<R> {
283 fn remember_group(&self, group: &str) {
284 let mut groups = self.known_groups.lock().unwrap();
285 if !groups.iter().any(|g| g == group) {
286 groups.push(group.to_string());
287 }
288 }
289
290 fn storage_path(&self, group: &str) -> crate::Result<PathBuf> {
291 #[cfg(target_os = "macos")]
292 {
293 if let Some(path) = crate::macos_transport::app_group_data_override() {
294 if let Some(parent) = path.parent() {
295 if !parent.exists() {
296 fs::create_dir_all(parent)?;
297 }
298 }
299 return Ok(path);
300 }
301 if let Some(dir) = macos_shared_container(group) {
302 if !dir.exists() {
303 fs::create_dir_all(&dir)?;
304 }
305 return Ok(dir.join("widget_data.json"));
306 }
307 Ok(crate::macos_transport::sandbox_widget_data_path(group))
308 }
309
310 #[cfg(target_os = "windows")]
311 {
312 for key in ["TAURI_WIDGETS_DATA", "WIDGET_DATA_DIR"] {
314 if let Ok(env_path) = std::env::var(key) {
315 let p = env_path.trim();
316 if !p.is_empty() {
317 let path = if p.to_ascii_lowercase().ends_with(".json") {
318 PathBuf::from(p)
319 } else {
320 PathBuf::from(p).join("widget_data.json")
321 };
322 if let Some(parent) = path.parent() {
323 if !parent.exists() {
324 fs::create_dir_all(parent)?;
325 }
326 }
327 return Ok(path);
328 }
329 }
330 }
331 let local = std::env::var("LOCALAPPDATA")
332 .map(PathBuf::from)
333 .or_else(|_| {
334 self.app
335 .path()
336 .app_data_dir()
337 .map_err(|e| Error::Io(e.to_string()))
338 })?;
339 let dir = local.join("tauri-plugin-widgets");
340 if !dir.exists() {
341 fs::create_dir_all(&dir)?;
342 }
343 let _ = group; Ok(dir.join("widget_data.json"))
345 }
346
347 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
348 {
349 let base = self
350 .app
351 .path()
352 .app_data_dir()
353 .map_err(|e| Error::Io(e.to_string()))?;
354 let dir = base.join("widgets");
355 if !dir.exists() {
356 fs::create_dir_all(&dir)?;
357 }
358 let safe: String = group
360 .chars()
361 .map(|c| {
362 if c.is_alphanumeric() || c == '.' || c == '-' {
363 c
364 } else {
365 '_'
366 }
367 })
368 .collect();
369 Ok(dir.join(format!("{safe}.json")))
370 }
371 }
372
373 fn load_map_locked<'a>(
374 store: &'a mut HashMap<String, DataMap>,
375 path: &PathBuf,
376 group: &str,
377 ) -> &'a mut DataMap {
378 #[cfg(not(target_os = "macos"))]
379 let _ = group; store
384 .entry(path.to_string_lossy().into_owned())
385 .or_insert_with(|| {
386 let mut maps = Vec::new();
387 if path.exists() {
388 if let Some(m) = fs::read_to_string(path)
389 .ok()
390 .and_then(|s| serde_json::from_str(&s).ok())
391 {
392 maps.push(m);
393 }
394 }
395 #[cfg(target_os = "macos")]
396 {
397 for t in crate::macos_transport::all_transports(group) {
398 if let Some(m) = t.read() {
399 maps.push(m);
400 }
401 }
402 }
403 store::pick_freshest(maps)
404 })
405 }
406
407 #[cfg(target_os = "macos")]
411 fn adopt_newer_disk_map(&self, map: &mut DataMap, group: &str) {
412 let disk = store::pick_freshest(
413 self.hot_transports(group)
414 .iter()
415 .filter_map(|t| t.read())
416 .collect::<Vec<DataMap>>(),
417 );
418 if store::map_nonce(&disk) > store::map_nonce(map) {
419 *map = disk;
420 }
421 }
422
423 fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
430 #[cfg(target_os = "macos")]
431 crate::macos_transport::with_store_lock(group, || {
432 let mut out = map.clone();
433 if self.sweep_due(group) {
434 crate::macos_transport::merge_pending_into_map(&mut out, group);
435 crate::macos_transport::clear_sibling_transports(
436 group,
437 self.macos_driver.name(),
438 &mut out,
439 );
440 } else {
441 crate::macos_transport::merge_pending_in(&mut out, &self.hot_transports(group));
442 }
443 self.macos_driver.write(&out)
444 })?;
445 #[cfg(target_os = "windows")]
446 {
447 let path = self.storage_path(group)?;
448 persist_windows_shared_map(&path, map, &[])?;
449 }
450 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
451 {
452 let path = self.storage_path(group)?;
453 let json = serde_json::to_string_pretty(map)?;
454 atomic_write(&path, json.as_bytes())?;
455 }
456 Ok(())
457 }
458
459 #[cfg(target_os = "macos")]
462 fn hot_transports(&self, group: &str) -> Vec<Arc<dyn Transport>> {
463 if crate::group::default_group() == Some(group) {
464 vec![self.macos_driver.clone()]
465 } else {
466 crate::macos_transport::all_transports(group)
467 }
468 }
469
470 #[cfg(target_os = "macos")]
475 fn sweep_due(&self, group: &str) -> bool {
476 const EVERY: std::time::Duration = std::time::Duration::from_secs(30);
477 let mut last = self.sibling_sweeps.lock().unwrap();
478 let now = std::time::Instant::now();
479 if last
480 .get(group)
481 .is_some_and(|t| now.duration_since(*t) < EVERY)
482 {
483 return false;
484 }
485 last.insert(group.to_string(), now);
486 true
487 }
488
489 pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
490 self.remember_group(group);
491 #[cfg(target_os = "macos")]
496 {
497 let path = self.storage_path(group)?;
498 let mut store = self.store.lock().unwrap();
499 let map = Self::load_map_locked(&mut store, &path, group);
500 self.adopt_newer_disk_map(map, group);
501 let current = self
503 .persist
504 .unwritten(group)
505 .into_iter()
506 .rev()
507 .find(|(k, _)| k == key)
508 .map(|(_, v)| v)
509 .or_else(|| map.get(key).cloned());
510 if current.as_deref() == Some(value) {
511 return Ok(true);
512 }
513 map.insert(key.into(), value.into());
514 store::touch_meta_above(
515 map,
516 crate::macos_transport::max_nonce_in(&self.hot_transports(group)),
517 );
518 drop(store);
519 self.queue_persist(group, Some((key, value)), false);
520 let _ = self.app.emit("widget-update", group);
521 Ok(true)
522 }
523 #[cfg(not(target_os = "macos"))]
524 self.set_items_locked(key, value, group)
525 }
526
527 #[cfg(target_os = "macos")]
529 fn queue_persist(&self, group: &str, kv: Option<(&str, &str)>, reload: bool) {
530 self.persist.push(group, kv, reload);
531 if !std::mem::replace(&mut *self.persist.started.lock().unwrap(), true) {
532 let app = self.app.clone();
533 let queue = self.persist.clone();
534 std::thread::spawn(move || loop {
535 let (group, job) = queue.next();
536 let Some(widget) = app.try_state::<Widget<R>>() else {
537 continue;
538 };
539 widget.inner().write_job(&group, job);
540 });
541 }
542 }
543
544 #[cfg(target_os = "macos")]
546 fn write_job(&self, group: &str, job: PersistJob) {
547 if !job.keys.is_empty() {
548 let res = crate::macos_transport::with_store_lock(group, || {
549 let path = self.storage_path(group)?;
550 let mut store = self.store.lock().unwrap();
551 let map = Self::load_map_locked(&mut store, &path, group);
552 self.adopt_newer_disk_map(map, group);
555 for (k, v) in self.persist.in_flight(group) {
557 map.insert(k, v);
558 }
559 store::touch_meta_above(
560 map,
561 crate::macos_transport::max_nonce_in(&self.hot_transports(group)),
562 );
563 let snapshot = map.clone();
564 drop(store);
565 self.persist_map(group, &snapshot)
566 });
567 if let Err(e) = res {
568 log::warn!("widget store write for '{group}' failed: {e}");
569 }
570 }
571 self.persist.done(group);
572 if job.reload {
573 let _ = self.reload_all_timelines_now();
574 }
575 }
576
577 #[cfg(target_os = "macos")]
579 pub fn flush_store(&self) {
580 self.persist.wait_idle();
581 }
582
583 #[cfg_attr(target_os = "macos", allow(dead_code))]
584 fn set_items_locked(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
585 let path = self.storage_path(group)?;
586 let mut store = self.store.lock().unwrap();
587 let map = Self::load_map_locked(&mut store, &path, group);
588 #[cfg(target_os = "macos")]
589 self.adopt_newer_disk_map(map, group);
590 if map.get(key).map(String::as_str) == Some(value) {
591 return Ok(true);
592 }
593 map.insert(key.into(), value.into());
594 #[cfg(target_os = "macos")]
595 {
596 let floor = crate::macos_transport::max_nonce_in(&self.hot_transports(group));
597 store::touch_meta_above(map, floor);
598 }
599 #[cfg(not(target_os = "macos"))]
600 {
601 store::touch_meta(map);
602 }
603 let snapshot = map.clone();
604 drop(store);
605 self.persist_map(group, &snapshot)?;
606 let _ = self.app.emit("widget-update", group);
607 Ok(true)
608 }
609
610 pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
611 #[cfg(target_os = "macos")]
612 {
613 let freshest = self.macos_driver_map(group)?;
614 Ok(freshest.get(key).cloned())
615 }
616 #[cfg(not(target_os = "macos"))]
617 {
618 let path = self.storage_path(group)?;
619 let mut store = self.store.lock().unwrap();
620 let map = Self::load_map_locked(&mut store, &path, group);
621 Ok(map.get(key).cloned())
622 }
623 }
624
625 pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
626 if config.url.as_deref().map_or(true, str::is_empty) {
627 self.windows.lock().unwrap().insert(
628 config.label.clone(),
629 crate::store::ActiveWidget {
630 widget_id: config.widget_id.clone().unwrap_or_else(|| "default".into()),
631 instance: config
632 .instance
633 .clone()
634 .or_else(|| Some(config.label.clone())),
635 size: Some(config.size.clone().unwrap_or_else(|| "small".into())),
636 last_seen_ms: None,
637 },
638 );
639 }
640 let app = self.app.clone();
641 #[cfg(all(target_os = "linux", feature = "linux"))]
644 {
645 if gtk::glib::MainContext::default().is_owner() {
646 return Self::create_widget_window_on_main(&app, config);
647 }
648 }
649
650 if on_main_thread() {
653 self.app
654 .run_on_main_thread(move || {
655 if let Err(e) = Self::create_widget_window_on_main(&app, config) {
656 log::error!("{e}");
657 }
658 })
659 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
660 return Ok(true);
661 }
662
663 let (tx, rx) = std::sync::mpsc::sync_channel(1);
664 self.app
665 .run_on_main_thread(move || {
666 let _ = tx.send(Self::create_widget_window_on_main(&app, config));
667 })
668 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
669 rx.recv()
670 .map_err(|_| Error::new("create_widget_window: main thread dropped"))?
671 }
672
673 fn create_widget_window_on_main(
674 app: &AppHandle<R>,
675 config: WidgetWindowConfig,
676 ) -> crate::Result<bool> {
677 let label_log = config.label.clone();
678 let url = match config.url.as_deref() {
679 Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
680 _ => {
681 let group = config
682 .group
683 .clone()
684 .or_else(|| crate::group::default_group().map(String::from))
685 .unwrap_or_default();
686 let group = group.as_str();
687 let size = config.size.as_deref().unwrap_or("small");
688 let widget_id = config.widget_id.as_deref().unwrap_or("default");
689 let instance = config.instance.as_deref().unwrap_or(&config.label);
691 builtin_widget_url(group, size, widget_id, instance)
692 }
693 };
694 let skip_taskbar = config.skip_taskbar;
695 let linux_pin = cfg!(all(target_os = "linux", feature = "linux"));
697 let modes = placement::window_modes(&config, linux_pin);
698 let target = window::resolve_target(app, &config);
699 let move_to = |w: &tauri::WebviewWindow<R>, t: &window::Target| {
700 let r = match t.physical {
701 Some(p) => w.set_position(p),
702 None => w.set_position(tauri::LogicalPosition::new(t.logical.0, t.logical.1)),
703 };
704 r.map_err(|e| Error::new(e.to_string()))
705 };
706 if let Some(prev) = app.get_webview_window(&config.label) {
709 let url = match url {
710 WebviewUrl::External(u) | WebviewUrl::CustomProtocol(u) => u,
711 WebviewUrl::App(path) => prev
712 .url()
713 .map_err(|e| Error::new(e.to_string()))?
714 .join(&path.to_string_lossy())
715 .map_err(|e| Error::new(e.to_string()))?,
716 _ => return Err(Error::new("create_widget_window: unsupported url")),
717 };
718 prev.navigate(url).map_err(|e| Error::new(e.to_string()))?;
719 prev.set_size(tauri::LogicalSize::new(config.width, config.height))
720 .map_err(|e| Error::new(e.to_string()))?;
721 if let Some(t) = &target {
722 move_to(&prev, t)?;
723 }
724 window::apply_modes(&prev, &modes, config.effects, true);
725 #[cfg(target_os = "windows")]
726 if modes.layer == crate::models::WindowLayer::Desktop {
727 if let Err(e) = crate::windows::attach_window(&prev) {
728 log::warn!("'{label_log}': {e}");
729 }
730 }
731 prev.show().map_err(|e| Error::new(e.to_string()))?;
732 Self::remember_open(app, &config);
733 log::debug!("reused widget window '{label_log}'");
734 return Ok(true);
735 }
736 let island_first_show = cfg!(target_os = "macos") && config.label == island::LABEL;
739 let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
740 .title(&config.label)
742 .inner_size(config.width, config.height)
743 .decorations(false)
744 .skip_taskbar(skip_taskbar)
745 .always_on_top(modes.on_top())
746 .resizable(false)
747 .focused(false)
749 .visible(!linux_pin && !island_first_show);
750 #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
752 {
753 builder = builder.transparent(true);
754 }
755 #[cfg(all(target_os = "macos", not(feature = "macos-private-api")))]
757 if config.label == island::LABEL {
758 builder = builder.background_color(tauri::window::Color(0, 0, 0, 255));
759 }
760
761 if let Some(t) = &target {
762 builder = builder.position(t.logical.0, t.logical.1);
763 }
764
765 let win = builder
766 .build()
767 .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
768 window::apply_modes(&win, &modes, config.effects, false);
769 if let Some(t) = target.as_ref().filter(|t| t.physical.is_some()) {
770 move_to(&win, t)?;
771 }
772 if island_first_show {
773 if let Some(t) = &target {
774 let _ = win.set_position(tauri::LogicalPosition::new(t.logical.0, t.logical.1));
775 }
776 win.show().map_err(|e| Error::new(e.to_string()))?;
777 }
778 #[cfg(target_os = "windows")]
780 if modes.layer == crate::models::WindowLayer::Desktop {
781 if let Err(e) = crate::windows::attach_window(&win) {
782 log::warn!("'{label_log}': {e}");
783 }
784 }
785 #[cfg(all(target_os = "linux", feature = "linux"))]
786 if !crate::linux::pin_widget_window(
787 &win,
788 &config,
789 &modes,
790 target.as_ref().map(|t| t.logical),
791 ) {
792 win.show().map_err(|e| Error::new(e.to_string()))?;
793 if let Some(t) = &target {
795 move_to(&win, t)?;
796 }
797 }
798 if config.remember_position != Some(false) {
799 window::track_position(&win);
800 }
801 Self::remember_open(app, &config);
802 log::debug!("created widget window '{label_log}'");
803 Ok(true)
804 }
805
806 fn remember_open(app: &AppHandle<R>, config: &WidgetWindowConfig) {
808 if config.label.starts_with("activity-") {
809 return;
810 }
811 if let Some(w) = app.try_state::<Widget<R>>() {
812 w.window_store.set_open(&config.label, Some(config));
813 }
814 }
815
816 pub fn restore_windows(&self) -> usize {
821 let list = self.window_store.open_windows();
822 let n = list.len();
823 for cfg in list {
824 let label = cfg.label.clone();
825 if let Err(e) = self.create_widget_window(cfg) {
826 log::warn!("widgets: restore window '{label}': {e}");
827 }
828 }
829 n
830 }
831
832 pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
833 self.windows.lock().unwrap().remove(label);
834 if !label.starts_with("activity-") {
835 self.window_store.set_open(label, None);
836 }
837 let app = self.app.clone();
838 let label = label.to_string();
839
840 #[cfg(all(target_os = "linux", feature = "linux"))]
841 {
842 if gtk::glib::MainContext::default().is_owner() {
843 return Self::close_widget_window_on_main(&app, &label);
844 }
845 }
846
847 if on_main_thread() {
848 return Self::close_widget_window_on_main(&app, &label);
849 }
850 let (tx, rx) = std::sync::mpsc::sync_channel(1);
851 self.app
852 .run_on_main_thread(move || {
853 let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
854 })
855 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
856
857 rx.recv()
858 .map_err(|_| Error::new("close_widget_window: main thread dropped"))?
859 }
860
861 fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
862 if let Some(win) = app.get_webview_window(label) {
863 win.close().map_err(|e| Error::new(e.to_string()))?;
864 Ok(true)
865 } else {
866 Ok(false)
867 }
868 }
869
870 pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
878 Ok(true)
880 }
881
882 pub fn reload_all_timelines(&self) -> crate::Result<bool> {
883 #[cfg(target_os = "macos")]
885 self.flush_store();
886 self.reload_all_timelines_now()
887 }
888
889 fn reload_all_timelines_now(&self) -> crate::Result<bool> {
890 let _ = self.app.emit("widget-reload", "all");
891 #[cfg(target_os = "macos")]
892 if !unsafe { macos_widget_reload_all() } {
893 return Err(Error::Unsupported(
894 "WidgetKit reload needs macOS 11+".into(),
895 ));
896 }
897 Ok(true)
898 }
899
900 pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
901 #[cfg(target_os = "macos")]
902 self.flush_store();
903 let _ = self.app.emit("widget-reload", of_kind);
904 #[cfg(target_os = "macos")]
905 {
906 let c = CString::new(of_kind)
907 .map_err(|_| Error::new("reload_timelines: kind contains a NUL byte"))?;
908 if !unsafe { macos_widget_reload_kind(c.as_ptr()) } {
909 return Err(Error::Unsupported(
910 "WidgetKit reload needs macOS 11+".into(),
911 ));
912 }
913 }
914 Ok(true)
915 }
916
917 pub fn request_widget(&self) -> crate::Result<bool> {
925 Err(Error::Unsupported(
926 "Use create_widget_window on desktop".into(),
927 ))
928 }
929
930 pub fn set_widget_config_from_path(
932 &self,
933 path: impl AsRef<std::path::Path>,
934 group: &str,
935 widget_id: &str,
936 skip_reload: bool,
937 ) -> crate::Result<ApplyOutcome> {
938 if widget_id.is_empty() {
939 return Err(Error::new("widget_id must not be empty"));
940 }
941 let path = path.as_ref();
942 let path_str = path
943 .to_str()
944 .ok_or_else(|| Error::new(format!("invalid UTF-8 path: {}", path.display())))?;
945 let config = crate::commands::load_widget_config_from_path(path_str)?;
946 self.set_widget_config(&config, group, widget_id, skip_reload)
947 }
948
949 pub fn set_widget_config(
950 &self,
951 config: &WidgetConfig,
952 group: &str,
953 widget_id: &str,
954 skip_reload: bool,
955 ) -> crate::Result<ApplyOutcome> {
956 if widget_id.is_empty() {
957 return Err(Error::new("widget_id must not be empty"));
958 }
959 self.remember_group(group);
960
961 let mut config =
962 crate::normalize::normalize(config, crate::capabilities::WidgetPlatform::current())
963 .config;
964 crate::image_prefetch::prefetch_remote_images(&mut config);
965
966 let json = serde_json::to_string(&config)
967 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
968 let compact: serde_json::Value = serde_json::from_str(&json)
969 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
970 let hash = config_content_hash(&json);
971 let key = config_key(widget_id);
972
973 let existing = self.get_items(&key, group)?;
974 let changed = existing.as_deref() != Some(json.as_str());
975
976 let _ = self.app.emit(
979 "widget-config-push",
980 serde_json::json!({
981 "group": group,
982 "widgetId": widget_id,
983 "config": compact,
984 }),
985 );
986
987 if !changed {
988 let outcome = ApplyOutcome::unchanged(hash);
989 self.trace.push(TraceEvent::ConfigSet {
990 widget_id: widget_id.into(),
991 nonce: 0,
992 bytes: json.len(),
993 changed: false,
994 skip: Some(SkipReason::Unchanged { hash }),
995 });
996 self.trace.push(TraceEvent::Reload {
997 performed: false,
998 reason: outcome.reload.clone(),
999 });
1000 self.maybe_flush_trace();
1001 return Ok(outcome);
1002 }
1003
1004 crate::capabilities::log_capabilities(&config);
1005 let t0 = std::time::Instant::now();
1006 self.set_items(&key, &json, group)?;
1007 let write_ms = t0.elapsed().as_millis() as u32;
1008 let transports = self.written_transport_names(group);
1009 let nonce = self
1010 .get_items("__meta_nonce__", group)
1011 .ok()
1012 .flatten()
1013 .and_then(|s| s.parse().ok())
1014 .unwrap_or(0);
1015
1016 self.trace.push(TraceEvent::ConfigSet {
1017 widget_id: widget_id.into(),
1018 nonce,
1019 bytes: json.len(),
1020 changed: true,
1021 skip: None,
1022 });
1023 for name in &transports {
1024 self.trace.push(TraceEvent::Write {
1025 transport: name.clone(),
1026 ok: true,
1027 duration_ms: write_ms,
1028 error: None,
1029 });
1030 }
1031
1032 #[cfg(target_os = "windows")]
1033 self.write_board_card(&config, group, widget_id)?;
1034
1035 let reload = if skip_reload {
1036 ReloadOutcome::Skipped {
1037 why: "skip_reload".into(),
1038 }
1039 } else {
1040 #[cfg(target_os = "macos")]
1042 let reloaded = {
1043 self.queue_persist(group, None, true);
1044 Ok::<bool, Error>(true)
1045 };
1046 #[cfg(not(target_os = "macos"))]
1047 let reloaded = self.reload_all_timelines();
1048 match reloaded {
1049 Ok(_) => ReloadOutcome::Ok,
1050 Err(e) => ReloadOutcome::Failed {
1051 error: e.to_string(),
1052 },
1053 }
1054 };
1055 self.trace.push(TraceEvent::Reload {
1056 performed: matches!(reload, ReloadOutcome::Ok),
1057 reason: reload.clone(),
1058 });
1059
1060 #[cfg(target_os = "macos")]
1061 self.ensure_action_poller();
1062
1063 self.maybe_flush_trace();
1064
1065 Ok(ApplyOutcome {
1066 written: true,
1067 reload,
1068 transports,
1069 skip: None,
1070 })
1071 }
1072
1073 #[cfg(target_os = "windows")]
1077 pub(crate) fn write_board_card(
1078 &self,
1079 config: &WidgetConfig,
1080 group: &str,
1081 widget_id: &str,
1082 ) -> crate::Result<()> {
1083 let (id, instance) = match widget_id.split_once(store::INSTANCE_SEPARATOR) {
1084 Some((id, inst)) => (id, Some(inst)),
1085 None => (widget_id, None),
1086 };
1087 let read_json = |prefix: &str| {
1088 store::lookup_keys(prefix, id, instance)
1089 .into_iter()
1090 .find_map(|k| self.get_items(&k, group).ok().flatten())
1091 .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
1092 };
1093 let host_data = read_json(store::DATA_KEY_PREFIX);
1094 let fetched = read_json(store::FETCHED_KEY_PREFIX);
1095 let data = crate::resolve::merge_data([host_data.as_ref(), fetched.as_ref()]);
1096 let locale = windows_locale();
1097 let now = store::now_ms();
1098 let raw = serde_json::to_value(config).unwrap_or_default();
1099 let last_fetch = self.board_fetch_source(&raw, group, widget_id, instance, &locale, now);
1100 let Some((result, _)) =
1101 crate::adaptive_card::board_card(config, data.as_ref(), now, &locale, instance)
1102 else {
1103 return Ok(());
1104 };
1105 let template = serde_json::to_string(&result.card)
1106 .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
1107 self.set_items(
1108 &crate::adaptive_card::ac_template_key(widget_id),
1109 &template,
1110 group,
1111 )?;
1112 self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
1113 let gen = {
1115 let mut timers = self.board_timers.lock().unwrap();
1116 let g = timers.entry(widget_id.to_string()).or_insert(0);
1117 *g += 1;
1118 *g
1119 };
1120 if let Some(at) = crate::resolve::next_wake_ms(&raw, now, last_fetch) {
1121 let app = self.app.clone();
1122 let (config, group, widget_id) =
1123 (config.clone(), group.to_string(), widget_id.to_string());
1124 std::thread::spawn(move || {
1125 std::thread::sleep(std::time::Duration::from_millis(
1126 at.saturating_sub(store::now_ms()) + 50,
1127 ));
1128 if let Some(w) = app.try_state::<Widget<R>>() {
1129 if w.board_timers.lock().unwrap().get(&widget_id) != Some(&gen) {
1130 return;
1131 }
1132 let current = w.get_widget_config(&group, &widget_id).ok().flatten();
1133 let cfg = current.unwrap_or(config);
1134 let _ = w.write_board_card(&cfg, &group, &widget_id);
1135 }
1136 });
1137 }
1138 Ok(())
1139 }
1140
1141 #[cfg(target_os = "windows")]
1145 fn board_fetch_source(
1146 &self,
1147 raw: &serde_json::Value,
1148 group: &str,
1149 widget_id: &str,
1150 instance: Option<&str>,
1151 locale: &str,
1152 now: u64,
1153 ) -> Option<u64> {
1154 let ctx = crate::resolve::ResolveContext {
1155 now_ms: now,
1156 locale,
1157 instance,
1158 ..Default::default()
1159 };
1160 let req = crate::resolve::source_request(raw, &ctx)?;
1161 let mut attempts = self.board_fetches.lock().unwrap();
1162 let last = attempts.get(widget_id).copied();
1163 let due = last.map_or(true, |t| now >= t + u64::from(req.refresh_minutes) * 60_000);
1164 if !due {
1165 return last;
1166 }
1167 attempts.insert(widget_id.to_string(), now);
1168 drop(attempts);
1169 #[cfg(feature = "image-prefetch")]
1170 {
1171 let app = self.app.clone();
1172 let (group, widget_id) = (group.to_string(), widget_id.to_string());
1173 std::thread::spawn(move || {
1174 let Some(text) = fetch_source_text(&req) else {
1175 log::warn!("widget source fetch failed for {widget_id}");
1176 return;
1177 };
1178 if let Some(w) = app.try_state::<Widget<R>>() {
1179 let key = format!("{}{widget_id}", store::FETCHED_KEY_PREFIX);
1180 if w.set_items(&key, &text, &group).is_ok() {
1181 if let Ok(Some(cfg)) = w.get_widget_config(&group, &widget_id) {
1182 let _ = w.write_board_card(&cfg, &group, &widget_id);
1183 }
1184 }
1185 }
1186 });
1187 }
1188 #[cfg(not(feature = "image-prefetch"))]
1189 let _ = (group, req);
1190 Some(now)
1191 }
1192
1193 pub fn data_changed(&self, group: &str, widget_id: &str, instance: Option<&str>) {
1195 #[cfg(target_os = "windows")]
1196 {
1197 let id = store::scoped_id(widget_id, instance);
1198 let cfg = self
1199 .get_widget_config(group, &id)
1200 .ok()
1201 .flatten()
1202 .or_else(|| self.get_widget_config(group, widget_id).ok().flatten());
1203 if let Some(cfg) = cfg {
1204 let _ = self.write_board_card(&cfg, group, &id);
1205 }
1206 }
1207 #[cfg(not(target_os = "windows"))]
1208 let _ = (group, widget_id, instance);
1209 }
1210
1211 fn written_transport_names(&self, group: &str) -> Vec<String> {
1213 #[cfg(target_os = "macos")]
1214 {
1215 let _ = group;
1216 vec![self.macos_driver.name().to_string()]
1217 }
1218 #[cfg(not(target_os = "macos"))]
1219 {
1220 let _ = group;
1221 vec!["file".into()]
1222 }
1223 }
1224
1225 pub fn get_widget_config(
1226 &self,
1227 group: &str,
1228 widget_id: &str,
1229 ) -> crate::Result<Option<WidgetConfig>> {
1230 if widget_id.is_empty() {
1231 return Err(Error::new("widget_id must not be empty"));
1232 }
1233 let raw = self.get_items(&config_key(widget_id), group)?;
1234 match raw {
1235 Some(json) => {
1236 let config: WidgetConfig = serde_json::from_str(&json)
1237 .map_err(|e| Error::new(format!("parse config: {e}")))?;
1238 Ok(Some(config))
1239 }
1240 None => Ok(None),
1241 }
1242 }
1243
1244 #[cfg(target_os = "macos")]
1246 fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
1247 let mut maps = Vec::new();
1248 if let Some(disk) = self.macos_driver.read() {
1249 maps.push(disk);
1250 }
1251 let path = self.storage_path(group)?;
1252 let mut store = self.store.lock().unwrap();
1253 let map = Self::load_map_locked(&mut store, &path, group);
1254 maps.push(map.clone());
1255 let mut freshest = store::pick_freshest(maps);
1256 if store::map_nonce(&freshest) > store::map_nonce(map) {
1257 *map = freshest.clone();
1258 }
1259 self.persist.overlay(group, &mut freshest);
1262 Ok(freshest)
1263 }
1264
1265 pub fn poll_pending_actions(
1268 &self,
1269 group: &str,
1270 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
1271 #[cfg(target_os = "macos")]
1272 return crate::macos_transport::with_store_lock(group, || {
1273 self.drain_pending_actions(group)
1274 });
1275 #[cfg(not(target_os = "macos"))]
1276 self.drain_pending_actions(group)
1277 }
1278
1279 fn drain_pending_actions(
1280 &self,
1281 group: &str,
1282 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
1283 self.remember_group(group);
1284
1285 #[cfg(target_os = "macos")]
1288 let sweep = self.sweep_due(group);
1289 #[cfg(target_os = "macos")]
1290 let polled: Vec<Arc<dyn Transport>> = if sweep {
1291 crate::macos_transport::all_transports(group)
1292 } else {
1293 self.hot_transports(group)
1294 };
1295 #[cfg(target_os = "macos")]
1296 let disk_maps: Vec<DataMap> = {
1297 let mut maps = Vec::new();
1298 for t in polled.iter() {
1299 if let Some(m) = t.read() {
1300 maps.push(m);
1301 }
1302 }
1303 maps
1304 };
1305 #[cfg(not(target_os = "macos"))]
1306 let disk_maps: Vec<DataMap> = {
1307 let path = self.storage_path(group)?;
1308 if path.exists() {
1309 fs::read_to_string(&path)
1310 .ok()
1311 .and_then(|s| serde_json::from_str(&s).ok())
1312 .into_iter()
1313 .collect()
1314 } else {
1315 Vec::new()
1316 }
1317 };
1318
1319 let freshest = store::pick_freshest(disk_maps);
1320
1321 let mut store = self.store.lock().unwrap();
1322 let path = self.storage_path(group)?;
1323 let map = Self::load_map_locked(&mut store, &path, group);
1324
1325 if store::map_nonce(&freshest) > store::map_nonce(map) {
1326 *map = freshest;
1327 }
1328
1329 #[cfg(target_os = "macos")]
1330 let actions = {
1331 let harvested = crate::macos_transport::harvest_pending_in(&polled);
1332 if !harvested.is_empty() {
1333 harvested
1334 } else {
1335 parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()))
1336 }
1337 };
1338 #[cfg(not(target_os = "macos"))]
1339 let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
1340
1341 if actions.is_empty() {
1342 return Ok(Vec::new());
1343 }
1344
1345 map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
1346 #[cfg(target_os = "macos")]
1348 self.persist.forget(group, PENDING_ACTIONS_KEY);
1349 #[cfg(target_os = "macos")]
1350 {
1351 let floor = crate::macos_transport::max_nonce_in(&self.hot_transports(group));
1352 store::touch_meta_above(map, floor);
1353 }
1354 #[cfg(not(target_os = "macos"))]
1355 {
1356 store::touch_meta(map);
1357 }
1358 let snapshot = map.clone();
1359 drop(store);
1360
1361 #[cfg(target_os = "windows")]
1362 persist_windows_shared_map(&self.storage_path(group)?, &snapshot, &actions)?;
1363 #[cfg(not(target_os = "windows"))]
1364 self.persist_map(group, &snapshot)?;
1365 let _ = self.app.emit("widget-update", group);
1366 #[cfg(target_os = "macos")]
1367 {
1368 crate::macos_transport::clear_pending_actions_in(&polled, &actions);
1369 }
1370
1371 Ok(actions)
1372 }
1373
1374 pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
1375 self.remember_group(&receipt.group);
1376 let trigger = receipt.trigger.clone().unwrap_or_else(|| "timeline".into());
1377 let lag_ms = {
1378 let since = self.trace.list_since(None);
1379 since
1380 .iter()
1381 .rev()
1382 .find_map(|e| match &e.event {
1383 TraceEvent::ConfigSet { nonce, .. }
1384 if *nonce == receipt.nonce && *nonce > 0 =>
1385 {
1386 Some(receipt.ts.saturating_sub(e.ts))
1387 }
1388 _ => None,
1389 })
1390 .unwrap_or(0)
1391 };
1392 self.trace.push(TraceEvent::Render {
1393 instance: receipt.instance.clone(),
1394 nonce: receipt.nonce,
1395 source: receipt.source.clone(),
1396 trigger,
1397 lag_ms,
1398 skipped: receipt.skipped.clone(),
1399 });
1400 self.receipts.upsert(receipt);
1401 if let Ok(dir) = self.app.path().app_data_dir() {
1402 let _ = self.receipts.save_to_path(&receipts_path(&dir));
1403 }
1404 self.maybe_flush_trace();
1405 Ok(true)
1406 }
1407
1408 pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
1409 Ok(self.receipts.list(group))
1410 }
1411
1412 pub fn mark_listener_ready(&self) {
1414 self.listener_gate.open();
1415 }
1416
1417 pub fn get_active_widgets(
1420 &self,
1421 group: &str,
1422 ) -> crate::Result<Vec<crate::store::ActiveWidget>> {
1423 let open: Vec<crate::store::ActiveWidget> = {
1424 let mut windows = self.windows.lock().unwrap();
1425 windows.retain(|label, _| self.app.get_webview_window(label).is_some());
1426 windows
1428 .values()
1429 .filter(|a| !a.widget_id.starts_with("activity."))
1430 .cloned()
1431 .collect()
1432 };
1433 #[allow(unused_mut)]
1434 let mut open = open;
1435 #[cfg(target_os = "windows")]
1437 if let Ok(Some(raw)) = self.get_items(store::BOARD_WIDGETS_KEY, group) {
1438 open.extend(store::parse_board_widgets(&raw));
1439 }
1440 let seen = store::active_from_receipts(
1441 &self.receipts.list(group),
1442 store::now_ms(),
1443 store::ACTIVE_RECEIPT_MAX_AGE_MS,
1444 );
1445 Ok(store::merge_active(open, seen))
1446 }
1447
1448 pub fn start_activity(
1457 &self,
1458 content: crate::models::ActivityContent,
1459 group: &str,
1460 ) -> crate::Result<crate::models::ActivityHandle> {
1461 let id = content.id.clone();
1462 self.write_activity(&content, group)?;
1463 let running = self.activities.lock().unwrap().contains_key(&id);
1465 match content.timestamp {
1466 Some(ts) => {
1467 self.activity_ts.lock().unwrap().insert(id.clone(), ts);
1468 }
1469 None if !running => {
1470 self.activity_ts.lock().unwrap().remove(&id);
1471 }
1472 None => {}
1473 }
1474 let handle = crate::models::ActivityHandle {
1475 id: id.clone(),
1476 surface: "desktop".into(),
1477 push_token: None,
1478 };
1479 let notified = crate::models::ActivityHandle {
1480 surface: "notification".into(),
1481 ..handle.clone()
1482 };
1483 if self.activities.lock().unwrap().contains_key(&id) {
1485 if self.island.lock().unwrap().contains(&id) {
1486 self.island.lock().unwrap().upsert(content, group);
1487 self.island_refresh()?;
1488 return Ok(handle);
1489 }
1490 if self.notifier.has(&id) {
1491 self.notify_activity(&content)?;
1492 return Ok(notified);
1493 }
1494 return Ok(handle);
1495 }
1496 let surface = self.cfg.activity_surface.as_deref().unwrap_or("island");
1497 if surface == "notification" && notify::Notifier::AVAILABLE {
1498 match self.notify_activity(&content) {
1499 Ok(()) => {
1500 self.activities
1501 .lock()
1502 .unwrap()
1503 .insert(id, group.to_string());
1504 return Ok(notified);
1505 }
1506 Err(e) => log::warn!("widgets: activity notification failed ({e}); using a window"),
1507 }
1508 }
1509 if surface != "window" && surface != "notification" {
1510 self.island.lock().unwrap().upsert(content.clone(), group);
1511 self.activities
1512 .lock()
1513 .unwrap()
1514 .insert(id.clone(), group.to_string());
1515 match self.island_refresh() {
1516 Ok(()) => return Ok(handle),
1517 Err(e) => {
1518 self.island.lock().unwrap().remove(&id);
1519 self.activities.lock().unwrap().remove(&id);
1520 if !notify::Notifier::AVAILABLE {
1521 return Err(e);
1522 }
1523 log::warn!("widgets: activity island failed ({e}); using a notification");
1524 self.notify_activity(&content)?;
1525 self.activities
1526 .lock()
1527 .unwrap()
1528 .insert(id, group.to_string());
1529 return Ok(notified);
1530 }
1531 }
1532 }
1533 let (x, y) = self.activity_origin();
1534 let opened = self.create_widget_window(WidgetWindowConfig {
1535 label: activity_window_label(&id),
1536 url: None,
1537 width: ACTIVITY_WINDOW_SIZE.0,
1538 height: ACTIVITY_WINDOW_SIZE.1,
1539 x,
1540 y,
1541 always_on_top: true,
1542 skip_taskbar: true,
1543 group: Some(group.to_string()),
1544 widget_id: Some(crate::models::activity_widget_id(&id)),
1545 size: Some("medium".into()),
1546 remember_position: Some(false),
1548 ..Default::default()
1549 });
1550 let handle = match opened {
1551 Ok(_) => handle,
1552 Err(e) if notify::Notifier::AVAILABLE => {
1554 log::warn!("widgets: activity window failed ({e}); using a notification");
1555 self.notify_activity(&content)?;
1556 notified
1557 }
1558 Err(e) => return Err(e),
1559 };
1560 self.activities
1561 .lock()
1562 .unwrap()
1563 .insert(id, group.to_string());
1564 Ok(handle)
1565 }
1566
1567 pub fn update_activity(
1568 &self,
1569 content: crate::models::ActivityContent,
1570 group: &str,
1571 ) -> crate::Result<bool> {
1572 if !self.activities.lock().unwrap().contains_key(&content.id) {
1573 return Err(Error::new(format!(
1574 "activity \"{}\" is not running",
1575 content.id
1576 )));
1577 }
1578 if let Some(ts) = content.timestamp {
1580 let mut last = self.activity_ts.lock().unwrap();
1581 if last.get(&content.id).is_some_and(|prev| ts < *prev) {
1582 return Ok(false);
1583 }
1584 last.insert(content.id.clone(), ts);
1585 }
1586 self.write_activity(&content, group)?;
1588 if self.notifier.has(&content.id) {
1589 self.notify_activity(&content)?;
1590 }
1591 let on_island = self.island.lock().unwrap().contains(&content.id);
1592 if on_island {
1593 self.island.lock().unwrap().upsert(content, group);
1594 self.island_refresh()?;
1595 }
1596 Ok(true)
1597 }
1598
1599 fn notify_activity(&self, content: &crate::models::ActivityContent) -> crate::Result<()> {
1600 let config = self.app.config();
1601 let app_name = if cfg!(target_os = "windows") {
1602 config.identifier.as_str()
1604 } else {
1605 config.product_name.as_deref().unwrap_or(&config.identifier)
1606 };
1607 self.notifier.show(app_name, content)
1608 }
1609
1610 pub fn end_activity(&self, id: &str, dismiss_after_ms: Option<u64>) -> crate::Result<bool> {
1611 if self.activities.lock().unwrap().remove(id).is_none() {
1612 return Ok(false);
1613 }
1614 self.activity_ts.lock().unwrap().remove(id);
1615 let label = activity_window_label(id);
1616 let on_island = self.island.lock().unwrap().contains(id);
1617 match dismiss_after_ms.filter(|ms| *ms > 0) {
1618 None if on_island => {
1619 self.island.lock().unwrap().remove(id);
1620 self.island_refresh()?;
1621 Ok(true)
1622 }
1623 None => {
1624 self.notifier.close(id);
1625 self.close_widget_window(&label)
1626 }
1627 Some(ms) => {
1628 let app = self.app.clone();
1629 let id = id.to_string();
1630 std::thread::spawn(move || {
1631 std::thread::sleep(std::time::Duration::from_millis(ms));
1632 if app
1634 .try_state::<Widget<R>>()
1635 .is_some_and(|w| w.activities.lock().unwrap().contains_key(&id))
1636 {
1637 return;
1638 }
1639 if let Some(w) = app.try_state::<Widget<R>>() {
1640 w.notifier.close(&id);
1641 if on_island {
1642 w.island.lock().unwrap().remove(&id);
1643 if let Err(e) = w.island_refresh() {
1644 log::warn!("widgets: activity island: {e}");
1645 }
1646 return;
1647 }
1648 }
1649 let handle = app.clone();
1650 let _ = app.run_on_main_thread(move || {
1651 if let Some(w) = handle.get_webview_window(&label) {
1652 let _ = w.close();
1653 }
1654 });
1655 });
1656 Ok(true)
1657 }
1658 }
1659 }
1660
1661 pub fn can_promote_activities(&self) -> crate::Result<bool> {
1663 Ok(true)
1664 }
1665
1666 pub fn open_activity_promotion_settings(&self) -> crate::Result<bool> {
1667 Ok(false)
1668 }
1669
1670 pub fn activity_push_to_start_token(&self) -> crate::Result<Option<String>> {
1672 Ok(None)
1673 }
1674
1675 pub fn list_activities(&self) -> crate::Result<Vec<String>> {
1676 let mut ids: Vec<String> = self.activities.lock().unwrap().keys().cloned().collect();
1677 ids.sort();
1678 Ok(ids)
1679 }
1680
1681 fn write_activity(
1682 &self,
1683 content: &crate::models::ActivityContent,
1684 group: &str,
1685 ) -> crate::Result<()> {
1686 let layout = content.effective_layout();
1687 let cfg = WidgetConfig {
1688 small: Some(layout.clone()),
1689 medium: Some(layout),
1690 ..Default::default()
1691 };
1692 self.set_widget_config(
1693 &cfg,
1694 group,
1695 &crate::models::activity_widget_id(&content.id),
1696 true,
1697 )?;
1698 Ok(())
1699 }
1700
1701 fn on_main<T: Send + 'static>(
1703 &self,
1704 f: impl FnOnce(&AppHandle<R>) -> T + Send + 'static,
1705 ) -> Option<T> {
1706 #[cfg(all(target_os = "linux", feature = "linux"))]
1707 if gtk::glib::MainContext::default().is_owner() {
1708 return Some(f(&self.app));
1709 }
1710 if on_main_thread() {
1711 return Some(f(&self.app));
1712 }
1713 let (tx, rx) = std::sync::mpsc::sync_channel(1);
1714 let app = self.app.clone();
1715 self.app
1716 .run_on_main_thread(move || {
1717 let _ = tx.send(f(&app));
1718 })
1719 .ok()?;
1720 rx.recv().ok()
1721 }
1722
1723 fn island_refresh(&self) -> crate::Result<()> {
1727 let screen = self
1728 .on_main(island::screen)
1729 .flatten()
1730 .unwrap_or(island::Screen::FALLBACK);
1731 let (layout, view, group) = {
1732 let isl = self.island.lock().unwrap();
1733 if isl.is_empty() {
1734 drop(isl);
1735 return self.close_widget_window(island::LABEL).map(|_| ());
1736 }
1737 (isl.layout(&screen), isl.view(&screen), isl.front_group())
1738 };
1739 let start_poller = !std::mem::replace(&mut self.island.lock().unwrap().polling, true);
1742 if start_poller {
1743 let app = self.app.clone();
1744 std::thread::spawn(move || Self::island_hover_poller(app));
1745 }
1746 if let Some(win) = self.app.get_webview_window(island::LABEL) {
1747 island::place(&win, layout.window);
1748 let _ = self.app.emit("activity-island", &view);
1750 return Ok(());
1751 }
1752 let r = layout.window;
1753 self.create_widget_window(WidgetWindowConfig {
1754 label: island::LABEL.into(),
1755 url: None,
1756 width: r.w,
1757 height: r.h,
1758 x: Some(r.x),
1759 y: Some(r.y),
1760 always_on_top: true,
1761 skip_taskbar: true,
1762 focusable: Some(false),
1763 all_workspaces: Some(true),
1764 shadow: Some(false),
1765 group,
1766 widget_id: Some(island::WIDGET_ID.into()),
1767 size: Some("medium".into()),
1768 remember_position: Some(false),
1769 ..Default::default()
1770 })
1771 .map(|_| ())
1772 }
1773
1774 fn island_hover_poller(app: AppHandle<R>) {
1778 let mut outside = 0;
1779 loop {
1780 std::thread::sleep(std::time::Duration::from_millis(80));
1781 let Some(w) = app.try_state::<Widget<R>>() else {
1782 return;
1783 };
1784 let expanded = {
1785 let mut isl = w.island.lock().unwrap();
1786 if isl.is_empty() {
1787 isl.polling = false;
1788 return;
1789 }
1790 isl.expanded
1791 };
1792 let Some(win) = app.get_webview_window(island::LABEL) else {
1793 continue;
1794 };
1795 let (Ok(p), Ok(pos), Ok(size)) = (
1797 app.cursor_position(),
1798 win.outer_position(),
1799 win.outer_size(),
1800 ) else {
1801 continue;
1802 };
1803 let inside = p.x >= pos.x as f64
1804 && p.y >= pos.y as f64
1805 && p.x < pos.x as f64 + size.width as f64
1806 && p.y < pos.y as f64 + size.height as f64;
1807 let r = match island::hover_step(inside, expanded, &mut outside) {
1808 island::Hover::Nothing => Ok(None),
1809 island::Hover::Expand => w.activity_island(Some(true), None),
1810 island::Hover::AskCollapse => {
1811 let screen = w
1812 .on_main(island::screen)
1813 .flatten()
1814 .unwrap_or(island::Screen::FALLBACK);
1815 let mut view = w.island.lock().unwrap().view(&screen);
1816 view.expanded = false;
1817 let _ = app.emit("activity-island", &view);
1818 Ok(None)
1819 }
1820 island::Hover::Collapse => w.activity_island(Some(false), None),
1821 };
1822 if let Err(e) = r {
1823 log::debug!("widgets: island hover: {e}");
1824 }
1825 }
1826 }
1827
1828 pub fn activity_island(
1831 &self,
1832 expanded: Option<bool>,
1833 focus: Option<&str>,
1834 ) -> crate::Result<Option<serde_json::Value>> {
1835 let changed = {
1836 let mut isl = self.island.lock().unwrap();
1837 if isl.is_empty() {
1838 return Ok(None);
1839 }
1840 let mut changed = focus.is_some_and(|id| isl.focus(id));
1841 if let Some(e) = expanded.filter(|e| *e != isl.expanded) {
1842 isl.expanded = e;
1843 changed = true;
1844 }
1845 changed
1846 };
1847 if changed {
1848 self.island_refresh()?;
1849 }
1850 let screen = self
1851 .on_main(island::screen)
1852 .flatten()
1853 .unwrap_or(island::Screen::FALLBACK);
1854 let view = self.island.lock().unwrap().view(&screen);
1855 Ok(Some(serde_json::to_value(view)?))
1856 }
1857
1858 fn activity_origin(&self) -> (Option<f64>, Option<f64>) {
1860 let Some(m) = island::primary_monitor(&self.app) else {
1861 return (None, None);
1862 };
1863 let scale = m.scale_factor();
1866 let area = m.work_area();
1867 let n = self.activities.lock().unwrap().len();
1868 let (x, y) = activity_slot(
1869 area.position.x as f64 / scale,
1870 area.position.y as f64 / scale,
1871 area.size.width as f64 / scale,
1872 n,
1873 );
1874 (Some(x), Some(y))
1875 }
1876
1877 pub fn get_widget_trace(
1878 &self,
1879 group: &str,
1880 since_ms: Option<u64>,
1881 ) -> crate::Result<WidgetTrace> {
1882 self.maybe_flush_trace();
1883 Ok(WidgetTrace {
1884 enabled: crate::trace::trace_enabled(),
1885 events: self.trace.list_since(since_ms),
1886 receipts: self.receipts.history(group),
1887 })
1888 }
1889
1890 pub fn flush_widget_trace(&self) -> crate::Result<bool> {
1891 if let Ok(dir) = self.app.path().app_data_dir() {
1892 self.trace.flush_to_path(&trace_path(&dir))?;
1893 }
1894 Ok(true)
1895 }
1896
1897 fn maybe_flush_trace(&self) {
1898 if !self.trace.needs_timed_flush() {
1899 return;
1900 }
1901 if let Ok(dir) = self.app.path().app_data_dir() {
1902 let _ = self.trace.flush_to_path(&trace_path(&dir));
1903 }
1904 }
1905
1906 #[cfg(target_os = "macos")]
1907 fn ensure_action_poller(&self) {
1908 let mut started = self.poller_started.lock().unwrap();
1909 if *started {
1910 return;
1911 }
1912 *started = true;
1913
1914 let groups_handle = self.app.clone();
1915 std::thread::spawn(move || loop {
1916 std::thread::sleep(std::time::Duration::from_millis(100));
1919 let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
1920 continue;
1921 };
1922 widget.inner().maybe_flush_trace();
1923 if !widget.inner().listener_gate.is_open(store::now_ms()) {
1924 continue;
1925 }
1926 let groups = widget.inner().known_groups.lock().unwrap().clone();
1927 for group in groups {
1928 match widget.inner().poll_pending_actions(&group) {
1929 Ok(actions) if !actions.is_empty() => {
1930 widget.inner().trace.push(TraceEvent::Poll {
1931 count: actions.len(),
1932 });
1933 for action in actions {
1934 match store::state_event(&action) {
1936 Some(ev) => {
1937 let _ = groups_handle.emit("widget-state-changed", ev);
1938 }
1939 None => {
1940 let _ = groups_handle.emit("widget-action", action);
1941 }
1942 }
1943 }
1944 }
1945 _ => {}
1946 }
1947 }
1948 });
1949 }
1950}
1951
1952#[cfg(target_os = "macos")]
1958#[derive(Default)]
1959struct PersistQueue {
1960 jobs: Mutex<HashMap<String, PersistJob>>,
1961 inflight: Mutex<HashMap<String, Vec<(String, String)>>>,
1963 changed: std::sync::Condvar,
1964 started: Mutex<bool>,
1965}
1966
1967#[cfg(target_os = "macos")]
1968#[derive(Default)]
1969struct PersistJob {
1970 keys: Vec<(String, String)>,
1971 reload: bool,
1972}
1973
1974#[cfg(target_os = "macos")]
1975impl PersistQueue {
1976 fn push(&self, group: &str, kv: Option<(&str, &str)>, reload: bool) {
1977 let mut jobs = self.jobs.lock().unwrap();
1978 let job = jobs.entry(group.to_string()).or_default();
1979 if let Some((k, v)) = kv {
1980 job.keys.retain(|(old, _)| old != k);
1981 job.keys.push((k.to_string(), v.to_string()));
1982 }
1983 job.reload |= reload;
1984 self.changed.notify_all();
1985 }
1986
1987 fn next(&self) -> (String, PersistJob) {
1989 let mut jobs = self.jobs.lock().unwrap();
1990 loop {
1991 if let Some(group) = jobs.keys().next().cloned() {
1992 let job = jobs.remove(&group).unwrap_or_default();
1993 self.inflight
1994 .lock()
1995 .unwrap()
1996 .insert(group.clone(), job.keys.clone());
1997 return (group, job);
1998 }
1999 jobs = self.changed.wait(jobs).unwrap();
2000 }
2001 }
2002
2003 fn done(&self, group: &str) {
2004 let _jobs = self.jobs.lock().unwrap();
2005 self.inflight.lock().unwrap().remove(group);
2006 self.changed.notify_all();
2007 }
2008
2009 fn unwritten(&self, group: &str) -> Vec<(String, String)> {
2011 let jobs = self.jobs.lock().unwrap();
2012 let mut out = self
2013 .inflight
2014 .lock()
2015 .unwrap()
2016 .get(group)
2017 .cloned()
2018 .unwrap_or_default();
2019 if let Some(job) = jobs.get(group) {
2020 out.extend(job.keys.iter().cloned());
2021 }
2022 out
2023 }
2024
2025 fn in_flight(&self, group: &str) -> Vec<(String, String)> {
2027 self.inflight
2028 .lock()
2029 .unwrap()
2030 .get(group)
2031 .cloned()
2032 .unwrap_or_default()
2033 }
2034
2035 fn forget(&self, group: &str, key: &str) {
2037 let mut jobs = self.jobs.lock().unwrap();
2038 if let Some(job) = jobs.get_mut(group) {
2039 job.keys.retain(|(k, _)| k != key);
2040 }
2041 if let Some(keys) = self.inflight.lock().unwrap().get_mut(group) {
2042 keys.retain(|(k, _)| k != key);
2043 }
2044 }
2045
2046 fn overlay(&self, group: &str, map: &mut DataMap) {
2047 for (k, v) in self.unwritten(group) {
2048 map.insert(k, v);
2049 }
2050 }
2051
2052 fn wait_idle(&self) {
2053 let mut jobs = self.jobs.lock().unwrap();
2054 while !jobs.is_empty() || !self.inflight.lock().unwrap().is_empty() {
2055 jobs = self.changed.wait(jobs).unwrap();
2056 }
2057 }
2058}
2059
2060#[cfg(target_os = "macos")]
2061extern "C" {
2062 fn macos_widget_reload_all() -> bool;
2063 fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
2064}
2065
2066#[cfg(target_os = "macos")]
2067fn macos_shared_container(group: &str) -> Option<PathBuf> {
2068 crate::macos_transport::shared_container_dir(group)
2069}
2070
2071fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
2072 let nanos = std::time::SystemTime::now()
2073 .duration_since(std::time::UNIX_EPOCH)
2074 .map(|d| d.as_nanos())
2075 .unwrap_or(0);
2076 let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
2077 fs::write(&tmp, data)?;
2078 match fs::rename(&tmp, path) {
2079 Ok(()) => Ok(()),
2080 Err(e) => {
2081 let _ = fs::remove_file(&tmp);
2082 Err(e)
2083 }
2084 }
2085}
2086
2087#[cfg(target_os = "windows")]
2091fn persist_windows_shared_map(
2092 path: &PathBuf,
2093 map: &DataMap,
2094 drained: &[crate::WidgetActionEnvelope],
2095) -> crate::Result<()> {
2096 use std::fs::OpenOptions;
2097 use std::os::windows::fs::OpenOptionsExt;
2098 use std::thread;
2099 use std::time::Duration;
2100
2101 if let Some(parent) = path.parent() {
2102 fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
2103 }
2104
2105 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
2107 let _lock = {
2108 let mut last_err = None;
2109 let mut held = None;
2110 for _ in 0..100 {
2111 let mut opts = OpenOptions::new();
2112 opts.read(true).write(true).create(true).share_mode(0); match opts.open(&lock_path) {
2114 Ok(f) => {
2115 held = Some(f);
2116 break;
2117 }
2118 Err(e) => {
2119 if e.raw_os_error() == Some(32) {
2121 last_err = Some(e);
2122 thread::sleep(Duration::from_millis(20));
2123 continue;
2124 }
2125 return Err(Error::Io(e.to_string()));
2126 }
2127 }
2128 }
2129 held.ok_or_else(|| {
2130 Error::Io(
2131 last_err
2132 .map(|e| e.to_string())
2133 .unwrap_or_else(|| "widget_data.json.lock busy".into()),
2134 )
2135 })?
2136 };
2137
2138 let mut merged: DataMap = if path.exists() {
2139 fs::read_to_string(path)
2140 .ok()
2141 .and_then(|s| serde_json::from_str(&s).ok())
2142 .unwrap_or_default()
2143 } else {
2144 DataMap::new()
2145 };
2146
2147 for (k, v) in map {
2148 if k == PENDING_ACTIONS_KEY {
2149 let rest = store::pending_minus_drained(merged.get(k).map(String::as_str), drained)?;
2151 merged.insert(k.clone(), rest);
2152 continue;
2153 }
2154 merged.insert(k.clone(), v.clone());
2155 }
2156
2157 if let Some(n) = map.get(store::META_NONCE_KEY) {
2159 merged.insert(store::META_NONCE_KEY.into(), n.clone());
2160 }
2161 if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
2162 merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
2163 }
2164
2165 let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
2166 atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
2167 Ok(())
2168}
2169
2170#[cfg(test)]
2171mod builtin_url_tests {
2172 use super::*;
2173 use proptest::prelude::*;
2174
2175 fn decode_label(label: &str) -> Option<String> {
2177 let mut out = String::new();
2178 let mut it = label.strip_prefix("activity-")?.chars();
2179 while let Some(c) = it.next() {
2180 match c {
2181 '_' => match it.next()? {
2182 '_' => out.push('_'),
2183 'd' => out.push('.'),
2184 _ => return None,
2185 },
2186 c => out.push(c),
2187 }
2188 }
2189 Some(out)
2190 }
2191
2192 proptest! {
2193 #[test]
2198 fn query_encoding_and_activity_labels_are_reversible(
2199 v in "[a-zA-Z0-9._~&=/?# %-]{0,16}",
2200 id in "[a-z._]{0,10}",
2201 ) {
2202 let enc = encode_query_component(&v);
2203 prop_assert!(!enc.contains(['&', '=', '?', '#', ' ', '/']), "{}", enc);
2204 let decoded: Vec<u8> = {
2205 let b = enc.as_bytes();
2206 let mut out = Vec::new();
2207 let mut i = 0;
2208 while i < b.len() {
2209 if b[i] == b'%' {
2210 out.push(u8::from_str_radix(&enc[i + 1..i + 3], 16).unwrap());
2211 i += 3;
2212 } else {
2213 out.push(b[i]);
2214 i += 1;
2215 }
2216 }
2217 out
2218 };
2219 prop_assert_eq!(String::from_utf8(decoded).unwrap(), v.clone());
2220 let WebviewUrl::External(url) = builtin_widget_url(&v, "small", "w", "1") else {
2221 panic!("external url")
2222 };
2223 let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
2224 prop_assert_eq!(pairs.len(), 4);
2225 prop_assert_eq!(&pairs[0], &("group".to_string(), v.clone()));
2226 prop_assert_eq!(&pairs[2], &("widgetId".to_string(), "w".to_string()));
2227
2228 prop_assert_eq!(decode_label(&activity_window_label(&id)), Some(id));
2229 }
2230 }
2231
2232 #[test]
2234 fn encoding_label_and_slot_examples() {
2235 assert_eq!(
2236 encode_query_component("small&widgetId=hijack"),
2237 "small%26widgetId%3Dhijack"
2238 );
2239 assert_eq!(encode_query_component("a=b&c"), "a%3Db%26c");
2240 assert_eq!(
2241 encode_query_component("group.com.example.app"),
2242 "group.com.example.app"
2243 );
2244 assert_eq!(encode_query_component("weather_1"), "weather_1");
2245 assert_eq!(activity_window_label("a.b"), "activity-a_db");
2246 assert_eq!(activity_window_label("a_b"), "activity-a__b");
2247 assert_ne!(activity_window_label("a_d"), activity_window_label("a.d"));
2248 assert_ne!(activity_window_label("x_.y"), activity_window_label("x._y"));
2249 let (w, h) = ACTIVITY_WINDOW_SIZE;
2250 assert_eq!(
2252 activity_slot(1920.0, 25.0, 1440.0, 1),
2253 (1920.0 + 1440.0 - w - 16.0, 25.0 + 16.0 + h + 8.0)
2254 );
2255 }
2256}