Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(<https://tauri.app/start/prerequisites/>).

```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
<http://localhost:6006>. 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.
Expand Down
9 changes: 9 additions & 0 deletions scripts/check-tokens.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
43 changes: 23 additions & 20 deletions src-tauri/src/commands/ai_dialog.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
// 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, 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<R: Runtime>(app: &AppHandle<R>) -> 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(());
}
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/commands/friends.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
//! 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 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`).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//!
//! `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;

Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/commands/models.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
//! GGUF model-file management on disk under `<data>/studyvis/models/<id>/`:
//! 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;
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/commands/sessions.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/src/commands/sidecar.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
//! llama-server sidecar lifecycle: spawn on demand on `127.0.0.1:<random
//! port>`, 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;
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -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],
Expand All @@ -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],
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/db/audit_events.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down
Loading
Loading