From d1fa12b82067f4e0bca65e25b4c13b0abf1ef939 Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sun, 30 Aug 2026 18:52:23 -0300 Subject: [PATCH] fix(desktop): integrate runtime setup and native token reports --- apps/desktop/e2e/desktop.spec.ts | 4 +- apps/desktop/e2e/runtime-integration.spec.ts | 122 +++++ apps/desktop/playwright.config.ts | 12 +- apps/desktop/src-tauri/src/desktop_queries.rs | 477 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 94 +++- apps/desktop/src-tauri/src/token_exports.rs | 300 +++++++++++ apps/desktop/src/App.test.tsx | 4 +- apps/desktop/src/App.tsx | 43 +- apps/desktop/src/bridge.ts | 30 +- apps/desktop/src/capability_registry.test.ts | 5 +- apps/desktop/src/capability_registry.ts | 7 +- .../src/components/IntegrationSetup.tsx | 44 ++ apps/desktop/src/components/Shell.tsx | 7 + apps/desktop/src/integration_setup.test.ts | 15 + apps/desktop/src/integration_setup.ts | 22 + apps/desktop/src/runtime_panels.css | 43 ++ apps/desktop/src/screens/ProvidersScreen.tsx | 18 +- apps/desktop/src/screens/SecondaryScreen.tsx | 2 +- apps/desktop/src/screens/SettingsScreen.tsx | 11 +- apps/desktop/src/screens/TokensScreen.tsx | 127 +++++ apps/desktop/src/settings_projection.test.ts | 8 +- apps/desktop/src/settings_projection.ts | 16 +- apps/desktop/src/token_report.test.ts | 3 +- apps/desktop/src/token_report.ts | 4 +- apps/desktop/src/token_usage.test.ts | 53 ++ apps/desktop/src/token_usage.ts | 116 +++++ docs/desktop/IMPLEMENTATION.md | 25 +- docs/desktop/INSTALLED-E2E.md | 14 +- docs/desktop/PROVIDERS.md | 21 + docs/desktop/RELEASE.md | 15 + docs/desktop/TOKEN-REPORTS.md | 44 +- 31 files changed, 1639 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/e2e/runtime-integration.spec.ts create mode 100644 apps/desktop/src-tauri/src/desktop_queries.rs create mode 100644 apps/desktop/src-tauri/src/token_exports.rs create mode 100644 apps/desktop/src/components/IntegrationSetup.tsx create mode 100644 apps/desktop/src/integration_setup.test.ts create mode 100644 apps/desktop/src/integration_setup.ts create mode 100644 apps/desktop/src/runtime_panels.css create mode 100644 apps/desktop/src/screens/TokensScreen.tsx create mode 100644 apps/desktop/src/token_usage.test.ts create mode 100644 apps/desktop/src/token_usage.ts diff --git a/apps/desktop/e2e/desktop.spec.ts b/apps/desktop/e2e/desktop.spec.ts index 3ecaf78..3e8e9a3 100644 --- a/apps/desktop/e2e/desktop.spec.ts +++ b/apps/desktop/e2e/desktop.spec.ts @@ -77,7 +77,7 @@ test("Bot Center exposes the canonical roster, timeline, rooms, and honest compu test("primary layouts fit desktop and compact widths", async ({ page }) => { for (const width of [1280, 768, 390]) { await page.setViewportSize({ width, height: 900 }); - for (const view of ["today", "chats", "teams", "automations", "apps", "home", "providers", "activity", "memory", "settings"]) { + for (const view of ["today", "chats", "teams", "automations", "apps", "home", "providers", "tokens", "activity", "memory", "settings"]) { await page.goto(`/?state=active&view=${view}`); const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); expect(overflow, `${view} overflows at ${width}px`).toBe(false); @@ -85,7 +85,7 @@ test("primary layouts fit desktop and compact widths", async ({ page }) => { } }); -test("the installed ambient path stays on the canonical five-surface route", async ({ page }) => { +test("the preview ambient path stays on the canonical five-surface route", async ({ page }) => { await page.goto("/?state=active&view=today"); await expect(page.getByRole("heading", { name: "Today" })).toBeVisible(); await page.getByRole("button", { name: "Chats" }).click(); diff --git a/apps/desktop/e2e/runtime-integration.spec.ts b/apps/desktop/e2e/runtime-integration.spec.ts new file mode 100644 index 0000000..4b830ba --- /dev/null +++ b/apps/desktop/e2e/runtime-integration.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createDemoSnapshot } from "../src/demo"; + +async function mockNativeBridge(page: Page, options: { signedOut?: boolean; failSetup?: boolean; failExport?: boolean } = {}) { + const snapshot = createDemoSnapshot("active"); + snapshot.source = "runtime"; + const report = { + schema: "workspace.token-analytics-report/v1", generated_by: "sqlite_ledger", now_epoch: 1788123153, + session_id: null, timezone_offset_seconds: 0, report_hash: `sha256:${"a".repeat(64)}`, + periods: ["today", "7d", "1m", "3m", "6m", "12m"].map((window) => ({ window, from_epoch: 1788000000, to_epoch: 1788123154, + totals: { sample_count: 2, input_tokens: 100, cached_input_tokens: 20, output_tokens: 30, reasoning_tokens: 7, paid_remote_tokens: 137, total_tokens: 137, missing_usage_events: 1, receipt_count: 2 }, + })), + }; + await page.addInitScript(({ snapshot, report, options }) => { + let signedOut = Boolean(options.signedOut); + const calls: Array<{ command: string; args: Record }> = []; + Object.assign(window, { __desktopTestCalls: calls, __TAURI_INTERNALS__: { + invoke: async (command: string, args: Record = {}) => { + calls.push({ command, args }); + if (command === "desktop_login") { await new Promise((resolve) => setTimeout(resolve, 50)); signedOut = false; return snapshot; } + if (command === "desktop_logout") signedOut = true; + if (["desktop_snapshot", "refresh_desktop_snapshot", "desktop_logout"].includes(command)) return signedOut ? { ...snapshot, access: { ...snapshot.access, state: "signed_out" } } : snapshot; + if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: `sha256:${"a".repeat(64)}`, changes: [{ label: "codex", changed: true, exists: true }] }; + if (command === "desktop_repair_providers") { + if (options.failSetup) throw "integration_plan_changed_review_again"; + return snapshot; + } + if (command === "desktop_token_report") { + const query = args.request as { repoPath?: string; sessionId?: string; fromEpoch?: number; toEpoch?: number; timezoneOffsetSeconds: number }; + if (query.repoPath === "/missing") throw "token_ledger_unavailable"; + return { ...report, timezone_offset_seconds: query.timezoneOffsetSeconds, session_id: query.sessionId ?? null, + periods: query.fromEpoch === undefined ? report.periods : [...report.periods, { ...report.periods[0], window: "custom", from_epoch: query.fromEpoch, to_epoch: query.toEpoch }], + }; + } + if (command === "desktop_export_token_report") { + await new Promise((resolve) => setTimeout(resolve, 150)); + if (options.failExport) throw "token_export_permission_denied"; + return { schema: "simplicio.desktop-token-export/v1", format: args.format, path: `/Downloads/simplicio-token-usage.${args.format}`, bytes: 1024 }; + } + throw `Unexpected test IPC command: ${command}`; + }, + } }); + }, { snapshot, report, options }); +} + +async function calls(page: Page, command: string) { + return page.evaluate((command) => (window as unknown as { __desktopTestCalls: Array<{ command: string; args: Record }> }).__desktopTestCalls.filter((row) => row.command === command), command); +} + +test("normal navigation reaches MCP setup and never installs without review and consent", async ({ page }) => { + await mockNativeBridge(page); + await page.goto("/"); + await page.getByRole("button", { name: "Integrações MCP", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Providers", exact: true })).toBeVisible(); + expect(await calls(page, "desktop_repair_providers")).toHaveLength(0); + await page.getByRole("button", { name: "Revisar configuração MCP" }).click(); + const apply = page.getByRole("button", { name: "Aplicar configuração MCP" }); + await expect(apply).toBeDisabled(); + await page.getByRole("checkbox", { name: /Autorizo o Runtime/ }).check(); + await apply.click(); + await expect(page.getByText(/Configuração concluída pelo Runtime/)).toBeVisible(); + expect(await calls(page, "desktop_repair_providers")).toEqual([{ command: "desktop_repair_providers", args: { planDigest: `sha256:${"a".repeat(64)}` } }]); +}); + +test("changed setup plans surface an actionable error without false success", async ({ page }) => { + await mockNativeBridge(page, { failSetup: true }); + await page.goto("/?view=providers"); + await page.getByRole("button", { name: "Revisar configuração MCP" }).click(); + await page.getByRole("checkbox", { name: /Autorizo o Runtime/ }).check(); + await page.getByRole("button", { name: "Aplicar configuração MCP" }).click(); + await expect(page.getByRole("alert")).toContainText("O plano mudou"); + await expect(page.getByRole("button", { name: "Aplicar configuração MCP" })).toBeHidden(); + await expect(page.getByText(/Configuração concluída pelo Runtime/)).toHaveCount(0); +}); + +test("token reports use filtered queries and send only a native report digest for export (mocked IPC)", async ({ page }, testInfo) => { + await mockNativeBridge(page); + await page.goto("/"); + await page.getByRole("button", { name: "Relatório de tokens", exact: true }).click(); + await expect(page.locator(".token-metric").first()).toContainText("137"); + await page.getByRole("combobox", { name: "Período", exact: true }).selectOption("7d"); + await page.getByLabel("Sessão (opcional)").fill("session-test"); + await page.getByLabel("Pasta do projeto (opcional)").fill("/tmp/project with spaces"); + await page.getByRole("button", { name: "Consultar uso" }).click(); + await expect(page.locator(".token-metric").first()).toContainText("137"); + await page.screenshot({ path: testInfo.outputPath("token-report.png"), fullPage: true }); + const requests = await calls(page, "desktop_token_report"); + expect(requests.at(-1)?.args.request).toMatchObject({ sessionId: "session-test", repoPath: "/tmp/project with spaces" }); + await page.getByRole("button", { name: "Exportar JSON" }).dblclick(); + await expect(page.getByRole("status")).toContainText("Exportado para /Downloads/simplicio-token-usage.json"); + await page.getByRole("button", { name: "Exportar CSV" }).click(); + await expect(page.getByRole("status")).toContainText("Exportado para /Downloads/simplicio-token-usage.csv"); + expect(await calls(page, "desktop_export_token_report")).toEqual(["json", "csv"].map((format) => ({ + command: "desktop_export_token_report", args: { reportHash: `sha256:${"a".repeat(64)}`, format }, + }))); + await page.getByLabel("Pasta do projeto (opcional)").fill("/missing"); + await page.getByRole("button", { name: "Consultar uso" }).click(); + await expect(page.getByRole("alert")).toContainText("não significa consumo zero"); + await expect(page.locator(".token-metric")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Exportar JSON" })).toHaveCount(0); + await expect(page.getByText(/Exportado para/)).toHaveCount(0); +}); + +test("native export failures remain visible and never claim a download succeeded (mocked IPC)", async ({ page }) => { + await mockNativeBridge(page, { failExport: true }); + await page.goto("/?view=tokens"); + const button = page.getByRole("button", { name: "Exportar JSON" }); + await button.click(); + await expect(page.getByRole("alert")).toContainText("O sistema não permitiu salvar em Downloads"); + await expect(page.getByText(/Exportado para/)).toHaveCount(0); + await expect(button).toBeEnabled(); +}); + +test("active login returns to Today and duplicate account effects stay serialized", async ({ page }) => { + await mockNativeBridge(page, { signedOut: true }); + await page.goto("/?view=settings"); + await page.getByRole("button", { name: /Continuar com Google/ }).dblclick(); + await expect(page.getByRole("heading", { name: "Today", exact: true })).toBeVisible(); + expect(await calls(page, "desktop_login")).toHaveLength(1); + await page.getByRole("button", { name: "Configurações", exact: true }).click(); + await expect(page.getByText(/Nenhum modelo foi informado/)).toBeVisible(); +}); diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 62a4d7c..fab99eb 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -1,12 +1,16 @@ import { defineConfig, devices } from "@playwright/test"; +const port = Number(process.env.SIMPLICIO_DESKTOP_TEST_PORT ?? "1420"); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid Desktop test port"); +const baseURL = `http://127.0.0.1:${port}`; + export default defineConfig({ testDir: "./e2e", fullyParallel: false, retries: 0, reporter: "line", use: { - baseURL: "http://127.0.0.1:1420", + baseURL, trace: "retain-on-failure", screenshot: "only-on-failure", }, @@ -17,9 +21,9 @@ export default defineConfig({ }, ], webServer: { - command: "npm run dev -- --host 127.0.0.1", - url: "http://127.0.0.1:1420", - reuseExistingServer: true, + command: `npm run dev -- --host 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: false, timeout: 120_000, }, }); diff --git a/apps/desktop/src-tauri/src/desktop_queries.rs b/apps/desktop/src-tauri/src/desktop_queries.rs new file mode 100644 index 0000000..f1bfbf9 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_queries.rs @@ -0,0 +1,477 @@ +//! Typed, bounded Desktop queries over the installed Runtime. No SQL or config writer lives here. +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const PERIODS: &[&str] = &["today", "7d", "1m", "3m", "6m", "12m", "custom"]; +const TOTALS: &[&str] = &[ + "sample_count", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "paid_remote_tokens", + "total_tokens", + "missing_usage_events", + "receipt_count", +]; + +fn query_string<'a>(request: &'a Value, key: &str, max: usize) -> Result, String> { + match request.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) + if !value.trim().is_empty() + && value.len() <= max + && !value.contains('\0') + && !value.trim().starts_with('-') => + { + Ok(Some(value.trim())) + } + _ => Err("token_query_invalid".into()), + } +} + +pub fn token_query_args(request: &Value, default_repo: &Path) -> Result, String> { + let fields = request.as_object().ok_or("token_query_invalid")?; + if fields.keys().any(|key| { + ![ + "repoPath", + "sessionId", + "fromEpoch", + "toEpoch", + "timezoneOffsetSeconds", + ] + .contains(&key.as_str()) + }) { + return Err("token_query_invalid".into()); + } + let offset = request + .get("timezoneOffsetSeconds") + .and_then(Value::as_i64) + .ok_or("token_query_invalid")?; + if !(-86_400..=86_400).contains(&offset) { + return Err("token_query_invalid".into()); + } + let repo = query_string(request, "repoPath", 4096)? + .map(PathBuf::from) + .unwrap_or_else(|| default_repo.to_path_buf()); + if !repo.is_absolute() { + return Err("token_query_invalid".into()); + } + let repo = repo.canonicalize().map_err(|_| "token_query_invalid")?; + if !repo.is_dir() { + return Err("token_query_invalid".into()); + } + let mut args = vec![ + "tokens".into(), + "report".into(), + "--json".into(), + "--tz-offset-seconds".into(), + offset.to_string(), + ]; + if let Some(session) = query_string(request, "sessionId", 256)? { + args.extend(["--session".into(), session.into()]); + } + match (request.get("fromEpoch"), request.get("toEpoch")) { + (None, None) => {} + (Some(from), Some(to)) => { + let from = from + .as_u64() + .filter(|n| *n <= MAX_SAFE_INTEGER) + .ok_or("token_query_invalid")?; + let to = to + .as_u64() + .filter(|n| *n <= MAX_SAFE_INTEGER) + .ok_or("token_query_invalid")?; + if from >= to { + return Err("token_query_invalid".into()); + } + args.extend([ + "--from".into(), + from.to_string(), + "--to".into(), + to.to_string(), + ]); + } + _ => return Err("token_query_invalid".into()), + } + let db = repo.join(".simplicio/token-usage.sqlite3"); + // Runtime's report opens SQLite with initialization. Do not create an empty ledger on a read. + let canonical_db = db.canonicalize().map_err(|_| "token_ledger_unavailable")?; + if !canonical_db.starts_with(&repo) || !canonical_db.is_file() { + return Err("token_ledger_unavailable".into()); + } + args.extend(["--db".into(), canonical_db.to_string_lossy().into_owned()]); + Ok(args) +} + +fn number(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_u64) + .filter(|n| *n <= MAX_SAFE_INTEGER) + .ok_or_else(|| "token_report_invalid".into()) +} + +pub fn project_token_report(value: Value) -> Result { + if value["schema"] != "workspace.token-analytics-report/v1" + || value["generated_by"] != "sqlite_ledger" + { + return Err("token_report_invalid".into()); + } + let digest = value["report_hash"] + .as_str() + .ok_or("token_report_invalid")?; + if digest.len() != 71 + || !digest.starts_with("sha256:") + || !digest[7..].bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err("token_report_invalid".into()); + } + let offset = value["timezone_offset_seconds"] + .as_i64() + .filter(|n| (-86_400..=86_400).contains(n)) + .ok_or("token_report_invalid")?; + let session = match &value["session_id"] { + Value::Null => Value::Null, + Value::String(id) if id.len() <= 256 => json!(id), + _ => return Err("token_report_invalid".into()), + }; + let raw_periods = value["periods"] + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 7) + .ok_or("token_report_invalid")?; + let mut periods = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for period in raw_periods { + let window = period["window"] + .as_str() + .filter(|window| PERIODS.contains(window)) + .ok_or("token_report_invalid")?; + if !seen.insert(window) { + return Err("token_report_invalid".into()); + } + let from = number(period, "from_epoch")?; + let to = number(period, "to_epoch")?; + if from >= to { + return Err("token_report_invalid".into()); + } + let mut totals = serde_json::Map::new(); + for key in TOTALS { + totals.insert((*key).into(), json!(number(&period["totals"], key)?)); + } + let n = |key: &str| totals[key].as_u64().unwrap_or_default(); + if n("cached_input_tokens") > n("input_tokens") + || n("missing_usage_events") > n("sample_count") + || n("receipt_count") > n("sample_count") + || n("total_tokens") != n("input_tokens") + n("output_tokens") + n("reasoning_tokens") + { + return Err("token_report_invalid".into()); + } + periods.push(json!({"window":window,"from_epoch":from,"to_epoch":to,"totals":totals})); + } + Ok( + json!({"schema":"workspace.token-analytics-report/v1","now_epoch":number(&value,"now_epoch")?, + "session_id":session,"timezone_offset_seconds":offset,"periods":periods,"generated_by":"sqlite_ledger","report_hash":digest}), + ) +} + +pub fn project_install_plan(plan: Value) -> Result { + if plan["schema"] != "simplicio.install-plan/v1" || plan["dry_run"] != true { + return Err("integration_plan_invalid".into()); + } + let raw = plan + .pointer("/apply_preview/config_diffs") + .and_then(Value::as_array) + .filter(|rows| rows.len() <= 64) + .ok_or("integration_plan_invalid")?; + let mut changes = Vec::new(); + for row in raw { + let label = row["label"] + .as_str() + .filter(|s| { + !s.is_empty() + && s.len() <= 128 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + }) + .ok_or("integration_plan_invalid")?; + let changed = row["changed"].as_bool().ok_or("integration_plan_invalid")?; + let exists = row["exists"].as_bool().ok_or("integration_plan_invalid")?; + changes.push(json!({"label":label,"changed":changed,"exists":exists})); + } + // Bind confirmation to the exact proposed config changes without sending their bodies to the UI. + let bytes = serde_json::to_vec(&json!({"preview":plan["apply_preview"],"configs":plan["generated_configs"],"binary":plan["binary"]})).map_err(|_| "integration_plan_invalid")?; + let digest = format!("sha256:{:x}", Sha256::digest(bytes)); + Ok( + json!({"schema":"simplicio.desktop-integration-plan/v1","source":"runtime","planDigest":digest,"changes":changes}), + ) +} + +/// Exit zero alone is not evidence of installation: a plan also exits successfully. +pub fn validate_install_receipt(receipt: &Value) -> Result<(), String> { + if receipt["schema"] != "simplicio.install-apply/v1" || receipt["status"] != "applied" { + return Err("integration_install_unconfirmed".into()); + } + let actions = receipt["actions"] + .as_array() + .filter(|rows| !rows.is_empty() && rows.len() <= 128) + .ok_or("integration_install_unconfirmed")?; + let mut seen = std::collections::BTreeSet::new(); + for action in actions { + let name = action["name"] + .as_str() + .filter(|name| !name.is_empty() && name.len() <= 160) + .ok_or("integration_install_unconfirmed")?; + if !seen.insert(name) || !matches!(action["status"].as_str(), Some("done" | "skipped")) { + return Err("integration_install_unconfirmed".into()); + } + } + if !seen.contains("binary-copy") + || !actions + .iter() + .any(|action| action["name"] == "install-manifest" && action["status"] == "done") + { + return Err("integration_install_unconfirmed".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report() -> Value { + json!({"schema":"workspace.token-analytics-report/v1","generated_by":"sqlite_ledger","now_epoch":100, + "session_id":null,"timezone_offset_seconds":0,"report_hash":format!("sha256:{}","a".repeat(64)), + "periods":[{"window":"today","from_epoch":0,"to_epoch":101,"totals":{ + "sample_count":1,"input_tokens":10,"cached_input_tokens":2,"output_tokens":3,"reasoning_tokens":1, + "paid_remote_tokens":14,"total_tokens":14,"missing_usage_events":0,"receipt_count":1}}]}) + } + + #[test] + fn token_projection_keeps_only_the_canonical_bounded_report() { + let mut raw = report(); + raw["raw_prompts"] = json!("secret"); + let projected = project_token_report(raw).unwrap(); + assert!(projected.get("raw_prompts").is_none()); + assert_eq!(projected["periods"][0]["totals"]["total_tokens"], 14); + } + + #[test] + fn invalid_counts_and_schema_fail_closed() { + let mut raw = report(); + raw["periods"][0]["totals"]["cached_input_tokens"] = json!(11); + assert!(project_token_report(raw).is_err()); + let mut raw = report(); + raw["schema"] = json!("other"); + assert!(project_token_report(raw).is_err()); + let mut raw = report(); + raw["periods"][0]["totals"]["total_tokens"] = json!(99); + assert!(project_token_report(raw).is_err()); + } + + #[test] + fn token_query_rejects_unknown_keys_before_filesystem_access() { + assert_eq!( + token_query_args(&json!({"command":"delete"}), Path::new("/absent")).unwrap_err(), + "token_query_invalid" + ); + assert_eq!( + token_query_args( + &json!({"timezoneOffsetSeconds":0,"repoPath":"relative"}), + Path::new("/absent") + ) + .unwrap_err(), + "token_query_invalid" + ); + } + + struct TestProject(PathBuf); + + impl TestProject { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "simplicio desktop query {} {nonce}", + std::process::id() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for TestProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn token_query_never_creates_a_ledger_and_keeps_values_in_individual_arguments() { + let project = TestProject::new(); + let query = json!({"timezoneOffsetSeconds":-10800,"sessionId":"session with spaces","fromEpoch":10,"toEpoch":20}); + assert_eq!( + token_query_args(&query, &project.0).unwrap_err(), + "token_ledger_unavailable" + ); + assert!(!project.0.join(".simplicio").exists()); + std::fs::create_dir(project.0.join(".simplicio")).unwrap(); + let db = project.0.join(".simplicio/token-usage.sqlite3"); + std::fs::write(&db, []).unwrap(); + assert_eq!( + token_query_args(&query, &project.0).unwrap(), + vec![ + "tokens", + "report", + "--json", + "--tz-offset-seconds", + "-10800", + "--session", + "session with spaces", + "--from", + "10", + "--to", + "20", + "--db", + db.canonicalize().unwrap().to_str().unwrap(), + ] + ); + for invalid in [ + json!({"timezoneOffsetSeconds":0,"sessionId":" --help"}), + json!({"timezoneOffsetSeconds":0,"fromEpoch":20,"toEpoch":10}), + json!({"timezoneOffsetSeconds":0,"fromEpoch":10}), + json!({"timezoneOffsetSeconds":86401}), + ] { + assert_eq!( + token_query_args(&invalid, &project.0).unwrap_err(), + "token_query_invalid" + ); + } + } + + #[cfg(unix)] + #[test] + fn token_query_rejects_a_ledger_symlink_outside_the_selected_project() { + let project = TestProject::new(); + let outside = TestProject::new(); + let external_db = outside.0.join("ledger.sqlite3"); + std::fs::write(&external_db, []).unwrap(); + std::fs::create_dir(project.0.join(".simplicio")).unwrap(); + std::os::unix::fs::symlink( + external_db, + project.0.join(".simplicio/token-usage.sqlite3"), + ) + .unwrap(); + assert_eq!( + token_query_args(&json!({"timezoneOffsetSeconds":0}), &project.0).unwrap_err(), + "token_ledger_unavailable" + ); + } + + #[test] + #[ignore = "requires SIMPLICIO_TEST_RUNTIME_BIN pointing to a verified native Runtime"] + fn signed_runtime_token_report_smoke() { + let binary = + std::env::var_os("SIMPLICIO_TEST_RUNTIME_BIN").expect("explicit Runtime path required"); + let project = TestProject::new(); + let db = project.0.join(".simplicio/token-usage.sqlite3"); + let input = project.0.join("synthetic-sample.json"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + for (id, session, tokens) in [ + ("desktop-smoke-1", "desktop smoke session", 100), + ("desktop-smoke-2", "another session", 900), + ] { + let sample = json!({"schema":"workspace.token-analytics/v1","sample_id":id,"receipt_ref":id, + "session_id":session,"occurred_at_epoch":now-1,"input_tokens":tokens,"cached_input_tokens":20, + "output_tokens":30,"reasoning_tokens":7,"paid_remote_tokens":tokens+37,"provenance":"measured"}); + std::fs::write(&input, serde_json::to_vec(&sample).unwrap()).unwrap(); + let output = std::process::Command::new(&binary) + .args(["tokens", "record", "--input"]) + .arg(&input) + .arg("--db") + .arg(&db) + .current_dir(&project.0) + .env("SIMPLICIO_DESKTOP_BRIDGE", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "synthetic record failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let args = token_query_args( + &json!({"timezoneOffsetSeconds":-10800,"sessionId":"desktop smoke session", + "fromEpoch":now-60,"toEpoch":now+1}), + &project.0, + ) + .unwrap(); + let output = std::process::Command::new(&binary) + .args(args) + .current_dir(&project.0) + .env("SIMPLICIO_DESKTOP_BRIDGE", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "report failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let report = project_token_report(serde_json::from_slice(&output.stdout).unwrap()).unwrap(); + assert_eq!(report["timezone_offset_seconds"], -10800); + assert_eq!(report["session_id"], "desktop smoke session"); + let periods = report["periods"].as_array().unwrap(); + assert_eq!(periods.len(), 7); + let custom = periods + .iter() + .find(|period| period["window"] == "custom") + .unwrap(); + assert_eq!(custom["totals"]["sample_count"], 1); + assert_eq!(custom["totals"]["total_tokens"], 137); + } + + #[test] + fn only_a_completed_install_receipt_can_report_success() { + let receipt = json!({"schema":"simplicio.install-apply/v1","status":"applied","actions":[ + {"name":"binary-copy","status":"skipped"}, + {"name":"assistant-config:codex","status":"done"}, + {"name":"path-registration","status":"skipped"}, + {"name":"install-manifest","status":"done"}, + ]}); + assert!(validate_install_receipt(&receipt).is_ok()); + let mut partial = receipt.clone(); + partial["status"] = json!("partial"); + assert!(validate_install_receipt(&partial).is_err()); + let mut failed = receipt.clone(); + failed["actions"][1]["status"] = json!("failed"); + assert!(validate_install_receipt(&failed).is_err()); + let mut missing = receipt; + missing["actions"] = json!([]); + assert!(validate_install_receipt(&missing).is_err()); + assert!(validate_install_receipt( + &json!({"schema":"simplicio.install-plan/v1","status":"planned"}) + ) + .is_err()); + } + + #[test] + fn installation_plan_is_redacted_and_digest_changes_with_payload() { + let mut plan = json!({"schema":"simplicio.install-plan/v1","dry_run":true,"apply_preview":{"config_diffs":[{"label":"codex","changed":true,"exists":true,"path":"/private/user/config","diff":"secret"}]}}); + let projected = project_install_plan(plan.clone()).unwrap(); + assert!(!projected.to_string().contains("secret")); + assert!(!projected.to_string().contains("/private")); + plan["apply_preview"]["config_diffs"][0]["diff"] = json!("new proposal"); + assert_ne!( + projected["planDigest"], + project_install_plan(plan).unwrap()["planDigest"] + ); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a55b379..b26da50 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,9 +2,14 @@ use serde_json::Value; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use tauri::Manager; +mod desktop_queries; mod legacy_snapshot; mod supervisor; +mod token_exports; + +static INSTALL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); const SNAPSHOT_SCHEMA: &str = "simplicio.desktop-snapshot/v1"; const MAX_SNAPSHOT_BYTES: usize = 65_536; @@ -17,7 +22,8 @@ const LEGACY_AUTH_ARGS: &[&str] = &["auth", "status", "--json"]; const LEGACY_STATUS_ARGS: &[&str] = &["status", "--json"]; const LEGACY_SAVINGS_ARGS: &[&str] = &["savings", "report", "--json"]; const LEGACY_INSTALL_ARGS: &[&str] = &["install", "--global", "--dry-run", "--json"]; -const INSTALL_ARGS: &[&str] = &["install", "--global", "--json"]; +// Only dispatched after the reviewed plan digest and explicit UI consent match. +const INSTALL_ARGS: &[&str] = &["install", "--global", "--yes", "--json"]; const SUBSCRIPTION_URL: &str = "https://simpleti.com.br/simplicio"; fn runtime_candidates_with( @@ -101,8 +107,73 @@ fn run_runtime_action(args: &[&str]) -> Result<(), String> { } fn repair_provider_integrations() -> Result<(), String> { - run_runtime_action(INSTALL_ARGS) - .map_err(|_| "O Runtime não conseguiu reparar as integrações".to_string()) + let receipt = run_runtime_json(INSTALL_ARGS) + .map_err(|_| "integration_install_unconfirmed".to_string())?; + desktop_queries::validate_install_receipt(&receipt) +} + +fn require_active_access() -> Result<(), String> { + if snapshot_from_runtime()? + .pointer("/access/state") + .and_then(Value::as_str) + != Some("active") + { + return Err("desktop_access_not_active".into()); + } + Ok(()) +} + +fn integration_plan_from_runtime() -> Result { + require_active_access()?; + desktop_queries::project_install_plan(run_runtime_json(LEGACY_INSTALL_ARGS)?) +} + +#[tauri::command] +async fn desktop_plan_integrations() -> Result { + tauri::async_runtime::spawn_blocking(integration_plan_from_runtime) + .await + .map_err(|_| "integration_plan_unavailable".to_string())? +} + +#[tauri::command] +async fn desktop_token_report( + request: Value, + reports: tauri::State<'_, token_exports::TokenReports>, +) -> Result { + let reports = reports.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + require_active_access()?; + let default_repo = std::env::var_os("SIMPLICIO_DESKTOP_REPO") + .or_else(|| std::env::var_os("HOME")) + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or("token_query_invalid")?; + let args = desktop_queries::token_query_args(&request, &default_repo)?; + let borrowed = args.iter().map(String::as_str).collect::>(); + reports.remember(run_runtime_json(&borrowed).map_err(|_| "token_report_unavailable")?) + }) + .await + .map_err(|_| "token_report_unavailable".to_string())? +} + +#[tauri::command] +async fn desktop_export_token_report( + app: tauri::AppHandle, + reports: tauri::State<'_, token_exports::TokenReports>, + report_hash: String, + format: String, +) -> Result { + let reports = reports.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + require_active_access()?; + let downloads = app + .path() + .download_dir() + .map_err(|_| "token_export_downloads_unavailable")?; + reports.save(&report_hash, &format, &downloads) + }) + .await + .map_err(|_| "token_export_write_failed".to_string())? } fn open_subscription_url() -> Result<(), String> { @@ -257,8 +328,15 @@ async fn desktop_logout() -> Result { } #[tauri::command] -async fn desktop_repair_providers() -> Result { - tauri::async_runtime::spawn_blocking(|| { +async fn desktop_repair_providers(plan_digest: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _guard = INSTALL_LOCK + .try_lock() + .map_err(|_| "integration_install_busy")?; + let plan = integration_plan_from_runtime()?; + if plan["planDigest"].as_str() != Some(plan_digest.as_str()) { + return Err("integration_plan_changed_review_again".into()); + } repair_provider_integrations()?; snapshot_from_runtime() }) @@ -293,12 +371,16 @@ async fn runtime_status() -> Result { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(token_exports::TokenReports::default()) .invoke_handler(tauri::generate_handler![ desktop_snapshot, refresh_desktop_snapshot, desktop_login, desktop_logout, desktop_repair_providers, + desktop_plan_integrations, + desktop_token_report, + desktop_export_token_report, desktop_open_subscription, desktop_bot_action, runtime_status @@ -368,7 +450,7 @@ mod tests { LEGACY_INSTALL_ARGS, ["install", "--global", "--dry-run", "--json"] ); - assert_eq!(INSTALL_ARGS, ["install", "--global", "--json"]); + assert_eq!(INSTALL_ARGS, ["install", "--global", "--yes", "--json"]); assert_eq!(SUBSCRIPTION_URL, "https://simpleti.com.br/simplicio"); } diff --git a/apps/desktop/src-tauri/src/token_exports.rs b/apps/desktop/src-tauri/src/token_exports.rs new file mode 100644 index 0000000..8dd2b04 --- /dev/null +++ b/apps/desktop/src-tauri/src/token_exports.rs @@ -0,0 +1,300 @@ +//! Export only reports already received from Runtime, never arbitrary WebView data or paths. +use crate::desktop_queries::project_token_report; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +const MAX_REPORTS: usize = 8; +const MAX_EXPORT_BYTES: usize = 65_536; +const QUALIFICATION: &str = "Recorded usage, not verified billing or savings. Per-sample provenance and costs are not exposed by this Runtime report."; +const TOTALS: &[&str] = &[ + "sample_count", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "paid_remote_tokens", + "total_tokens", + "missing_usage_events", + "receipt_count", +]; + +#[derive(Clone, Default)] +pub struct TokenReports(Arc>>); + +impl TokenReports { + pub fn remember(&self, raw: Value) -> Result { + let report = project_token_report(raw)?; + let mut reports = self.0.lock().map_err(|_| "token_report_unavailable")?; + reports.retain(|item| item["report_hash"] != report["report_hash"]); + reports.push_back(report.clone()); + while reports.len() > MAX_REPORTS { + reports.pop_front(); + } + Ok(report) + } + + /// `downloads` is resolved by the native OS path API, not an IPC argument. + pub fn save(&self, report_hash: &str, format: &str, downloads: &Path) -> Result { + if !matches!(format, "json" | "csv") { + return Err("token_export_invalid_format".into()); + } + let report = self + .0 + .lock() + .map_err(|_| "token_report_unavailable")? + .iter() + .find(|item| item["report_hash"].as_str() == Some(report_hash)) + .cloned() + .ok_or("token_export_report_expired")?; + let body = encode(&report, format)?; + if !downloads.is_absolute() || !downloads.is_dir() { + return Err("token_export_downloads_unavailable".into()); + } + for suffix in 0..1000 { + let filename = if suffix == 0 { + format!("simplicio-token-usage.{format}") + } else { + format!("simplicio-token-usage ({suffix}).{format}") + }; + let path = downloads.join(filename); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(&path) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(write_error(error.kind()).into()), + }; + if let Err(error) = file.write_all(&body).and_then(|_| file.sync_all()) { + drop(file); + // This exact file was exclusively created by this export, never pre-existing data. + let _ = std::fs::remove_file(&path); + return Err(write_error(error.kind()).into()); + } + return Ok(json!({ + "schema": "simplicio.desktop-token-export/v1", "format": format, + "path": path.to_string_lossy(), "bytes": body.len(), + })); + } + Err("token_export_names_exhausted".into()) + } +} + +fn write_error(kind: ErrorKind) -> &'static str { + match kind { + ErrorKind::PermissionDenied => "token_export_permission_denied", + ErrorKind::NotFound => "token_export_downloads_unavailable", + _ => "token_export_write_failed", + } +} + +fn encode(report: &Value, format: &str) -> Result, String> { + let report = project_token_report(report.clone())?; + let body = if format == "json" { + serde_json::to_vec_pretty(&json!({"report": report, "qualification": QUALIFICATION})) + .map_err(|_| "token_export_write_failed")? + } else { + // No user-controlled session text, paths, raw samples or spreadsheet formulas in CSV. + let mut csv = format!("window,from_epoch,to_epoch,{},timezone_offset_seconds,session_scope,report_hash,qualification\n", TOTALS.join(",")); + for period in report["periods"].as_array().ok_or("token_report_invalid")? { + let mut columns = vec![ + period["window"] + .as_str() + .ok_or("token_report_invalid")? + .into(), + period["from_epoch"].to_string(), + period["to_epoch"].to_string(), + ]; + columns.extend(TOTALS.iter().map(|key| period["totals"][*key].to_string())); + columns.push(report["timezone_offset_seconds"].to_string()); + columns.push( + if report["session_id"].is_null() { + "all_sessions" + } else { + "filtered_session" + } + .into(), + ); + columns.push( + report["report_hash"] + .as_str() + .ok_or("token_report_invalid")? + .into(), + ); + columns.push("recorded_usage_not_verified_billing_or_savings".into()); + csv.push_str(&columns.join(",")); + csv.push('\n'); + } + csv.into_bytes() + }; + if body.len() > MAX_EXPORT_BYTES { + return Err("token_report_invalid".into()); + } + Ok(body) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn report(index: u64) -> Value { + json!({"schema":"workspace.token-analytics-report/v1","generated_by":"sqlite_ledger","now_epoch":100, + "session_id":"=PRIVATE_FORMULA()","timezone_offset_seconds":-10800,"report_hash":format!("sha256:{index:064x}"), + "raw_prompts":"private prompt", "path":"/private/project", + "periods":[{"window":"today","from_epoch":0,"to_epoch":101,"totals":{ + "sample_count":1,"input_tokens":10,"cached_input_tokens":2,"output_tokens":3,"reasoning_tokens":1, + "paid_remote_tokens":14,"total_tokens":14,"missing_usage_events":0,"receipt_count":1}}]}) + } + + struct Downloads(PathBuf); + impl Downloads { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "simplicio-token-export-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for Downloads { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn native_exports_are_qualified_runtime_aggregates_not_webview_data() { + let reports = TokenReports::default(); + let expected = reports.remember(report(1)).unwrap(); + let downloads = Downloads::new(); + for format in ["json", "csv"] { + let receipt = reports + .save( + expected["report_hash"].as_str().unwrap(), + format, + &downloads.0, + ) + .unwrap(); + assert_eq!(receipt["schema"], "simplicio.desktop-token-export/v1"); + let path = Path::new(receipt["path"].as_str().unwrap()); + let bytes = std::fs::read(path).unwrap(); + assert_eq!(receipt["bytes"], bytes.len()); + let text = String::from_utf8(bytes).unwrap(); + assert!(!text.contains("private prompt") && !text.contains("/private/project")); + if format == "json" { + let exported: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(exported["report"], expected); + assert_eq!(exported["qualification"], QUALIFICATION); + } else { + assert!(text + .contains("today,0,101,1,10,2,3,1,14,14,0,1,-10800,filtered_session,sha256:")); + assert!(text.contains("recorded_usage_not_verified_billing_or_savings")); + assert!(!text.contains("PRIVATE_FORMULA")); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + } + + #[test] + fn exports_do_not_overwrite_existing_files_or_accept_arbitrary_formats() { + let reports = TokenReports::default(); + let expected = reports.remember(report(1)).unwrap(); + let hash = expected["report_hash"].as_str().unwrap(); + let downloads = Downloads::new(); + let existing = downloads.0.join("simplicio-token-usage.json"); + std::fs::write(&existing, b"keep existing data").unwrap(); + let receipt = reports.save(hash, "json", &downloads.0).unwrap(); + assert!(receipt["path"] + .as_str() + .unwrap() + .ends_with("simplicio-token-usage (1).json")); + assert_eq!(std::fs::read(&existing).unwrap(), b"keep existing data"); + assert_eq!( + reports + .save(hash, "../../unsafe", &downloads.0) + .unwrap_err(), + "token_export_invalid_format" + ); + assert_eq!( + reports + .save(hash, "json", &downloads.0.join("absent")) + .unwrap_err(), + "token_export_downloads_unavailable" + ); + assert!(!downloads.0.join("absent").exists()); + assert_eq!( + write_error(ErrorKind::PermissionDenied), + "token_export_permission_denied" + ); + } + + #[test] + fn only_recent_validated_native_reports_can_be_exported() { + let reports = TokenReports::default(); + assert!(reports.remember(json!({"report_hash":"fake"})).is_err()); + let first = reports.remember(report(0)).unwrap(); + let downloads = Downloads::new(); + for index in 1..=MAX_REPORTS as u64 { + reports.remember(report(index)).unwrap(); + } + assert_eq!(reports.0.lock().unwrap().len(), MAX_REPORTS); + assert_eq!( + reports + .save(first["report_hash"].as_str().unwrap(), "json", &downloads.0) + .unwrap_err(), + "token_export_report_expired" + ); + assert_eq!( + reports + .save("forged-digest", "csv", &downloads.0) + .unwrap_err(), + "token_export_report_expired" + ); + assert_eq!(std::fs::read_dir(&downloads.0).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn export_never_follows_a_preexisting_filename_symlink() { + let reports = TokenReports::default(); + let expected = reports.remember(report(1)).unwrap(); + let downloads = Downloads::new(); + let target = downloads.0.join("protected.json"); + std::fs::write(&target, b"keep protected data").unwrap(); + std::os::unix::fs::symlink(&target, downloads.0.join("simplicio-token-usage.json")) + .unwrap(); + let receipt = reports + .save( + expected["report_hash"].as_str().unwrap(), + "json", + &downloads.0, + ) + .unwrap(); + assert!(receipt["path"] + .as_str() + .unwrap() + .ends_with("simplicio-token-usage (1).json")); + assert_eq!(std::fs::read(target).unwrap(), b"keep protected data"); + } +} diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 56b56e5..02c6c1a 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -56,7 +56,9 @@ describe("Simplicio Desktop product states", () => { expect(html).toContain("Apps"); expect(html).toContain("Configurações"); expect(html).toContain("v3.8.39"); - expect((html.match(/class=\"nav-item/g) ?? []).length).toBe(6); + expect(html).toContain("Integrações MCP"); + expect(html).toContain("Relatório de tokens"); + expect((html.match(/class=\"nav-item/g) ?? []).length).toBe(8); }); it("keeps the five main surfaces projection-first and bounded", () => { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ebaf01d..a4a540c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { DesktopSnapshot } from "./contracts"; import type { BotActionRequest } from "./bot_center"; import { snapshotWithDemoBots } from "./bot_center"; @@ -20,6 +20,8 @@ import { SettingsScreen } from "./screens/SettingsScreen"; import { ActivityScreen } from "./screens/ActivityScreen"; import { BotCenterScreen } from "./screens/BotCenterScreen"; import { ProductSurfaceScreen } from "./screens/ProductScreens"; +import { TokensScreen } from "./screens/TokensScreen"; +import "./runtime_panels.css"; function initialView(): View { if (typeof window === "undefined") return "home"; @@ -33,6 +35,7 @@ function initialView(): View { requested === "home" || requested === "bot" || requested === "providers" || + requested === "tokens" || requested === "activity" || requested === "memory" || requested === "settings" @@ -49,6 +52,7 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn const [action, setAction] = useState<"login" | "logout" | "refresh" | "repair" | "subscribe" | "bot" | null>(null); const [actionError, setActionError] = useState(null); const [view, setView] = useState(initialView); + const actionLock = useRef(false); useEffect(() => { if (initialSnapshot) return; @@ -69,6 +73,8 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn }, [initialSnapshot]); async function refresh() { + if (actionLock.current) return; + actionLock.current = true; setAction("refresh"); setActionError(null); try { @@ -80,25 +86,35 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn setActionError("Não foi possível atualizar o Runtime."); } finally { setAction(null); + actionLock.current = false; } } - async function repairProviders() { + async function repairProviders(planDigest: string): Promise { + if (actionLock.current) return false; + actionLock.current = true; setAction("repair"); setActionError(null); try { - const next = await repairDesktopProviders(); + const next = await repairDesktopProviders(planDigest); setSnapshot(next); setBotCenter(next.botCenter); setLoadFailed(false); - } catch { - setActionError("Não foi possível reparar as integrações com segurança."); + return true; + } catch (error) { + setActionError(String(error).includes("integration_plan_changed") + ? "O plano mudou. Revise a configuração novamente antes de aplicar." + : "O Runtime não confirmou a instalação completa. Pode haver alterações parciais; atualize o diagnóstico e revise um novo plano antes de tentar novamente."); + return false; } finally { setAction(null); + actionLock.current = false; } } async function login() { + if (actionLock.current) return; + actionLock.current = true; setAction("login"); setActionError(null); try { @@ -106,14 +122,18 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn setSnapshot(next); setBotCenter(next.botCenter); setLoadFailed(false); + if (next.access.state === "active") setView("today"); } catch { setActionError("O login não foi concluído."); } finally { setAction(null); + actionLock.current = false; } } async function subscribe() { + if (actionLock.current) return; + actionLock.current = true; setAction("subscribe"); setActionError(null); try { @@ -122,10 +142,13 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn setActionError("Não foi possível abrir os planos."); } finally { setAction(null); + actionLock.current = false; } } async function logout() { + if (actionLock.current) return; + actionLock.current = true; setAction("logout"); setActionError(null); try { @@ -137,10 +160,13 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn setActionError("Não foi possível sair com segurança."); } finally { setAction(null); + actionLock.current = false; } } async function botAction(request: BotActionRequest) { + if (actionLock.current) return; + actionLock.current = true; setAction("bot"); setActionError(null); try { @@ -149,6 +175,7 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn setActionError("O Agent API não aceitou esta ação; nenhuma mudança local foi aplicada."); } finally { setAction(null); + actionLock.current = false; } } @@ -176,6 +203,7 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn return ( + {actionError &&
{actionError}
} {view === "home" && ( )} {view === "memory" && } + {view === "tokens" && } {view === "settings" && ( - + )} {view === "activity" && }
diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index a3e8ac6..ac1952f 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -4,6 +4,8 @@ import { createDemoSnapshot } from "./demo"; import type { BotCenterSnapshot } from "./contracts"; import type { BotActionRequest } from "./bot_center"; import { applyDemoBotAction } from "./bot_center"; +import { parseTokenExportReceipt, parseTokenUsageReport, type TokenQuery, type TokenUsageReport } from "./token_usage"; +import { parseIntegrationPlan, type IntegrationPlan } from "./integration_setup"; function previewState(): AccessState { const requested = new URLSearchParams(window.location.search).get("state"); @@ -32,7 +34,8 @@ export async function loadDesktopSnapshot(): Promise { export async function beginDesktopLogin(): Promise { if (!isTauri()) return createDemoSnapshot("active"); - return withTimeout(invoke("desktop_login"), 120_000, "Tempo limite do login excedido."); + // Runtime owns OAuth expiry; a frontend timeout must not authorize a duplicate login. + return invoke("desktop_login"); } export async function logoutDesktop(): Promise { @@ -45,13 +48,26 @@ export async function refreshDesktopSnapshot(): Promise { return invoke("refresh_desktop_snapshot"); } -export async function repairDesktopProviders(): Promise { +export async function planDesktopIntegrations(): Promise { + if (!isTauri()) return { schema: "simplicio.desktop-integration-plan/v1", source: "preview", planDigest: `sha256:${"0".repeat(64)}`, changes: [{ label: "codex", exists: true, changed: false }, { label: "grok", exists: true, changed: true }] }; + return parseIntegrationPlan(await withTimeout(invoke("desktop_plan_integrations"), 60_000, "integration_plan_timeout")); +} + +export async function loadDesktopTokenReport(request: TokenQuery): Promise { + if (!isTauri()) throw new Error("preview_no_runtime"); + return parseTokenUsageReport(await withTimeout(invoke("desktop_token_report", { request }), 60_000, "token_report_timeout")); +} + +export async function exportDesktopTokenReport(reportHash: string, format: "json" | "csv") { + if (!isTauri()) throw new Error("preview_no_runtime"); + // Native state owns the report and destination. Never send a file body or path over IPC. + return parseTokenExportReceipt(await invoke("desktop_export_token_report", { reportHash, format })); +} + +export async function repairDesktopProviders(planDigest: string): Promise { if (!isTauri()) return createDemoSnapshot(previewState()); - return withTimeout( - invoke("desktop_repair_providers"), - 120_000, - "Tempo limite do reparo de integrações excedido.", - ); + // Do not release the UI mutation lock on a timer while the native installer may still run. + return invoke("desktop_repair_providers", { planDigest }); } export async function dispatchDesktopBotAction( diff --git a/apps/desktop/src/capability_registry.test.ts b/apps/desktop/src/capability_registry.test.ts index 9476646..2700362 100644 --- a/apps/desktop/src/capability_registry.test.ts +++ b/apps/desktop/src/capability_registry.test.ts @@ -10,9 +10,10 @@ describe("capability.registry/v1", () => { expect(registry.capabilities.every((item) => item.available === false)).toBe(true); }); - it("requires a healthy Runtime source before exposing an app", () => { + it("does not substitute Runtime health for a capability probe or dispatch contract", () => { const snapshot = createDemoSnapshot("active"); snapshot.source = "runtime"; - expect(createCapabilityRegistry(snapshot).capabilities.every((item) => item.available)).toBe(true); + expect(createCapabilityRegistry(snapshot).capabilities.every((item) => !item.available)).toBe(true); + expect(createCapabilityRegistry(snapshot).capabilities.every((item) => item.reasonCode !== "capability_probe_verified")).toBe(true); }); }); diff --git a/apps/desktop/src/capability_registry.ts b/apps/desktop/src/capability_registry.ts index ebd91c4..fa9f50e 100644 --- a/apps/desktop/src/capability_registry.ts +++ b/apps/desktop/src/capability_registry.ts @@ -28,16 +28,15 @@ export function createCapabilityRegistry(snapshot: DesktopSnapshot, generatedAt { id: "build.workspace", category: "Build", name: "Files, PDF & Code", description: "Construir em um Workspace governado.", requiresApproval: true }, { id: "teach.compiler", category: "Learn", name: "Teach Simplicio", description: "Gravar e revisar uma rotina reproduzível.", requiresApproval: true }, ]; - const runtime = snapshot.source === "runtime" && snapshot.runtime.state === "healthy"; return { schema: "capability.registry/v1", generatedAt, source: snapshot.source, capabilities: descriptions.map((capability) => ({ ...capability, - available: runtime, - reasonCode: runtime ? "capability_probe_verified" : "capability_unverified", + available: false, + reasonCode: "desktop_capability_dispatch_unavailable", })), - reasonCode: runtime ? "capability.registry_ready" : "capability.registry_unavailable", + reasonCode: "capability.registry_unavailable", }; } diff --git a/apps/desktop/src/components/IntegrationSetup.tsx b/apps/desktop/src/components/IntegrationSetup.tsx new file mode 100644 index 0000000..ff39182 --- /dev/null +++ b/apps/desktop/src/components/IntegrationSetup.tsx @@ -0,0 +1,44 @@ +import { useRef, useState } from "react"; +import { planDesktopIntegrations } from "../bridge"; +import type { IntegrationPlan } from "../integration_setup"; + +export function IntegrationSetup({ busy, onApply }: { busy: boolean; onApply: (digest: string) => Promise }) { + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [message, setMessage] = useState(null); + const loadingLock = useRef(false); + + async function preview() { + if (loadingLock.current || busy) return; + loadingLock.current = true; + setLoading(true); setPlan(null); setConfirmed(false); setMessage(null); + try { setPlan(await planDesktopIntegrations()); } + catch { setMessage("Não foi possível preparar o plano. Nenhuma configuração foi alterada."); } + finally { setLoading(false); loadingLock.current = false; } + } + + async function apply() { + if (!plan || !confirmed || busy || loadingLock.current) return; + loadingLock.current = true; + try { + const ok = await onApply(plan.planDigest); + setMessage(ok ? "Configuração concluída pelo Runtime. Abra uma nova sessão nos clientes para confirmar a conexão MCP." : "A configuração não foi confirmada. Revise um novo plano antes de tentar novamente."); + } finally { + setPlan(null); setConfirmed(false); loadingLock.current = false; + } + } + + return
+
Instalação guiada

Configurar o Simplicio nos seus apps

+

O app inclui o Runtime. Após sua confirmação, ele copia o binário para a instalação gerenciada e registra MCP e hooks nos clientes detectados, com backups. Este fluxo não altera o PATH nem inicia um serviço global; plugins de marketplace e autorizações continuam sob controle de cada host.

+ + {plan &&
+

{plan.source === "preview" ? "Demonstração: nenhuma alteração real." : "Plano do Runtime, ainda não executado."} {plan.changes.filter((row) => row.changed).length} alterações propostas.

+
    {plan.changes.map((row) =>
  • {row.label}{row.changed ? row.exists ? "Atualizar" : "Criar" : "Já configurado"}
  • )}
+ +
+
} + {message &&

{message}

} +
; +} diff --git a/apps/desktop/src/components/Shell.tsx b/apps/desktop/src/components/Shell.tsx index 016d5dd..503ff6f 100644 --- a/apps/desktop/src/components/Shell.tsx +++ b/apps/desktop/src/components/Shell.tsx @@ -13,6 +13,7 @@ export type View = | "home" | "bot" | "providers" + | "tokens" | "activity" | "memory" | "settings"; @@ -29,6 +30,7 @@ const legacyLabels: Partial> = { home: t("nav.today"), bot: "Bot Center", providers: t("nav.providers"), + tokens: "Relatório de tokens", activity: t("nav.activity"), memory: t("nav.memory"), settings: t("nav.settings"), @@ -64,6 +66,11 @@ export function Shell({ children, snapshot, view, onViewChange }: ShellProps) { {item.label} ))} +

RUNTIME

+ {([ + { id: "providers", label: "Integrações MCP", icon: "providers" }, + { id: "tokens", label: "Relatório de tokens", icon: "activity" }, + ] as const).map((item) => )}

CONTA

- )} + +
{connected}conectados
+
{registered}registrados
{detected}detectados
{attention}requer atenção

Credenciais permanecem no provider.

diff --git a/apps/desktop/src/screens/SecondaryScreen.tsx b/apps/desktop/src/screens/SecondaryScreen.tsx index c328e3b..8112680 100644 --- a/apps/desktop/src/screens/SecondaryScreen.tsx +++ b/apps/desktop/src/screens/SecondaryScreen.tsx @@ -2,7 +2,7 @@ import type { DesktopSnapshot } from "../contracts"; import type { View } from "../components/Shell"; import { Glyph } from "../components/Brand"; -type LegacyView = Exclude; +type LegacyView = Exclude; const copy: Record = { bot: { diff --git a/apps/desktop/src/screens/SettingsScreen.tsx b/apps/desktop/src/screens/SettingsScreen.tsx index c9d14c0..8f9401d 100644 --- a/apps/desktop/src/screens/SettingsScreen.tsx +++ b/apps/desktop/src/screens/SettingsScreen.tsx @@ -1,5 +1,6 @@ import type { DesktopSnapshot } from "../contracts"; import { Glyph } from "../components/Brand"; +import { createSettingsProjection } from "../settings_projection"; export function redactedDiagnostic(snapshot: DesktopSnapshot) { return { @@ -40,6 +41,7 @@ export function SettingsScreen({ onLogout: () => void; logoutBusy: boolean; }) { + const inventory = createSettingsProjection(snapshot); return (
@@ -57,8 +59,8 @@ export function SettingsScreen({
Identidade
{snapshot.access.identityKnown ? "confirmada" : "não disponível"}
Expiração
{snapshot.access.expiresAt ? new Date(snapshot.access.expiresAt).toLocaleDateString("pt-BR") : "não informada"}
- - + @@ -76,6 +78,11 @@ export function SettingsScreen({

O export omite email, caminhos, prompts, configurações, credenciais, skills e ledger bruto.

+
+

Modelos / LLMs informados pelo Runtime

+ {inventory.models.length ?
    {inventory.models.map((model) =>
  • {model.label} · somente leitura
  • )}
:

Nenhum modelo foi informado pelo Runtime. Login ativo e MCP configurado não significam que um LLM esteja conectado.

} +

O inventário depende do Agent Plane; não são criadas credenciais, modelos padrão ou conexões presumidas.

+
PrivacidadeDados locais sob controle

O logout revoga a sessão no Runtime e limpa as credenciais locais; nenhum token é mantido nesta tela.

diff --git a/apps/desktop/src/screens/TokensScreen.tsx b/apps/desktop/src/screens/TokensScreen.tsx new file mode 100644 index 0000000..3f8bf49 --- /dev/null +++ b/apps/desktop/src/screens/TokensScreen.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from "react"; +import { Glyph } from "../components/Brand"; +import { exportDesktopTokenReport, loadDesktopTokenReport } from "../bridge"; +import { TOKEN_PERIODS, tokenErrorMessage, tokenExportErrorMessage, type TokenPeriod, type TokenQuery, type TokenUsageReport } from "../token_usage"; + +export function TokensScreen() { + const [period, setPeriod] = useState("1m"); + const [repoPath, setRepoPath] = useState(""); + const [sessionId, setSessionId] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [report, setReport] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + const [exportPath, setExportPath] = useState(null); + const [exportError, setExportError] = useState(null); + const exportLock = useRef(false); + const sequence = useRef(0); + + async function load(query: TokenQuery) { + const request = ++sequence.current; + setBusy(true); + setError(null); + setReport(null); + setExportPath(null); + setExportError(null); + try { + const next = await loadDesktopTokenReport(query); + if (request === sequence.current) setReport(next); + } catch (cause) { + if (request === sequence.current) setError(tokenErrorMessage(cause)); + } finally { + if (request === sequence.current) setBusy(false); + } + } + + useEffect(() => { + void load({ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }); + return () => { sequence.current += 1; }; + }, []); + + function invalidate() { + sequence.current += 1; + setReport(null); + setError(null); + setBusy(false); + setExportPath(null); + setExportError(null); + } + + function submit() { + if (exportLock.current) return; + const query: TokenQuery = { timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }; + if (repoPath.trim()) query.repoPath = repoPath.trim(); + if (sessionId.trim()) query.sessionId = sessionId.trim(); + if (period === "custom") { + query.fromEpoch = Math.floor(new Date(from).getTime() / 1000); + query.toEpoch = Math.floor(new Date(to).getTime() / 1000); + if (!Number.isSafeInteger(query.fromEpoch) || !Number.isSafeInteger(query.toEpoch) + || query.fromEpoch < 0 || query.fromEpoch >= query.toEpoch) { + invalidate(); + setError(tokenErrorMessage("token_query_invalid")); + return; + } + } + void load(query); + } + + async function exportReport(format: "json" | "csv") { + if (!report || exportLock.current) return; + exportLock.current = true; + setExporting(true); + setExportPath(null); + setExportError(null); + try { + const receipt = await exportDesktopTokenReport(report.report_hash, format); + setExportPath(receipt.path); + } catch (cause) { + setExportError(tokenExportErrorMessage(cause)); + } finally { + exportLock.current = false; + setExporting(false); + } + } + + const selected = report?.periods.find((item) => item.window === period); + const totals = selected?.totals; + const hasUsage = Boolean(totals && totals.sample_count > totals.missing_usage_events); + const metric = (value: number | undefined) => hasUsage && value !== undefined ? value.toLocaleString("pt-BR") : "—"; + + return ( +
+
+
Recibos do Runtime

Relatório de tokens

Uso registrado por período e sessão. Economia de contexto não é consumo faturado.

+
+
{ event.preventDefault(); submit(); }}> + + + + {period === "custom" && <>} + +
+ {error &&
{error}
} + {!error && !busy && !selected &&

Selecione os filtros e consulte o Runtime para este período.

} + {busy &&

Consultando o ledger local pelo Runtime…

} + {selected && <> +
+ {[["Total registrado", totals?.total_tokens], ["Entrada", totals?.input_tokens], ["Saída", totals?.output_tokens], ["Entrada em cache", totals?.cached_input_tokens], ["Raciocínio", totals?.reasoning_tokens], ["Remoto pago informado", totals?.paid_remote_tokens]].map(([label, value]) =>
{label}{metric(value as number | undefined)}
)} +
+
+

Cobertura e proveniência

+

{totals?.sample_count} {totals?.sample_count === 1 ? "evento" : "eventos"} · {totals?.receipt_count} {totals?.receipt_count === 1 ? "recibo" : "recibos"} · {totals?.missing_usage_events} {totals?.missing_usage_events === 1 ? "evento sem uso informado" : "eventos sem uso informado"}.

+ {!hasUsage &&

Não há uso informado neste recorte. Os traços não representam consumo zero.

} +

{new Date(selected.from_epoch * 1000).toLocaleString("pt-BR")} até {new Date(selected.to_epoch * 1000).toLocaleString("pt-BR")} (fim exclusivo).

+

O contrato atual não separa estimativas por amostra, modelos ou harnesses e não fornece custo. Os totais são uso registrado, não cobrança nem economia comprovada.

+ {report?.report_hash} +

Exportação local para Downloads, sem substituir arquivos existentes.

+
+ {exporting &&

Salvando o relatório em Downloads…

} + {exportPath &&

Exportado para {exportPath}

} + {exportError &&

{exportError}

} +
+ } +
+ ); +} diff --git a/apps/desktop/src/settings_projection.test.ts b/apps/desktop/src/settings_projection.test.ts index fe29535..3befd66 100644 --- a/apps/desktop/src/settings_projection.test.ts +++ b/apps/desktop/src/settings_projection.test.ts @@ -11,10 +11,12 @@ describe("settings.projection/v1", () => { expect(JSON.stringify(projection)).not.toContain("voce@example.com"); }); - it("requires Runtime authority for model/tool/skill enablement", () => { + it("does not infer model/tool/skill inventory from a healthy Runtime", () => { const snapshot = createDemoSnapshot("active"); - expect(createSettingsProjection(snapshot).models[0].selectable).toBe(false); + expect(createSettingsProjection(snapshot).models).toEqual([]); snapshot.source = "runtime"; - expect(createSettingsProjection(snapshot).models[0].selectable).toBe(true); + expect(createSettingsProjection(snapshot).models).toEqual([]); + expect(createSettingsProjection(snapshot).tools).toEqual([]); + expect(createSettingsProjection(snapshot).reasonCode).toBe("settings.model_inventory_unavailable"); }); }); diff --git a/apps/desktop/src/settings_projection.ts b/apps/desktop/src/settings_projection.ts index 269af1e..7f982cf 100644 --- a/apps/desktop/src/settings_projection.ts +++ b/apps/desktop/src/settings_projection.ts @@ -1,4 +1,5 @@ import type { DesktopSnapshot } from "./contracts"; +import { providerRegistry } from "./provider_registry"; export interface SettingsProjection { schema: "settings.projection/v1"; @@ -13,16 +14,19 @@ export interface SettingsProjection { } export function createSettingsProjection(snapshot: DesktopSnapshot, generatedAt = snapshot.generatedAt): SettingsProjection { - const runtime = snapshot.source === "runtime" && snapshot.runtime.state === "healthy"; + const bots = snapshot.source === "runtime" && snapshot.botCenter?.source === "runtime" ? snapshot.botCenter.bots.slice(0, 32) : []; + const models = [...new Set(bots.filter((bot) => bot.model).map((bot) => `${bot.provider ?? "provider não informado"} · ${bot.model}`))]; + const tools = [...new Set(bots.flatMap((bot) => bot.toolset))].slice(0, 128); + const skills = [...new Set(bots.flatMap((bot) => bot.skills))].slice(0, 128); return { schema: "settings.projection/v1", generatedAt, source: snapshot.source, - providers: snapshot.providers.slice(0, 32).map((provider) => ({ id: provider.id, name: provider.name, state: provider.state, availableActions: provider.availableActions })), - models: [{ id: "model-runtime-default", label: "Modelo fornecido pelo Runtime", selectable: runtime, reasonCode: runtime ? "model_probe_verified" : "model_probe_unavailable" }], - tools: [{ id: "tool-runtime-registry", label: "Registry de tools", enabled: runtime, reasonCode: runtime ? "tool_probe_verified" : "tool_probe_unavailable" }], - skills: [{ id: "skill-runtime-registry", label: "Registry de skills", enabled: runtime, reasonCode: runtime ? "skill_probe_verified" : "skill_probe_unavailable" }], + providers: providerRegistry(snapshot.providers).slice(0, 32).map((provider) => ({ id: provider.id, name: provider.name, state: provider.state, availableActions: provider.availableActions })), + models: models.map((label) => ({ id: label, label, selectable: false, reasonCode: "runtime_reported_read_only" })), + tools: tools.map((label) => ({ id: label, label, enabled: false, reasonCode: "runtime_reported_read_only" })), + skills: skills.map((label) => ({ id: label, label, enabled: false, reasonCode: "runtime_reported_read_only" })), secretPolicy: { bodiesVisible: false, writeOnly: true, rawExport: false }, - reasonCode: runtime ? "settings.projection_ready" : "settings.projection_unavailable", + reasonCode: bots.length ? "settings.runtime_reported_inventory" : "settings.model_inventory_unavailable", }; } diff --git a/apps/desktop/src/token_report.test.ts b/apps/desktop/src/token_report.test.ts index 038728b..d690816 100644 --- a/apps/desktop/src/token_report.test.ts +++ b/apps/desktop/src/token_report.test.ts @@ -6,8 +6,9 @@ describe("insights.tokens/v1", () => { it("keeps measured/estimated evidence distinguishable", () => { const report = createTokenReport(createDemoSnapshot("active")); expect(report.schema).toBe("insights.tokens/v1"); - expect(report.savedTokens).toBe(1_842_610); + expect(report.savedTokens).toBeNull(); expect(report.proofKind).toBe("mixed"); + expect(report.reasonCode).toBe("insights.savings_not_measured_usage"); expect(report.providerCacheHitPercent).toBeNull(); }); diff --git a/apps/desktop/src/token_report.ts b/apps/desktop/src/token_report.ts index 6f8307c..48039e8 100644 --- a/apps/desktop/src/token_report.ts +++ b/apps/desktop/src/token_report.ts @@ -14,7 +14,7 @@ export interface TokenReport { export function createTokenReport(snapshot: DesktopSnapshot, generatedAt = snapshot.generatedAt): TokenReport { const savings = snapshot.savings; - const measured = savings.proofKind === "measured" || savings.proofKind === "mixed" || savings.proofKind === "replayed"; + const measured = savings.proofKind === "measured" && savings.ledgerStatus === "valid"; return { schema: "insights.tokens/v1", generatedAt, @@ -24,6 +24,6 @@ export function createTokenReport(snapshot: DesktopSnapshot, generatedAt = snaps providerCacheHitPercent: savings.providerCache.proofKind === "measured" ? savings.providerCache.hitPercent : null, proofKind: savings.proofKind, telemetrySource: savings.providerCache.telemetrySource, - reasonCode: measured ? "insights.tokens_projection_ready" : "insights.tokens_telemetry_unavailable", + reasonCode: measured ? "insights.tokens_projection_ready" : savings.proofKind === "mixed" || savings.proofKind === "replayed" ? "insights.savings_not_measured_usage" : "insights.tokens_telemetry_unavailable", }; } diff --git a/apps/desktop/src/token_usage.test.ts b/apps/desktop/src/token_usage.test.ts new file mode 100644 index 0000000..bfe32f4 --- /dev/null +++ b/apps/desktop/src/token_usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseTokenUsageReport, parseTokenExportReceipt, tokenExportErrorMessage, tokenErrorMessage } from "./token_usage"; + +function fixture() { + return { + schema: "workspace.token-analytics-report/v1", generated_by: "sqlite_ledger", now_epoch: 100, + session_id: null, timezone_offset_seconds: 0, report_hash: `sha256:${"a".repeat(64)}`, + periods: [{ window: "today", from_epoch: 0, to_epoch: 101, totals: { + sample_count: 2, input_tokens: 10, cached_input_tokens: 3, output_tokens: 4, + reasoning_tokens: 1, paid_remote_tokens: 15, total_tokens: 15, missing_usage_events: 1, receipt_count: 2, + } }], + }; +} + +describe("Runtime token usage report", () => { + it("validates the real ledger shape and strips unrelated content", () => { + const report = parseTokenUsageReport({ ...fixture(), prompts: "private", path: "/private" }); + expect(report.periods[0].totals.total_tokens).toBe(15); + expect(report).not.toHaveProperty("prompts"); + expect(report).not.toHaveProperty("path"); + }); + + it("rejects wrong schemas, duplicate periods and unsafe or inconsistent totals", () => { + const wrong = fixture(); wrong.schema = "insights.tokens/v1"; + expect(() => parseTokenUsageReport(wrong)).toThrow("token_report_invalid"); + const duplicate = fixture(); duplicate.periods.push(duplicate.periods[0]); + expect(() => parseTokenUsageReport(duplicate)).toThrow(); + for (const value of [-1, Infinity, Number.MAX_SAFE_INTEGER + 1, 1.5, 11]) { + const bad = fixture(); bad.periods[0].totals.cached_input_tokens = value; + expect(() => parseTokenUsageReport(bad)).toThrow(); + } + const bad = fixture(); bad.periods[0].totals.total_tokens = 999; + expect(() => parseTokenUsageReport(bad)).toThrow(); + }); + + it("accepts only confirmed native export receipts", () => { + const receipt = { schema: "simplicio.desktop-token-export/v1", format: "json", path: "/Downloads/simplicio-token-usage.json", bytes: 500 }; + expect(parseTokenExportReceipt(receipt).path).toBe(receipt.path); + for (const invalid of [null, {}, { ...receipt, schema: "other" }, { ...receipt, bytes: 0 }, { ...receipt, format: "sh" }]) { + expect(() => parseTokenExportReceipt(invalid)).toThrow("token_export_unconfirmed"); + } + }); + + it("surfaces native export permission and stale report failures without false success", () => { + expect(tokenExportErrorMessage("token_export_permission_denied")).toContain("nenhuma permissão foi alterada"); + expect(tokenExportErrorMessage("token_export_report_expired")).toContain("Consulte o uso novamente"); + expect(tokenExportErrorMessage("unknown failure")).toContain("Não foi possível confirmar"); + }); + + it("does not treat a missing ledger as zero consumption", () => { + expect(tokenErrorMessage("token_ledger_unavailable")).toContain("não significa consumo zero"); + }); +}); diff --git a/apps/desktop/src/token_usage.ts b/apps/desktop/src/token_usage.ts new file mode 100644 index 0000000..0e9d3a4 --- /dev/null +++ b/apps/desktop/src/token_usage.ts @@ -0,0 +1,116 @@ +/** The Runtime owns aggregation. The Desktop only validates and presents its report. */ +export const TOKEN_PERIODS = [ + ["today", "Hoje"], ["7d", "7 dias"], ["1m", "1 mês"], + ["3m", "3 meses"], ["6m", "6 meses"], ["12m", "12 meses"], + ["custom", "Personalizado"], +] as const; + +export type TokenPeriod = (typeof TOKEN_PERIODS)[number][0]; + +export interface TokenQuery { + repoPath?: string; + sessionId?: string; + fromEpoch?: number; + toEpoch?: number; + timezoneOffsetSeconds: number; +} + +export interface TokenTotals { + sample_count: number; + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + paid_remote_tokens: number; + total_tokens: number; + missing_usage_events: number; + receipt_count: number; +} + +export interface TokenUsageReport { + schema: "workspace.token-analytics-report/v1"; + now_epoch: number; + session_id: string | null; + timezone_offset_seconds: number; + periods: Array<{ window: TokenPeriod; from_epoch: number; to_epoch: number; totals: TokenTotals }>; + generated_by: "sqlite_ledger"; + report_hash: string; +} + +const totalKeys: ReadonlyArray = [ + "sample_count", "input_tokens", "cached_input_tokens", "output_tokens", + "reasoning_tokens", "paid_remote_tokens", "total_tokens", "missing_usage_events", "receipt_count", +]; + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("token_report_invalid"); + return value as Record; +} + +function integer(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("token_report_invalid"); + return value; +} + +/** Reject malformed IPC responses and omit unknown fields, raw samples and paths. */ +export function parseTokenUsageReport(value: unknown): TokenUsageReport { + const report = object(value); + if (report.schema !== "workspace.token-analytics-report/v1" || report.generated_by !== "sqlite_ledger" + || typeof report.report_hash !== "string" || !/^sha256:[a-f0-9]{64}$/.test(report.report_hash) + || !Array.isArray(report.periods) || report.periods.length === 0 || report.periods.length > 7 + || !(report.session_id === null || (typeof report.session_id === "string" && report.session_id.length <= 256)) + || typeof report.timezone_offset_seconds !== "number" || !Number.isInteger(report.timezone_offset_seconds) + || Math.abs(report.timezone_offset_seconds) > 86_400) throw new Error("token_report_invalid"); + const seen = new Set(); + const periods = report.periods.map((raw) => { + const period = object(raw); + if (typeof period.window !== "string" || !TOKEN_PERIODS.some(([id]) => id === period.window) + || seen.has(period.window)) throw new Error("token_report_invalid"); + seen.add(period.window); + const from = integer(period.from_epoch); + const to = integer(period.to_epoch); + if (from >= to) throw new Error("token_report_invalid"); + const rawTotals = object(period.totals); + const totals = Object.fromEntries(totalKeys.map((key) => [key, integer(rawTotals[key])])) as unknown as TokenTotals; + if (totals.cached_input_tokens > totals.input_tokens || totals.missing_usage_events > totals.sample_count + || totals.receipt_count > totals.sample_count + || totals.total_tokens !== totals.input_tokens + totals.output_tokens + totals.reasoning_tokens) { + throw new Error("token_report_invalid"); + } + return { window: period.window as TokenPeriod, from_epoch: from, to_epoch: to, totals }; + }); + return { + schema: "workspace.token-analytics-report/v1", now_epoch: integer(report.now_epoch), + session_id: report.session_id, timezone_offset_seconds: report.timezone_offset_seconds, + periods, generated_by: "sqlite_ledger", report_hash: report.report_hash, + }; +} + +export function parseTokenExportReceipt(value: unknown): { format: "json" | "csv"; path: string; bytes: number } { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("token_export_unconfirmed"); + const receipt = value as Record; + if (receipt.schema !== "simplicio.desktop-token-export/v1" || !["json", "csv"].includes(String(receipt.format)) + || typeof receipt.path !== "string" || !receipt.path || receipt.path.length > 4096 || receipt.path.includes("\0") + || typeof receipt.bytes !== "number" || !Number.isSafeInteger(receipt.bytes) || receipt.bytes < 1 + || receipt.bytes > 65_536) throw new Error("token_export_unconfirmed"); + return { format: receipt.format as "json" | "csv", path: receipt.path, bytes: receipt.bytes }; +} + +export function tokenExportErrorMessage(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error); + if (reason.includes("token_export_report_expired")) return "O relatório saiu do cache local. Consulte o uso novamente antes de exportar."; + if (reason.includes("token_export_permission_denied")) return "O sistema não permitiu salvar em Downloads. Confira as permissões do app; nenhuma permissão foi alterada."; + if (reason.includes("token_export_downloads_unavailable")) return "A pasta Downloads não está disponível. Restaure a pasta e tente novamente."; + if (reason.includes("token_export_names_exhausted")) return "Há muitas exportações com esse nome em Downloads. Organize os arquivos antes de tentar novamente."; + if (reason.includes("desktop_access_not_active")) return "A sessão ou assinatura não está ativa no Runtime. Verifique sua conta antes de exportar."; + return "Não foi possível confirmar a exportação. Verifique Downloads e o espaço em disco antes de tentar novamente."; +} + +export function tokenErrorMessage(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error); + if (reason.includes("token_ledger_unavailable")) return "Nenhum ledger de uso encontrado nesta pasta. Selecione o projeto que recebeu os eventos do Runtime; ausência de telemetria não significa consumo zero."; + if (reason.includes("preview_no_runtime")) return "A demonstração não consulta seu uso. Abra o app instalado para ler os recibos do Runtime."; + if (reason.includes("token_query_invalid")) return "Confira a pasta absoluta, a sessão e as datas. O início deve ser anterior ao fim."; + if (reason.includes("token_report_invalid")) return "O Runtime retornou um relatório incompatível. Nenhum total foi exibido."; + return "Não foi possível consultar o relatório no Runtime. Nenhum consumo foi presumido."; +} diff --git a/docs/desktop/IMPLEMENTATION.md b/docs/desktop/IMPLEMENTATION.md index b883b6d..f7c6b41 100644 --- a/docs/desktop/IMPLEMENTATION.md +++ b/docs/desktop/IMPLEMENTATION.md @@ -2,7 +2,8 @@ `apps/desktop` is the Tauri 2 + React/TypeScript shell. It has one authority boundary: the Tauri process invokes the signed Simplicio Runtime sidecar and -the UI renders only the versioned `simplicio.desktop-snapshot/v1` response. +the UI renders the versioned `simplicio.desktop-snapshot/v1` response and +allowlisted, redacted token-report and integration-plan contracts. The application has four access states: @@ -11,11 +12,13 @@ The application has four access states: | `signed_out` | not started | Google login entry point | | `inactive` | disabled | subscription and refresh actions | | `unknown` | disabled | diagnostic refresh, never treated as unpaid | -| `active` | snapshot-backed | Home, Providers, Activity, Memory, Settings | +| `active` | snapshot-backed | Today, Chats, Teams, Automations, Apps, MCP integrations, token reports and Settings | The snapshot is bounded to 65,536 bytes, five activity rows, and 32 providers. -It is redacted at the boundary: paths, configuration bodies, credentials, -prompts, skill bodies, and raw ledgers never reach the UI contract. Optional +It is redacted at the boundary: source paths, configuration bodies, credentials, +prompts, skill bodies, and raw ledgers never reach the snapshot contract. The +separate receipt for an explicitly requested export identifies only the newly +saved report file; it does not expose the ledger's path or raw samples. Optional Fast is not required and never injects into hooks. All mutations are represented as governed, non-executed actions in the @@ -24,10 +27,24 @@ shell command, stores a token, or claims a provider handshake without a Runtime receipt. Browser preview uses labelled demo data only; the packaged Tauri path fails closed on an invalid or missing snapshot. +Successful active login opens Today. Account effects are serialized and +native installation stays locked until completion; there is no frontend +timeout that falsely unlocks an in-flight OAuth or configuration mutation. +Errors remain visible after entering the shell. Runtime health alone does +not certify a model inventory, provider handshake or workspace dispatcher: +unsupported actions remain disabled with a reason. + +See [TOKEN-REPORTS.md](TOKEN-REPORTS.md) for the ledger query boundary and +[PROVIDERS.md](PROVIDERS.md) for review/consent, installation receipts and +the distinction between registration and a live connection. Browser E2E +tests use an explicitly mocked native IPC boundary; they do not certify a +real provider login, installation, native platform or published artifact. + Local acceptance: ```bash npm test npm run build +npm run test:e2e cargo test --manifest-path src-tauri/Cargo.toml ``` diff --git a/docs/desktop/INSTALLED-E2E.md b/docs/desktop/INSTALLED-E2E.md index b11a81c..d1bfdfc 100644 --- a/docs/desktop/INSTALLED-E2E.md +++ b/docs/desktop/INSTALLED-E2E.md @@ -6,6 +6,18 @@ The installed E2E route verifies the canonical user path: It also checks the legacy contextual surfaces (Activity, Providers, Memory and Settings), responsive widths, account transitions, downloads and honest disabled -states. The browser suite must run against the built Desktop/sidecar and a +states. Native acceptance must run against the built Desktop/sidecar and a Runtime fixture; a missing browser or missing Runtime is an environment failure, not permission to turn the tests into a mock. + +The preview and mocked-IPC tests in `apps/desktop/e2e` are separate UI contract +checks, not installed acceptance. Real acceptance also launches the freshly +built native executable against the verified Runtime and an isolated synthetic +ledger, verifies navigation and the read-only integration plan, and records +what was not exercised (fresh OAuth, live host handshakes, config writes and +other native platforms). Token export acceptance must click both JSON and CSV +in the native WebView, inspect the actual saved files and verify that navigation +still works afterward. A Chromium download test is not evidence that a macOS +WebView download works. Preserve pre-existing files in Downloads and clean up +only the synthetic exports owned by that test. Do not label a preview-only route +as installed E2E. diff --git a/docs/desktop/PROVIDERS.md b/docs/desktop/PROVIDERS.md index 38cd9fe..f13a3c8 100644 --- a/docs/desktop/PROVIDERS.md +++ b/docs/desktop/PROVIDERS.md @@ -23,3 +23,24 @@ back the resulting document before reporting `registered`. Connect, verify, repair, and instruction actions must be idempotent. The registry intentionally does not mark `hermes-agent` as supported until its clean-profile installer and provider-path E2E contract is available. + +## Reviewed installation from Desktop + +The **Integrações MCP** navigation entry exposes a read-only review first. +`desktop_plan_integrations` runs `install --global --dry-run --json`, returns +only bounded target labels/change states, and hashes the underlying proposed +configuration without disclosing config bodies or paths to the webview. + +After explicit consent, `desktop_repair_providers` serializes installation, +requires active access, regenerates the plan and rejects a changed digest. +It then runs `install --global --yes --json`: omitting `--yes` only produces +a plan, even on exit zero. Success requires `simplicio.install-apply/v1`, +`status: applied`, successful/skipped actions and a completed rollback +manifest. A failure may leave partial changes; the UI asks for diagnostics +and a fresh review rather than claiming that nothing happened. + +This flow copies the bundled Runtime to its managed location and delegates +MCP/hook writes and backups to Runtime. It does not change PATH, launch a +global service or install every host's marketplace plugin. Those remain +explicit host-specific steps. A successful install is not a live MCP +handshake; reopen the client session to verify connection separately. diff --git a/docs/desktop/RELEASE.md b/docs/desktop/RELEASE.md index 2d2ffd9..dd452b4 100644 --- a/docs/desktop/RELEASE.md +++ b/docs/desktop/RELEASE.md @@ -24,3 +24,18 @@ The Tauri bundle must include the exact verified Runtime release binary through SHA-256 matches the public Runtime asset. The Desktop bridge resolves this bundled sidecar before any managed per-user or `PATH` fallback. Target-specific binary files are release staging material and remain outside Git. + +Repeat this identity check **after bundling and all platform signing**, against +the sidecar inside the final app (on macOS: `Contents/MacOS/simplicio`). Tauri's +macOS bundler can re-sign `externalBin` even with an ad-hoc identity, changing +the Runtime bytes after the initial staging check. Such a package is not the +verified Runtime release and must not be published as one. + +For local ad-hoc macOS packaging, restore the exact already-verified Runtime +asset inside the app and re-seal only the outer app, without recursive signing. +Then verify the app's code signature recursively and compare the sidecar SHA-256 +and Ed25519 signature against the original Runtime release again. Do not rewrite +the Runtime's public signature or provenance to accept a bundler-modified binary. +Any required change to the Runtime's platform signature belongs in a new signed +Runtime release, not an invisible Desktop packaging rewrite. Ad-hoc signing is +not Developer ID signing or Apple notarization. diff --git a/docs/desktop/TOKEN-REPORTS.md b/docs/desktop/TOKEN-REPORTS.md index 9bb863e..cd15a48 100644 --- a/docs/desktop/TOKEN-REPORTS.md +++ b/docs/desktop/TOKEN-REPORTS.md @@ -1,8 +1,42 @@ # Token Reports -`insights.tokens/v1` is a read-only projection of savings, cost and cache -receipts. It preserves evidence labels (`measured`, `estimated`, `replayed`, -`mixed` or `unavailable`) and never computes a provider cache hit locally. +The Runtime navigation entry **Relatório de tokens** queries the Runtime's +SQLite ledger through the bounded `desktop_token_report` IPC command. The +Desktop does not aggregate SQL or read raw usage samples itself. -The Desktop shows a dash when the Runtime did not provide proof. Raw prompts, -ledgers and credentials are not included in the projection. +The bridge accepts an optional absolute project directory and session ID, +a timezone offset, and paired custom start/end epochs. It invokes +`simplicio tokens report --json` with individual arguments and accepts only +`workspace.token-analytics-report/v1` with `generated_by: sqlite_ledger`. +Today, 7-day, 1/3/6/12-month and custom windows come from that report. The +default project is `SIMPLICIO_DESKTOP_REPO` or the user's home directory. + +An existing `.simplicio/token-usage.sqlite3` must resolve inside the selected +project. A query never creates an empty ledger to make missing telemetry look +like zero consumption. Missing usage is shown as a dash; errors and changed +filters clear stale totals and exports. Reports are limited to seven unique +windows, bounded strings and coherent non-negative, JavaScript-safe counts. + +JSON and CSV exports use the native `desktop_export_token_report` command, +not WebView Blob downloads. Only the digest and format cross IPC: the native +bridge retains at most eight validated Runtime reports and exports the exact +queried snapshot. An expired digest requires a new query. JavaScript cannot +provide a file body or destination. The destination is the OS Downloads folder; +exclusive file creation adds a suffix on collision and never follows an existing +filename symlink or overwrites an export. OS permission errors stay visible, +and success is shown only after the native write and flush complete. No OS +permission or security setting is changed by the Desktop. + +The files contain validated aggregate fields only. JSON includes +the report digest and the qualification that recorded usage is not verified +billing or savings. CSV includes the same qualification as a fixed label, +the report digest, timezone offset and whether a session filter was used; +it excludes user-controlled text and raw samples. The +current Runtime report does not expose per-sample provenance, model/harness +breakdowns or costs; the Desktop does not invent them. Browser preview has +no real usage report. Access must be active in the Runtime snapshot. + +The separate `insights.tokens/v1` summary preserves savings/cache evidence +labels. Only `measured` savings with a valid ledger reference may populate its +saved-token counter; `mixed`, `estimated`, `replayed` and missing receipts do +not become measured usage. Neither surface calculates provider cache hits.