Skip to main content

ai_usagebar/tray/
payload.rs

1//! Wrap `usage --json` for the popover and derive the tray icon severity.
2//!
3//! Pure JSON in, JSON out — no HWND, no `$HOME`, no clock. The host supplies
4//! `now_ms` so tests pin the countdown.
5
6use serde_json::{Value, json};
7
8use super::icon::Severity;
9use crate::display::sanitize_untrusted_field;
10
11/// Default poll interval when `[tray] refresh_minutes` is unset. The provider
12/// cache TTL stays 60 s; the tray just asks less often. The live value is
13/// `HostFacts::refresh_secs`.
14pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300);
15
16/// Everything the host knows that is not part of the usage report: its own
17/// version, the Run-key state, the registered shortcut and the update
18/// machinery. One struct so a new fact does not grow `wrap_report`'s arity.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HostFacts {
21    /// Seconds between full reports; `[tray] refresh_minutes` × 60.
22    pub refresh_secs: u64,
23    /// Canonical "Ctrl+Shift+U" spelling of the registered shortcut, or empty.
24    pub shortcut: String,
25    /// Why the last `set-shortcut` was refused (already taken, unparsable), or empty.
26    pub shortcut_error: String,
27    pub startup_enabled: bool,
28    /// Latest known release when it is newer than `version`.
29    pub update: Option<UpdateFact>,
30    /// Wall-clock ms of the last successful or failed release check, 0 = never.
31    pub update_checked_at: i64,
32    /// "auto" | "notify" | "off".
33    pub updates: String,
34    pub version: String,
35    /// Vendors whose active login the popover can switch. Only the macOS host
36    /// fills this; an empty list hides the control everywhere else.
37    pub accounts: Vec<AccountSwitchFact>,
38}
39
40/// One vendor's switchable logins, as the popover renders them beside each
41/// account's card.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct AccountSwitchFact {
44    /// Report entry slug: "anthropic" or "openai".
45    pub vendor: String,
46    /// Label the vendor's default login belongs to; `None` when it is not a
47    /// managed account.
48    pub active: Option<String>,
49    /// Labels that can be made active.
50    pub labels: Vec<String>,
51    /// Label of the last switch requested, running or finished; empty before
52    /// the first one.
53    pub target: String,
54    /// Whether that switch is still running.
55    pub switching: bool,
56    /// Why that switch failed, or empty.
57    pub error: String,
58}
59
60/// State of a newer release as the popover renders it.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct UpdateFact {
63    /// Human-readable reason when `state` is "failed", or empty.
64    pub error: String,
65    /// "checking" | "available" | "downloading" | "installing" | "failed".
66    pub state: String,
67    /// Release page for the human; never opened by the host itself.
68    pub url: String,
69    /// Bare "X.Y.Z".
70    pub version: String,
71}
72
73impl HostFacts {
74    pub fn new(version: &str, startup_enabled: bool) -> Self {
75        Self {
76            startup_enabled,
77            updates: "notify".into(),
78            version: version.into(),
79            ..Self::default()
80        }
81    }
82}
83
84impl Default for HostFacts {
85    fn default() -> Self {
86        Self {
87            refresh_secs: POLL_INTERVAL.as_secs(),
88            shortcut: String::new(),
89            shortcut_error: String::new(),
90            startup_enabled: false,
91            update: None,
92            update_checked_at: 0,
93            updates: String::new(),
94            version: String::new(),
95            accounts: Vec::new(),
96        }
97    }
98}
99
100/// GitHub page this binary was built from (`Cargo.toml` `repository`), or
101/// empty when that field is not a GitHub URL. The About screen opens it.
102fn repository_page() -> String {
103    let raw = crate::update::SOURCE_REPOSITORY
104        .trim()
105        .trim_end_matches('/');
106    let raw = raw.strip_suffix(".git").unwrap_or(raw);
107    if raw.starts_with("https://github.com/") {
108        raw.to_string()
109    } else {
110        String::new()
111    }
112}
113
114/// Map a manual release check onto the fact the popover already renders.
115/// `Ok(None)` is "up to date" and clears any previous fact.
116///
117/// macOS-only: the macOS host's manual check maps through here, while the
118/// Windows host builds its facts inline around its pending/snooze state.
119#[cfg(target_os = "macos")]
120pub fn fact_after_check(
121    outcome: Result<Option<crate::update::Release>, String>,
122) -> Option<UpdateFact> {
123    match outcome {
124        Ok(Some(release)) => Some(UpdateFact {
125            error: String::new(),
126            state: "available".into(),
127            url: release.html_url,
128            version: release.version,
129        }),
130        Ok(None) => None,
131        Err(error) => Some(UpdateFact {
132            error,
133            state: "failed".into(),
134            url: String::new(),
135            version: String::new(),
136        }),
137    }
138}
139
140fn host_os() -> &'static str {
141    if cfg!(target_os = "macos") {
142        "macos"
143    } else if cfg!(windows) {
144        "windows"
145    } else {
146        "linux"
147    }
148}
149
150/// Build the object the WebView's `apply` function consumes.
151pub fn wrap_report(
152    report_json: &str,
153    facts: &HostFacts,
154    now_ms: i64,
155    host_error: Option<&str>,
156) -> Value {
157    let poll_ms = i64::try_from(facts.refresh_secs)
158        .unwrap_or(i64::MAX / 1_000)
159        .saturating_mul(1_000);
160    let update = facts.update.as_ref().map(|u| {
161        json!({
162            "version": sanitize_untrusted_field(&u.version),
163            "url": sanitize_untrusted_field(&u.url),
164            "state": u.state,
165            "error": sanitize_untrusted_field(&u.error),
166        })
167    });
168    let accounts: serde_json::Map<String, Value> = facts
169        .accounts
170        .iter()
171        .map(|fact| {
172            (
173                fact.vendor.clone(),
174                json!({
175                    "active": fact.active.as_deref().map(sanitize_untrusted_field),
176                    "labels": fact
177                        .labels
178                        .iter()
179                        .map(|label| sanitize_untrusted_field(label))
180                        .collect::<Vec<_>>(),
181                    "target": sanitize_untrusted_field(&fact.target),
182                    "switching": fact.switching,
183                    "error": sanitize_untrusted_field(&fact.error),
184                }),
185            )
186        })
187        .collect();
188    let mut payload = json!({
189        "version": facts.version,
190        "generated_at": now_ms,
191        "next_refresh_at": now_ms.saturating_add(poll_ms),
192        "refresh_minutes": facts.refresh_secs / 60,
193        "startup_enabled": facts.startup_enabled,
194        "os": host_os(),
195        "shortcut": facts.shortcut,
196        "shortcut_error": sanitize_untrusted_field(&facts.shortcut_error),
197        "updates": facts.updates,
198        "update": update,
199        "update_checked_at": facts.update_checked_at,
200        "repository": repository_page(),
201        "accounts": accounts,
202        "host_error": host_error.map(sanitize_untrusted_field),
203        "primary": Value::Null,
204        "entries": [],
205    });
206    if host_error.is_some() {
207        return payload;
208    }
209    match serde_json::from_str::<Value>(report_json) {
210        Ok(Value::Object(map)) => {
211            if let Some(obj) = payload.as_object_mut() {
212                if let Some(primary) = map.get("primary") {
213                    obj.insert("primary".into(), primary.clone());
214                }
215                if let Some(entries) = map.get("entries") {
216                    obj.insert("entries".into(), with_sign_in_hints(entries));
217                }
218            }
219            payload
220        }
221        _ => {
222            payload["host_error"] = json!(sanitize_untrusted_field(
223                "The usage report did not contain valid JSON."
224            ));
225            payload
226        }
227    }
228}
229
230/// Attach each entry's sign-in sentence from [`VendorId::sign_in_hint`].
231///
232/// The popover needs a "how do I fix this?" line on an error card. It must not
233/// keep its own table for that: a JS object literal silently omits a provider
234/// nobody remembered, while the Rust match cannot compile without one. The
235/// entry id is `vendor` or `vendor@account`, so the slug is the part before
236/// `@`; an id that matches no vendor is left without a hint rather than guessed.
237fn with_sign_in_hints(entries: &Value) -> Value {
238    let Some(list) = entries.as_array() else {
239        return entries.clone();
240    };
241    Value::Array(
242        list.iter()
243            .map(|entry| {
244                let mut entry = entry.clone();
245                let slug = entry
246                    .get("id")
247                    .and_then(Value::as_str)
248                    .map(|id| id.split('@').next().unwrap_or(id).to_ascii_lowercase());
249                let hint = slug.and_then(|slug| {
250                    crate::vendor::VendorId::all()
251                        .iter()
252                        .find(|v| v.slug() == slug)
253                        .map(|v| v.sign_in_hint())
254                });
255                if let (Some(hint), Some(obj)) = (hint, entry.as_object_mut()) {
256                    obj.insert("sign_in".into(), json!(hint));
257                }
258                entry
259            })
260            .collect(),
261    )
262}
263
264pub fn host_payload(value: &Value) -> String {
265    value.to_string()
266}
267
268/// Worst severity across every metric, treating fetch/host errors as critical.
269pub fn worst_severity(payload: &Value) -> Severity {
270    if payload
271        .get("host_error")
272        .and_then(Value::as_str)
273        .is_some_and(|s| !s.is_empty())
274    {
275        return Severity::Critical;
276    }
277    let mut worst = Severity::Low;
278    let Some(entries) = payload.get("entries").and_then(Value::as_array) else {
279        return worst;
280    };
281    for entry in entries {
282        if entry.get("status").and_then(Value::as_str) == Some("error")
283            || entry
284                .get("error")
285                .and_then(Value::as_str)
286                .is_some_and(|s| !s.is_empty())
287        {
288            return Severity::Critical;
289        }
290        let Some(sections) = entry.get("sections").and_then(Value::as_array) else {
291            continue;
292        };
293        for section in sections {
294            if section.get("type").and_then(Value::as_str) != Some("metric") {
295                continue;
296            }
297            if let Some(sev) = section
298                .get("severity")
299                .and_then(Value::as_str)
300                .and_then(Severity::from_report_str)
301                && sev.rank() > worst.rank()
302            {
303                worst = sev;
304            }
305        }
306    }
307    worst
308}
309
310#[cfg(test)]
311mod tests {
312
313    /// The popover renders "how do I fix this?" on an error card. That sentence
314    /// must come from the host: the frontend kept its own table first, and it
315    /// disagreed with `VendorId` for five of eight providers before shipping.
316    #[test]
317    fn every_entry_carries_its_sign_in_hint_from_the_vendor() {
318        let report = r#"{"primary":"anthropic","entries":[
319            {"id":"anthropic","status":"error","error":"not signed in"},
320            {"id":"openai@work","status":"error","error":"not signed in"},
321            {"id":"cursor","status":"ready"},
322            {"id":"custom:mytool","status":"ready"}
323        ]}"#;
324        let payload = wrap_report(report, &facts("1.0.0", false), 0, None);
325        let entries = payload["entries"].as_array().expect("entries");
326
327        assert_eq!(
328            entries[0]["sign_in"],
329            json!(crate::vendor::VendorId::Anthropic.sign_in_hint())
330        );
331        // "vendor@account" resolves on the vendor half.
332        assert_eq!(
333            entries[1]["sign_in"],
334            json!(crate::vendor::VendorId::Openai.sign_in_hint())
335        );
336        // A vendor that has no CLI login still says how to sign in.
337        assert_eq!(
338            entries[2]["sign_in"],
339            json!(crate::vendor::VendorId::Cursor.sign_in_hint())
340        );
341        // An id that is not a built-in vendor gets no invented hint.
342        assert!(entries[3].get("sign_in").is_none(), "{:?}", entries[3]);
343    }
344
345    /// Every id `usage --json` can emit must resolve, or the popover shows a
346    /// generic line for a provider we do know how to sign in.
347    #[test]
348    fn every_vendor_slug_resolves_to_a_hint() {
349        for vendor in crate::vendor::VendorId::all() {
350            let report = format!(
351                r#"{{"entries":[{{"id":"{}","status":"error","error":"x"}}]}}"#,
352                vendor.slug()
353            );
354            let payload = wrap_report(&report, &facts("1.0.0", false), 0, None);
355            let hint = payload["entries"][0]["sign_in"].as_str().unwrap_or("");
356            assert!(!hint.is_empty(), "{} has no sign-in hint", vendor.slug());
357        }
358    }
359    use super::*;
360
361    fn sample_report() -> String {
362        json!({
363            "primary": "anthropic",
364            "entries": [
365                {
366                    "id": "anthropic",
367                    "name": "anthropic",
368                    "display_name": "Claude",
369                    "short_name": "cld",
370                    "plan": "Team 5x",
371                    "status": "ready",
372                    "error": null,
373                    "stale": false,
374                    "sections": [
375                        {
376                            "type": "metric",
377                            "label": "Weekly",
378                            "percent": 19,
379                            "value": "19%",
380                            "detail": "Resets in 1d 16h",
381                            "severity": "low",
382                            "reset_at": null
383                        },
384                        {
385                            "type": "metric",
386                            "label": "Session",
387                            "percent": 0,
388                            "value": "0%",
389                            "detail": "",
390                            "severity": "low",
391                            "reset_at": null
392                        }
393                    ]
394                },
395                {
396                    "id": "openai",
397                    "short_name": "gpt",
398                    "status": "ready",
399                    "error": null,
400                    "sections": [{
401                        "type": "metric",
402                        "label": "Session",
403                        "percent": 91,
404                        "value": "91%",
405                        "detail": "",
406                        "severity": "critical",
407                        "reset_at": null
408                    }]
409                }
410            ]
411        })
412        .to_string()
413    }
414
415    fn facts(version: &str, startup_enabled: bool) -> HostFacts {
416        HostFacts::new(version, startup_enabled)
417    }
418
419    #[test]
420    fn wrap_copies_entries_and_stamps_refresh() {
421        let payload = wrap_report(&sample_report(), &facts("1.10.0", true), 1_000, None);
422        assert_eq!(payload["version"], "1.10.0");
423        assert_eq!(payload["generated_at"], 1_000);
424        assert_eq!(payload["next_refresh_at"], 301_000);
425        assert_eq!(payload["refresh_minutes"], 5);
426        assert_eq!(payload["startup_enabled"], true);
427        let os = payload["os"].as_str().unwrap_or("");
428        assert!(
429            os == "macos" || os == "windows" || os == "linux",
430            "unexpected os {os}"
431        );
432        assert_eq!(payload["shortcut"], "");
433        assert_eq!(payload["shortcut_error"], "");
434        assert_eq!(payload["updates"], "notify");
435        assert!(payload["update"].is_null());
436        assert_eq!(payload["update_checked_at"], 0);
437        assert!(
438            payload["repository"]
439                .as_str()
440                .unwrap_or("")
441                .starts_with("https://github.com/")
442        );
443        assert!(payload["host_error"].is_null());
444        assert_eq!(payload["primary"], "anthropic");
445        assert_eq!(payload["entries"][0]["short_name"], "cld");
446    }
447
448    #[test]
449    fn wrap_carries_shortcut_and_update_facts_sanitized() {
450        let mut host = facts("1.10.0", false);
451        host.shortcut = "Ctrl+Shift+U".into();
452        host.shortcut_error = "already taken\u{1b}[31m".into();
453        host.updates = "auto".into();
454        host.update_checked_at = 42;
455        host.update = Some(UpdateFact {
456            error: String::new(),
457            state: "available".into(),
458            url: "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0".into(),
459            version: "1.11.0".into(),
460        });
461        let payload = wrap_report(&sample_report(), &host, 0, None);
462        assert_eq!(payload["shortcut"], "Ctrl+Shift+U");
463        assert!(
464            !payload["shortcut_error"]
465                .as_str()
466                .unwrap()
467                .contains('\u{1b}')
468        );
469        assert_eq!(payload["updates"], "auto");
470        assert_eq!(payload["update_checked_at"], 42);
471        assert_eq!(payload["update"]["version"], "1.11.0");
472        assert_eq!(payload["update"]["state"], "available");
473        assert_eq!(payload["update"]["error"], "");
474    }
475
476    #[cfg(target_os = "macos")]
477    #[test]
478    fn fact_after_check_maps_newer_current_and_failure() {
479        use crate::update::Release;
480
481        let newer = super::fact_after_check(Ok(Some(Release {
482            assets: Vec::new(),
483            html_url: "https://github.com/akitaonrails/ai-usagebar/releases/tag/v9.0.0".into(),
484            version: "9.0.0".into(),
485        })))
486        .expect("a newer release is a fact");
487        assert_eq!(newer.state, "available");
488        assert_eq!(newer.version, "9.0.0");
489        assert!(super::fact_after_check(Ok(None)).is_none());
490        let failed = super::fact_after_check(Err("offline".into())).expect("a failure is a fact");
491        assert_eq!(failed.state, "failed");
492        assert_eq!(failed.error, "offline");
493    }
494
495    #[test]
496    fn host_error_wins_over_report_body() {
497        let payload = wrap_report(
498            "not-json",
499            &facts("1.0.0", false),
500            0,
501            Some("no vendors enabled"),
502        );
503        assert_eq!(payload["host_error"], "no vendors enabled");
504        assert_eq!(payload["entries"].as_array().unwrap().len(), 0);
505        assert_eq!(worst_severity(&payload), Severity::Critical);
506    }
507
508    #[test]
509    fn invalid_report_json_becomes_a_host_error() {
510        let payload = wrap_report("{", &facts("1.0.0", false), 0, None);
511        assert!(
512            payload["host_error"]
513                .as_str()
514                .unwrap()
515                .contains("valid JSON")
516        );
517        assert_eq!(worst_severity(&payload), Severity::Critical);
518    }
519
520    #[test]
521    fn worst_severity_is_the_hottest_metric_not_the_primary() {
522        let payload = wrap_report(&sample_report(), &facts("1.10.0", false), 0, None);
523        assert_eq!(worst_severity(&payload), Severity::Critical);
524    }
525
526    #[test]
527    fn entry_error_is_critical_and_lands_in_the_tooltip() {
528        let report = json!({
529            "primary": "openai",
530            "entries": [{
531                "id": "openai",
532                "short_name": "gpt",
533                "status": "error",
534                "error": "not signed in",
535                "sections": []
536            }]
537        })
538        .to_string();
539        let payload = wrap_report(&report, &facts("1.10.0", false), 0, None);
540        assert_eq!(worst_severity(&payload), Severity::Critical);
541    }
542
543    #[test]
544    fn empty_report_uses_a_generic_tooltip() {
545        let payload = wrap_report("{}", &facts("1.0.0", false), 0, None);
546        assert_eq!(worst_severity(&payload), Severity::Low);
547        assert_eq!(POLL_INTERVAL.as_secs(), 300);
548    }
549
550    #[test]
551    fn next_refresh_follows_the_configured_interval() {
552        let mut host = facts("1.10.0", false);
553        host.refresh_secs = 60;
554        let payload = wrap_report(&sample_report(), &host, 1_000, None);
555        assert_eq!(payload["next_refresh_at"], 61_000);
556        assert_eq!(payload["refresh_minutes"], 1);
557
558        host.refresh_secs = 600;
559        let payload = wrap_report(&sample_report(), &host, 1_000, None);
560        assert_eq!(payload["next_refresh_at"], 601_000);
561        assert_eq!(payload["refresh_minutes"], 10);
562    }
563
564    #[test]
565    fn switchable_accounts_are_keyed_by_vendor() {
566        let mut host = facts("1.10.0", false);
567        host.accounts = vec![AccountSwitchFact {
568            vendor: "openai".into(),
569            active: Some("main".into()),
570            labels: vec!["main".into(), "work\u{1b}[31m".into()],
571            target: "work".into(),
572            switching: false,
573            error: "no stored Codex login".into(),
574        }];
575        let payload = wrap_report(&sample_report(), &host, 0, None);
576        let openai = &payload["accounts"]["openai"];
577        assert_eq!(openai["active"], "main");
578        assert_eq!(openai["labels"][0], "main");
579        assert!(!openai["labels"][1].as_str().unwrap().contains('\u{1b}'));
580        assert_eq!(openai["target"], "work");
581        assert_eq!(openai["switching"], false);
582        assert_eq!(openai["error"], "no stored Codex login");
583        assert!(payload["accounts"].get("anthropic").is_none());
584    }
585
586    #[test]
587    fn no_switchable_accounts_is_an_empty_object() {
588        let payload = wrap_report(&sample_report(), &facts("1.10.0", false), 0, None);
589        assert_eq!(payload["accounts"], json!({}));
590    }
591}