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
52 changes: 52 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# SQLRite Desktop

A Tauri 2 + Svelte 5 SQL playground that embeds the SQLRite engine
directly (no FFI hop). Open or create a `.sqlrite` file, browse tables,
run SQL, and — with an Anthropic API key configured — turn
natural-language questions into SQL via the **Ask…** button.

## Run (dev)

```sh
cd desktop
npm install
npm run tauri dev
```

The app starts in-memory ("scratch") — use **New…** / **Open…** to back
it with a file.

## Configuring `ask`

The **Ask…** button calls the LLM server-side (the Rust backend), so the
API key never crosses into the webview. There are two ways to provide it:

1. **⚙ Settings dialog (recommended).** Click the gear icon in the
header and paste an Anthropic key. It's persisted to
`$APP_DATA/com.sqlrite.desktop/settings.json`:

```
~/Library/Application Support/com.sqlrite.desktop/ (macOS)
└── settings.json # { "anthropic_api_key": "sk-ant-…", "model": "…", "max_tokens": … }
```

This works regardless of how the app is launched — including from
Finder / the Dock, which do **not** inherit a shell's environment.

2. **Env var fallback.** If no key is saved in Settings, the backend
falls back to `SQLRITE_LLM_API_KEY` from the environment that launched
the app. Handy for `npm run tauri dev` from a terminal, but it won't
reach the app if you launch it from Finder.

`get_ask_settings` returns `has_api_key: bool` (never the key value), so
the UI can show which source is active — "key configured in settings",
"picked up from SQLRITE_LLM_API_KEY env var", or "no key configured".
With no key from either source, **Ask…** returns a clear
"no API key configured" error pointing back at the ⚙ gear icon.

### Security note

The settings file is **plain JSON on disk** — better UX than an env-var
gate and good enough for a local-first example app, but less secure than
the OS keychain. A production desktop app shipping the same pattern
should reach for `tauri-plugin-keyring` / `keyring-rs` instead.
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 53 additions & 11 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,27 @@
// Prevent a second console window on Windows in release mode.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod settings;

use std::path::PathBuf;
use std::sync::Mutex;

use serde::Serialize;
use sqlrite::ask::{AskConfig, ask_with_database};
use sqlrite::ask::ask_with_database;
use sqlrite::sql::db::table::Value;
use sqlrite::{Database, SQLRiteError, process_command};
use tauri::{Manager, State};

/// Holds the single active database for the app. A `None` means "no
/// database open yet" — the frontend should nudge the user toward
/// `.open`.
use settings::{AskSettings, AskSettingsDto, AskSettingsUpdate};

/// Holds the single active database for the app plus the path to the
/// on-disk `settings.json`. The settings path is immutable for the
/// app's lifetime, so it lives outside the mutex; reads/writes go
/// through `AskSettings::load` / `AskSettings::save` which touch disk
/// directly.
struct AppState {
db: Mutex<Database>,
settings_path: PathBuf,
}

/// Mirrors `SecondaryIndex` enough for the UI's sidebar — just name,
Expand Down Expand Up @@ -176,12 +183,11 @@ fn table_rows(
/// Flow:
/// 1. Lock the `Database` (so the schema dump is consistent with what
/// the user would see if they ran `.tables`).
/// 2. Read `AskConfig` from the process's environment
/// (`SQLRITE_LLM_API_KEY` etc.). Tauri inherits the parent shell's
/// env when launched via `npm run tauri dev` / when launched from
/// a terminal in production. If the var isn't set, the call fails
/// with a clear "missing API key" message that the frontend surfaces
/// in the existing error slot.
/// 2. Build `AskConfig` from the saved settings (`settings.json`),
/// falling back to `SQLRITE_LLM_API_KEY` in the environment when no
/// key is saved. If neither is present, the call fails with a clear
/// "no API key configured" message — pointing at the ⚙ gear icon —
/// that the frontend surfaces in the existing error slot.
/// 3. Call into `sqlrite::ask::ask_with_database` — schema dump +
/// cache-friendly prompt + sync HTTP POST happens in the Rust
/// backend. **The API key never crosses into the webview.** That's
Expand All @@ -203,7 +209,8 @@ fn table_rows(
/// strings ask() produces.
#[tauri::command]
fn ask_sql(question: String, state: State<'_, AppState>) -> Result<AskCommandResult, String> {
let cfg = AskConfig::from_env().map_err(|e| format!("ask config error: {e}"))?;
let saved = AskSettings::load(&state.settings_path);
let cfg = settings::build_ask_config(&saved)?;
let locked = state.db.lock().map_err(engine_err)?;
let resp = ask_with_database(&*locked, &question, &cfg).map_err(engine_err)?;
Ok(AskCommandResult {
Expand All @@ -212,6 +219,28 @@ fn ask_sql(question: String, state: State<'_, AppState>) -> Result<AskCommandRes
})
}

/// Returns the current `ask` settings, scrubbed for the webview — the
/// raw API key never crosses the IPC boundary, only `has_api_key`.
#[tauri::command]
fn get_ask_settings(state: State<'_, AppState>) -> Result<AskSettingsDto, String> {
Ok(AskSettings::load(&state.settings_path).to_dto())
}

/// Applies a partial update to `settings.json` (three-valued per field —
/// see [`AskSettingsUpdate`]) and returns the scrubbed result.
#[tauri::command]
fn update_ask_settings(
update: AskSettingsUpdate,
state: State<'_, AppState>,
) -> Result<AskSettingsDto, String> {
let mut current = AskSettings::load(&state.settings_path);
current.apply_update(update);
current
.save(&state.settings_path)
.map_err(|e| format!("save settings: {e}"))?;
Ok(current.to_dto())
}

#[tauri::command]
fn execute_sql(sql: String, state: State<'_, AppState>) -> Result<CommandResult, String> {
let mut locked = state.db.lock().map_err(engine_err)?;
Expand Down Expand Up @@ -344,8 +373,19 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
// Resolve the OS app-data directory so the `ask` settings
// file has a stable home. The playground starts in-memory
// (no DB file), but the settings live on disk regardless so
// a saved API key survives restarts.
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("app_data_dir: {e}"))?;
std::fs::create_dir_all(&app_data_dir)?;
let settings_path = settings::settings_path(&app_data_dir);
app.manage(AppState {
db: Mutex::new(Database::new("scratch".into())),
settings_path,
});
Ok(())
})
Expand All @@ -356,6 +396,8 @@ fn main() {
table_rows,
execute_sql,
ask_sql,
get_ask_settings,
update_ask_settings,
])
.run(tauri::generate_context!())
.expect("error while running sqlrite-desktop");
Expand Down
Loading
Loading