1use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19pub const CONFIG_KEY_PREFIX: &str = "config:";
21pub const PENDING_ACTIONS_KEY: &str = "pending_actions";
23pub const META_NONCE_KEY: &str = "__meta_nonce__";
25pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__";
27
28pub type DataMap = HashMap<String, String>;
30
31pub fn config_key(widget_id: &str) -> String {
33 format!("{CONFIG_KEY_PREFIX}{widget_id}")
34}
35
36pub const INSTANCE_SEPARATOR: char = '#';
39
40pub fn scoped_id(widget_id: &str, instance: Option<&str>) -> String {
42 match instance.filter(|i| !i.is_empty()) {
43 Some(i) => format!("{widget_id}{INSTANCE_SEPARATOR}{i}"),
44 None => widget_id.to_string(),
45 }
46}
47
48pub const DATA_KEY_PREFIX: &str = "data:";
50pub const FETCHED_KEY_PREFIX: &str = "fetched:";
52pub const INSTANCES_KEY_PREFIX: &str = "instances:";
54
55pub fn data_key(widget_id: &str, instance: Option<&str>) -> String {
57 format!("{DATA_KEY_PREFIX}{}", scoped_id(widget_id, instance))
58}
59
60pub const STATE_KEY_PREFIX: &str = "state:";
62pub const ACTIVITY_TEMPLATE_KEY_PREFIX: &str = "activity-template:";
64
65pub const STATE_ACTION: &str = "$state";
68
69pub fn state_event(env: &WidgetActionEnvelope) -> Option<serde_json::Value> {
72 use serde_json::Value;
73 if env.action != STATE_ACTION {
74 return None;
75 }
76 let p: Value = env
77 .payload
78 .as_deref()
79 .and_then(|p| serde_json::from_str(p).ok())
80 .unwrap_or_default();
81 Some(serde_json::json!({
82 "group": env.group,
83 "widgetId": p.get("widgetId").cloned().unwrap_or(Value::String(env.widget_id.clone())),
84 "instance": p.get("instance").cloned().unwrap_or(Value::Null),
85 "state": p.get("state").cloned().unwrap_or(Value::Null),
86 }))
87}
88
89pub fn state_key(widget_id: &str, instance: Option<&str>) -> String {
91 format!("{STATE_KEY_PREFIX}{}", scoped_id(widget_id, instance))
92}
93
94pub fn activity_template_key(name: &str) -> String {
96 format!("{ACTIVITY_TEMPLATE_KEY_PREFIX}{name}")
97}
98
99pub fn merged_state(
102 layers: &[Option<&serde_json::Value>],
103) -> serde_json::Map<String, serde_json::Value> {
104 let mut out = serde_json::Map::new();
105 for layer in layers.iter().flatten() {
106 if let Some(m) = layer.as_object() {
107 out.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
108 }
109 }
110 out
111}
112
113pub fn apply_state_patch(
118 layer: &mut serde_json::Map<String, serde_json::Value>,
119 current: &serde_json::Map<String, serde_json::Value>,
120 patch: &serde_json::Map<String, serde_json::Value>,
121) {
122 use serde_json::Value;
123 for (k, v) in patch {
124 let now = current.get(k);
125 let step = |d: i64| -> Value {
126 match now {
127 Some(Value::Number(n)) => n
129 .as_i64()
130 .and_then(|i| i.checked_add(d))
131 .map(Value::from)
132 .unwrap_or_else(|| Value::from(n.as_f64().unwrap_or(0.0) + d as f64)),
133 _ => Value::from(d),
134 }
135 };
136 let next = match v {
137 Value::Null => {
138 layer.remove(k);
139 continue;
140 }
141 Value::String(op) if op == "$toggle" => {
142 Value::Bool(!matches!(now, Some(Value::Bool(true))))
143 }
144 Value::String(op) if op == "$inc" => step(1),
145 Value::String(op) if op == "$dec" => step(-1),
146 other => other.clone(),
147 };
148 layer.insert(k.clone(), next);
149 }
150}
151
152pub fn instances_key(widget_id: &str) -> String {
154 format!("{INSTANCES_KEY_PREFIX}{widget_id}")
155}
156
157pub fn lookup_keys(prefix: &str, widget_id: &str, instance: Option<&str>) -> Vec<String> {
160 let mut keys = Vec::with_capacity(2);
161 if instance.is_some_and(|i| !i.is_empty()) {
162 keys.push(format!("{prefix}{}", scoped_id(widget_id, instance)));
163 }
164 keys.push(format!("{prefix}{widget_id}"));
165 keys
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "camelCase")]
172pub struct WidgetInstanceOption {
173 pub id: String,
175 pub title: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ActiveWidget {
183 pub widget_id: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub instance: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub size: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub last_seen_ms: Option<u64>,
195}
196
197pub fn merge_active(live: Vec<ActiveWidget>, seen: Vec<ActiveWidget>) -> Vec<ActiveWidget> {
200 let mut out = live;
201 for s in seen {
202 match out
203 .iter_mut()
204 .find(|a| a.widget_id == s.widget_id && a.instance == s.instance)
205 {
206 Some(a) => {
207 a.last_seen_ms = a.last_seen_ms.or(s.last_seen_ms);
208 a.size = a.size.take().or(s.size);
209 }
210 None => out.push(s),
211 }
212 }
213 out.sort_by(|a, b| (&a.widget_id, &a.instance).cmp(&(&b.widget_id, &b.instance)));
214 out
215}
216
217pub fn merge_native_active(
224 live: Vec<ActiveWidget>,
225 seen: Vec<ActiveWidget>,
226 exact: bool,
227) -> Vec<ActiveWidget> {
228 if exact {
229 let mut merged = merge_active(live.clone(), seen);
230 merged.retain(|a| {
231 live.iter()
232 .any(|l| l.widget_id == a.widget_id && l.instance == a.instance)
233 });
234 return merged;
235 }
236 let mut out: Vec<ActiveWidget> = Vec::new();
237 for l in &live {
238 let instances: Vec<&ActiveWidget> = seen
239 .iter()
240 .filter(|s| s.widget_id == l.widget_id && s.instance.is_some())
241 .collect();
242 if instances.is_empty() {
243 let receipt = seen.iter().find(|s| s.widget_id == l.widget_id);
244 out.push(ActiveWidget {
245 last_seen_ms: receipt.and_then(|r| r.last_seen_ms),
246 ..l.clone()
247 });
248 } else {
249 for s in instances {
250 if !out.contains(s) {
251 out.push(s.clone());
252 }
253 }
254 }
255 }
256 out.sort_by(|a, b| {
257 (&a.widget_id, &a.instance, &a.size).cmp(&(&b.widget_id, &b.instance, &b.size))
258 });
259 out
260}
261
262pub const BOARD_WIDGETS_KEY: &str = "board_widgets";
264
265pub fn parse_board_widgets(raw: &str) -> Vec<ActiveWidget> {
267 #[derive(Deserialize)]
268 #[serde(rename_all = "camelCase")]
269 struct Board {
270 widget_id: String,
271 instance: String,
272 }
273 serde_json::from_str::<Vec<Board>>(raw)
274 .unwrap_or_default()
275 .into_iter()
276 .map(|b| ActiveWidget {
277 widget_id: b.widget_id,
278 instance: Some(b.instance),
279 size: Some("medium".into()),
280 last_seen_ms: None,
281 })
282 .collect()
283}
284
285pub const ACTIVE_RECEIPT_MAX_AGE_MS: u64 = 48 * 60 * 60 * 1000;
287
288pub fn active_from_receipts(
291 receipts: &[crate::receipt::WidgetRenderReceipt],
292 now_ms: u64,
293 max_age_ms: u64,
294) -> Vec<ActiveWidget> {
295 let mut best: HashMap<(String, String), &crate::receipt::WidgetRenderReceipt> = HashMap::new();
296 for r in receipts {
297 if now_ms.saturating_sub(r.ts) > max_age_ms {
298 continue;
299 }
300 let key = (r.widget_id.clone(), r.instance.clone());
301 match best.get(&key) {
302 Some(prev) if prev.ts >= r.ts => {}
303 _ => {
304 best.insert(key, r);
305 }
306 }
307 }
308 let mut out: Vec<ActiveWidget> = best
309 .into_values()
310 .map(|r| ActiveWidget {
311 widget_id: r.widget_id.clone(),
312 instance: Some(r.instance.clone()).filter(|i| !i.is_empty()),
313 size: r.size.clone(),
314 last_seen_ms: Some(r.ts),
315 })
316 .collect();
317 out.sort_by(|a, b| (&a.widget_id, &a.instance).cmp(&(&b.widget_id, &b.instance)));
318 out
319}
320
321pub fn now_ms() -> u64 {
323 SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .map(|d| d.as_millis() as u64)
326 .unwrap_or(0)
327}
328
329pub fn map_nonce(map: &DataMap) -> u64 {
331 map.get(META_NONCE_KEY)
332 .and_then(|s| s.parse().ok())
333 .unwrap_or(0)
334}
335
336pub fn map_updated_at(map: &DataMap) -> u64 {
338 map.get(META_UPDATED_AT_KEY)
339 .and_then(|s| s.parse().ok())
340 .unwrap_or(0)
341}
342
343pub fn touch_meta(map: &mut DataMap) {
345 let next = map_nonce(map).saturating_add(1);
346 map.insert(META_NONCE_KEY.into(), next.to_string());
347 map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
348}
349
350pub fn touch_meta_above(map: &mut DataMap, floor: u64) {
356 let next = map_nonce(map)
357 .saturating_add(1)
358 .max(floor.saturating_add(1));
359 map.insert(META_NONCE_KEY.into(), next.to_string());
360 map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
361}
362
363pub fn map_has_config(map: &DataMap) -> bool {
365 map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX))
366}
367
368pub fn pick_freshest(maps: impl IntoIterator<Item = DataMap>) -> DataMap {
374 let mut best: Option<DataMap> = None;
375 let mut best_has_config = false;
376 let mut best_nonce = 0u64;
377 let mut best_ts = 0u64;
378 for map in maps {
379 let has_config = map_has_config(&map);
380 let n = map_nonce(&map);
381 let t = map_updated_at(&map);
382 let better = match &best {
383 None => true,
384 Some(_) if has_config && !best_has_config => true,
385 Some(_) if !has_config && best_has_config => false,
386 Some(_) => n > best_nonce || (n == best_nonce && t > best_ts),
387 };
388 if better {
389 best_has_config = has_config;
390 best_nonce = n;
391 best_ts = t;
392 best = Some(map);
393 }
394 }
395 best.unwrap_or_default()
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct WidgetActionEnvelope {
402 pub action: String,
404 #[serde(default)]
406 pub payload: Option<String>,
407 pub ts: u64,
409 pub widget_id: String,
411 pub group: String,
413}
414
415impl WidgetActionEnvelope {
416 pub fn new(
418 action: impl Into<String>,
419 payload: Option<String>,
420 widget_id: impl Into<String>,
421 group: impl Into<String>,
422 ) -> Self {
423 Self {
424 action: action.into(),
425 payload,
426 ts: now_ms(),
427 widget_id: widget_id.into(),
428 group: group.into(),
429 }
430 }
431}
432
433#[derive(Debug)]
440pub struct ListenerGate {
441 ready: std::sync::atomic::AtomicBool,
442 started_ms: u64,
443 fallback_ms: u64,
444}
445
446impl ListenerGate {
447 pub const DEFAULT_FALLBACK_MS: u64 = 10_000;
449
450 pub fn new(now_ms: u64, fallback_ms: u64) -> Self {
452 Self {
453 ready: std::sync::atomic::AtomicBool::new(false),
454 started_ms: now_ms,
455 fallback_ms,
456 }
457 }
458
459 pub fn open(&self) {
461 self.ready.store(true, std::sync::atomic::Ordering::SeqCst);
462 }
463
464 pub fn remaining_ms(&self, now_ms: u64) -> u64 {
466 if self.is_open(now_ms) {
467 0
468 } else {
469 (self.started_ms + self.fallback_ms).saturating_sub(now_ms)
470 }
471 }
472
473 pub fn is_open(&self, now_ms: u64) -> bool {
475 self.ready.load(std::sync::atomic::Ordering::SeqCst)
476 || now_ms.saturating_sub(self.started_ms) >= self.fallback_ms
477 }
478}
479
480pub fn parse_pending_actions(raw: Option<&str>) -> Vec<WidgetActionEnvelope> {
482 let Some(s) = raw.filter(|s| !s.is_empty()) else {
483 return Vec::new();
484 };
485 serde_json::from_str(s).unwrap_or_default()
486}
487
488pub fn pending_minus_drained(
492 disk_raw: Option<&str>,
493 drained: &[WidgetActionEnvelope],
494) -> crate::Result<String> {
495 let rest: Vec<_> = parse_pending_actions(disk_raw)
496 .into_iter()
497 .filter(|a| !drained.contains(a))
498 .collect();
499 encode_pending_actions(&rest)
500}
501
502pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
504 Ok(serde_json::to_string(actions)?)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn receipt(id: &str, inst: &str, ts: u64) -> crate::receipt::WidgetRenderReceipt {
512 crate::receipt::WidgetRenderReceipt {
513 widget_id: id.into(),
514 group: "g".into(),
515 instance: inst.into(),
516 nonce: 0,
517 size: Some("small".into()),
518 theme: None,
519 schema: 1,
520 source: "prefs".into(),
521 trigger: None,
522 rendered: vec![],
523 skipped: vec![],
524 ts,
525 }
526 }
527
528 fn aw(id: &str, inst: Option<&str>, size: Option<&str>, seen: Option<u64>) -> ActiveWidget {
529 ActiveWidget {
530 widget_id: id.into(),
531 instance: inst.map(Into::into),
532 size: size.map(Into::into),
533 last_seen_ms: seen,
534 }
535 }
536
537 #[test]
540 fn lookup_keys_scenario() {
541 assert_eq!(scoped_id("w", Some("")), "w");
542 assert_eq!(config_key("w"), "config:w");
543 assert_eq!(
544 lookup_keys(CONFIG_KEY_PREFIX, "w", Some("1")),
545 ["config:w#1", "config:w"]
546 );
547 assert_eq!(lookup_keys(CONFIG_KEY_PREFIX, "w", Some("")), ["config:w"]);
548 assert_eq!(lookup_keys(FETCHED_KEY_PREFIX, "w", None), ["fetched:w"]);
549 }
550
551 #[test]
554 fn placement_listing_scenario() {
555 let rs = [
556 receipt("w", "2", 900),
557 receipt("w", "1", 500),
558 receipt("w", "1", 800),
559 receipt("w", "1", 700),
560 receipt("old", "", 99),
561 receipt("a", "", 1000),
562 ];
563 let got = active_from_receipts(&rs, 1000, 900);
564 assert_eq!(
565 got,
566 [
567 ActiveWidget {
568 size: Some("small".into()),
569 ..aw("a", None, None, Some(1000))
570 },
571 ActiveWidget {
572 size: Some("small".into()),
573 ..aw("w", Some("1"), None, Some(800))
574 },
575 ActiveWidget {
576 size: Some("small".into()),
577 ..aw("w", Some("2"), None, Some(900))
578 },
579 ]
580 );
581 assert_eq!(
583 active_from_receipts(&[receipt("x", "", 100)], 1000, 900).len(),
584 1
585 );
586 let h = 60 * 60 * 1000;
587 let rs = [
588 receipt("fresh", "", 100 * h - 47 * h),
589 receipt("stale", "", 100 * h - 49 * h),
590 ];
591 let got = active_from_receipts(&rs, 100 * h, ACTIVE_RECEIPT_MAX_AGE_MS);
592 assert_eq!(
593 got.iter().map(|a| a.widget_id.as_str()).collect::<Vec<_>>(),
594 ["fresh"]
595 );
596
597 let got = merge_active(
599 vec![
600 aw("w", Some("win"), None, None),
601 aw("b", None, Some("large"), None),
602 ],
603 vec![
604 aw("w", Some("win"), Some("small"), Some(5)),
605 aw("a", None, None, Some(1)),
606 aw("b", None, Some("small"), Some(2)),
607 ],
608 );
609 assert_eq!(
610 got,
611 vec![
612 aw("a", None, None, Some(1)),
613 aw("b", None, Some("large"), Some(2)),
614 aw("w", Some("win"), Some("small"), Some(5)),
615 ]
616 );
617 let got = merge_native_active(
619 vec![aw("w", Some("7"), None, None)],
620 vec![
621 aw("w", Some("7"), Some("small"), Some(3)),
622 aw("w", Some("8"), None, Some(4)),
623 ],
624 true,
625 );
626 assert_eq!(got, vec![aw("w", Some("7"), Some("small"), Some(3))]);
627 let got = merge_native_active(
629 vec![
630 aw("w", None, Some("small"), None),
631 aw("w", None, Some("medium"), None),
632 aw("plain", None, Some("large"), None),
633 ],
634 vec![
635 aw("w", Some("nyc"), Some("small"), Some(5)),
636 aw("w", Some("ldn"), Some("medium"), Some(6)),
637 aw("plain", None, Some("large"), Some(9)),
638 aw("gone", Some("x"), None, Some(1)),
639 ],
640 false,
641 );
642 assert_eq!(
643 got,
644 vec![
645 aw("plain", None, Some("large"), Some(9)),
646 aw("w", Some("ldn"), Some("medium"), Some(6)),
647 aw("w", Some("nyc"), Some("small"), Some(5)),
648 ]
649 );
650 assert_eq!(
652 merge_native_active(vec![aw("n", None, None, None)], vec![], false),
653 vec![aw("n", None, None, None)]
654 );
655 assert_eq!(
657 parse_board_widgets(r#"[{"widgetId":"default","instance":"B1"}]"#),
658 vec![aw("default", Some("B1"), Some("medium"), None)]
659 );
660 assert!(parse_board_widgets("nope").is_empty());
661 }
662
663 #[test]
666 fn transport_freshness_scenario() {
667 let m = |tag: &str, config: bool, nonce: u64, ts: u64| {
668 let mut d = DataMap::new();
669 d.insert("tag".into(), tag.into());
670 if config {
671 d.insert("config:w".into(), "{}".into());
672 }
673 d.insert(META_NONCE_KEY.into(), nonce.to_string());
674 d.insert(META_UPDATED_AT_KEY.into(), ts.to_string());
675 d
676 };
677 let pick = |maps: Vec<DataMap>| pick_freshest(maps)["tag"].clone();
678
679 let before = now_ms();
681 assert!(before > 1_600_000_000_000, "now_ms is Unix ms");
682 let mut live = m("live", true, 7, 0);
683 touch_meta(&mut live);
684 assert_eq!(map_nonce(&live), 8);
685 assert!(map_updated_at(&live) >= before);
686 assert_eq!(map_updated_at(&m("x", false, 0, 123)), 123);
687 assert_eq!(
688 (map_nonce(&DataMap::new()), map_updated_at(&DataMap::new())),
689 (0, 0)
690 );
691 assert!(map_has_config(&live) && !map_has_config(&m("p", false, 0, 0)));
692
693 let probe = m("probe", false, 99, u64::MAX);
695 assert_eq!(pick(vec![probe.clone(), live.clone()]), "live");
696 assert_eq!(pick(vec![live.clone(), probe]), "live");
697 let stale = m("stale", true, 65, 0);
699 assert_eq!(pick(vec![live.clone(), stale.clone()]), "stale");
700 touch_meta_above(&mut live, 65);
701 assert_eq!(map_nonce(&live), 66);
702 assert_eq!(pick(vec![stale, live]), "live");
703
704 assert_eq!(pick(vec![m("a", false, 1, 9), m("b", false, 2, 0)]), "b");
706 assert_eq!(pick(vec![m("a", true, 1, 9), m("b", true, 2, 0)]), "b");
707 assert_eq!(pick(vec![m("a", false, 2, 0), m("b", false, 1, 9)]), "a");
708 assert_eq!(pick(vec![m("a", false, 2, 1), m("b", false, 2, 5)]), "b");
710 assert_eq!(pick(vec![m("a", false, 2, 5), m("b", false, 2, 1)]), "a");
711 assert_eq!(pick(vec![m("a", false, 2, 5), m("b", false, 2, 5)]), "a");
712 assert!(pick_freshest(Vec::new()).is_empty());
713 }
714
715 #[test]
718 fn action_queue_scenario() {
719 let g = ListenerGate::new(1_000, 500);
720 assert!(!g.is_open(1_000));
721 assert!(!g.is_open(1_499));
722 assert!(g.is_open(1_500));
723 assert!(!g.is_open(0));
724 assert_eq!(g.remaining_ms(1_200), 300);
725 assert_eq!(g.remaining_ms(1_500), 0);
726 g.open();
727 assert_eq!(g.remaining_ms(1_000), 0);
728 assert!(g.is_open(1_000));
729
730 let a = WidgetActionEnvelope::new("a", None, "w", "g");
731 let b = WidgetActionEnvelope::new("b", Some("1".into()), "w", "g");
732 let disk = encode_pending_actions(&[a.clone(), b.clone()]).unwrap();
733 let left = pending_minus_drained(Some(&disk), &[a]).unwrap();
734 assert_eq!(parse_pending_actions(Some(&left)), vec![b]);
735 assert_eq!(pending_minus_drained(None, &[]).unwrap(), "[]");
736 assert!(parse_pending_actions(Some("junk")).is_empty());
737 }
738}