diff --git a/AGENTS.md b/AGENTS.md index 88f195ccc..6fb530200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,14 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa | [synkronus-portal](synkronus-portal/) | Web administration | React, TypeScript, Vite | [synkronus-portal/AGENTS.md](synkronus-portal/AGENTS.md) | | [packages/tokens](packages/tokens/) | Design tokens (`@ode/tokens`) | Style Dictionary | [packages/tokens/AGENTS.md](packages/tokens/AGENTS.md) | | [packages/components](packages/components/) | Shared UI (`@ode/components`) | React | [packages/components/AGENTS.md](packages/components/AGENTS.md) | +| [desktop](desktop/) | Data management + Forms / app workbench (Tauri) | React, Rust | [desktop/AGENTS.md](desktop/AGENTS.md) | --- ## Cross-cutting contracts - **Formulus ↔ WebView (custom apps + formplayer):** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) is the **source of truth** for the injected JavaScript API. Formplayer copies a synced TypeScript snapshot via `npm run sync-interface` in `formulus-formplayer` (see [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md)). +- **ODE Desktop workbench developer mode:** local custom app mirror under `bundles/dev-local/` (profile-scoped); see [desktop/AGENTS.md](desktop/AGENTS.md) and [developer mode guide](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode). - **Built-in attachment fields:** `photo`, `audio`, `video`, and generic file (`select_file`) persist attachment **basenames** (and metadata) in observation JSON while binaries live under Formulus **`attachments/`** storage and sync via the attachment pipeline—see published docs ([form specifications](https://opendataensemble.org/docs/reference/form-specifications), [form design guide](https://opendataensemble.org/docs/guides/form-design)) and [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts). - **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS). @@ -75,13 +77,9 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa --- -## Planned (not shipped here) +## Roadmap -Do **not** implement or assume APIs for these as if they were in-repo unless issues/specs say otherwise: - -- **ODE Desktop** — Tauri app: **Data management** and **Forms / app workbench** in one shell; see [ROADMAP.md](ROADMAP.md). - -See [product roadmap context](https://opendataensemble.org/docs/) and organization roadmaps on [GitHub](https://github.com/OpenDataEnsemble). +ODE Desktop ships in [`desktop/`](desktop/) (see [desktop/AGENTS.md](desktop/AGENTS.md)). Broader product direction: [ROADMAP.md](ROADMAP.md) and [opendataensemble.org](https://opendataensemble.org/docs/). --- diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md new file mode 100644 index 000000000..7a19520a3 --- /dev/null +++ b/desktop/AGENTS.md @@ -0,0 +1,98 @@ +# ODE Desktop — AI and developer guide + +**ODE Desktop** (`desktop/`) is the Tauri + React + Rust app for **Data management** (observations, sync, import) and the **Forms / app workbench** (bundles, form preview, custom app embed). User-facing overview: [desktop/README.md](README.md). + +Published docs: [ODE Desktop developer mode](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode) (local custom app iteration). + +--- + +## Layout + +| Area | Path | Notes | +| ----------------- | -------------------------------------------------------------- | --------------------------------------------------------- | +| Frontend | `src/` | React, Zustand (`useCustodianStore`), workbench pages | +| Backend | `src-tauri/src/lib.rs` | Workspace, bundles, SQLite, sync, dev mirror | +| Formplayer assets | `public/formplayer_dist/` | Copy from `formulus-formplayer` (`pnpm build:formplayer`) | +| Bridge | `public/formulus-injection.js`, `src/lib/formPreviewBridge.ts` | Same contract as Formulus WebView | + +**Profiles** are server-scoped settings in Tauri config: workspace path, Synk credentials, and workbench options. Switching profile switches workspace + DB. + +--- + +## Workbench developer mode + +Lets authors iterate on a **local build** of a custom app (e.g. `dist/`) against a profile’s real observations **without** overwriting the Synk-downloaded bundle in `bundles/active/`. + +### Profile fields + +| Field | Type | Purpose | +| ------------------------ | ---------------- | ----------------------------------------------- | +| `customAppDeveloperMode` | `boolean` | When true, workbench app + forms use dev mirror | +| `customAppLocalFolder` | `string \| null` | Absolute path to folder containing `index.html` | + +Persisted per profile via `upsertProfileRemote` / Rust `ServerProfile`. + +### Workspace paths + +| Mode | Custom app | Forms | +| ---- | --------------------------------- | -------------------------------------------------------------- | +| Off | `bundles/active/app/` | `bundles/active/forms/` | +| On | `bundles/dev-local/app/` (mirror) | `bundles/dev-local/forms/` (mirror if `/forms` exists) | + +Synk downloads and **Refresh from server** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified. + +### UI + +- **Configure:** Workbench → **Bundles** → `DeveloperModePanel` (`variant="full"`): Off/On toggle, folder picker, Browse, Refresh app. +- **Banner:** When on, `DeveloperModePanel` (`variant="banner"`) in `App.tsx` `Shell` on all `/workbench/*` routes, **above** activity/sync banners (path + Refresh app). +- **Consumers:** `WorkbenchCustomAppPage` (embed only), `FormPreviewPage`, `formPreviewBridge` (`getCustomAppUri`, `getFormSpecsUri` via Rust dev-aware roots). + +### Mirror command + +`refresh_custom_app_dev_mirror` (TS: `tauriClient.refreshCustomAppDevMirror()`): + +1. Validates `index.html` at source root. +2. Copies source tree → `bundles/dev-local/app/`. +3. If `source/forms/` exists, copies → `bundles/dev-local/forms/`. + +On success, Zustand bumps `devMirrorGeneration` so embeds and form lists reload. + +### Key TypeScript + +- `src/hooks/useDeveloperMode.ts` — profile read/write, refresh, generation counter from store. +- `src/components/DeveloperModePanel.tsx` — full vs banner UI; auto-mirror `useEffect` only on `variant="full"`. +- `src/lib/bundleLayout.ts` — `bundleSegment()`, `bundleFormsRel()`. +- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`. + +### Key Rust + +- `profile_developer_mode`, `bundle_segment`, `bundle_form_roots_for_ctx` +- Dev-aware: `list_active_bundle_forms`, `read_bundle_form_spec`, `get_active_bundle_forms_file_base_url`, `scan_bundle_custom_question_types`, `bundle_app_config_path` +- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree` + +### Errors + +Developer mode on with missing/invalid folder → blocking error in UI; no silent fallback to `bundles/active/` for custom app load. + +--- + +## Bridge and bundles + +- **Contract source of truth:** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts). +- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. +- **Extensions:** `bundleExtensionLoader.ts` merges `forms/ext.json` like Formulus `ExtensionService`; pass `developerMode` for path prefix. + +--- + +## Commands + +```bash +cd desktop +pnpm dev # Vite +pnpm tauri dev # Full app +pnpm test # Vitest +pnpm typecheck +cd src-tauri && cargo test +``` + +Conventional Commits; see root [AGENTS.md](../AGENTS.md) and [.github/CICD.md](../.github/CICD.md). diff --git a/desktop/README.md b/desktop/README.md index be4f53ddf..9f3190179 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -84,9 +84,22 @@ Override at generation time: CI regenerates the client and **fails** if the repo does not match (`ode-desktop` workflow). +## Developer mode (Workbench) + +For **custom app authors** testing locally before publishing a bundle: + +1. Workbench → **Bundles** → turn **Developer mode** **On** and pick a folder that contains **`index.html`** (e.g. your `dist/` output). +2. Optional: add **`forms/`** next to `index.html` with the usual `{formType}/schema.json` + `ui.json` layout for **Form preview**. +3. Use **Refresh app** after each build (also available from the orange Workbench banner while mode is on). + +Mirrored files live under **`bundles/dev-local/app/`** and **`bundles/dev-local/forms/`** in the active profile workspace. Synk downloads stay in **`bundles/active/`** — use **Refresh from server** on Bundles to update those. + +User guide: [ODE Desktop developer mode](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode). Agent reference: [AGENTS.md](AGENTS.md). + ## Architecture pointers - **Bridge contract**: [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts) — source of truth for `formulusAPI` / postMessage. After changes, run **`sync-interface`** in `formulus-formplayer` and mirror behavior in the desktop WebView host. +- **Dev mirror paths**: `bundles/dev-local/app/`, `bundles/dev-local/forms/` when developer mode is on; `bundles/active/` otherwise (see [AGENTS.md](AGENTS.md)). - **Form preview host** (Workbench → Form preview): `public/formulus-injection.js` + iframe shim; parent handles `postMessage` in **`src/lib/formPreviewBridge.ts`** (explicit matrix per `FormulusInjectionScript` request `type`; device APIs including camera, audio, and video are stubbed in preview; observations + URIs use Tauri where applicable). Nested **sub-observation** flows (`openFormplayer` + `options.subObservationMode`) open a stacked Form preview iframe and resolve the parent promise with `FormCompletionResult` without persisting the child as a top-level observation. - **Bundle extensions**: merge rules for `forms/ext.json` and `forms/{form}/ext.json` follow Formulus `ExtensionService`; see `src/lib/bundleResolution.ts`. - **Embedded formplayer**: production build copied into `public/formplayer_dist/`; load in a WebView with the same **`FormInitData`** expectations as mobile (see `src/lib/formplayerHost.ts` for placeholder types). diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e8cf5b110..9cacf2899 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,12 +1,12 @@ use std::{ collections::{HashMap, HashSet}, fs, - io::{BufWriter, Cursor}, + io::{BufWriter, Cursor, Write}, ops::{Deref, DerefMut}, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, sync::{Arc, Mutex}, - time::UNIX_EPOCH, + time::{Instant, UNIX_EPOCH}, }; use chrono::{DateTime, Utc}; @@ -26,6 +26,8 @@ use zip::read::ZipArchive; use zip::write::SimpleFileOptions; use zip::{CompressionMethod, ZipWriter}; +mod observation_index; +mod observation_query; mod sync_engine; #[derive(Debug, Error)] @@ -111,6 +113,10 @@ struct ServerProfile { environment: ProfileEnvironment, #[serde(default)] default_app_mode: DefaultAppMode, + #[serde(default)] + custom_app_developer_mode: bool, + #[serde(default)] + custom_app_local_folder: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -694,6 +700,7 @@ fn init_db(conn: &Connection) -> Result<(), CustodianError> { ); CREATE INDEX IF NOT EXISTS idx_observations_dirty ON observations(dirty); CREATE INDEX IF NOT EXISTS idx_observations_sync_status ON observations(sync_status); + CREATE INDEX IF NOT EXISTS idx_observations_form_type ON observations(form_type); CREATE TABLE IF NOT EXISTS sync_state ( id INTEGER PRIMARY KEY CHECK (id = 1), last_pull_at TEXT, @@ -710,9 +717,28 @@ fn init_db(conn: &Connection) -> Result<(), CustodianError> { )?; migrate_repository_generation_fresh_install_defaults(conn)?; sync_engine::migrate_sync_jobs_db(conn).map_err(CustodianError::Sqlite)?; + observation_index::migrate_index_schema(conn).map_err(CustodianError::Sqlite)?; Ok(()) } +fn bundle_app_config_path(ctx: &AppCtxHandle) -> Result { + let workspace = get_workspace_path(ctx).map_err(|e| e.to_string())?; + let dev = profile_developer_mode(ctx)?; + let seg = bundle_segment(dev); + Ok(workspace + .join("bundles") + .join(seg) + .join("app") + .join("app.config.json")) +} + +fn load_active_index_defs(ctx: &AppCtxHandle) -> Vec { + bundle_app_config_path(ctx) + .ok() + .map(|p| observation_index::load_index_config(&p)) + .unwrap_or_default() +} + /// Older builds defaulted `repository_generation` to 1, which Synkronus treats as an explicit /// epoch — fresh profiles then got HTTP 409 against servers at generation > 1. Generation `0` /// means "not yet aligned" (omit epoch on pull/push like Formulus). Reset rows that still look @@ -788,6 +814,8 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { attachments_path: None, environment: ProfileEnvironment::default(), default_app_mode: DefaultAppMode::default(), + custom_app_developer_mode: false, + custom_app_local_folder: None, }], } } @@ -809,6 +837,8 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig attachments_path: None, environment: ProfileEnvironment::default(), default_app_mode: DefaultAppMode::default(), + custom_app_developer_mode: false, + custom_app_local_folder: None, }], } } @@ -921,20 +951,120 @@ fn path_is_strict_descendant(ancestor: &Path, maybe_desc: &Path) -> bool { } fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), CustodianError> { + copy_dir_recursive_counting(src, dst, &mut 0) +} + +fn should_skip_mirror_entry(name: &str) -> bool { + matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini") +} + +fn copy_dir_recursive_counting( + src: &Path, + dst: &Path, + copied_files: &mut u64, +) -> Result<(), CustodianError> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if should_skip_mirror_entry(name_str.as_ref()) { + continue; + } let path = entry.path(); - let target = dst.join(entry.file_name()); + let target = dst.join(&name); if path.is_dir() { - copy_dir_recursive(&path, &target)?; + copy_dir_recursive_counting(&path, &target, copied_files)?; } else { fs::copy(&path, &target)?; + *copied_files += 1; } } Ok(()) } +const CUSTOM_APP_DEV_MIRROR_INDEX_REL: &str = "bundles/dev-local/app/index.html"; + +fn validate_custom_app_dev_source_folder(source: &Path) -> Result<(), String> { + if !source.exists() { + return Err(format!("local folder does not exist: {}", source.display())); + } + if !source.is_dir() { + return Err("local folder must be a directory".to_string()); + } + let index = source.join("index.html"); + if !index.is_file() { + return Err("local folder must contain index.html".to_string()); + } + Ok(()) +} + +fn mirror_custom_app_dev_folder(ws: &Path, source: &Path) -> Result { + validate_custom_app_dev_source_folder(source).map_err(CustodianError::Message)?; + let dev_local = ws.join("bundles/dev-local"); + if dev_local.exists() { + fs::remove_dir_all(&dev_local)?; + } + let mirror_app = dev_local.join("app"); + let mut copied_files = 0u64; + copy_dir_recursive_counting(source, &mirror_app, &mut copied_files)?; + let mirrored_index = mirror_app.join("index.html"); + if !mirrored_index.is_file() { + return Err(CustodianError::Message( + "mirror failed: index.html missing after copy".to_string(), + )); + } + let forms_src = source.join("forms"); + if forms_src.is_dir() { + let mirror_forms = dev_local.join("forms"); + copy_dir_recursive_counting(&forms_src, &mirror_forms, &mut copied_files)?; + } + Ok(copied_files) +} + +fn profile_developer_mode(ctx: &AppCtxHandle) -> Result { + let cfg = ctx + .config + .lock() + .map_err(|_| "failed to lock config".to_string())?; + let profile = active_profile_ref(&cfg).map_err(|e: CustodianError| e.to_string())?; + Ok(profile.custom_app_developer_mode) +} + +fn bundle_segment(dev: bool) -> &'static str { + if dev { "dev-local" } else { "active" } +} + +fn bundle_form_roots(workspace: &Path, dev: bool) -> Vec { + let seg = bundle_segment(dev); + vec![ + workspace.join("bundles").join(seg).join("forms"), + workspace + .join("bundles") + .join(seg) + .join("app") + .join("forms"), + ] +} + +fn bundle_form_roots_for_ctx(ctx: &AppCtxHandle) -> Result, String> { + let ws = get_workspace_path(ctx).map_err(|e| e.to_string())?; + let dev = profile_developer_mode(ctx)?; + Ok(bundle_form_roots(&ws, dev)) +} + +fn bundle_relative_dirs_for_ctx( + ctx: &AppCtxHandle, + suffixes: &[&str], +) -> Result, String> { + let dev = profile_developer_mode(ctx)?; + let seg = bundle_segment(dev); + Ok(suffixes + .iter() + .map(|s| format!("bundles/{seg}/{s}")) + .collect()) +} + fn rename_or_move_entry(src: &Path, dst: &Path) -> Result<(), CustodianError> { if fs::rename(src, dst).is_err() { if src.is_dir() { @@ -1634,6 +1764,17 @@ fn save_observation( .map_err(|err| err.to_string())?; tx.commit().map_err(|err| err.to_string())?; + let defs = load_active_index_defs(&ctx); + if !defs.is_empty() { + let ft = req.form_type.as_deref().unwrap_or(""); + let is_deleted = req.extras.as_ref().and_then(|e| e.deleted).unwrap_or(false); + if is_deleted { + let _ = observation_index::delete_observation_indexes(&conn, &req.id); + } else { + let _ = + observation_index::incremental_reindex(&conn, &req.id, ft, &payload_raw, &defs); + } + } } get_observation(req.id, ctx) } @@ -1857,6 +1998,283 @@ fn map_observation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result, + params: &[observation_query::SqlParam], +) -> Result<(), rusqlite::Error> { + for (i, p) in params.iter().enumerate() { + let idx = i + 1; + match p { + observation_query::SqlParam::Text(s) => stmt.raw_bind_parameter(idx, s.as_str())?, + observation_query::SqlParam::Integer(n) => stmt.raw_bind_parameter(idx, *n)?, + observation_query::SqlParam::Real(f) => stmt.raw_bind_parameter(idx, *f)?, + observation_query::SqlParam::Null => { + stmt.raw_bind_parameter(idx, rusqlite::types::Null)? + } + } + } + Ok(()) +} + +fn query_sql_preview(sql: &str) -> String { + const MAX_SQL_CHARS: usize = 240; + let compact = sql.split_whitespace().collect::>().join(" "); + if compact.len() <= MAX_SQL_CHARS { + compact + } else { + format!("{}...", &compact[..MAX_SQL_CHARS]) + } +} + +fn query_filter_preview(filter: Option<&Value>) -> String { + const MAX_FILTER_CHARS: usize = 240; + let Some(f) = filter else { + return "null".to_string(); + }; + let raw = serde_json::to_string(f).unwrap_or_else(|_| "".to_string()); + if raw.len() <= MAX_FILTER_CHARS { + raw + } else { + format!("{}...", &raw[..MAX_FILTER_CHARS]) + } +} + +fn query_log(message: &str) { + eprintln!("{message}"); + let _ = std::io::stderr().flush(); +} + +fn query_param_values(params: &[observation_query::SqlParam]) -> String { + params + .iter() + .map(|p| match p { + observation_query::SqlParam::Text(s) => format!("'{}'", s.replace('\'', "''")), + observation_query::SqlParam::Integer(n) => n.to_string(), + observation_query::SqlParam::Real(f) => f.to_string(), + observation_query::SqlParam::Null => "NULL".to_string(), + }) + .collect::>() + .join(", ") +} + +fn query_param_summary(params: &[observation_query::SqlParam]) -> String { + params + .iter() + .enumerate() + .map(|(i, p)| { + let label = match p { + observation_query::SqlParam::Text(_) => "text", + observation_query::SqlParam::Integer(_) => "int", + observation_query::SqlParam::Real(_) => "real", + observation_query::SqlParam::Null => "null", + }; + format!("${}:{}", i + 1, label) + }) + .collect::>() + .join(", ") +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RebuildObservationIndexesResult { + generation: i64, + last_rebuild_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateObservationSqliteIndexesResult { + created_count: usize, + executed_statements: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct IndexRebuildStatus { + active_generation: i64, + last_rebuild_at: Option, +} + +#[tauri::command] +fn query_observations( + req: observation_query::QueryObservationsRequest, + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result, String> { + let started = Instant::now(); + let conn = open_db(&ctx).map_err(|err| err.to_string())?; + let defs = load_active_index_defs(&ctx); + let mut index_keys = observation_index::index_keys_set(&defs); + let filter_ref = req.filter.as_ref(); + if filter_ref.is_some() && !index_keys.is_empty() { + let active_generation = observation_index::active_generation(&conn).unwrap_or(1); + let has_index_rows = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM observation_index WHERE index_generation = ?1 LIMIT 1)", + params![active_generation], + |row| row.get::<_, i64>(0), + ) + .map(|v| v == 1) + .unwrap_or(false); + if !has_index_rows { + query_log( + "[query_observations] active observation_index rows missing; forcing json_extract fallback for correctness", + ); + index_keys.clear(); + } + } + let compiled = observation_query::compile_observation_query( + &req.form_type, + req.include_deleted.unwrap_or(false), + filter_ref, + &index_keys, + ) + .map_err(|e| format!("{}: {}", e.code, e.message))?; + + if !compiled.warnings.is_empty() { + for warning in &compiled.warnings { + eprintln!("observation query warning: {warning}"); + } + } + + let mut sql = compiled.sql; + if let Some(limit) = req.limit { + sql.push_str(&format!( + " ORDER BY o.last_saved_at DESC LIMIT {}", + limit.clamp(1, 5000) + )); + } else { + sql.push_str(" ORDER BY o.last_saved_at DESC LIMIT 5000"); + } + + query_log(&format!( + "[query_observations] start form_type={} include_deleted={} has_filter={} limit={} sql=\"{}\" param_count={} params=[{}]", + req.form_type, + req.include_deleted.unwrap_or(false), + filter_ref.is_some(), + req.limit.unwrap_or(5000), + query_sql_preview(&sql), + compiled.params.len(), + query_param_summary(&compiled.params), + )); + query_log(&format!("[query_observations] sql_full={}", sql)); + query_log(&format!( + "[query_observations] params_full=[{}]", + query_param_values(&compiled.params) + )); + query_log(&format!( + "[query_observations] filter_ast={}", + query_filter_preview(filter_ref) + )); + let mut stmt = conn.prepare(&sql).map_err(|err| err.to_string())?; + bind_query_params(&mut stmt, &compiled.params).map_err(|err| err.to_string())?; + let mut out = Vec::new(); + let mut rows = stmt.raw_query(); + while let Some(row) = rows.next().map_err(|err| { + query_log(&format!( + "[query_observations] error phase=iterate elapsed_ms={} err={}", + started.elapsed().as_millis(), + err + )); + err.to_string() + })? { + out.push(map_observation_row(row).map_err(|err| { + query_log(&format!( + "[query_observations] error phase=map_row elapsed_ms={} err={}", + started.elapsed().as_millis(), + err + )); + err.to_string() + })?); + } + query_log(&format!( + "[query_observations] done rows={} elapsed_ms={}", + out.len(), + started.elapsed().as_millis() + )); + Ok(out) +} + +#[tauri::command] +async fn rebuild_observation_indexes( + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let ctx = ctx.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let defs = load_active_index_defs(&ctx); + let conn = open_db(&ctx).map_err(|err| err.to_string())?; + let generation = + observation_index::rebuild_all_indexes(&conn, &defs).map_err(|err| err.to_string())?; + let last_rebuild_at: Option = conn + .query_row( + "SELECT last_rebuild_at FROM observation_index_meta WHERE id = 1", + [], + |r| r.get(0), + ) + .ok(); + Ok(RebuildObservationIndexesResult { + generation, + last_rebuild_at, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn create_observation_sqlite_indexes( + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let ctx = ctx.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let defs = load_active_index_defs(&ctx); + if defs.is_empty() { + return Ok(CreateObservationSqliteIndexesResult { + created_count: 0, + executed_statements: Vec::new(), + }); + } + let conn = open_db(&ctx).map_err(|err| err.to_string())?; + let executed = observation_index::create_missing_sqlite_indexes(&conn, &defs) + .map_err(|e| e.to_string())?; + query_log(&format!( + "[create_observation_sqlite_indexes] created_count={}", + executed.len() + )); + for statement in &executed { + query_log(&format!( + "[create_observation_sqlite_indexes] executed {}", + statement + )); + } + Ok(CreateObservationSqliteIndexesResult { + created_count: executed.len(), + executed_statements: executed, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +fn get_observation_index_status( + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let conn = open_db(&ctx).map_err(|err| err.to_string())?; + let active_generation = + observation_index::active_generation(&conn).map_err(|err| err.to_string())?; + let last_rebuild_at: Option = conn + .query_row( + "SELECT last_rebuild_at FROM observation_index_meta WHERE id = 1", + [], + |r| r.get(0), + ) + .ok(); + Ok(IndexRebuildStatus { + active_generation, + last_rebuild_at, + }) +} + /// Locally dirty observations eligible for sync push (`dirty = 1`, `sync_status = 'dirty'`). /// Cap matches `list_observations_page` max. const MAX_DIRTY_OBSERVATIONS_FOR_PUSH: i64 = 10_000; @@ -2848,6 +3266,123 @@ fn check_workspace_attachment_presence( Ok(out) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CustomAppDevMirrorResult { + source_path: String, + mirrored_index_relative_path: String, + copied_files: u64, + index_defs_loaded: usize, + #[serde(skip_serializing_if = "Option::is_none")] + index_rebuild_generation: Option, + index_rebuild_scheduled: bool, + sqlite_indexes_needed: bool, + pending_sqlite_index_statements: Vec, +} + +/// Copies the active profile's configured local custom app folder into +/// `bundles/dev-local/app/` (developer mode mirror). Does not modify the source folder. +#[tauri::command] +fn refresh_custom_app_dev_mirror( + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let started = Instant::now(); + let source_path = { + let cfg = ctx + .config + .lock() + .map_err(|_| "failed to lock config".to_string())?; + let profile = active_profile_ref(&cfg).map_err(|e: CustodianError| e.to_string())?; + if !profile.custom_app_developer_mode { + return Err("developer mode is not enabled for the active profile".to_string()); + } + profile + .custom_app_local_folder + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + "custom app local folder is not configured for the active profile".to_string() + })? + }; + let source = PathBuf::from(&source_path); + validate_custom_app_dev_source_folder(&source)?; + let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; + let mirror_started = Instant::now(); + let copied_files = mirror_custom_app_dev_folder(&ws, &source).map_err(|e| e.to_string())?; + let app_config_path = bundle_app_config_path(&ctx).ok(); + query_log(&format!( + "[refresh_custom_app_dev_mirror] source_path={} app_config_path={} app_config_exists={}", + source_path, + app_config_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()), + app_config_path + .as_ref() + .map(|p| p.exists()) + .unwrap_or(false), + )); + let defs = load_active_index_defs(&ctx); + let index_rebuild_generation = None; + let index_rebuild_scheduled = false; + let (sqlite_indexes_needed, pending_sqlite_index_statements) = if defs.is_empty() { + query_log( + "[refresh_custom_app_dev_mirror] no observationIndexes found in mirrored app.config.json", + ); + (false, Vec::new()) + } else { + match open_db(&ctx) { + Ok(conn) => { + let missing = observation_index::missing_sqlite_indexes(&conn, &defs) + .map_err(|e| e.to_string())?; + let pending: Vec = + missing.iter().map(|idx| format!("{};", idx.sql)).collect(); + let needed = !pending.is_empty(); + query_log(&format!( + "[refresh_custom_app_dev_mirror] sqlite_indexes_needed={} defs={} pending_count={}", + needed, + defs.len(), + pending.len() + )); + if needed { + query_log("[refresh_custom_app_dev_mirror] pending_index_sql begin"); + for statement in &pending { + query_log(&format!( + "[refresh_custom_app_dev_mirror] pending_index_sql {}", + statement + )); + } + query_log("[refresh_custom_app_dev_mirror] pending_index_sql end"); + } + (needed, pending) + } + Err(err) => { + query_log(&format!( + "[refresh_custom_app_dev_mirror] sqlite index check skipped err={err}" + )); + (false, Vec::new()) + } + } + }; + query_log(&format!( + "[refresh_custom_app_dev_mirror] done copied_files={} mirror_elapsed_ms={} total_elapsed_ms={}", + copied_files, + mirror_started.elapsed().as_millis(), + started.elapsed().as_millis() + )); + Ok(CustomAppDevMirrorResult { + source_path, + mirrored_index_relative_path: CUSTOM_APP_DEV_MIRROR_INDEX_REL.to_string(), + copied_files, + index_defs_loaded: defs.len(), + index_rebuild_generation, + index_rebuild_scheduled, + sqlite_indexes_needed, + pending_sqlite_index_statements, + }) +} + /// Write arbitrary bytes under the active profile workspace (e.g. `bundles/app-bundle.zip`). /// Rejects empty paths, `..`, and other traversal attempts. #[tauri::command] @@ -2952,18 +3487,17 @@ fn apply_app_bundle_download( if legacy.exists() { let _ = fs::remove_file(&legacy); } + let app_config = active_dir.join("app/app.config.json"); + if app_config.exists() { + let defs = observation_index::load_index_config(&app_config); + let conn = open_db(ctx)?; + let _ = observation_index::rebuild_all_indexes(&conn, &defs); + } Ok(state) }) .map_err(|e: CustodianError| e.to_string()) } -fn active_bundle_form_roots(workspace: &Path) -> Vec { - vec![ - workspace.join("bundles/active/forms"), - workspace.join("bundles/active/app/forms"), - ] -} - fn reserved_form_dir_name(name: &str) -> bool { matches!(name, "extensions" | "question_types" | "validators") || name.starts_with('.') @@ -2985,10 +3519,10 @@ fn sanitize_form_type_id(raw: &str) -> Result { fn list_active_bundle_forms( ctx: tauri::State<'_, AppCtxHandle>, ) -> Result, String> { - let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; + let roots = bundle_form_roots_for_ctx(&ctx)?; let mut seen = HashSet::new(); let mut out = Vec::new(); - for root in active_bundle_form_roots(&ws) { + for root in roots { let rd = match fs::read_dir(&root) { Ok(r) => r, Err(_) => continue, @@ -3019,8 +3553,10 @@ fn read_bundle_form_spec( ctx: tauri::State<'_, AppCtxHandle>, ) -> Result { let ft = sanitize_form_type_id(&form_type)?; - let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - for root in active_bundle_form_roots(&ws) { + let dev = profile_developer_mode(&ctx)?; + let seg = bundle_segment(dev); + let roots = bundle_form_roots_for_ctx(&ctx)?; + for root in roots { let dir = root.join(&ft); let schema_path = dir.join("schema.json"); let ui_path = dir.join("ui.json"); @@ -3039,7 +3575,7 @@ fn read_bundle_form_spec( } } Err(format!( - "Form \"{}\" not found under bundles/active (expected schema.json + ui.json).", + "Form \"{}\" not found under bundles/{seg} (expected schema.json + ui.json).", ft )) } @@ -3134,10 +3670,12 @@ fn get_active_bundle_forms_file_base_url( ctx: tauri::State<'_, AppCtxHandle>, ) -> Result { let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - let forms = ws.join("bundles/active/forms"); + let dev = profile_developer_mode(&ctx)?; + let seg = bundle_segment(dev); + let forms = ws.join("bundles").join(seg).join("forms"); Url::from_directory_path(&forms) .map(|u| u.to_string().trim_end_matches('/').to_string()) - .map_err(|()| "failed to build file URL for bundles/active/forms".to_string()) + .map_err(|()| format!("failed to build file URL for bundles/{seg}/forms")) } /// `file://` URL for an existing directory under the workspace (trailing slash per `Url` rules). @@ -3198,21 +3736,28 @@ fn resolve_attachment_file_url( #[tauri::command] fn scan_bundle_custom_question_types(ctx: tauri::State<'_, AppCtxHandle>) -> Result { let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - // Prefer `bundles/active/app/**` (Synkronus zip layout) before legacy flat paths. - let qt_dirs = [ - "bundles/active/app/question_types", - "bundles/active/app/forms/question_types", - "bundles/active/question_types", - "bundles/active/forms/question_types", - ]; - let val_dirs = [ - "bundles/active/app/validators", - "bundles/active/app/forms/validators", - "bundles/active/validators", - "bundles/active/forms/validators", - ]; - let custom_types = scan_js_modules_first_wins(&ws, &qt_dirs, false)?; - let validators = scan_js_modules_first_wins(&ws, &val_dirs, true)?; + let qt_dirs = bundle_relative_dirs_for_ctx( + &ctx, + &[ + "app/question_types", + "app/forms/question_types", + "question_types", + "forms/question_types", + ], + )?; + let val_dirs = bundle_relative_dirs_for_ctx( + &ctx, + &[ + "app/validators", + "app/forms/validators", + "validators", + "forms/validators", + ], + )?; + let qt_refs: Vec<&str> = qt_dirs.iter().map(String::as_str).collect(); + let val_refs: Vec<&str> = val_dirs.iter().map(String::as_str).collect(); + let custom_types = scan_js_modules_first_wins(&ws, &qt_refs, false)?; + let validators = scan_js_modules_first_wins(&ws, &val_refs, true)?; Ok(bundle_cqt_to_json(custom_types, validators)) } @@ -3258,6 +3803,7 @@ fn import_observations_run( let mut imported = 0usize; let mut conflicts = 0usize; + let index_defs = load_active_index_defs(ctx); for observation in observations { if mark_pending { upsert_observation_from_local_import(&tx, &observation) @@ -3269,6 +3815,17 @@ fn import_observations_run( conflicts += 1; } } + if !index_defs.is_empty() { + let payload = serde_json::to_string(&observation.data).map_err(|e| e.to_string())?; + let form_type = observation.form_type.as_deref().unwrap_or(""); + let _ = observation_index::incremental_reindex( + &tx, + &observation.observation_id, + form_type, + &payload, + &index_defs, + ); + } imported += 1; } if !mark_pending { @@ -3419,6 +3976,7 @@ fn reset_local_workspace_data(ctx: tauri::State<'_, AppCtxHandle>) -> Result").unwrap(); + assert!(validate_custom_app_dev_source_folder(Path::new(&base)).is_ok()); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn mirror_custom_app_dev_folder_copies_tree() { + let base = std::env::temp_dir().join(format!("ode_dev_app_mirror_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let source = base.join("source"); + let ws = base.join("workspace"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(ws.join("bundles/active/app")).unwrap(); + fs::write(source.join("index.html"), b"dev").unwrap(); + fs::write(source.join("app.js"), b"console.log(1)").unwrap(); + fs::create_dir_all(source.join("forms/demo")).unwrap(); + fs::write(source.join("forms/demo/schema.json"), b"{}").unwrap(); + fs::write(source.join("forms/demo/ui.json"), b"{}").unwrap(); + let copied = mirror_custom_app_dev_folder(Path::new(&ws), Path::new(&source)).unwrap(); + assert_eq!(copied, 6); + let mirrored = ws.join("bundles/dev-local/app/index.html"); + assert!(mirrored.is_file()); + assert!(ws.join("bundles/dev-local/app/app.js").is_file()); + assert!( + ws.join("bundles/dev-local/forms/demo/schema.json") + .is_file() + ); + let _ = fs::remove_dir_all(&base); + } + #[test] fn resolve_attachment_falls_back_to_legacy_flat_root() { let base = @@ -3648,4 +4253,24 @@ mod tests { assert!(!p.to_string_lossy().contains("synced")); let _ = fs::remove_dir_all(&base); } + + #[test] + fn bound_query_params_execute_with_raw_query() { + let conn = Connection::open_in_memory().unwrap(); + let mut stmt = conn.prepare("SELECT ?1 AS a, ?2 AS b").unwrap(); + let params = vec![ + SqlParam::Text("household".to_string()), + SqlParam::Integer(7), + ]; + bind_query_params(&mut stmt, ¶ms).unwrap(); + + let mut rows = stmt.raw_query(); + let row = rows.next().unwrap().unwrap(); + let a: String = row.get(0).unwrap(); + let b: i64 = row.get(1).unwrap(); + + assert_eq!(a, "household"); + assert_eq!(b, 7); + assert!(rows.next().unwrap().is_none()); + } } diff --git a/desktop/src-tauri/src/observation_index.rs b/desktop/src-tauri/src/observation_index.rs new file mode 100644 index 000000000..02adb7a2e --- /dev/null +++ b/desktop/src-tauri/src/observation_index.rs @@ -0,0 +1,471 @@ +//! Local observation_index EAV table (never synced) + snapshot generation rebuild. + +use rusqlite::{Connection, params}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashSet; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservationIndexDef { + pub key: String, + pub path: String, + #[serde(default)] + pub value_type: Option, + #[serde(default)] + pub form_types: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +struct AppConfigIndexes { + #[serde(rename = "observationIndexes", alias = "observation_indexes", default)] + observation_indexes: Vec, +} + +pub fn migrate_index_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS observation_index_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_generation INTEGER NOT NULL DEFAULT 1, + building_generation INTEGER, + last_rebuild_at TEXT + ); + INSERT OR IGNORE INTO observation_index_meta(id, active_generation) VALUES (1, 1); + + CREATE TABLE IF NOT EXISTS observation_index ( + observation_id TEXT NOT NULL, + index_key TEXT NOT NULL, + index_generation INTEGER NOT NULL, + value_text TEXT, + value_num REAL, + PRIMARY KEY (observation_id, index_key, index_generation) + ); + CREATE INDEX IF NOT EXISTS idx_observation_index_lookup + ON observation_index(index_generation, index_key, value_text, observation_id); + CREATE INDEX IF NOT EXISTS idx_observation_index_lookup_num + ON observation_index(index_generation, index_key, value_num, observation_id); + "#, + )?; + Ok(()) +} + +pub fn load_index_config(bundle_app_config_path: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(bundle_app_config_path) else { + return Vec::new(); + }; + let Ok(cfg) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + cfg.observation_indexes + .into_iter() + .filter(|d| !d.key.is_empty() && !d.path.is_empty()) + .collect() +} + +pub fn index_keys_set(defs: &[ObservationIndexDef]) -> HashSet { + defs.iter().map(|d| d.key.clone()).collect() +} + +fn form_type_matches(form_type: &str, patterns: Option<&Vec>) -> bool { + let Some(patterns) = patterns else { + return true; + }; + for p in patterns { + if p.ends_with('*') { + let prefix = &p[..p.len() - 1]; + if form_type.starts_with(prefix) { + return true; + } + } else if form_type == p { + return true; + } + } + false +} + +fn json_path_to_key(path: &str) -> String { + path.strip_prefix("$.").unwrap_or(path).to_string() +} + +fn extract_scalar(payload: &str, path: &str) -> Option { + let v: Value = serde_json::from_str(payload).ok()?; + let key = json_path_to_key(path); + v.get(&key).cloned() +} + +pub fn reindex_observation( + conn: &Connection, + observation_id: &str, + form_type: &str, + payload: &str, + defs: &[ObservationIndexDef], + generation: i64, +) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM observation_index WHERE observation_id = ?1 AND index_generation = ?2", + params![observation_id, generation], + )?; + for def in defs { + if !form_type_matches(form_type, def.form_types.as_ref()) { + continue; + } + let Some(val) = extract_scalar(payload, &def.path) else { + continue; + }; + if val.is_null() { + continue; + } + let (value_text, value_num) = scalar_to_columns(&val, def.value_type.as_deref()); + conn.execute( + "INSERT OR REPLACE INTO observation_index (observation_id, index_key, index_generation, value_text, value_num) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![observation_id, def.key, generation, value_text, value_num], + )?; + } + Ok(()) +} + +fn scalar_to_columns(val: &Value, value_type: Option<&str>) -> (Option, Option) { + if (value_type == Some("number") || val.is_number()) + && let Some(n) = val.as_f64() + { + return (None, Some(n)); + } + if val.is_string() { + return (Some(val.as_str().unwrap().to_string()), None); + } + if val.is_number() { + return (Some(val.to_string()), val.as_f64()); + } + if val.as_bool().is_some() { + return (Some(val.to_string()), None); + } + (Some(val.to_string()), None) +} + +pub fn rebuild_all_indexes( + conn: &Connection, + defs: &[ObservationIndexDef], +) -> rusqlite::Result { + let active: i64 = conn.query_row( + "SELECT active_generation FROM observation_index_meta WHERE id = 1", + [], + |r| r.get(0), + )?; + let new_gen = if active == 1 { 2 } else { 1 }; + + conn.execute( + "UPDATE observation_index_meta SET building_generation = ?1 WHERE id = 1", + params![new_gen], + )?; + + conn.execute( + "DELETE FROM observation_index WHERE index_generation = ?1", + params![new_gen], + )?; + + let mut stmt = conn.prepare("SELECT id, form_type, payload FROM observations")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + })?; + + for row in rows { + let (id, form_type, payload) = row?; + let ft = form_type.unwrap_or_default(); + reindex_observation(conn, &id, &ft, &payload, defs, new_gen)?; + } + + recreate_sqlite_indexes(conn, defs)?; + + conn.execute( + "DELETE FROM observation_index WHERE index_generation != ?1", + params![new_gen], + )?; + + conn.execute( + "UPDATE observation_index_meta SET active_generation = ?1, building_generation = NULL, last_rebuild_at = datetime('now') WHERE id = 1", + params![new_gen], + )?; + + Ok(new_gen) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedSqliteIndex { + pub name: String, + pub sql: String, +} + +pub fn planned_sqlite_indexes(defs: &[ObservationIndexDef]) -> Vec { + let mut out = Vec::new(); + out.push(PlannedSqliteIndex { + name: "idx_observations_form_type".to_string(), + sql: "CREATE INDEX IF NOT EXISTS idx_observations_form_type ON observations(form_type)" + .to_string(), + }); + for def in defs { + let idx_name = format!("idx_{}_text", sanitize_ident(&def.key)); + out.push(PlannedSqliteIndex { + name: idx_name.clone(), + sql: format!( + "CREATE INDEX IF NOT EXISTS {idx_name} ON observation_index(value_text) WHERE index_key = '{}'", + def.key.replace('\'', "''") + ), + }); + + let expr_name = format!("data_{}", sanitize_ident(&def.key)); + let json_path = if def.path.starts_with("$.") { + def.path.clone() + } else { + format!("$.{}", def.path) + }; + out.push(PlannedSqliteIndex { + name: expr_name.clone(), + sql: format!( + "CREATE INDEX IF NOT EXISTS {expr_name} ON observations(json_extract(payload, '{json_path}'))" + ), + }); + } + out +} + +fn sqlite_index_exists(conn: &Connection, name: &str) -> rusqlite::Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", + params![name], + |r| r.get(0), + )?; + Ok(count > 0) +} + +pub fn missing_sqlite_indexes( + conn: &Connection, + defs: &[ObservationIndexDef], +) -> rusqlite::Result> { + let mut missing = Vec::new(); + for idx in planned_sqlite_indexes(defs) { + if !sqlite_index_exists(conn, &idx.name)? { + missing.push(idx); + } + } + Ok(missing) +} + +pub fn create_missing_sqlite_indexes( + conn: &Connection, + defs: &[ObservationIndexDef], +) -> rusqlite::Result> { + let missing = missing_sqlite_indexes(conn, defs)?; + let mut executed = Vec::new(); + for idx in missing { + conn.execute(&idx.sql, [])?; + executed.push(format!("{};", idx.sql)); + } + Ok(executed) +} + +pub fn recreate_sqlite_indexes( + conn: &Connection, + defs: &[ObservationIndexDef], +) -> rusqlite::Result<()> { + for idx in planned_sqlite_indexes(defs) { + conn.execute(&idx.sql, [])?; + } + Ok(()) +} + +fn sanitize_ident(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +pub fn delete_observation_indexes(conn: &Connection, observation_id: &str) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM observation_index WHERE observation_id = ?1", + params![observation_id], + )?; + Ok(()) +} + +pub fn active_generation(conn: &Connection) -> rusqlite::Result { + conn.query_row( + "SELECT active_generation FROM observation_index_meta WHERE id = 1", + [], + |r| r.get(0), + ) +} + +pub fn incremental_reindex( + conn: &Connection, + observation_id: &str, + form_type: &str, + payload: &str, + defs: &[ObservationIndexDef], +) -> rusqlite::Result<()> { + let generation = active_generation(conn)?; + reindex_observation(conn, observation_id, form_type, payload, defs, generation) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn test_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + migrate_index_schema(&conn).unwrap(); + conn.execute_batch( + "CREATE TABLE observations ( + id TEXT PRIMARY KEY, + form_type TEXT, + payload TEXT NOT NULL + );", + ) + .unwrap(); + conn + } + + fn sample_defs() -> Vec { + vec![ + ObservationIndexDef { + key: "p_id".into(), + path: "$.p_id".into(), + value_type: None, + form_types: None, + }, + ObservationIndexDef { + key: "age".into(), + path: "$.age".into(), + value_type: Some("number".into()), + form_types: Some(vec!["person".into()]), + }, + ] + } + + #[test] + fn incremental_reindex_writes_index_rows() { + let conn = test_conn(); + let defs = sample_defs(); + conn.execute( + "INSERT INTO observations (id, form_type, payload) VALUES ('obs1', 'person', '{\"p_id\":\"P1\",\"age\":30}')", + [], + ) + .unwrap(); + incremental_reindex( + &conn, + "obs1", + "person", + "{\"p_id\":\"P1\",\"age\":30}", + &defs, + ) + .unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observation_index WHERE observation_id = 'obs1' AND index_generation = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + let p_id: String = conn + .query_row( + "SELECT value_text FROM observation_index WHERE observation_id = 'obs1' AND index_key = 'p_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(p_id, "P1"); + let age: f64 = conn + .query_row( + "SELECT value_num FROM observation_index WHERE observation_id = 'obs1' AND index_key = 'age'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(age, 30.0); + } + + #[test] + fn form_type_glob_skips_non_matching() { + let conn = test_conn(); + let defs = sample_defs(); + incremental_reindex( + &conn, + "obs1", + "household", + "{\"p_id\":\"H1\",\"age\":5}", + &defs, + ) + .unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observation_index WHERE observation_id = 'obs1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn rebuild_swaps_generation() { + let conn = test_conn(); + let defs = sample_defs(); + conn.execute( + "INSERT INTO observations (id, form_type, payload) VALUES ('obs1', 'person', '{\"p_id\":\"P1\"}')", + [], + ) + .unwrap(); + let gen1 = rebuild_all_indexes(&conn, &defs).unwrap(); + assert_eq!(gen1, 2); + let active: i64 = active_generation(&conn).unwrap(); + assert_eq!(active, 2); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observation_index WHERE index_generation = 2", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(rows, 1); + } + + #[test] + fn missing_sqlite_indexes_detects_absent_names() { + let conn = test_conn(); + let defs = sample_defs(); + let missing = missing_sqlite_indexes(&conn, &defs).unwrap(); + let names: Vec<&str> = missing.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"idx_observations_form_type")); + assert!(names.contains(&"idx_p_id_text")); + assert!(names.contains(&"data_p_id")); + recreate_sqlite_indexes(&conn, &defs).unwrap(); + let missing_after = missing_sqlite_indexes(&conn, &defs).unwrap(); + assert!(missing_after.is_empty()); + } + + #[test] + fn delete_observation_indexes_removes_rows() { + let conn = test_conn(); + let defs = sample_defs(); + incremental_reindex(&conn, "obs1", "person", "{\"p_id\":\"P1\"}", &defs).unwrap(); + delete_observation_indexes(&conn, "obs1").unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM observation_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/desktop/src-tauri/src/observation_query.rs b/desktop/src-tauri/src/observation_query.rs new file mode 100644 index 000000000..0007ec46d --- /dev/null +++ b/desktop/src-tauri/src/observation_query.rs @@ -0,0 +1,374 @@ +//! Compile ObservationFilter AST to parameterized SQL for Desktop custodian DB. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryObservationsRequest { + pub form_type: String, + pub include_deleted: Option, + pub filter: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryCompileError { + pub code: String, + pub message: String, +} + +#[derive(Debug)] +pub struct CompiledSql { + pub sql: String, + pub params: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub enum SqlParam { + Text(String), + Integer(i64), + Real(f64), + Null, +} + +pub fn compile_observation_query( + form_type: &str, + include_deleted: bool, + filter: Option<&Value>, + index_keys: &HashSet, +) -> Result { + let _ = include_deleted; + let mut warnings = Vec::new(); + let mut params: Vec = Vec::new(); + let mut where_parts = Vec::new(); + let normalized_form_type = form_type.trim(); + if !normalized_form_type.is_empty() && normalized_form_type != "*" { + where_parts.push("o.form_type = ?".to_string()); + params.push(SqlParam::Text(normalized_form_type.to_string())); + } + + if let Some(f) = filter { + match compile_filter_node(f, index_keys, &mut params, &mut warnings) { + Ok(sql) => where_parts.push(sql), + Err(e) => return Err(e), + } + } + + let sql = format!( + "SELECT o.id, o.payload, o.form_type, o.updated_at, o.remote_updated_at, o.dirty, o.sync_status, o.conflict_payload, o.last_saved_at, o.last_pushed_at, o.observation_extras FROM observations o WHERE {}", + where_parts.join(" AND ") + ); + + Ok(CompiledSql { + sql, + params, + warnings, + }) +} + +fn compile_filter_node( + filter: &Value, + index_keys: &HashSet, + params: &mut Vec, + warnings: &mut Vec, +) -> Result { + let obj = filter.as_object().ok_or_else(|| QueryCompileError { + code: "INVALID_FILTER".into(), + message: "Filter must be an object".into(), + })?; + + let op = obj.get("op").and_then(|v| v.as_str()); + + if op == Some("and") || op == Some("or") { + let conditions = obj + .get("conditions") + .and_then(|v| v.as_array()) + .filter(|a| !a.is_empty()) + .ok_or_else(|| QueryCompileError { + code: "EMPTY_LOGICAL".into(), + message: "Logical filter must have conditions".into(), + })?; + let joiner = if op == Some("and") { " AND " } else { " OR " }; + let mut parts = Vec::new(); + for c in conditions { + parts.push(format!( + "({})", + compile_filter_node(c, index_keys, params, warnings)? + )); + } + return Ok(format!("({})", parts.join(joiner))); + } + + if op == Some("any") { + return compile_quantifier(obj, params); + } + + compile_condition(obj, index_keys, params, warnings) +} + +fn compile_quantifier( + obj: &serde_json::Map, + params: &mut Vec, +) -> Result { + let path = obj + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| QueryCompileError { + code: "INVALID_QUANTIFIER".into(), + message: "any() requires path".into(), + })?; + let alias = obj + .get("as") + .and_then(|v| v.as_str()) + .ok_or_else(|| QueryCompileError { + code: "INVALID_QUANTIFIER".into(), + message: "any() requires as".into(), + })?; + let where_clause = obj.get("where").ok_or_else(|| QueryCompileError { + code: "INVALID_QUANTIFIER".into(), + message: "any() requires where".into(), + })?; + let where_obj = where_clause.as_object().ok_or_else(|| QueryCompileError { + code: "INVALID_QUANTIFIER".into(), + message: "where must be object".into(), + })?; + let member_field = where_obj + .get("field") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let value = where_obj.get("value").cloned().unwrap_or(Value::Null); + let member_key = member_field + .strip_prefix(&format!("{alias}.")) + .or_else(|| member_field.strip_prefix("data.")) + .unwrap_or(member_field); + let array_path = path.strip_prefix("data.").unwrap_or(path); + let json_path = format!("$.{}", array_path); + let p = push_param(params, &value); + Ok(format!( + "EXISTS (SELECT 1 FROM json_each(o.payload, '{json_path}') AS {alias} WHERE json_extract({alias}.value, '$.{member_key}') = {p})" + )) +} + +fn compile_condition( + obj: &serde_json::Map, + index_keys: &HashSet, + params: &mut Vec, + warnings: &mut Vec, +) -> Result { + let field = obj + .get("field") + .and_then(|v| v.as_str()) + .ok_or_else(|| QueryCompileError { + code: "INVALID_FIELD".into(), + message: "Condition requires field".into(), + })?; + let op = obj + .get("op") + .and_then(|v| v.as_str()) + .ok_or_else(|| QueryCompileError { + code: "INVALID_OP".into(), + message: "Condition requires op".into(), + })?; + let value = obj.get("value").cloned().unwrap_or(Value::Null); + + if field == "observation_id" { + let p = push_param(params, &value); + return Ok(format!("o.id = {p}")); + } + + if !field.starts_with("data.") { + return Err(QueryCompileError { + code: "INVALID_FIELD".into(), + message: format!("Unknown field: {field}"), + }); + } + + let index_key = &field[5..]; + let json_path = format!("$.{}", index_key); + + if index_keys.contains(index_key) { + return compile_index_condition(index_key, op, &value, params); + } + + warnings.push(format!( + "Undeclared index for {field}; using json_extract fallback" + )); + compile_json_extract(op, &json_path, &value, params) +} + +fn compile_index_condition( + index_key: &str, + op: &str, + value: &Value, + params: &mut Vec, +) -> Result { + let key_ph = push_param(params, &Value::String(index_key.to_string())); + + if op == "in" { + let arr = value.as_array().ok_or_else(|| QueryCompileError { + code: "INVALID_IN".into(), + message: "in requires array value".into(), + })?; + if arr.is_empty() { + return Ok("0".into()); + } + let placeholders: Vec = arr.iter().map(|v| push_param(params, v)).collect(); + let all_num = arr.iter().all(|v| v.is_number()); + if all_num { + return Ok(format!( + "EXISTS (SELECT 1 FROM observation_index idx WHERE idx.observation_id = o.id AND idx.index_key = {key_ph} AND idx.index_generation = (SELECT active_generation FROM observation_index_meta WHERE id = 1) AND idx.value_num IN ({}))", + placeholders.join(",") + )); + } + return Ok(format!( + "EXISTS (SELECT 1 FROM observation_index idx WHERE idx.observation_id = o.id AND idx.index_key = {key_ph} AND idx.index_generation = (SELECT active_generation FROM observation_index_meta WHERE id = 1) AND idx.value_text IN ({}))", + placeholders.join(",") + )); + } + + let numeric_ops = ["eq", "neq", "gt", "gte", "lt", "lte"]; + if value.is_number() && numeric_ops.contains(&op) { + let p = push_param(params, value); + let sql_op = match op { + "eq" => "=", + "neq" => "!=", + "gt" => ">", + "gte" => ">=", + "lt" => "<", + "lte" => "<=", + _ => "=", + }; + return Ok(format!( + "EXISTS (SELECT 1 FROM observation_index idx WHERE idx.observation_id = o.id AND idx.index_key = {key_ph} AND idx.index_generation = (SELECT active_generation FROM observation_index_meta WHERE id = 1) AND idx.value_num {sql_op} {p})" + )); + } + + if op == "eq" || op == "neq" { + let p = push_param(params, value); + let sql_op = if op == "eq" { "=" } else { "!=" }; + return Ok(format!( + "EXISTS (SELECT 1 FROM observation_index idx WHERE idx.observation_id = o.id AND idx.index_key = {key_ph} AND idx.index_generation = (SELECT active_generation FROM observation_index_meta WHERE id = 1) AND idx.value_text {sql_op} {p})" + )); + } + + Err(QueryCompileError { + code: "UNSUPPORTED_OP".into(), + message: format!("Unsupported op {op} on indexed field"), + }) +} + +fn compile_json_extract( + op: &str, + json_path: &str, + value: &Value, + params: &mut Vec, +) -> Result { + let expr = format!("json_extract(o.payload, '{json_path}')"); + if op == "in" { + let arr = value.as_array().ok_or_else(|| QueryCompileError { + code: "INVALID_IN".into(), + message: "in requires array".into(), + })?; + if arr.is_empty() { + return Ok("0".into()); + } + let ph: Vec = arr.iter().map(|v| push_param(params, v)).collect(); + return Ok(format!("{} IN ({})", expr, ph.join(","))); + } + let sql_op = match op { + "eq" => "=", + "neq" => "!=", + "gt" => ">", + "gte" => ">=", + "lt" => "<", + "lte" => "<=", + _ => { + return Err(QueryCompileError { + code: "UNSUPPORTED_OP".into(), + message: format!("Unsupported op {op}"), + }); + } + }; + let p = push_param(params, value); + Ok(format!("{expr} {sql_op} {p}")) +} + +fn push_param(params: &mut Vec, value: &Value) -> String { + match value { + Value::Null => { + params.push(SqlParam::Null); + } + Value::Bool(b) => { + params.push(SqlParam::Integer(if *b { 1 } else { 0 })); + } + Value::Number(n) => { + if let Some(i) = n.as_i64() { + params.push(SqlParam::Integer(i)); + } else if let Some(f) = n.as_f64() { + params.push(SqlParam::Real(f)); + } + } + Value::String(s) => { + params.push(SqlParam::Text(s.clone())); + } + _ => { + params.push(SqlParam::Text(value.to_string())); + } + } + "?".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::fs; + use std::path::PathBuf; + + #[test] + fn runs_golden_fixtures() { + let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + dir.push("../../packages/observation-query/fixtures"); + for entry in fs::read_dir(&dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let text = fs::read_to_string(&path).unwrap(); + let fixture: Value = serde_json::from_str(&text).unwrap(); + let name = fixture["name"].as_str().unwrap(); + let expect_error = fixture["expectError"].as_bool().unwrap_or(false); + let index_keys: HashSet = fixture["indexKeys"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + let filter = fixture.get("filter"); + let result = compile_observation_query( + fixture["formType"].as_str().unwrap(), + !fixture["includeDeleted"].as_bool().unwrap_or(false), + filter, + &index_keys, + ); + if expect_error { + assert!(result.is_err(), "expected error for {name}"); + continue; + } + let compiled = result.expect(&name); + let fragments = fixture["expectedSqlFragmentsByDialect"]["desktop"] + .as_array() + .or_else(|| fixture["expectedSqlFragments"].as_array()); + if let Some(fragments) = fragments { + for frag in fragments { + let s = frag.as_str().unwrap(); + assert!(compiled.sql.contains(s), "{name} missing {s}"); + } + } + } + } +} diff --git a/desktop/src/App.css b/desktop/src/App.css index 95f60f64d..15a845561 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -215,6 +215,243 @@ body { word-break: break-all; } +.page.page-custom-app .custom-app-dev-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem; + border-radius: 0.5rem; + background: #0a1228; + border: 1px solid #2d3449; +} + +.page.page-custom-app .custom-app-dev-toggle-label { + font-size: 0.78rem; + font-weight: 600; + color: #90a3cb; + padding: 0 0.35rem 0 0.5rem; + white-space: nowrap; +} + +.page.page-custom-app .custom-app-dev-toggle-btn { + font-family: 'Space Grotesk', sans-serif; + font-size: 0.8rem; + min-width: 2.75rem; + padding: 0.4rem 0.65rem; + border: none; + border-radius: 0.4rem; + cursor: pointer; + background: transparent; + color: #90a3cb; + transition: + background 120ms ease, + color 120ms ease; +} + +.page.page-custom-app .custom-app-dev-toggle-btn:hover:not(:disabled) { + color: #d9e3fd; + background: #1b2742; +} + +.page.page-custom-app .custom-app-dev-toggle-btn-active { + background: #2f2ebe; + color: #f3f5ff; +} + +.page.page-custom-app .custom-app-dev-toggle-btn-active:hover:not(:disabled) { + background: #3a38c4; + color: #fff; +} + +.page.page-custom-app + .custom-app-dev-toggle-btn-on.custom-app-dev-toggle-btn-active { + background: #b45309; + color: #fff8f0; +} + +.page.page-custom-app + .custom-app-dev-toggle-btn-on.custom-app-dev-toggle-btn-active:hover:not( + :disabled + ) { + background: #c96a12; + color: #fff; +} + +.page.page-custom-app .custom-app-dev-panel { + flex-shrink: 0; + margin-bottom: 0.75rem; + transition: + background 160ms ease, + border-color 160ms ease; +} + +.page.page-custom-app .custom-app-dev-panel-active { + background: rgba(180, 110, 35, 0.14); + border-color: rgba(210, 140, 60, 0.5); +} + +.page.page-custom-app + .custom-app-dev-panel-active + .custom-app-dev-folder-label { + color: #e8c48a; +} + +.page.page-custom-app .custom-app-dev-folder-label { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.page.page-custom-app .custom-app-dev-folder-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.page.page-custom-app .custom-app-dev-folder-input { + flex: 1; + min-width: 0; +} + +.page.page-custom-app .custom-app-dev-hint { + margin: 0.5rem 0 0; +} + +.dev-mode-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem; + border-radius: 0.5rem; + background: #0a1228; + border: 1px solid #2d3449; +} + +.dev-mode-toggle-label { + font-size: 0.78rem; + font-weight: 600; + color: #90a3cb; + padding: 0 0.35rem 0 0.5rem; + white-space: nowrap; +} + +.dev-mode-toggle-btn { + font-family: 'Space Grotesk', sans-serif; + font-size: 0.8rem; + min-width: 2.75rem; + padding: 0.4rem 0.65rem; + border: none; + border-radius: 0.4rem; + cursor: pointer; + background: transparent; + color: #90a3cb; + transition: + background 120ms ease, + color 120ms ease; +} + +.dev-mode-toggle-btn:hover:not(:disabled) { + color: #d9e3fd; + background: #1b2742; +} + +.dev-mode-toggle-btn-active { + background: #2f2ebe; + color: #f3f5ff; +} + +.dev-mode-toggle-btn-active:hover:not(:disabled) { + background: #3a38c4; + color: #fff; +} + +.dev-mode-toggle-btn-on.dev-mode-toggle-btn-active { + background: #b45309; + color: #fff8f0; +} + +.dev-mode-toggle-btn-on.dev-mode-toggle-btn-active:hover:not(:disabled) { + background: #c96a12; + color: #fff; +} + +.dev-mode-config-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.dev-mode-config-header h3 { + margin: 0; +} + +.dev-mode-panel { + flex-shrink: 0; + margin-bottom: 0.75rem; + transition: + background 160ms ease, + border-color 160ms ease; +} + +.dev-mode-panel-active { + background: rgba(180, 110, 35, 0.14); + border-color: rgba(210, 140, 60, 0.5); +} + +.dev-mode-panel-active .dev-mode-folder-label { + color: #e8c48a; +} + +.dev-mode-folder-label { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.dev-mode-folder-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.dev-mode-folder-input { + flex: 1; + min-width: 0; +} + +.dev-mode-hint { + margin: 0.5rem 0 0; +} + +.dev-mode-banner { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin: 0 0 0.75rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; +} + +.dev-mode-banner-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.dev-mode-banner-path { + font-family: ui-monospace, monospace; + font-size: 0.82rem; + word-break: break-all; + color: #e8d4b0; +} + .page.page-custom-app .custom-app-embed-panel { flex: 1; min-height: 0; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 6043ca0a5..f821097cb 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -15,6 +15,8 @@ import { SyncPage } from './pages/SyncPage'; import { ProfilesPage } from './pages/ProfilesPage'; import { ImportPage } from './pages/ImportPage'; import { FormPreviewPage } from './pages/FormPreviewPage'; +import { DeveloperModePanel } from './components/DeveloperModePanel'; +import { ObservationIndexPrompt } from './components/ObservationIndexPrompt'; import { WorkbenchBundlesPage } from './pages/WorkbenchBundlesPage'; import { WorkbenchCustomAppPage } from './pages/WorkbenchCustomAppPage'; import { useSynkServerStatus } from './hooks/useSynkServerStatus'; @@ -174,6 +176,8 @@ function Shell() { const year = useMemo(() => new Date().getFullYear(), []); const location = useLocation(); const isWorkbench = location.pathname.startsWith('/workbench'); + const activeProfile = useCustodianStore(selectActiveProfileState); + const developerMode = Boolean(activeProfile?.customAppDeveloperMode); const navItems = isWorkbench ? WORKBENCH_NAV : DATA_NAV; const syncMessage = useCustodianStore(s => s.syncMessage); const syncActivity = useCustodianStore(selectSyncActivity); @@ -246,6 +250,10 @@ function Shell() {
+ + {isWorkbench && developerMode ? ( + + ) : null} {showActivityBanner ? (
${baseHref ? wrapped : ''}${html}`; } +export type CustomAppEmbedMode = 'bundle' | 'developer'; + export type CustomAppEmbedProps = { - /** Change when the active bundle (or profile) changes to reload the iframe. */ + /** Change when the active source (bundle, dev mirror, or profile) changes to reload the iframe. */ mountKey: string; + mode: CustomAppEmbedMode; + /** Workspace-relative path to `index.html`; defaults from {@link mode}. */ + indexRelativePath?: string; + loadingLabel?: string; }; +function defaultIndexRelativePath(mode: CustomAppEmbedMode): string { + return mode === 'developer' + ? WORKSPACE_BUNDLE_DEV_APP_INDEX + : CUSTOM_APP_BUNDLE_INDEX_REL; +} + /** - * Loads bundles/active/app/index.html, injects the Formulus bridge, writes merged HTML + * Loads a workspace `index.html`, injects the Formulus bridge, writes merged HTML * back to disk, and loads the iframe via convertFileSrc (real asset document URL). * * A blob: document can resolve subresources against the parent origin instead of the * the asset tree; loading the actual index.html from the asset protocol fixes 404s. * - * Patches: rewriteEmbeddedBundleHtml + patchWorkspaceAppBundleAbsolutePaths (idempotent). + * Patches: rewriteEmbeddedBundleHtml + patchWorkspaceAppBundleAbsolutePaths (bundle mode only). */ export const CustomAppEmbed = forwardRef< HTMLIFrameElement, CustomAppEmbedProps ->(function CustomAppEmbed({ mountKey }, ref) { +>(function CustomAppEmbed( + { mountKey, mode, indexRelativePath, loadingLabel }, + ref, +) { const innerRef = useRef(null); const setRefs = useCallback( (el: HTMLIFrameElement | null) => { @@ -96,6 +112,8 @@ export const CustomAppEmbed = forwardRef< const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const indexRel = indexRelativePath ?? defaultIndexRelativePath(mode); + const mountBlob = useCallback(async () => { const el = innerRef.current; if (!el) { @@ -108,16 +126,12 @@ export const CustomAppEmbed = forwardRef< if (!workspace) { throw new Error('No workspace configured for the active profile.'); } - const indexPath = await join( - workspace, - 'bundles', - 'active', - 'app', - 'index.html', - ); + const indexPath = await join(workspace, ...indexRel.split('/')); const appDirPath = await dirname(indexPath); - await patchWorkspaceAppBundleAbsolutePaths(); - let html = await tauriClient.readWorkspaceTextFile(CUSTOM_APP_INDEX_REL); + if (mode === 'bundle') { + await patchWorkspaceAppBundleAbsolutePaths(); + } + let html = await tauriClient.readWorkspaceTextFile(indexRel); html = stripOdeDesktopInjection(html); html = rewriteEmbeddedBundleHtml(html); const indexAssetUrl = convertFileSrc(indexPath); @@ -130,10 +144,7 @@ export const CustomAppEmbed = forwardRef< const stub = buildHostStub(); const doc = injectIntoHead(html, stub, baseHref); const enc = new TextEncoder(); - await tauriClient.writeWorkspaceFile( - CUSTOM_APP_INDEX_REL, - enc.encode(doc), - ); + await tauriClient.writeWorkspaceFile(indexRel, enc.encode(doc)); // Query busts document cache. Do not use a `#fragment` here: many SPAs use the // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; @@ -145,17 +156,22 @@ export const CustomAppEmbed = forwardRef< setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, []); + }, [indexRel, mode]); useEffect(() => { void mountBlob(); }, [mountKey, mountBlob]); + const defaultLoadingLabel = + mode === 'developer' + ? 'Loading custom app from developer mirror…' + : 'Loading custom app from active bundle…'; + return (
{error ?

{error}

: null} {loading && !error ? ( -

Loading custom app from active bundle…

+

{loadingLabel ?? defaultLoadingLabel}

) : null}