From a963c1ec5d989915ba9ea9f948ae776ab4cead1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:25:00 +0000 Subject: [PATCH 1/2] docs: orient future contributors and AI agents across README, code, and doc map README gains a Developing section (toolchain floors, sidecar fetch, dev loops, the full gate list vs the pre-commit subset, a house-rules digest, and doc-map pointers). CLAUDE.md gains the comment-shorthand legend (V*-P* build phases, I## ledger entries, IMPROVEMENTS item IDs by section letter, PR-## review findings) and lists IMPROVEMENTS.md in the doc map. IMPROVEMENTS.md is marked retired: a code-level audit verified 55 of its 56 items shipped (P1 partial), with the judgment calls recorded so the item text can't be mistaken for open work. Module docstrings added where the survey found high-risk gaps: - TS: crypto/identity.ts (derivation contract, noble sha512 side effect, NaCl box compat), crypto/topics.ts (rendezvous wire contracts), trystero/index.ts (fan-out wrapper, merged-room scope), db/{identity,sessions,friends}.ts (camelCase-in/snake_case-out convention), the five bare stores, Home.tsx mount invariants, add-friend dialog pair, Tauri-coupled settings categories, system listeners, onboarding store. - Rust: //! headers on every module (lib.rs spine ordering/teardown rules, sidecar lifecycle invariants, forward-only migration rules, DB corruption policy, keychain custody model, model-download guards) plus /// on load-bearing items; ai_dialog.rs's banner converted to rustdoc. - scripts/check-tokens.ts gets the file header its siblings already had. No behavior changes; comments and markdown only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0171qBAM76TzzpJptZuiua1o --- CLAUDE.md | 14 ++++- IMPROVEMENTS.md | 20 ++++++- README.md | 56 ++++++++++++++++++- scripts/check-tokens.ts | 9 +++ src-tauri/src/commands/ai_dialog.rs | 40 ++++++------- src-tauri/src/commands/friends.rs | 6 ++ src-tauri/src/commands/identity.rs | 14 +++++ src-tauri/src/commands/mod.rs | 8 +++ src-tauri/src/commands/models.rs | 14 +++++ src-tauri/src/commands/sessions.rs | 6 ++ src-tauri/src/commands/sidecar.rs | 19 +++++++ src-tauri/src/commands/system.rs | 18 ++++++ src-tauri/src/crypto.rs | 15 +++++ src-tauri/src/db/audit_events.rs | 5 ++ src-tauri/src/db/friends.rs | 6 ++ src-tauri/src/db/migrations.rs | 10 ++++ src-tauri/src/db/mod.rs | 19 +++++++ src-tauri/src/db/sessions.rs | 9 +++ src-tauri/src/lib.rs | 19 +++++++ src/features/friends/AddFriendDialog.tsx | 9 +++ src/features/friends/AddFriendDialogView.tsx | 6 ++ src/features/onboarding/store.ts | 5 ++ .../settings/categories/AboutCategory.tsx | 6 ++ .../settings/categories/AdvancedCategory.tsx | 6 ++ .../settings/categories/IdentityCategory.tsx | 8 +++ .../settings/categories/SessionsCategory.tsx | 6 ++ src/features/system/PttListener.tsx | 7 +++ src/features/system/autostart.ts | 4 ++ src/features/system/useAutostart.ts | 5 ++ src/lib/crypto/identity.ts | 31 ++++++++++ src/lib/crypto/topics.ts | 11 ++++ src/lib/db/friends.ts | 6 ++ src/lib/db/identity.ts | 8 +++ src/lib/db/sessions.ts | 6 ++ src/lib/trystero/index.ts | 10 ++++ src/lib/utils.ts | 3 + src/routes/Home.tsx | 16 ++++++ src/stores/auditStore.ts | 8 +++ src/stores/friendsStore.ts | 5 ++ src/stores/identityStore.ts | 7 +++ src/stores/sessionStore.ts | 8 +++ src/stores/settingsStore.ts | 20 +++++++ 42 files changed, 483 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8364f5..bcf69c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,23 @@ Supporting references: - **`CHANGELOG.md`** — release history by version era (V1 / V2 / V3). Keep it current when you ship a release. - **`ISSUES.md`** — the audit ledger (Sev1–Sev4). **`I9` (Pomodoro broadcaster takeover) and `I18` (sidecar model-path sandbox) are accepted deviations** under the friends-only threat model — do not "fix" them without an explicit request. -- **`README.md`** / **`INSTALL.md`** — user-facing entry point and install walkthrough. +- **`IMPROVEMENTS.md`** — a **retired** backlog snapshot (2026-06, v1.2.0-era): a 2026-07 code audit found 55 of its 56 items shipped and only P1 (Linux) partially open — see its Status block. Its item IDs (`F6`, `A2`, …) live on as comment tags at the implementing code sites; do not treat item text as open work. +- **`README.md`** / **`INSTALL.md`** — user-facing entry point (plus a Developing section) and install walkthrough. - **`BUILD-PROMPTS.md`** — **historical**. The sequenced prompts that originally built V0→V3, kept for provenance. It is *not* a live spec; do not paste from it or treat its phase scaffolding as current process. If a request conflicts with PLAN / ARCHITECTURE / DESIGN-SYSTEM, surface the conflict; don't silently deviate. +### Comment shorthand used throughout the code + +Inline comments cite compact tags instead of restating history. Decode them as: + +- **`V1-P4` / `V2-P7` / `V3-P3`** — build phases from `BUILD-PROMPTS.md` (provenance only; the referenced behavior is described where the tag appears). +- **`I9`, `I16`, …** — entries in the `ISSUES.md` audit ledger. +- **`F6`, `U4`, `S2`, `A2`, `D1`, `R7`, `X4`, `N5`, …** — items from the `IMPROVEMENTS.md` backlog, letter = section (F friend-finding/connection, U UI/UX/a11y, S session robustness, A AI quality, D data/identity/recovery, R stats/report, X release/distribution, N new features/lifecycle). +- **`PR-27`** — a finding from that pull request's review, addressed in code. + +The tag is a pointer to the fuller story; the comment beside it should already carry what you need to edit safely. When you fix something traceable to one of these, keep the tag convention. + ## Working agreement - **Reasoning budget is unlimited.** Think deeply when the task warrants it. There is no token concern. diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 2f70c7f..f7bf7d0 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,8 +1,22 @@ # StudyVis improvement backlog -A forward-looking backlog of concrete, code-grounded improvements for StudyVis (shipped, feature-complete v1.2.0). Distinct from [`ISSUES.md`](ISSUES.md), which is the _audit ledger_ of already-found-and-fixed (or deliberately deferred) defects — this document is the menu of work still worth doing. - -**Produced:** 2026-06-11, from a multi-agent survey that read the real source across all eight subsystems (friend discovery, sessions/WebRTC, AI pipeline, UI/design system, settings/stats/report, identity/crypto/DB, build/release, and the docs/threat-model), deduped to themes, and pressure-tested with a completeness critic. Every item cites the files it touches; nothing here is implemented yet. +A point-in-time backlog of concrete, code-grounded improvements for StudyVis, written against v1.2.0. Distinct from [`ISSUES.md`](ISSUES.md), which is the _audit ledger_ of already-found-and-fixed (or deliberately deferred) defects. + +**Produced:** 2026-06-11, from a multi-agent survey that read the real source across all eight subsystems (friend discovery, sessions/WebRTC, AI pipeline, UI/design system, settings/stats/report, identity/crypto/DB, build/release, and the docs/threat-model), deduped to themes, and pressure-tested with a completeness critic. Every item cites the files it touches; nothing here was implemented at the time of writing. + +> ## Status — implemented (2026-07 audit) +> +> **This backlog is retired, not live.** A code-level audit on 2026-07-10 verified every item against the current source: **55 of 56 shipped** (largely in v1.2.1, with v1.2.2 and v1.3.1 following), **1 partial (P1)**, **0 open**. The item IDs (`F1`, `A5`, `D5`, …) now appear as comment tags at the exact code sites that implemented them — see the comment-shorthand legend in `CLAUDE.md`. +> +> Exceptions and judgment calls, so nobody re-litigates them from the item text alone: +> +> - **P1 (Linux deferral checklist) — partial.** Its literal ask shipped — the deferral now has a concrete trigger + unblock checklist (PLAN §8) — but the Linux work the checklist describes (keyring feature, `.AppImage` job, smoke-test re-run) has not started. +> - **X2** shipped via its sanctioned alternative: the Intel-Mac claim was dropped from the docs rather than adding an `x64.dmg` build. +> - **X7** shipped as the documented triage (ISSUES.md I19); the "when convenient" dev-dep bumps remain untaken by design. +> - **S2** closed the privacy defect with a stuck-key guard (120 s, not ~30 s) + per-session reset; the suggested blur-release was considered and deliberately rejected (`PttListener.tsx`) because the PTT shortcut is system-wide. +> - **N3** shipped as a global toggle only (no per-friend mute). **S4**'s output picker is inoperative on macOS WKWebView (`setSinkId` unsupported — documented in code); per-peer volume works everywhere. +> +> Item descriptions below state the **v1.2.0-era gap** they were written against — line numbers and "currently…" claims describe that snapshot, not today's code. Read them as historical rationale for the shipped behavior, not as open work. ## Scope & constraints diff --git a/README.md b/README.md index 88bce93..322a93b 100644 --- a/README.md +++ b/README.md @@ -294,9 +294,63 @@ where you'd see it surface. `PLAN.md`, `ARCHITECTURE.md`, and `DESIGN-SYSTEM.md` are the canonical specs — each the source of truth for its concern. `CHANGELOG.md` and `ISSUES.md` track release history and the audit -ledger; `BUILD-PROMPTS.md` is the historical build plan. This README +ledger; `IMPROVEMENTS.md` is a retired backlog snapshot; +`BUILD-PROMPTS.md` is the historical build plan. `CLAUDE.md` is the +working agreement for contributors and AI coding agents. This README is the user-facing entry point. +## Developing + +The stack is Tauri 2 + React 19 + Vite 8 + TypeScript strict. You +need Node 20.19+ (or 22.12+ — Vite 8's floor), npm, and a Rust stable +toolchain with the Tauri 2 platform prerequisites for your OS +(). + +```sh +npm install # frontend + tooling deps +scripts/fetch-llama-server.sh # one-time: AI sidecar binaries (gitignored; + # `tauri dev`/`tauri build` need them present) +npm run tauri dev # full desktop app — React UI + Rust shell +``` + +Two lighter loops when you don't need the desktop shell: + +- `npm run dev` — Vite frontend only. Fast UI iteration; Tauri APIs + are absent, so identity, DB, P2P-adjacent commands, and the AI + sidecar don't function. +- `npm run storybook` — component workbench at + . Every primitive and feature component has + a story; a dev-only primitive gallery also lives at `/style` in the + running app. + +Before opening a PR, all gates must pass (husky pre-commit enforces +only a subset — lint, prettier, `tsc --noEmit`, token/string guards, +`cargo fmt --check`): + +```sh +npm run build && npm run lint && npm run test +npm run check-tokens && npm run check-strings && npm run check-contrast +npm run build-storybook && npm run check-a11y +(cd src-tauri && cargo test && cargo fmt --check && cargo clippy) +``` + +**Read before changing code.** `CLAUDE.md` (repo root) is the +working agreement — house rules, doc map, quality gates — for human +contributors and AI coding agents alike. The load-bearing rules, in +one breath: every design value comes from `src/design/tokens.ts`; +user-facing copy lives in `src/strings.ts`; Radix/shadcn primitives +are imported only inside `src/components/ui/`; SQLite migrations are +forward-only; peer wire formats and identity derivation are +cross-version compatibility contracts (friends update manually and +at different times); accessibility (WCAG AA, axe-clean stories, +reduced-motion) is a gate, not a nicety; and no telemetry, ever. + +`ISSUES.md` entries `I9` and `I18` are accepted deviations under the +friends-only threat model — leave them unless explicitly asked. +`IMPROVEMENTS.md` is a retired 2026-06 backlog snapshot — a code +audit found essentially all of it shipped (see its Status block); +don't pick items up from it as open work. + ## Versioning 1.x is the running release series, all friends-only unsigned builds. diff --git a/scripts/check-tokens.ts b/scripts/check-tokens.ts index 3d559e5..3e6f7f8 100644 --- a/scripts/check-tokens.ts +++ b/scripts/check-tokens.ts @@ -1,4 +1,13 @@ #!/usr/bin/env tsx +// Design-token guard (DESIGN-SYSTEM.md §7 rule 1): every color, px value, and +// easing must come from src/design/tokens.ts, so this script walks src/ and +// fails on hardcoded visual values — raw hex, inline cubic-bezier, px inside +// style props, and Tailwind arbitrary-bracket px/ring/hex utilities. +// tokens.ts itself is allowlisted; a few vendored shadcn primitives carry +// per-rule exemptions below. Runs in pre-commit, CI, and the release-prep +// gate (`npm run check-tokens`); exits 1 with file:line:col per violation. +// Its siblings check-strings.ts / check-contrast.ts follow the same shape. + import { readFile, readdir } from 'node:fs/promises' import { resolve, relative, join, sep } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/src-tauri/src/commands/ai_dialog.rs b/src-tauri/src/commands/ai_dialog.rs index 368227c..e8d25c0 100644 --- a/src-tauri/src/commands/ai_dialog.rs +++ b/src-tauri/src/commands/ai_dialog.rs @@ -1,33 +1,33 @@ -// V2-P7 — Floating Ctrl+] AI dialog window. -// -// The PTT-AI shortcut handler calls `toggle_ai_dialog` which either -// creates the second WebviewWindow (transparent, alwaysOnTop, no -// decorations, skipTaskbar, focused) or destroys the existing one. The -// macOS-only branch sets the NSWindowCollectionBehavior to -// `canJoinAllSpaces | fullScreenAuxiliary` so the dialog appears over -// fullscreen apps (ARCHITECTURE.md §12). -// -// Transparent windows on macOS require `app.macOSPrivateApi: true` in -// tauri.conf.json — that flag is added in this slice. It locks the app -// out of the Mac App Store, but V1's friends-only distribution (PLAN.md -// §5) already retired that path. +//! Floating Ctrl+] AI dialog window (V2-P7). +//! +//! The PTT-AI shortcut handler calls `toggle_ai_dialog` which either +//! creates the second WebviewWindow (transparent, alwaysOnTop, no +//! decorations, skipTaskbar, focused) or destroys the existing one. The +//! macOS-only branch sets the NSWindowCollectionBehavior to +//! `canJoinAllSpaces | fullScreenAuxiliary` so the dialog appears over +//! fullscreen apps (ARCHITECTURE.md §12). +//! +//! Transparent windows on macOS require `app.macOSPrivateApi: true` in +//! tauri.conf.json. That flag locks the app out of the Mac App Store, but +//! V1's friends-only distribution (PLAN.md §5) already retired that path. use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; +/// Label for the dialog webview. Referenced from `lib.rs` (quit teardown) and +/// `system.rs` (AI-features-off teardown), and scoped by +/// `capabilities/ai-dialog.json` — renaming it breaks all three. pub const AI_DIALOG_LABEL: &str = "ai-dialog"; const DIALOG_WIDTH: f64 = 460.0; const DIALOG_HEIGHT: f64 = 220.0; -// Toggles the dialog: closes it if already open, otherwise creates a new -// one. The handler returns Result so any window-builder failure can -// propagate up through the shortcut handler's `let _ =` capture (which -// logs in dev but keeps the app responsive). +/// Toggles the dialog: destroys it if already open (`destroy()`, not +/// `close()`, so the close-requested interception in `lib.rs` can't veto a +/// toggle), otherwise creates it. Not a `#[tauri::command]` — invoked from +/// the global-shortcut handler in `lib.rs`, which `let _ =`-captures the +/// Result to keep the app responsive on builder failure. pub fn toggle_ai_dialog(app: &AppHandle) -> Result<(), tauri::Error> { if let Some(existing) = app.get_webview_window(AI_DIALOG_LABEL) { - // Window already open — destroy it. `close()` would let Tauri's - // close-requested handler intercept; `destroy()` is the - // unconditional teardown we want for a toggle. existing.destroy()?; return Ok(()); } diff --git a/src-tauri/src/commands/friends.rs b/src-tauri/src/commands/friends.rs index bc73791..734f547 100644 --- a/src-tauri/src/commands/friends.rs +++ b/src-tauri/src/commands/friends.rs @@ -1,3 +1,9 @@ +//! Friends commands: thin sync wrappers over `db::friends`, plus the +//! encrypted `.svfb` friends-list backup (export/import, macOS/Windows only — +//! it needs the keychain identity). The backup's authenticity envelope is +//! security-critical; see the banner above `BACKUP_MAGIC` before touching the +//! format. + use serde::Serialize; use tauri::State; diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs index d6396b1..fa334db 100644 --- a/src-tauri/src/commands/identity.rs +++ b/src-tauri/src/commands/identity.rs @@ -1,3 +1,17 @@ +//! Identity commands: keychain custody of the private keys, the public +//! `identity.json` record, and keyring-backed sign / box-encrypt / decrypt. +//! +//! Key custody model: the Ed25519 + X25519 private keys (both HKDF-derived +//! from the 24-word mnemonic on the JS side — see `src/lib/crypto/identity.ts`) +//! are handed to Rust exactly once at save time and live in the OS keychain +//! (service `com.studyvis.app`, user `identity-keys`) — never on plaintext +//! disk. JS asks Rust to sign/open-box on its behalf afterwards. The module is +//! macOS/Windows-only because `keyring` has no Linux backend wired +//! (`commands/mod.rs`). +//! +//! `identity.json` (public keys + display name + fingerprint) lives in the +//! SQLite data dir, NOT the Tauri `app_data_dir` that holds `settings.json`. + use std::fs; use std::path::PathBuf; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cfd34bc..3d7b8a3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,11 @@ +//! Tauri command modules — the entire IPC surface callable from JS. +//! +//! Every command must also be listed in `generate_handler!` in `lib.rs`, under +//! a matching `#[cfg]`. The gates here are load-bearing: `identity` needs the +//! `keyring` crate (macOS/Windows only — Linux ships no keychain backend), and +//! the `desktop` modules use tray/shortcut/window APIs absent on mobile. +//! Getting a gate wrong silently drops a whole command group at compile time. + pub mod friends; pub mod sessions; diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index fd7369d..b46bf30 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -1,3 +1,17 @@ +//! GGUF model-file management on disk under `/studyvis/models//`: +//! resumable downloads from Hugging Face, install-state checks, removal, and +//! keychain custody of the optional HF token (gated-model tier). +//! +//! Notes for editors: +//! - `validate_model_id` is a path-traversal guard — every path-building fn +//! funnels through it because the JS-supplied id becomes a directory name. +//! - Downloads stream sequentially with SHA-256 verification, resume from a +//! kept `.tmp` via HTTP Range, and rename atomically on success; per-model +//! cancellation rides an `AtomicBool` in `DownloadState`. +//! - Install state lives on the FILESYSTEM (and `models.json` on the JS +//! side), not in SQLite — the `models` table from migration 002 is a +//! currently-unused placeholder. + use std::collections::HashMap; use std::fs::{self, File}; use std::io::Write; diff --git a/src-tauri/src/commands/sessions.rs b/src-tauri/src/commands/sessions.rs index 626e416..403bf96 100644 --- a/src-tauri/src/commands/sessions.rs +++ b/src-tauri/src/commands/sessions.rs @@ -1,3 +1,9 @@ +//! Session + audit-event commands: thin sync wrappers over `db::sessions` and +//! `db::audit_events` (same held-guard, no-async locking pattern as +//! `commands/friends.rs`). Upsert/cascade semantics live in the `db` layer; +//! the TS callers are `src/lib/db/sessions.ts` and `src/lib/db/audit.ts` +//! (camelCase invoke args in, snake_case serde rows out). + use tauri::State; use crate::db::{audit_events, sessions, DbPool}; diff --git a/src-tauri/src/commands/sidecar.rs b/src-tauri/src/commands/sidecar.rs index 3f30d50..ef3047f 100644 --- a/src-tauri/src/commands/sidecar.rs +++ b/src-tauri/src/commands/sidecar.rs @@ -1,3 +1,22 @@ +//! llama-server sidecar lifecycle: spawn on demand on `127.0.0.1:`, crash-restart within a budget, and kill explicitly at exit. +//! +//! Lifecycle invariants (the highest-risk area in the Rust tree): +//! - `SidecarState::kill_blocking` is the ONLY thing that stops the process. +//! Neither OS kills the child when the parent exits, and `CommandChild` +//! does not kill on drop — every exit path (`RunEvent::Exit*` in `lib.rs`, +//! `system_relaunch_app`) must reach it or a multi-GB llama-server outlives +//! the app holding its port and model file. +//! - The `generation` counter + `shutting_down` flag (below) keep the +//! crash-restart watcher honest: a stale watcher must never re-incarnate a +//! process that a newer start or an exit already superseded. +//! - `TARGET_TRIPLE` / `SIDECAR_NAME` couple to `scripts/fetch-llama-server.sh` +//! output and `tauri.conf.json`'s `externalBin` + `resources` globs. +//! +//! JS drives this via `sidecar_start` / `sidecar_stop` / `sidecar_status` +//! (`src/features/ai/sidecar.ts`) and talks to the spawned server directly +//! over OpenAI-compatible HTTP. + use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::net::TcpListener; diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 95ee6fc..1b8fdec 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -1,3 +1,19 @@ +//! System commands and the app's managed runtime flags. +//! +//! Defines the atomic flag types (`QuitFlag`, `MinimizeToTrayFlag`, +//! `AiFeaturesFlag`, `SessionActiveFlag`, `ShortcutBindings`) that `lib.rs` +//! `manage()`s at setup and consults in the window/run-event handlers, plus +//! assorted commands: autostart, global-shortcut rebinding, quit/relaunch, +//! text-file export, opening OS settings panes (macOS-only deep links), the +//! battery probe, and the opt-in GitHub release check. All flags use +//! `Ordering::Relaxed` deliberately — last-write-wins between the JS setters +//! and the event handlers is fine, and no flag guards memory another thread +//! publishes. +//! +//! Gotcha: `system_relaunch_app` must kill the sidecar itself before calling +//! `app.restart()` — restart skips `RunEvent::Exit`, so the normal sidecar +//! teardown in `lib.rs` never runs on that path. + use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; @@ -10,6 +26,8 @@ use tauri_plugin_opener::OpenerExt; use crate::db::data_dir; +/// Armed by `app_quit` after the in-app confirm; `lib.rs`'s close-requested +/// handler lets the window close once armed. pub struct QuitFlag(pub AtomicBool); impl QuitFlag { diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs index 7648729..bea7ce6 100644 --- a/src-tauri/src/crypto.rs +++ b/src-tauri/src/crypto.rs @@ -1,8 +1,20 @@ +//! NaCl `crypto_box` (X25519 + XSalsa20-Poly1305) helpers shared by the +//! identity and friends-backup commands. +//! +//! Wire-format contract: output must stay byte-compatible with libsodium's +//! `crypto_box_easy` and with the JS side (`src/lib/crypto/identity.ts`), which +//! seals invite envelopes this module opens. A shared libsodium test vector is +//! pinned in `tests/box_decrypt_vector.rs` and mirrored in +//! `tests/unit/identity.test.ts` — if either fails, invites break on the wire. + use crypto_box::{aead::Aead, PublicKey as BoxPublicKey, SalsaBox, SecretKey as BoxSecretKey}; pub const X_KEY_LEN: usize = 32; pub const NONCE_LEN: usize = 24; +/// Opens a `crypto_box_easy`-compatible box from `their_x_pub` addressed to +/// `my_x_priv`. Errors are deliberately unspecific ("decrypt failed") so a +/// caller can't leak why an envelope was rejected. pub fn nacl_box_decrypt( their_x_pub: &[u8; X_KEY_LEN], my_x_priv: &[u8; X_KEY_LEN], @@ -17,6 +29,9 @@ pub fn nacl_box_decrypt( .map_err(|_| "decrypt failed".to_string()) } +/// Seals a `crypto_box_easy`-compatible box for `their_x_pub`. The nonce is +/// caller-supplied (24 random bytes per message) and travels alongside the +/// ciphertext unencrypted, per NaCl convention. pub fn nacl_box_encrypt( their_x_pub: &[u8; X_KEY_LEN], my_x_priv: &[u8; X_KEY_LEN], diff --git a/src-tauri/src/db/audit_events.rs b/src-tauri/src/db/audit_events.rs index 59ca293..5424554 100644 --- a/src-tauri/src/db/audit_events.rs +++ b/src-tauri/src/db/audit_events.rs @@ -1,3 +1,8 @@ +//! `audit_events` table queries — the per-session signed event log, plus the +//! cross-session read (`list_all`) behind the Stats focus-insights view. +//! Inserts are idempotent on `sig`; rows are only ever deleted via the +//! session cascade in `sessions.rs`. + use rusqlite::{params, Connection, Result}; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/db/friends.rs b/src-tauri/src/db/friends.rs index 3db1d5f..98c820a 100644 --- a/src-tauri/src/db/friends.rs +++ b/src-tauri/src/db/friends.rs @@ -1,3 +1,9 @@ +//! `friends` table queries. `ed_pubkey_hex` is the primary key and canonical +//! identity; `add` is an upsert on it (re-pairing refreshes `x_pubkey_hex` / +//! `display_name` / `paired_at` but never resets `last_studied_with`). Serde +//! emits field names verbatim (snake_case) — the TS `Friend` type in +//! `src/lib/db/friends.ts` mirrors them; keep the two aligned. + use rusqlite::{params, Connection, OptionalExtension, Result}; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index d8f7d7c..2923830 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -1,3 +1,13 @@ +//! Forward-only, versioned schema migrations. +//! +//! Friends install releases manually and out of order in time, so the schema +//! is a cross-version compatibility surface: **never edit a shipped migration +//! in place** (001 was amended pre-release; that door is closed). To change +//! the schema, add `migrations/NNN_name.sql` and append a `(NNN, include_str!)` +//! tuple to `MIGRATIONS` — `MAX_KNOWN_VERSION` derives from the last entry. +//! There are no down migrations. Write DDL idempotently (`IF NOT EXISTS`) and +//! add an upgrade test alongside the existing ones below. + use rusqlite::{Connection, TransactionBehavior}; const MIGRATION_001_INITIAL: &str = include_str!("migrations/001_initial.sql"); diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f8f70b9..4565dcc 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,3 +1,18 @@ +//! Local SQLite bootstrap: open `app.db` under `path::data_dir()/studyvis/`, +//! run forward-only migrations, and recover from proven corruption. +//! +//! All persistence goes through here — the frontend never opens a DB handle; +//! it calls the thin command wrappers in `commands/{friends,sessions}.rs`, +//! which query via the submodules below. The data directory is `studyvis` +//! under the OS data dir (NOT the `com.studyvis.app` bundle identifier — that +//! names the keychain service and a *different* Tauri `app_data_dir` used for +//! `settings.json`). +//! +//! Failure policy on open: `NewerVersion` (schema from a future build) shows a +//! blocking "update needed" dialog and exits, leaving the file untouched; only +//! DEFINITIVE corruption (see `is_definitely_corrupt`) sets the file aside as +//! `app.db.corrupt-` and recreates. A merely locked/busy DB is preserved. + use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -11,6 +26,10 @@ pub mod friends; pub mod migrations; pub mod sessions; +/// The app's single shared connection behind a `Mutex` (not an actual pool), +/// managed as Tauri state in `lib.rs`. Commands lock it synchronously for +/// their whole body — keep command fns non-async so a caller can never hold +/// the guard across an `.await`. pub struct DbPool(pub Arc>); pub struct DbInit { diff --git a/src-tauri/src/db/sessions.rs b/src-tauri/src/db/sessions.rs index 540fe58..6c96fa7 100644 --- a/src-tauri/src/db/sessions.rs +++ b/src-tauri/src/db/sessions.rs @@ -1,3 +1,12 @@ +//! `sessions` table queries, backing the post-session report and Stats. +//! +//! `insert` is a split-semantics upsert: the lifecycle fields (`started_at`, +//! `ended_at`, `total_minutes`) overwrite authoritatively so a re-summarize +//! can correct them, while the report fields coalesce (a partial upsert never +//! clobbers an earlier value). There is no FK to `audit_events` — `delete` / +//! `clear_all` cascade manually inside one transaction. Serde emits verbatim +//! snake_case, mirrored by the TS `SessionRecord`; keep them aligned. + use rusqlite::{params, Connection, OptionalExtension, Result}; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c16a3a5..0e6fa29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,22 @@ +//! App spine: builds the Tauri app — plugin registration, the full +//! `generate_handler!` command table, managed state, window/run-event +//! handlers, and `setup_desktop` (tray, shortcuts, boot reads, menu). +//! +//! Ordering and teardown rules that are easy to break from a distance: +//! - The single-instance plugin is registered FIRST, before anything else +//! initializes in a second process. +//! - Every `#[tauri::command]` must appear in the `generate_handler!` table +//! below under a `#[cfg]` matching its module's gate in `commands/mod.rs`. +//! - Close-requested on the main window is intercepted by a quit state +//! machine over `QuitFlag` / `MinimizeToTrayFlag` / `SessionActiveFlag` +//! (mid-session close emits `quit-requested` to JS for the confirm sheet). +//! - The llama-server sidecar is killed only in `RunEvent::ExitRequested | +//! Exit` — keep that arm intact (see `commands/sidecar.rs` for why). +//! - Boot reads (`settings.json` via Tauri's `app_data_dir`) happen here +//! before JS hydrates; the main window ships `visible: false` in +//! `tauri.conf.json` and is only shown at the end of `setup_desktop`, after +//! `apply_window_style`, to avoid a one-frame native-chrome flash. + mod commands; pub mod crypto; pub mod db; diff --git a/src/features/friends/AddFriendDialog.tsx b/src/features/friends/AddFriendDialog.tsx index 3ed7aa8..cfbc0b3 100644 --- a/src/features/friends/AddFriendDialog.tsx +++ b/src/features/friends/AddFriendDialog.tsx @@ -1,3 +1,12 @@ +// Container for the add-a-friend dialog: owns the pairing lifecycle (card +// mode by default; legacy 12-word host/join under a fallback link), the +// abort/cleanup of the live trystero pairing room, and the routing of pasted +// or scanned input via `interpretImportText`. All rendering is delegated to +// the pure AddFriendDialogView. Cleanup subtlety: Radix only fires +// onOpenChange on user-initiated closes, so a parent-driven close (deep link +// arriving mid-pairing) is caught by a separate `[open]` effect that aborts +// the orphaned room. + import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' diff --git a/src/features/friends/AddFriendDialogView.tsx b/src/features/friends/AddFriendDialogView.tsx index dde27d3..883de82 100644 --- a/src/features/friends/AddFriendDialogView.tsx +++ b/src/features/friends/AddFriendDialogView.tsx @@ -1,3 +1,9 @@ +// Pure presentational surface for the add-a-friend dialog: the default +// ContactCard mode (show my code as QR + copyable link, paste/scan theirs) +// and the legacy 12-word host/join tabs, with the F1/F5 connection-status +// hints. All pairing state arrives via the `AddFriendPhase` discriminated +// union from the AddFriendDialog container — no trystero, no stores here. + import { useCallback, useEffect, useRef, useState } from 'react' import { CheckIcon, CopyIcon } from 'lucide-react' import { toast } from 'sonner' diff --git a/src/features/onboarding/store.ts b/src/features/onboarding/store.ts index 048342e..c54c214 100644 --- a/src/features/onboarding/store.ts +++ b/src/features/onboarding/store.ts @@ -1,3 +1,8 @@ +// Onboarding-completion flag, persisted as `onboarding_completed_at` in the +// Tauri store file `app-state.json` (separate from settings.json). Outside a +// Tauri runtime (Storybook / node tests) the read resolves to 'pending' and +// writes no-op, so onboarding simply renders. + import { useCallback, useEffect, useState } from 'react' import { LazyStore } from '@tauri-apps/plugin-store' diff --git a/src/features/settings/categories/AboutCategory.tsx b/src/features/settings/categories/AboutCategory.tsx index b3385bb..bb84e17 100644 --- a/src/features/settings/categories/AboutCategory.tsx +++ b/src/features/settings/categories/AboutCategory.tsx @@ -1,3 +1,9 @@ +// Settings → About: version (from Vite's __APP_VERSION__ define), a link to +// the GitHub releases page, and the X4 opt-in new-version check. The check is +// OFF by default and this component is the zero-outbound guarantee: while the +// toggle is off, `system_fetch_latest_version` is never invoked — the one +// sanctioned outbound request beyond P2P/Nostr (PLAN §3) only fires when on. + import { useCallback, useEffect, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { ExternalLinkIcon } from 'lucide-react' diff --git a/src/features/settings/categories/AdvancedCategory.tsx b/src/features/settings/categories/AdvancedCategory.tsx index f594fb8..fdb4433 100644 --- a/src/features/settings/categories/AdvancedCategory.tsx +++ b/src/features/settings/categories/AdvancedCategory.tsx @@ -1,3 +1,9 @@ +// Settings → Advanced: autostart toggle, diagnostics (reveal llama-server +// log, copy version/OS/log-path to clipboard — nothing uploads), open the +// data folder, replay onboarding, and clear-all-session-history behind a +// confirm dialog. Everything here rides Tauri `invoke`; outside the desktop +// runtime the rows render but the actions fail into toasts. + import { useCallback, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { diff --git a/src/features/settings/categories/IdentityCategory.tsx b/src/features/settings/categories/IdentityCategory.tsx index af28e7b..0db81f2 100644 --- a/src/features/settings/categories/IdentityCategory.tsx +++ b/src/features/settings/categories/IdentityCategory.tsx @@ -1,3 +1,11 @@ +// Settings → Identity: display-name edit, public-key copy, the +// restore-from-24-words entry point (the Recover flow itself is lifted into +// Settings.tsx so it can replace the whole shell), and the encrypted +// friends-list backup — `friends_export` / `friends_import` Tauri commands +// writing/reading a sealed `.svfriends` file only this identity can mint or +// open (see src-tauri/src/commands/friends.rs for the format's security +// envelope). + import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { open, save } from '@tauri-apps/plugin-dialog' diff --git a/src/features/settings/categories/SessionsCategory.tsx b/src/features/settings/categories/SessionsCategory.tsx index 00a4ac6..aa672dc 100644 --- a/src/features/settings/categories/SessionsCategory.tsx +++ b/src/features/settings/categories/SessionsCategory.tsx @@ -1,3 +1,9 @@ +// Settings → Sessions: lists past sessions from SQLite, opens a session's +// Report (lifted into Settings.tsx so it replaces the shell), and deletes a +// single session behind a confirm — the Rust command drops the session row +// and its audit_events in one transaction, then the list re-reads SQLite so +// what's shown always matches what's stored. + import { useCallback, useEffect, useState } from 'react' import { Trash2Icon } from 'lucide-react' import { toast } from 'sonner' diff --git a/src/features/system/PttListener.tsx b/src/features/system/PttListener.tsx index 315ef01..2d1e9eb 100644 --- a/src/features/system/PttListener.tsx +++ b/src/features/system/PttListener.tsx @@ -1,3 +1,10 @@ +// Mounted once in App.tsx: wires the Rust global-shortcut PTT events +// (`ptt-friends-pressed` / `-released`) to the PTT store. Deliberately has NO +// window-blur release failsafe — the shortcut is system-wide, so PTT must +// keep transmitting while the user holds the key in another app; a dropped +// release is covered by the store's stuck-key guard and per-session reset +// (S2). No-op outside the Tauri runtime. + import { useEffect } from 'react' import { listen, type UnlistenFn } from '@tauri-apps/api/event' diff --git a/src/features/system/autostart.ts b/src/features/system/autostart.ts index 8ff611a..c67898a 100644 --- a/src/features/system/autostart.ts +++ b/src/features/system/autostart.ts @@ -1,3 +1,7 @@ +// Thin bridge to the Rust autostart commands (tauri-plugin-autostart). +// Tauri-only — callers go through useAutostart, which reports 'unavailable' +// outside the desktop runtime. + import { invoke } from '@tauri-apps/api/core' export async function setAutostartEnabled(enabled: boolean): Promise { diff --git a/src/features/system/useAutostart.ts b/src/features/system/useAutostart.ts index df93c32..a92dbab 100644 --- a/src/features/system/useAutostart.ts +++ b/src/features/system/useAutostart.ts @@ -1,3 +1,8 @@ +// Hook wrapping the OS launch-at-login toggle. The OS registry is the source +// of truth (state is re-read after every write); a request-id guard makes +// rapid toggles last-write-wins instead of interleaving. Reports +// 'unavailable' outside the Tauri runtime so Storybook renders a disabled row. + import { useCallback, useEffect, useRef, useState } from 'react' import { getAutostartEnabled, setAutostartEnabled } from './autostart' diff --git a/src/lib/crypto/identity.ts b/src/lib/crypto/identity.ts index 48299ee..03070f0 100644 --- a/src/lib/crypto/identity.ts +++ b/src/lib/crypto/identity.ts @@ -1,3 +1,23 @@ +// Identity crypto: 24-word BIP39 mnemonic → Ed25519 (signing) + X25519 (box +// encryption) keypairs, Ed25519 sign/verify, and a NaCl `crypto_box` +// implementation. Everything here is a CROSS-VERSION CONTRACT (ARCHITECTURE +// §3): +// +// - The derivation path — mnemonicToSeedSync(phrase, '') then HKDF-SHA256 +// with the salt/info constants below — deterministically reproduces a +// user's keys from their 24 words. Changing any constant re-derives +// different keys for every existing user, breaking their identity and +// every friend's saved pubkeys. Never edit; introduce ':v2' infos behind +// an explicit migration if a change is ever needed. +// - boxEncrypt/boxDecrypt must stay byte-compatible with libsodium's +// crypto_box_easy and the Rust side (src-tauri/src/crypto.rs), which opens +// these envelopes via the keychain path. A shared libsodium test vector is +// pinned in tests/unit/identity.test.ts and src-tauri/tests/. +// +// Do not send raw bytes elsewhere for signing: peers verify signatures +// against pubkeys exchanged at pairing, so signature/format drift strands +// older builds. + import * as ed from '@noble/ed25519' import { x25519 } from '@noble/curves/ed25519.js' import { xsalsa20poly1305, hsalsa } from '@noble/ciphers/salsa.js' @@ -11,6 +31,9 @@ import { } from '@scure/bip39' import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js' +// Load-bearing module side effect, not a dead assignment: @noble/ed25519 v3 +// ships hash-less and throws from sign/getPublicKey until an sha512 impl is +// installed. Must run before any ed.* call in this module. ed.hashes.sha512 = sha512 const HKDF_SALT = new TextEncoder().encode('studyvis') @@ -90,6 +113,10 @@ export function verifyMessage( } } +// NaCl box key derivation (crypto_box_beforenm): X25519 ECDH, then HSalsa20 +// keyed with the "expand 32-byte k" sigma constant and a 16-byte zero nonce. +// Hand-rolled because @noble/ciphers exposes the primitives but not the box +// composition; byte-compatible with libsodium — verified by the pinned vector. const SIGMA = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]) function bytesToU32LE(bytes: Uint8Array, count: number): Uint32Array { @@ -143,6 +170,10 @@ export function boxDecrypt( return xsalsa20poly1305(key, nonce).decrypt(ciphertext) } +// Fingerprint of the SECRET phrase (sha256 of the space-joined words, first +// 16 bytes as hex) — not a pubkey fingerprint. Persisted to identity.json so +// the recovery flow can tell "same identity re-entered" from "different +// mnemonic"; it reveals nothing recoverable about the words themselves. export function mnemonicFingerprint(mnemonic: Mnemonic): string { const phrase = mnemonic.join(' ') const digest = sha256(new TextEncoder().encode(phrase)) diff --git a/src/lib/crypto/topics.ts b/src/lib/crypto/topics.ts index 392b2d3..83f820b 100644 --- a/src/lib/crypto/topics.ts +++ b/src/lib/crypto/topics.ts @@ -1,3 +1,14 @@ +// Deterministic trystero room-topic + password derivations (inbox, pairing, +// session, presence), plus the pairing safety number. Every topic is +// hex(sha256(label + payload)) where the version-tagged label +// ('studyvis::v1:') is a RENDEZVOUS WIRE CONTRACT: two peers meet only +// if they derive the identical string, so changing a label, a payload +// encoding, or the digest strands every peer on an older build. The payload +// encoding is part of that contract and deliberately differs per kind — +// pubkey-derived topics hash the base64 of the key, sessionTopic hashes the +// hex of the id, pair topics hash the words joined by '-'. Bump the :v1: +// suffix (and coordinate both sides) rather than editing a derivation. + import { sha256 } from '@noble/hashes/sha2.js' import { bytesToBase64, bytesToHex, hexToBytes } from '@/lib/encoding' diff --git a/src/lib/db/friends.ts b/src/lib/db/friends.ts index b970975..adaec8c 100644 --- a/src/lib/db/friends.ts +++ b/src/lib/db/friends.ts @@ -1,3 +1,9 @@ +// Typed wrappers over the Rust `friends_*` commands (local SQLite friends +// list). `Friend` mirrors serde's snake_case response verbatim (same +// convention as SessionRecord / AuditEventRecord); `ed_pubkey_hex` is the +// canonical identity and `addFriend` upserts on it Rust-side, so re-import +// of a known friend is idempotent. + import { invoke } from '@tauri-apps/api/core' export type Friend = { diff --git a/src/lib/db/identity.ts b/src/lib/db/identity.ts index 00ab70b..f73a887 100644 --- a/src/lib/db/identity.ts +++ b/src/lib/db/identity.ts @@ -1,3 +1,11 @@ +// Typed wrappers over the Rust `identity_*` commands. `IdentityRecord` is the +// on-disk identity.json schema (snake_case; renaming a field breaks load). +// The `*WithKeyring` functions are the KEYCHAIN twin of the pure crypto in +// @/lib/crypto/identity.ts: same byte formats, but the private key never +// enters JS — Rust reads it from the OS keychain. Post-onboarding code should +// use these; the pure variants exist for derivation-time use, when the +// mnemonic (and thus the keys) are legitimately in memory. + import { invoke } from '@tauri-apps/api/core' import { base64ToBytes, bytesToBase64, bytesToHex } from '@/lib/encoding' diff --git a/src/lib/db/sessions.ts b/src/lib/db/sessions.ts index 93b483c..ab2f929 100644 --- a/src/lib/db/sessions.ts +++ b/src/lib/db/sessions.ts @@ -1,3 +1,9 @@ +// Typed wrappers over the Rust `sessions_*` commands (local SQLite session +// history). Two near-identical shapes on purpose: `SessionRow` (camelCase) is +// the JS→Rust INSERT input — Tauri auto-camelCases invoke args — while +// `SessionRecord` (snake_case) is serde's response shape on reads. Don't +// merge them. + import { invoke } from '@tauri-apps/api/core' export type SessionRow = { diff --git a/src/lib/trystero/index.ts b/src/lib/trystero/index.ts index 7c43622..bbaa51f 100644 --- a/src/lib/trystero/index.ts +++ b/src/lib/trystero/index.ts @@ -1,3 +1,13 @@ +// Wrapper over trystero's joinRoom: every room in the app goes through +// `joinTopic`, which returns a `TopicRoom` that (a) fans out +// onPeerJoin/Leave/Stream to MULTIPLE subscribers — raw trystero rooms are +// last-listener-wins, so calling room.onPeerJoin directly silently clobbers +// every other subscriber — and (b) can race several discovery transports +// (Nostr + MQTT) on one topic and merge them, deduping peers by their shared +// peerId. Merged rooms are for short-lived pairing only; long-lived rooms +// (inbox, presence, session) stay single-strategy Nostr to avoid duplicate +// peer connections. + import { getRelaySockets as getMqttRelaySockets, joinRoom as joinRoomMqtt, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 10e4c1a..2ae3fb6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,9 @@ import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' +// Standard shadcn classname combiner: clsx for conditional joining, then +// tailwind-merge so a caller-supplied class beats a same-property default +// ("p-2" passed after "p-4" wins instead of both landing in the DOM). export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0a6fa7f..422dec1 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,3 +1,19 @@ +// Top-level view orchestrator: switches between loading / identity-error / +// onboarding / active-session / report / settings / friends-list, and owns +// the flows that must survive any of those views — the add-friend dialog, +// contact-card import, deep-link routing, and the AI topic gate that queues a +// host/guest session start behind the "what are you working on?" prompt. +// +// Mount-structure invariants, easy to break by "simplifying" the render: +// - `InboxBoot` renders exactly ONCE, outside the view switch, so the +// always-on inbox + presence subscriptions never unmount on a view change. +// - The `tail` block (inbox boot, deep-link boot, import dialog, topic gate) +// travels with EVERY view including the active session, so a deep link or +// invite arriving mid-session isn't dropped. +// - The identity 'error' status renders IdentityLoadError and must never +// fall through to Onboarding — its create path would overwrite still-valid +// keychain keys (D1). + import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router' import { Settings2Icon } from 'lucide-react' diff --git a/src/stores/auditStore.ts b/src/stores/auditStore.ts index 1e1b935..fe6599a 100644 --- a/src/stores/auditStore.ts +++ b/src/stores/auditStore.ts @@ -1,3 +1,11 @@ +// Zustand store for the live session's verified audit events (the shared +// panel), plus best-effort persistence to SQLite and the two protocol +// helpers: `buildAuditEvent` (sign over the canonical bytes) and +// `verifyIncomingAuditEvent` (verify against the signed-hello peer binding — +// never against the wire's own `who` field). Callers must `flushPending()` +// before reading audit rows back for the report, so in-flight persists land +// first. + import { create } from 'zustand' import { diff --git a/src/stores/friendsStore.ts b/src/stores/friendsStore.ts index 9bda4b6..cdfa7f9 100644 --- a/src/stores/friendsStore.ts +++ b/src/stores/friendsStore.ts @@ -1,3 +1,8 @@ +// Zustand store mirroring the SQLite friends table. `add` re-lists from the +// DB after insert (canonical ordering); `remove` filters locally +// (optimistic); `markStudied` is best-effort and lets a failed re-list +// resync on the next load. + import { create } from 'zustand' import { diff --git a/src/stores/identityStore.ts b/src/stores/identityStore.ts index 1145af0..0480a46 100644 --- a/src/stores/identityStore.ts +++ b/src/stores/identityStore.ts @@ -1,3 +1,10 @@ +// Zustand store owning the local identity lifecycle. `create()`/`recover()` +// persist NOTHING — they return `{ record, commit }` and keys reach the +// keychain only when `commit()` runs (after the user confirms their backup), +// so an abandoned onboarding never mints a half-saved identity. The mnemonic +// itself is never persisted anywhere. Note the module-load side effect at the +// bottom of this file: importing the store fires the initial `refresh()`. + import { create } from 'zustand' import { diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 76dc01f..17cada1 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -1,3 +1,11 @@ +// Zustand store for the live study-session lifecycle: status, the peers map, +// the declared-topic trio (see the three per-field comments below — they have +// distinct lifetimes), and the cumulative seen-peer sets that survive +// `peerLeft` on purpose so session history stays correct on the +// everyone-else-leaves auto-end path. Mutated by `features/session/*` +// (host/join/lifecycle/hello); read by SessionView, the AI sample loop, and +// the leave handler. + import { create } from 'zustand' import type { TopicRoom } from '@/lib/trystero' diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index d633ae1..f78f567 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,3 +1,23 @@ +// Zustand store for every persisted user setting, backed by Tauri's +// `LazyStore` file `settings.json` (in Tauri's app_data_dir — NOT +// localStorage, and a different directory from the SQLite data dir). Setters +// update memory optimistically and write through to disk; per-key validators +// fail closed where a bad persisted value would brick the app at boot. +// +// Two coupling patterns to keep in mind when editing: +// - Boot caches: theme, windowStyle, and reduceMotion are ALSO mirrored to +// localStorage ('studyvis.theme', 'studyvis.windowStyle', …) because inline +// pre-paint scripts in index.html / ai-dialog.html read them synchronously +// to avoid a first-paint flash. Dropping a mirror write reintroduces FOUC. +// - Rust push-downs: some keys must reach Rust to take effect +// (minimize-to-tray, AI-features flag, shortcut accelerators — the latter +// registers with the OS before persisting and rolls back on failure). +// `lib.rs` also reads this file directly at boot, before JS hydrates. +// +// Not everything applies live: relay-URL changes need an app relaunch (rooms +// open at boot and never close), windowStyle applies at next process start, +// captureDisplays at the next sample-loop boot. + import { create } from 'zustand' import { invoke } from '@tauri-apps/api/core' import { LazyStore } from '@tauri-apps/plugin-store' From 65fe8ac0ab7861b0506c1ada5b619de22ea90a6b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:46:15 +0000 Subject: [PATCH 2/2] docs: correct four comment inaccuracies flagged in PR #48 review The ai-dialog destroy() rationale wrongly credited lib.rs interception (its close-requested handler only acts on the main window); the identity module claimed a strictly-once key handoff (save accepts idempotent retries and confirmed overwrites); the onboarding store note conflated the null-returning helper with the hook's 'pending' status; and the autostart hook does not re-read OS state after a write. Also tightens the sessions.ts camelCase/snake_case direction note. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0171qBAM76TzzpJptZuiua1o --- src-tauri/src/commands/ai_dialog.rs | 13 ++++++++----- src-tauri/src/commands/identity.rs | 8 +++++--- src/features/onboarding/store.ts | 5 +++-- src/features/system/useAutostart.ts | 8 +++++--- src/lib/db/sessions.ts | 6 +++--- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/ai_dialog.rs b/src-tauri/src/commands/ai_dialog.rs index e8d25c0..1eabf16 100644 --- a/src-tauri/src/commands/ai_dialog.rs +++ b/src-tauri/src/commands/ai_dialog.rs @@ -21,11 +21,14 @@ pub const AI_DIALOG_LABEL: &str = "ai-dialog"; const DIALOG_WIDTH: f64 = 460.0; const DIALOG_HEIGHT: f64 = 220.0; -/// Toggles the dialog: destroys it if already open (`destroy()`, not -/// `close()`, so the close-requested interception in `lib.rs` can't veto a -/// toggle), otherwise creates it. Not a `#[tauri::command]` — invoked from -/// the global-shortcut handler in `lib.rs`, which `let _ =`-captures the -/// Result to keep the app responsive on builder failure. +/// Toggles the dialog: destroys it if already open, otherwise creates it. +/// `destroy()` rather than `close()` because a toggle wants immediate, +/// unconditional teardown — `close()` goes through the asynchronous +/// close-requested roundtrip (interceptable machinery this window doesn't +/// need; `lib.rs`'s handler only acts on the `main` window anyway). Not a +/// `#[tauri::command]` — invoked from the global-shortcut handler in +/// `lib.rs`, which `let _ =`-captures the Result to keep the app responsive +/// on builder failure. pub fn toggle_ai_dialog(app: &AppHandle) -> Result<(), tauri::Error> { if let Some(existing) = app.get_webview_window(AI_DIALOG_LABEL) { existing.destroy()?; diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs index fa334db..c66346e 100644 --- a/src-tauri/src/commands/identity.rs +++ b/src-tauri/src/commands/identity.rs @@ -3,9 +3,11 @@ //! //! Key custody model: the Ed25519 + X25519 private keys (both HKDF-derived //! from the 24-word mnemonic on the JS side — see `src/lib/crypto/identity.ts`) -//! are handed to Rust exactly once at save time and live in the OS keychain -//! (service `com.studyvis.app`, user `identity-keys`) — never on plaintext -//! disk. JS asks Rust to sign/open-box on its behalf afterwards. The module is +//! are submitted to Rust at save/recovery time (`identity_save_keys` accepts +//! idempotent retries and, on the confirmed recovery path, explicit +//! overwrites) and are retained in the OS keychain (service +//! `com.studyvis.app`, user `identity-keys`) — never on plaintext disk. JS +//! asks Rust to sign/open-box on its behalf afterwards. The module is //! macOS/Windows-only because `keyring` has no Linux backend wired //! (`commands/mod.rs`). //! diff --git a/src/features/onboarding/store.ts b/src/features/onboarding/store.ts index c54c214..a1fbba0 100644 --- a/src/features/onboarding/store.ts +++ b/src/features/onboarding/store.ts @@ -1,7 +1,8 @@ // Onboarding-completion flag, persisted as `onboarding_completed_at` in the // Tauri store file `app-state.json` (separate from settings.json). Outside a -// Tauri runtime (Storybook / node tests) the read resolves to 'pending' and -// writes no-op, so onboarding simply renders. +// Tauri runtime (Storybook / node tests) `readOnboardingCompletedAt()` +// resolves to null and the writes no-op; `useOnboardingState()` maps that to +// a 'pending' status, so onboarding simply renders. import { useCallback, useEffect, useState } from 'react' import { LazyStore } from '@tauri-apps/plugin-store' diff --git a/src/features/system/useAutostart.ts b/src/features/system/useAutostart.ts index a92dbab..3140b70 100644 --- a/src/features/system/useAutostart.ts +++ b/src/features/system/useAutostart.ts @@ -1,7 +1,9 @@ // Hook wrapping the OS launch-at-login toggle. The OS registry is the source -// of truth (state is re-read after every write); a request-id guard makes -// rapid toggles last-write-wins instead of interleaving. Reports -// 'unavailable' outside the Tauri runtime so Storybook renders a disabled row. +// of truth: initial state is read from it at mount, and a successful toggle +// sets local state from the requested value (no post-write re-read). A +// request-id guard makes rapid toggles last-write-wins instead of +// interleaving. Reports 'unavailable' outside the Tauri runtime so Storybook +// renders a disabled row. import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/src/lib/db/sessions.ts b/src/lib/db/sessions.ts index ab2f929..81b9936 100644 --- a/src/lib/db/sessions.ts +++ b/src/lib/db/sessions.ts @@ -1,8 +1,8 @@ // Typed wrappers over the Rust `sessions_*` commands (local SQLite session // history). Two near-identical shapes on purpose: `SessionRow` (camelCase) is -// the JS→Rust INSERT input — Tauri auto-camelCases invoke args — while -// `SessionRecord` (snake_case) is serde's response shape on reads. Don't -// merge them. +// the JS→Rust INSERT input — Tauri's invoke layer expects camelCase keys and +// matches them to the command's snake_case parameters — while `SessionRecord` +// (snake_case) is serde's response shape on reads. Don't merge them. import { invoke } from '@tauri-apps/api/core'