From 08de1aa7681ca9a764fee2c1233dacbe23c5f7eb Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 16:45:10 +0200 Subject: [PATCH 1/8] fix(desktop): handle downloads --- desktop/src-tauri/Cargo.lock | 7 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 258 ++++++++++++++++-- desktop/src/App.test.tsx | 2 +- .../lib/__tests__/formPreviewBridge.test.ts | 30 +- desktop/src/lib/formPreviewBridge.ts | 37 ++- desktop/src/lib/tauriClient.ts | 22 ++ desktop/src/lib/workspacePaths.ts | 7 + .../src/services/synk/GeneratedSyncGateway.ts | 16 +- desktop/src/services/synk/SyncGateway.ts | 3 +- desktop/src/store/useCustodianStore.ts | 58 ++-- desktop/src/types/domain.ts | 3 + .../src/renderers/PhotoQuestionRenderer.tsx | 32 ++- 13 files changed, 429 insertions(+), 47 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 1460d0591..c38106eed 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2579,6 +2579,7 @@ dependencies = [ "tauri-plugin-opener", "thiserror 2.0.18", "url", + "urlencoding", "uuid", "walkdir", "zip", @@ -5011,6 +5012,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 29d1c37b0..079433453 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -32,4 +32,5 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "sync-s zip = { version = "2", default-features = false, features = ["deflate"] } walkdir = "2" url = "2" +urlencoding = "2" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 536b7b681..2e217117a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -125,12 +125,74 @@ fn sqlite_path_for_workspace(workspace: &Path) -> PathBuf { workspace.join("sqlite").join("custodian.sqlite3") } +/// V2 attachment layout (matches Formulus `attachmentStorage` / `WebViewFileUrlResolver`). +/// `pending/` is the upload queue; `pending_upload/` is legacy v1 (still checked when resolving). +const ATTACH_SUBDIR_DRAFT: &str = "draft"; +const ATTACH_SUBDIR_PENDING: &str = "pending"; +const ATTACH_SUBDIR_SYNCED: &str = "synced"; +const ATTACH_LEGACY_PENDING_UPLOAD: &str = "pending_upload"; + +fn attachments_root(workspace: &Path) -> PathBuf { + workspace.join("attachments") +} + fn ensure_workspace_layout(workspace: &Path) -> Result<(), CustodianError> { fs::create_dir_all(workspace.join("sqlite"))?; - fs::create_dir_all(workspace.join("attachments"))?; + let root = attachments_root(workspace); + fs::create_dir_all(&root)?; + fs::create_dir_all(root.join(ATTACH_SUBDIR_DRAFT))?; + fs::create_dir_all(root.join(ATTACH_SUBDIR_PENDING))?; + fs::create_dir_all(root.join(ATTACH_SUBDIR_SYNCED))?; + migrate_attachments_flat_to_synced_layout(workspace)?; Ok(()) } +/// One-shot: move loose files from `attachments/` into `attachments/synced/`. +fn migrate_attachments_flat_to_synced_layout(workspace: &Path) -> Result<(), CustodianError> { + let root = attachments_root(workspace); + if !root.is_dir() { + return Ok(()); + } + let synced = root.join(ATTACH_SUBDIR_SYNCED); + fs::create_dir_all(&synced)?; + for entry in fs::read_dir(&root)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + let dest = synced.join(entry.file_name()); + if !dest.exists() { + fs::rename(&path, &dest)?; + } + } + Ok(()) +} + +/// Lookup order matches Formulus `resolveAttachmentFileUrl` (draft → synced → pending → legacy). +fn resolve_attachment_path(workspace: &Path, basename: &str) -> Option { + let root = attachments_root(workspace); + let candidates = [ + root.join(ATTACH_SUBDIR_DRAFT).join(basename), + root.join(ATTACH_SUBDIR_SYNCED).join(basename), + root.join(ATTACH_SUBDIR_PENDING).join(basename), + root.join(basename), + root.join(ATTACH_LEGACY_PENDING_UPLOAD).join(basename), + ]; + for p in candidates { + if p.is_file() { + return Some(p); + } + } + None +} + +fn attachment_path_synced(workspace: &Path, basename: &str) -> PathBuf { + attachments_root(workspace) + .join(ATTACH_SUBDIR_SYNCED) + .join(basename) +} + fn derived_database_path_for_profile(profile: &ServerProfile) -> Result { let ws = profile .workspace_path @@ -458,7 +520,43 @@ fn init_db(conn: &Connection) -> Result<(), CustodianError> { )?; migrate_sync_state_columns(conn)?; conn.execute( - "INSERT OR IGNORE INTO sync_state(id, last_pull_at, last_push_at, last_error, repository_generation, observation_sync_version, last_attachment_version) VALUES (1, NULL, NULL, NULL, 1, 0, 0)", + "INSERT OR IGNORE INTO sync_state(id, last_pull_at, last_push_at, last_error, repository_generation, observation_sync_version, last_attachment_version) VALUES (1, NULL, NULL, NULL, 0, 0, 0)", + [], + )?; + migrate_repository_generation_fresh_install_defaults(conn)?; + Ok(()) +} + +/// 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 +/// like the old default and have no synced data to `0`. +fn migrate_repository_generation_fresh_install_defaults( + conn: &Connection, +) -> Result<(), rusqlite::Error> { + let row = conn + .query_row( + "SELECT repository_generation, observation_sync_version, last_attachment_version FROM sync_state WHERE id = 1", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)), + ) + .optional()?; + let Some((repo, obs_ver, att_ver)) = row else { + return Ok(()); + }; + if repo != 1 || obs_ver != 0 || att_ver != 0 { + return Ok(()); + } + let obs_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM observations", + [], + |row| row.get(0), + )?; + if obs_count > 0 { + return Ok(()); + } + conn.execute( + "UPDATE sync_state SET repository_generation = 0 WHERE id = 1", [], )?; Ok(()) @@ -471,7 +569,7 @@ fn migrate_sync_state_columns(conn: &Connection) -> Result<(), rusqlite::Error> .collect::>()?; if !cols.iter().any(|c| c == "repository_generation") { conn.execute( - "ALTER TABLE sync_state ADD COLUMN repository_generation INTEGER NOT NULL DEFAULT 1", + "ALTER TABLE sync_state ADD COLUMN repository_generation INTEGER NOT NULL DEFAULT 0", [], )?; } @@ -1718,13 +1816,75 @@ fn write_workspace_attachment( ctx: tauri::State<'_, AppCtx>, ) -> Result<(), String> { let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - let path = ws.join("attachments").join(&attachment_id); + let path = attachment_path_synced(&ws, attachment_id.trim()); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } fs::write(&path, data).map_err(|e| e.to_string()) } +/// GET `GET {base}/api/attachments/{id}` with Bearer auth and write bytes under `attachments/synced/`. +/// Used during sync so downloads do not rely on the WebView `fetch` implementation (CORS / TLS quirks). +/// `x_ode_version` is required: Synkronus [`formulusversion.Middleware`] rejects requests without `x-ode-version`. +#[tauri::command] +async fn download_workspace_attachment_from_url( + base_url: String, + bearer_token: String, + attachment_id: String, + x_ode_version: String, + ctx: tauri::State<'_, AppCtx>, +) -> Result<(), String> { + let base = base_url.trim(); + if base.is_empty() { + return Err("base_url is required".to_string()); + } + let token = bearer_token.trim(); + if token.is_empty() { + return Err("bearer token is required".to_string()); + } + let ode_ver = x_ode_version.trim(); + if ode_ver.is_empty() { + return Err("x_ode_version is required".to_string()); + } + let t = attachment_id.trim(); + if t.is_empty() || t.contains('/') || t.contains('\\') || t.contains("..") { + return Err("invalid attachment_id".to_string()); + } + let url = format!( + "{}/api/attachments/{}", + base.trim_end_matches('/'), + urlencoding::encode(t) + ); + let parsed = Url::parse(&url).map_err(|e| format!("invalid attachment URL: {e}"))?; + + let client = reqwest::Client::new(); + let res = client + .get(parsed) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header("x-ode-version", ode_ver) + .send() + .await + .map_err(|e| format!("attachment request failed: {e}"))?; + if !res.status().is_success() { + return Err(format!( + "attachment download failed: HTTP {}", + res.status() + )); + } + let bytes = res + .bytes() + .await + .map_err(|e| format!("attachment read failed: {e}"))?; + + let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; + let path = attachment_path_synced(&ws, t); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&path, bytes).map_err(|e| e.to_string())?; + Ok(()) +} + /// Write arbitrary bytes under the active profile workspace (e.g. `bundles/app-bundle.zip`). /// Rejects empty paths, `..`, and other traversal attempts. #[tauri::command] @@ -2031,26 +2191,43 @@ fn workspace_directory_file_url( .map_err(|()| "invalid directory for file URL".to_string()) } -/// Resolve `attachments/` to a `file://` URL if the file exists (basename only; no path segments). -#[tauri::command] -fn workspace_attachment_file_url( +/// Resolve an attachment basename to a `file://` URL (draft → synced → pending → legacy roots). +/// Same lookup order as Formulus `resolveAttachmentFileUrl`. +fn resolve_workspace_attachment_file_url( file_name: String, - ctx: tauri::State<'_, AppCtx>, + ctx: &AppCtx, ) -> Result, String> { let t = file_name.trim(); if t.is_empty() || t.contains('/') || t.contains('\\') || t.contains("..") { return Err("invalid attachment file name".to_string()); } - let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - let path = ws.join("attachments").join(t); - if !path.is_file() { + let ws = get_workspace_path(ctx).map_err(|e| e.to_string())?; + let Some(path) = resolve_attachment_path(&ws, t) else { return Ok(None); - } + }; Url::from_file_path(&path) .map(|u| Some(u.to_string())) .map_err(|()| "invalid file URL".to_string()) } +/// Resolve `attachments/.../` to a `file://` URL if the file exists (basename only). +#[tauri::command] +fn workspace_attachment_file_url( + file_name: String, + ctx: tauri::State<'_, AppCtx>, +) -> Result, String> { + resolve_workspace_attachment_file_url(file_name, &ctx) +} + +/// Alias for [`workspace_attachment_file_url`] — basename-only resolution across draft/synced/pending. +#[tauri::command] +fn resolve_attachment_file_url( + file_name: String, + ctx: tauri::State<'_, AppCtx>, +) -> Result, String> { + resolve_workspace_attachment_file_url(file_name, &ctx) +} + #[tauri::command] fn scan_bundle_custom_question_types(ctx: tauri::State<'_, AppCtx>) -> Result { let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; @@ -2078,9 +2255,22 @@ fn remove_workspace_attachment( ctx: tauri::State<'_, AppCtx>, ) -> Result<(), String> { let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; - let path = ws.join("attachments").join(&attachment_id); - if path.exists() { - fs::remove_file(&path).map_err(|e| e.to_string())?; + let t = attachment_id.trim(); + if t.is_empty() || t.contains('/') || t.contains('\\') || t.contains("..") { + return Err("invalid attachment id".to_string()); + } + let root = attachments_root(&ws); + let paths = [ + root.join(ATTACH_SUBDIR_DRAFT).join(t), + root.join(ATTACH_SUBDIR_SYNCED).join(t), + root.join(ATTACH_SUBDIR_PENDING).join(t), + root.join(t), + root.join(ATTACH_LEGACY_PENDING_UPLOAD).join(t), + ]; + for path in paths { + if path.is_file() { + fs::remove_file(&path).map_err(|e| e.to_string())?; + } } Ok(()) } @@ -2398,6 +2588,7 @@ pub fn run() { move_workspace, backup_workspace, write_workspace_attachment, + download_workspace_attachment_from_url, write_workspace_file, get_app_bundle_state, apply_app_bundle_download, @@ -2408,6 +2599,7 @@ pub fn run() { get_active_bundle_forms_file_base_url, workspace_directory_file_url, workspace_attachment_file_url, + resolve_attachment_file_url, scan_bundle_custom_question_types, remove_workspace_attachment, get_observation, @@ -2427,7 +2619,10 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{parse_time, should_mark_conflict}; + use std::fs; + use std::path::Path; + + use super::{parse_time, resolve_attachment_path, should_mark_conflict}; #[test] fn parse_time_handles_valid_timestamp() { @@ -2460,4 +2655,35 @@ mod tests { "weird_name" ); } + + #[test] + fn resolve_attachment_prefers_draft_over_synced() { + let base = std::env::temp_dir().join(format!( + "ode_attach_test_draft_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("attachments/draft")).unwrap(); + fs::create_dir_all(base.join("attachments/synced")).unwrap(); + fs::write(base.join("attachments/synced/a.jpg"), b"1").unwrap(); + fs::write(base.join("attachments/draft/a.jpg"), b"2").unwrap(); + let p = resolve_attachment_path(Path::new(&base), "a.jpg").unwrap(); + assert!(p.to_string_lossy().contains("draft")); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn resolve_attachment_falls_back_to_legacy_flat_root() { + let base = std::env::temp_dir().join(format!( + "ode_attach_test_legacy_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("attachments")).unwrap(); + fs::write(base.join("attachments/b.jpg"), b"x").unwrap(); + let p = resolve_attachment_path(Path::new(&base), "b.jpg").unwrap(); + assert_eq!(p.file_name().unwrap(), "b.jpg"); + assert!(!p.to_string_lossy().contains("synced")); + let _ = fs::remove_dir_all(&base); + } } diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index cd1aa2bf8..9243e5716 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -38,7 +38,7 @@ vi.mock('./lib/tauriClient', () => ({ listObservationsPage: vi.fn().mockResolvedValue({ rows: [], total: 0 }), listFormTypes: vi.fn().mockResolvedValue([]), getSyncState: vi.fn().mockResolvedValue({ - repositoryGeneration: 1, + repositoryGeneration: 0, observationSyncVersion: 0, lastAttachmentVersion: 0, }), diff --git a/desktop/src/lib/__tests__/formPreviewBridge.test.ts b/desktop/src/lib/__tests__/formPreviewBridge.test.ts index 767357238..b91413380 100644 --- a/desktop/src/lib/__tests__/formPreviewBridge.test.ts +++ b/desktop/src/lib/__tests__/formPreviewBridge.test.ts @@ -6,6 +6,7 @@ import { handleFormPreviewBridgeMessage, postFormplayerBridgeReply, } from '../formPreviewBridge'; +import { tauriClient } from '../tauriClient'; vi.mock('@tauri-apps/api/core', () => ({ convertFileSrc: (path: string) => `asset:${path}`, @@ -25,7 +26,7 @@ vi.mock('../tauriClient', () => ({ .mockResolvedValue('file:///tmp/ws/bundles/active/forms'), workspaceDirectoryFileUrl: vi .fn() - .mockResolvedValue('file:///tmp/ws/attachments/'), + .mockResolvedValue('file:///tmp/ws/attachments/synced/'), workspaceAttachmentFileUrl: vi.fn().mockResolvedValue(null), }, })); @@ -84,6 +85,33 @@ describe('handleFormPreviewBridgeMessage', () => { expect(payload.error).toContain(DESKTOP_FORM_PREVIEW_PREFIX); }); + it('getAttachmentUri maps file:// through convertFileSrc for iframe img', async () => { + vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockResolvedValueOnce( + 'file:///home/u/ws/attachments/synced/a.jpg', + ); + const postMessage = vi.fn(); + const iframe = { + contentWindow: { postMessage } as unknown as Window, + } as HTMLIFrameElement; + + await handleFormPreviewBridgeMessage( + JSON.stringify({ + type: 'getAttachmentUri', + messageId: 'ga1', + fileName: 'a.jpg', + }), + { + iframe, + onFinalize: async () => ({ error: 'no' }), + }, + ); + + expect(postMessage).toHaveBeenCalledTimes(1); + const payload = JSON.parse(postMessage.mock.calls[0][0] as string); + expect(payload.type).toBe('getAttachmentUri_response'); + expect(payload.result).toBe('asset:/home/u/ws/attachments/synced/a.jpg'); + }); + it('ignores messages without messageId', async () => { const postMessage = vi.fn(); const iframe = { diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index 693fd2242..ac7910468 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -17,8 +17,8 @@ * | `runLocalModel` | **Stub** — no on-device ML in preview. | * | `getCurrentUser` | Active profile `username` + `label` as `displayName` (from `get_settings`). | * | `getThemeMode` | `'system'`. | - * | `getAttachmentUri` | `workspace/attachments/` → `file://` if file exists, else `null`. | - * | `getAttachmentsUri` | `file://` for `attachments/` directory if it exists. | + * | `getAttachmentUri` | Basename lookup in `attachments/{draft,synced,pending,...}` → Tauri `convertFileSrc` URL (or `null`). | + * | `getAttachmentsUri` | `file://` for `attachments/synced/` (canonical listing; matches Formulus `getAttachmentsDirectoryFileUrl`). | * | `getCustomAppUri` | Tauri asset URL for `bundles/active/` (directory of `app/`), not `file://`. | * | `getFormSpecsUri` | `getActiveBundleFormsFileBaseUrl()` (`bundles/active/forms`). | * @@ -97,6 +97,31 @@ function stubReason(detail: string): { error: string } { }; } +/** Map `file:///…` to a filesystem path suitable for {@link convertFileSrc} (Linux + Windows). */ +function fileUrlToLocalPath(fileUrl: string): string { + const url = new URL(fileUrl); + let pathname = decodeURIComponent(url.pathname.replace(/\+/g, ' ')); + if (/^\/[A-Za-z]:\//.test(pathname)) { + pathname = pathname.slice(1); + } + return pathname; +} + +/** Raw `file://` URLs are blocked for `` inside the formplayer iframe; use asset protocol. */ +function attachmentFileUrlForWebview(raw: string | null): string | null { + if (raw == null) { + return null; + } + if (!raw.startsWith('file://')) { + return raw; + } + try { + return convertFileSrc(fileUrlToLocalPath(raw)); + } catch { + return raw; + } +} + function getByPath(obj: unknown, path: string): unknown { const parts = path.split('.').filter(Boolean); let cur: unknown = obj; @@ -440,8 +465,10 @@ export async function handleFormPreviewBridgeMessage( case 'getAttachmentUri': { const fileName = String(data.fileName ?? ''); try { - const url = await tauriClient.workspaceAttachmentFileUrl(fileName); - reply('getAttachmentUri', { result: url }); + const raw = await tauriClient.workspaceAttachmentFileUrl(fileName); + reply('getAttachmentUri', { + result: attachmentFileUrlForWebview(raw), + }); } catch (e) { reply('getAttachmentUri', { error: e instanceof Error ? e.message : String(e), @@ -453,7 +480,7 @@ export async function handleFormPreviewBridgeMessage( case 'getAttachmentsUri': { try { const url = - await tauriClient.workspaceDirectoryFileUrl('attachments'); + await tauriClient.workspaceDirectoryFileUrl('attachments/synced'); reply('getAttachmentsUri', { result: url }); } catch (e) { reply('getAttachmentsUri', { diff --git a/desktop/src/lib/tauriClient.ts b/desktop/src/lib/tauriClient.ts index a04eebe0b..c00a0a05a 100644 --- a/desktop/src/lib/tauriClient.ts +++ b/desktop/src/lib/tauriClient.ts @@ -84,6 +84,20 @@ export const tauriClient = { attachmentId, data: Array.from(data), }), + /** GET `{base}/api/attachments/{id}` with Bearer token via native HTTP (avoids WebView fetch issues). */ + downloadWorkspaceAttachmentFromUrl: (args: { + baseUrl: string; + bearerToken: string; + attachmentId: string; + /** Must match OpenAPI `x-ode-version` (Synkronus rejects protected routes without it). */ + xOdeVersion: string; + }) => + invokeSafe('download_workspace_attachment_from_url', { + baseUrl: args.baseUrl, + bearerToken: args.bearerToken, + attachmentId: args.attachmentId, + xOdeVersion: args.xOdeVersion, + }), /** Relative to active profile workspace root (e.g. `bundles/app-bundle.zip`). */ writeWorkspaceFile: (relativePath: string, data: Uint8Array) => invokeSafe('write_workspace_file', { @@ -115,8 +129,16 @@ export const tauriClient = { invokeSafe('get_active_bundle_forms_file_base_url'), workspaceDirectoryFileUrl: (relativePath: string) => invokeSafe('workspace_directory_file_url', { relativePath }), + /** + * Basename-only resolution across `attachments/draft`, `attachments/synced`, + * `attachments/pending`, and legacy flat / `pending_upload` (matches Formulus + * `resolveAttachmentFileUrl`). + */ workspaceAttachmentFileUrl: (fileName: string) => invokeSafe('workspace_attachment_file_url', { fileName }), + /** Same behavior as {@link workspaceAttachmentFileUrl} (Tauri alias). */ + resolveAttachmentFileUrl: (fileName: string) => + invokeSafe('resolve_attachment_file_url', { fileName }), scanBundleCustomQuestionTypes: () => invokeSafe>('scan_bundle_custom_question_types'), removeWorkspaceAttachment: (attachmentId: string) => diff --git a/desktop/src/lib/workspacePaths.ts b/desktop/src/lib/workspacePaths.ts index a86e3f387..ad655d256 100644 --- a/desktop/src/lib/workspacePaths.ts +++ b/desktop/src/lib/workspacePaths.ts @@ -16,3 +16,10 @@ export function workspaceAttachmentsDir(workspaceRoot: string): string { const base = workspaceRoot.replace(/[/\\]+$/, ''); return `${base}${sep}attachments`; } + +/** Canonical committed/downloaded copies (Formulus `synced/`). */ +export function workspaceAttachmentsSyncedDir(workspaceRoot: string): string { + const sep = workspaceRoot.includes('\\') ? '\\' : '/'; + const base = workspaceRoot.replace(/[/\\]+$/, ''); + return `${base}${sep}attachments${sep}synced`; +} diff --git a/desktop/src/services/synk/GeneratedSyncGateway.ts b/desktop/src/services/synk/GeneratedSyncGateway.ts index fa23347ec..4d38dd2b5 100644 --- a/desktop/src/services/synk/GeneratedSyncGateway.ts +++ b/desktop/src/services/synk/GeneratedSyncGateway.ts @@ -245,20 +245,24 @@ export class GeneratedSyncGateway implements SyncGateway { async pull(request: PullRequest): Promise { try { const api = this.createApi(request.baseUrl, request.token); + const gen = + request.repositoryGeneration != null && request.repositoryGeneration > 0 + ? request.repositoryGeneration + : undefined; const syncPullRequest: SyncPullRequest = { client_id: request.clientId, schema_types: request.schemaTypes, since: request.sinceVersion ? { version: request.sinceVersion } : undefined, - repository_generation: request.repositoryGeneration, + ...(gen != null ? { repository_generation: gen } : {}), }; const response = await api.syncPull({ xOdeVersion: SYNKRONUS_CLIENT_VERSION, syncPullRequest, limit: request.limit, - xRepositoryGeneration: request.repositoryGeneration, + ...(gen != null ? { xRepositoryGeneration: gen } : {}), }); return { @@ -285,17 +289,21 @@ export class GeneratedSyncGateway implements SyncGateway { const payloadObservations = request.observations.map( mapObservationToOpenApi, ); + const gen = + request.repositoryGeneration != null && request.repositoryGeneration > 0 + ? request.repositoryGeneration + : undefined; const syncPushRequest: SyncPushRequest = { transmission_id: crypto.randomUUID(), client_id: request.clientId, records: payloadObservations, - repository_generation: request.repositoryGeneration, + ...(gen != null ? { repository_generation: gen } : {}), }; const response = await api.syncPush({ xOdeVersion: SYNKRONUS_CLIENT_VERSION, syncPushRequest, - xRepositoryGeneration: request.repositoryGeneration, + ...(gen != null ? { xRepositoryGeneration: gen } : {}), }); const failedIds = (response.failed_records ?? []) .map(entry => { diff --git a/desktop/src/services/synk/SyncGateway.ts b/desktop/src/services/synk/SyncGateway.ts index 17319f66a..c7054f639 100644 --- a/desktop/src/services/synk/SyncGateway.ts +++ b/desktop/src/services/synk/SyncGateway.ts @@ -27,7 +27,7 @@ export interface PullRequest { schemaTypes?: string[]; sinceVersion?: number; limit?: number; - /** Monotonic repository epoch; omit for first sync (server treats as 1). */ + /** Monotonic repository epoch; omit when 0 / unknown so the server adopts its current generation (fresh profile). */ repositoryGeneration?: number; } @@ -36,6 +36,7 @@ export interface PushRequest { token: string; clientId: string; observations: ObservationRecord[]; + /** Omit when 0 / unknown (fresh profile) so Synkronus accepts the push like a first-time client. */ repositoryGeneration?: number; } diff --git a/desktop/src/store/useCustodianStore.ts b/desktop/src/store/useCustodianStore.ts index 309ed23c9..9b5e8924a 100644 --- a/desktop/src/store/useCustodianStore.ts +++ b/desktop/src/store/useCustodianStore.ts @@ -72,7 +72,9 @@ async function pullSyncWithAttachments( let page = await pullPage(obsVer > 0 ? obsVer : undefined, repoGen); - if (page.repositoryGeneration > repoGen) { + // Fresh workspace uses generation 0 until the first successful pull; do not treat + // "server > 0" as a server-side reset (that would archive an empty profile). + if (repoGen > 0 && page.repositoryGeneration > repoGen) { await tauriClient.archiveWorkspaceForRepositoryGeneration(); await tauriClient.setSyncState({ repositoryGeneration: page.repositoryGeneration, @@ -110,27 +112,40 @@ async function pullSyncWithAttachments( ); let attachmentsDownloaded = 0; + let attachmentsFailed = 0; try { const manifest = await api.getAttachmentManifest({ xOdeVersion: SYNKRONUS_CLIENT_VERSION, - xRepositoryGeneration: attState.repositoryGeneration, + ...(attState.repositoryGeneration > 0 + ? { xRepositoryGeneration: attState.repositoryGeneration } + : {}), attachmentManifestRequest: { client_id: clientId, since_version: attState.lastAttachmentVersion, + ...(attState.repositoryGeneration > 0 + ? { repository_generation: attState.repositoryGeneration } + : {}), }, }); - for (const op of manifest.operations) { - if (op.operation === 'download' && op.download_url) { - const res = await fetch(op.download_url, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - continue; + const ops = manifest.operations ?? []; + for (const op of ops) { + if (op.operation === 'download' && op.attachment_id) { + try { + await tauriClient.downloadWorkspaceAttachmentFromUrl({ + baseUrl, + bearerToken: token, + attachmentId: op.attachment_id, + xOdeVersion: SYNKRONUS_CLIENT_VERSION, + }); + attachmentsDownloaded += 1; + } catch (e) { + attachmentsFailed += 1; + console.error( + `Attachment download failed (${op.attachment_id}):`, + e, + ); } - const buf = new Uint8Array(await res.arrayBuffer()); - await tauriClient.writeWorkspaceAttachment(op.attachment_id, buf); - attachmentsDownloaded += 1; } else if (op.operation === 'delete') { await tauriClient.removeWorkspaceAttachment(op.attachment_id); } @@ -141,11 +156,12 @@ async function pullSyncWithAttachments( repositoryGeneration: manifest.repository_generation ?? last.repositoryGeneration, }); - } catch { + } catch (err) { // Attachment endpoints may be unavailable; observation import still succeeded. + console.error('Attachment manifest download failed:', err); } - return { imported, conflicts, attachmentsDownloaded }; + return { imported, conflicts, attachmentsDownloaded, attachmentsFailed }; } async function callAdminRepositoryReset(baseUrl: string, token: string) { @@ -560,8 +576,13 @@ export const useCustodianStore = create((set, get) => ({ await get().loadObservations(); await get().loadHealth(); const att = result.attachmentsDownloaded ?? 0; + const af = result.attachmentsFailed ?? 0; + const attSuffix = + af > 0 + ? `${att} attachment file(s), ${af} failed` + : `${att} attachment file(s)`; set({ - syncMessage: `Pulled ${result.imported} observations (${result.conflicts} conflicts), ${att} attachment file(s).`, + syncMessage: `Pulled ${result.imported} observations (${result.conflicts} conflicts), ${attSuffix}.`, }); return result; }; @@ -648,10 +669,15 @@ export const useCustodianStore = create((set, get) => ({ await get().loadObservations(); await get().loadHealth(); const att = result.attachmentsDownloaded ?? 0; + const af = result.attachmentsFailed ?? 0; + const attSuffix = + af > 0 + ? `${att} attachment file(s), ${af} failed` + : `${att} attachment file(s)`; set({ syncMessage: `Server repository reset (generation ${reset.repository_generation}). ` + - `Pulled ${result.imported} observations (${result.conflicts} conflicts), ${att} attachment file(s).`, + `Pulled ${result.imported} observations (${result.conflicts} conflicts), ${attSuffix}.`, }); return result; }; diff --git a/desktop/src/types/domain.ts b/desktop/src/types/domain.ts index 8b322de61..6bf1b08ef 100644 --- a/desktop/src/types/domain.ts +++ b/desktop/src/types/domain.ts @@ -63,9 +63,12 @@ export interface ImportResult { conflicts: number; /** Local attachment files written during pull (when attachment sync runs). */ attachmentsDownloaded?: number; + /** Download attempts that failed after a manifest op (e.g. HTTP error). */ + attachmentsFailed?: number; } export interface SyncStateInfo { + /** `0` means not aligned with Synkronus yet (omit epoch on API calls; fresh profile). */ repositoryGeneration: number; observationSyncVersion: number; lastAttachmentVersion: number; diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index 0d586d5b7..2bcff7571 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -26,6 +26,25 @@ const parsePx = (value: string): number => { return parseInt(value.replace('px', ''), 10); }; +/** + * True when `uri` points at another device's filesystem (e.g. Formulus Android + * after Synk pull). Those paths must not be used in img/src — resolve via + * `filename` + {@link FormulusClient.getAttachmentUri} instead. + */ +function isForeignDeviceFileUri(uri: string): boolean { + const u = uri.trim(); + if (!u) { + return false; + } + if (u.includes('/data/user/') || u.includes('org.opendataensemble.formulus')) { + return true; + } + if (u.includes('/var/mobile/') || u.includes('/Application/')) { + return true; + } + return false; +} + // Tester function to identify photo question types export const photoQuestionTester = rankWith( 5, // High priority for photo questions @@ -77,13 +96,20 @@ const PhotoQuestionRenderer: React.FC = ({ // Get the current photo data from the form data (now JSON format) const currentPhotoData = data || null; - // Prefer uri; else resolve basename via bridge (draft → committed → pending upload) + // Prefer local uri (this device); else resolve basename via bridge (draft → committed → synced copy) useEffect(() => { let cancelled = false; const run = async () => { console.log('Photo data changed:', currentPhotoData); - if (currentPhotoData?.uri) { - const u = currentPhotoData.uri; + const rawUri = + typeof currentPhotoData?.uri === 'string' + ? currentPhotoData.uri + : null; + if ( + rawUri && + !isForeignDeviceFileUri(rawUri) + ) { + const u = rawUri; const display = u.startsWith('file://') || u.startsWith('http') ? u : `file://${u}`; console.log('Setting photo URL from stored data:', display); From be10745ff2b49ab95d5e8b585728734568b02a86 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:09:07 +0200 Subject: [PATCH 2/8] fix: clean-up attachment data to use filename instead of uri --- desktop/public/formulus-injection.js | 2444 +++++++---------- .../lib/__tests__/formPreviewBridge.test.ts | 85 + .../__tests__/sanitizeFormSavedData.test.ts | 28 + desktop/src/lib/buildFormPreviewInit.ts | 3 +- desktop/src/lib/formPreviewBridge.ts | 63 +- desktop/src/lib/sanitizeFormSavedData.ts | 72 + formulus-formplayer/src/mocks/webview-mock.ts | 4 +- .../src/renderers/PhotoQuestionRenderer.tsx | 136 +- .../src/services/FormulusInterface.ts | 24 +- .../src/types/FormulusInterfaceDefinition.ts | 27 +- .../assets/webview/FormulusInjectionScript.js | 2443 +++++++--------- formulus/assets/webview/formulus-api.js | 429 ++- .../src/services/WebViewFileUrlResolver.ts | 18 + .../webview/FormulusInterfaceDefinition.ts | 27 +- .../src/webview/FormulusMessageHandlers.ts | 23 +- .../webview/FormulusMessageHandlers.types.ts | 5 +- 16 files changed, 2719 insertions(+), 3112 deletions(-) create mode 100644 desktop/src/lib/__tests__/sanitizeFormSavedData.test.ts create mode 100644 desktop/src/lib/sanitizeFormSavedData.ts diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index ad7d926e0..0a6c34984 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,53 +1,38 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-04-09T07:22:39.750Z +// Last generated: 2026-04-17T14:59:47.429Z -/* global formulus */ -(function () { +(function() { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return ( - globalThis.formulus || - window.formulus || - (typeof formulus !== 'undefined' ? formulus : undefined) - ); + return globalThis.formulus || window.formulus || (typeof formulus !== 'undefined' ? formulus : undefined); } function isFormulusAvailable() { const api = getFormulus(); - return ( - api && typeof api === 'object' && typeof api.getVersion === 'function' - ); + return api && typeof api === 'object' && typeof api.getVersion === 'function'; } // Idempotent guard to avoid double-initialization when scripts are reinjected - if (globalThis.__formulusBridgeInitialized) { + if ((globalThis).__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug( - 'Formulus bridge already initialized and functional. Skipping duplicate injection.', - ); + console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); return; } else { - console.warn( - 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', - ); + console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug( - 'Formulus interface already exists and is functional. Skipping injection.', - ); + console.debug('Formulus interface already exists and is functional. Skipping injection.'); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn( - 'Formulus interface exists but appears non-functional. Re-injecting...', - ); + console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); } // Helper function to handle callbacks @@ -63,7 +48,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -76,1541 +61,1200 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if ( - data.type === 'callback' && - data.callbackId && - callbacks[data.callbackId] - ) { + if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - if ( - data.type === 'onFormulusReady' && - globalThis.formulusCallbacks?.onFormulusReady - ) { + + + if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error( - 'Global handleMessage: Error processing message:', - e, - 'Raw event.data:', - event.data, - ); + console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getVersion callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getVersion_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + // getVersion: => Promise + getVersion: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getVersion_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getVersion' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getVersion', messageId, - }), - ); - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAvailableForms_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAvailableForms' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAvailableForms', messageId, - }), - ); - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record => Promise - openFormplayer: function (formType, params, savedData) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'openFormplayer callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'openFormplayer_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + openFormplayer: function(formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'openFormplayer_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'openFormplayer' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - }), - ); - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function (formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getObservations callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getObservations_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function(formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getObservations_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getObservations' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted, - }), - ); - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise - getObservationsByQuery: function (options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getObservationsByQuery_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + includeDeleted: includeDeleted + })); + + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + getObservationsByQuery: function(options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getObservationsByQuery' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options, - }), - ); - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function (formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'submitObservation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'submitObservation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function(formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'submitObservation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'submitObservation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData, - }), - ); - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function (observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'updateObservation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'updateObservation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + formType: formType, + finalData: finalData + })); + + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function(observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'updateObservation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'updateObservation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData, - }), - ); - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestCamera callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestCamera_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + finalData: finalData + })); + + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestCamera_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestCamera' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestLocation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestLocation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestLocation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestLocation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestFile callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestFile_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestFile_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestFile' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function (fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'launchIntent callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'launchIntent_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function(fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'launchIntent_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'launchIntent' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec, - }), - ); - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function (fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'callSubform callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'callSubform_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId, + intentSpec: intentSpec + })); + + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function(fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'callSubform_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'callSubform' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options, - }), - ); - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestAudio callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestAudio_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestAudio_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestAudio' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestQrcode callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestQrcode_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestQrcode_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestQrcode' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestBiometric callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestBiometric_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'requestBiometric_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestBiometric' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestConnectivityStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestConnectivityStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestConnectivityStatus', messageId, - }), - ); - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestSyncStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestSyncStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestSyncStatus', messageId, - }), - ); - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function (fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'runLocalModel callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'runLocalModel_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function(fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'runLocalModel_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'runLocalModel' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input, - }), - ); - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getCurrentUser_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + input: input + })); + + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getCurrentUser' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getCurrentUser', messageId, - }), - ); - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getThemeMode callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getThemeMode_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getThemeMode_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getThemeMode' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getThemeMode', messageId, - }), - ); - }); - }, - - // getAttachmentUri: fileName: string => Promise - getAttachmentUri: function (fileName) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAttachmentUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function(fileName) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAttachmentUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName, - }), - ); - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAttachmentsUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fileName: fileName + })); + + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAttachmentsUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAttachmentsUri', messageId, - }), - ); - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getCustomAppUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getCustomAppUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getCustomAppUri', messageId, - }), - ); - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getFormSpecsUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getFormSpecsUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getFormSpecsUri', messageId, - }), - ); - }); - }, + + })); + + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - globalThis.__formulusBridgeInitialized = true; + (globalThis).__formulusBridgeInitialized = true; // Simple API availability check for internal use - // eslint-disable-next-line @typescript-eslint/no-unused-vars function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now(), - }), - ); + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now() + })); } } // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'onFormulusReady', - }), - ); + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'onFormulusReady' + })); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } + })(); diff --git a/desktop/src/lib/__tests__/formPreviewBridge.test.ts b/desktop/src/lib/__tests__/formPreviewBridge.test.ts index b91413380..b3039849b 100644 --- a/desktop/src/lib/__tests__/formPreviewBridge.test.ts +++ b/desktop/src/lib/__tests__/formPreviewBridge.test.ts @@ -112,6 +112,91 @@ describe('handleFormPreviewBridgeMessage', () => { expect(payload.result).toBe('asset:/home/u/ws/attachments/synced/a.jpg'); }); + it('getAttachmentUri resolves basename from filename descriptor', async () => { + vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockResolvedValueOnce( + 'file:///home/u/ws/attachments/synced/b.jpg', + ); + const postMessage = vi.fn(); + const iframe = { + contentWindow: { postMessage } as unknown as Window, + } as HTMLIFrameElement; + + await handleFormPreviewBridgeMessage( + JSON.stringify({ + type: 'getAttachmentUri', + messageId: 'ga2', + fileName: { filename: 'b.jpg' }, + }), + { + iframe, + onFinalize: async () => ({ error: 'no' }), + }, + ); + + expect(tauriClient.workspaceAttachmentFileUrl).toHaveBeenCalledWith( + 'b.jpg', + ); + expect(postMessage).toHaveBeenCalledTimes(1); + const payload = JSON.parse(postMessage.mock.calls[0][0] as string); + expect(payload.type).toBe('getAttachmentUri_response'); + expect(payload.result).toBe('asset:/home/u/ws/attachments/synced/b.jpg'); + }); + + it('getAttachmentUri returns null when workspace resolves to a foreign file URL', async () => { + vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockClear(); + vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockResolvedValueOnce( + 'file:///data/user/0/org.opendataensemble.formulus/files/x.jpg', + ); + const postMessage = vi.fn(); + const iframe = { + contentWindow: { postMessage } as unknown as Window, + } as HTMLIFrameElement; + + await handleFormPreviewBridgeMessage( + JSON.stringify({ + type: 'getAttachmentUri', + messageId: 'gaForeign', + fileName: 'x.jpg', + }), + { + iframe, + onFinalize: async () => ({ error: 'no' }), + }, + ); + + const payload = JSON.parse(postMessage.mock.calls[0][0] as string); + expect(payload.type).toBe('getAttachmentUri_response'); + expect(payload.result).toBeNull(); + }); + + it('getAttachmentUri returns null for descriptor without filename', async () => { + vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockClear(); + const postMessage = vi.fn(); + const iframe = { + contentWindow: { postMessage } as unknown as Window, + } as HTMLIFrameElement; + + await handleFormPreviewBridgeMessage( + JSON.stringify({ + type: 'getAttachmentUri', + messageId: 'ga3', + fileName: { + uri: 'file:///ignored/stale.jpg', + }, + }), + { + iframe, + onFinalize: async () => ({ error: 'no' }), + }, + ); + + expect(tauriClient.workspaceAttachmentFileUrl).not.toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledTimes(1); + const payload = JSON.parse(postMessage.mock.calls[0][0] as string); + expect(payload.type).toBe('getAttachmentUri_response'); + expect(payload.result).toBeNull(); + }); + it('ignores messages without messageId', async () => { const postMessage = vi.fn(); const iframe = { diff --git a/desktop/src/lib/__tests__/sanitizeFormSavedData.test.ts b/desktop/src/lib/__tests__/sanitizeFormSavedData.test.ts new file mode 100644 index 000000000..3b4f8bc04 --- /dev/null +++ b/desktop/src/lib/__tests__/sanitizeFormSavedData.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizePortableAttachmentsInFormData } from '../sanitizeFormSavedData'; + +describe('sanitizePortableAttachmentsInFormData', () => { + it('removes uri/url and reduces path-like filename to basename', () => { + const out = sanitizePortableAttachmentsInFormData({ + photo: { + id: '8d89369e-260b-4816-85af-b03418f6bc7b', + filename: + 'file:///data/user/0/org.opendataensemble.formulus/files/attachments/synced/8d89369e-260b-4816-85af-b03418f6bc7b.jpg', + uri: 'file:///data/user/0/org.opendataensemble.formulus/files/attachments/synced/8d89369e-260b-4816-85af-b03418f6bc7b.jpg', + metadata: { width: 1, height: 1 }, + }, + }); + expect(out.photo).toEqual({ + id: '8d89369e-260b-4816-85af-b03418f6bc7b', + filename: '8d89369e-260b-4816-85af-b03418f6bc7b.jpg', + metadata: { width: 1, height: 1 }, + }); + }); + + it('does not strip arbitrary objects with filename-like keys', () => { + const out = sanitizePortableAttachmentsInFormData({ + note: { filename: 'readme', body: 'x' }, + }); + expect(out).toEqual({ note: { filename: 'readme', body: 'x' } }); + }); +}); diff --git a/desktop/src/lib/buildFormPreviewInit.ts b/desktop/src/lib/buildFormPreviewInit.ts index c3f91cbeb..00061b918 100644 --- a/desktop/src/lib/buildFormPreviewInit.ts +++ b/desktop/src/lib/buildFormPreviewInit.ts @@ -1,4 +1,5 @@ import type { FormInitData } from './formplayerHost'; +import { sanitizePortableAttachmentsInFormData } from './sanitizeFormSavedData'; export function buildFormPreviewInit(args: { formType: string; @@ -15,7 +16,7 @@ export function buildFormPreviewInit(args: { formType: args.formType, observationId: args.observationId ?? null, params: args.params, - savedData: args.savedData, + savedData: sanitizePortableAttachmentsInFormData(args.savedData), formSchema: args.formSchema, uiSchema: args.uiSchema, extensions: args.extensions, diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index ac7910468..09c2d8fa6 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -17,7 +17,7 @@ * | `runLocalModel` | **Stub** — no on-device ML in preview. | * | `getCurrentUser` | Active profile `username` + `label` as `displayName` (from `get_settings`). | * | `getThemeMode` | `'system'`. | - * | `getAttachmentUri` | Basename lookup in `attachments/{draft,synced,pending,...}` → Tauri `convertFileSrc` URL (or `null`). | + * | `getAttachmentUri` | Basename string or `{ filename }` only; workspace lookup → `convertFileSrc` (or `null`). | * | `getAttachmentsUri` | `file://` for `attachments/synced/` (canonical listing; matches Formulus `getAttachmentsDirectoryFileUrl`). | * | `getCustomAppUri` | Tauri asset URL for `bundles/active/` (directory of `app/`), not `file://`. | * | `getFormSpecsUri` | `getActiveBundleFormsFileBaseUrl()` (`bundles/active/forms`). | @@ -107,6 +107,17 @@ function fileUrlToLocalPath(fileUrl: string): string { return pathname; } +/** Paths that cannot be loaded inside the desktop WebView (other host / mobile app dirs). */ +function isForeignDeviceFileUrl(url: string): boolean { + const u = url.toLowerCase(); + return ( + u.includes('/data/user/') || + u.includes('org.opendataensemble.formulus') || + u.includes('/var/mobile/') || + u.includes('/application/') + ); +} + /** Raw `file://` URLs are blocked for `` inside the formplayer iframe; use asset protocol. */ function attachmentFileUrlForWebview(raw: string | null): string | null { if (raw == null) { @@ -115,10 +126,33 @@ function attachmentFileUrlForWebview(raw: string | null): string | null { if (!raw.startsWith('file://')) { return raw; } + if (isForeignDeviceFileUrl(raw)) { + return null; + } try { return convertFileSrc(fileUrlToLocalPath(raw)); } catch { - return raw; + return null; + } +} + +async function resolveAttachmentUriForFormPreview( + fileRef: string | { filename?: string }, +): Promise { + if (typeof fileRef === 'string') { + const raw = await tauriClient.workspaceAttachmentFileUrl(fileRef); + return attachmentFileUrlForWebview(raw); + } + const fname = + typeof fileRef.filename === 'string' ? fileRef.filename.trim() : ''; + if (!fname) { + return null; + } + try { + const raw = await tauriClient.workspaceAttachmentFileUrl(fname); + return attachmentFileUrlForWebview(raw); + } catch { + return null; } } @@ -463,12 +497,27 @@ export async function handleFormPreviewBridgeMessage( return; case 'getAttachmentUri': { - const fileName = String(data.fileName ?? ''); try { - const raw = await tauriClient.workspaceAttachmentFileUrl(fileName); - reply('getAttachmentUri', { - result: attachmentFileUrlForWebview(raw), - }); + const ref = data.fileName ?? data.filename; + if (ref == null) { + reply('getAttachmentUri', { result: null }); + return; + } + if (typeof ref === 'string' && !ref.trim()) { + reply('getAttachmentUri', { result: null }); + return; + } + if (typeof ref === 'object' && ref !== null && !Array.isArray(ref)) { + const o = ref as Record; + const hasFn = + typeof o.filename === 'string' && o.filename.trim() !== ''; + if (!hasFn) { + reply('getAttachmentUri', { result: null }); + return; + } + } + const result = await resolveAttachmentUriForFormPreview(ref); + reply('getAttachmentUri', { result }); } catch (e) { reply('getAttachmentUri', { error: e instanceof Error ? e.message : String(e), diff --git a/desktop/src/lib/sanitizeFormSavedData.ts b/desktop/src/lib/sanitizeFormSavedData.ts new file mode 100644 index 000000000..177cae736 --- /dev/null +++ b/desktop/src/lib/sanitizeFormSavedData.ts @@ -0,0 +1,72 @@ +/** + * Strip host-specific attachment URLs from observation `savedData` before the + * formplayer sees it. Mobile rows often carry `uri` / `url` and path-like + * `filename` values; the WebView must resolve display URLs only via + * `getAttachmentUri(basename)`. + */ + +const ATTACHMENT_NAME_LIKE = + /\.(jpe?g|png|gif|bmp|webp|pdf|docx?)$/i; + +function attachmentBasenameOnly(raw: string): string { + const t = raw.trim().replace(/\\/g, '/'); + const last = t.split('/').pop()?.trim() ?? ''; + return last; +} + +function isAttachmentLikeObject(o: Record): boolean { + const fn = o.filename; + if (typeof fn !== 'string' || !fn.trim()) { + return false; + } + const base = attachmentBasenameOnly(fn); + if (!base || base.includes('..')) { + return false; + } + if (ATTACHMENT_NAME_LIKE.test(base)) { + return true; + } + if (typeof o.uri === 'string' || typeof o.url === 'string') { + return true; + } + if (o.metadata !== null && typeof o.metadata === 'object') { + return !Array.isArray(o.metadata); + } + return false; +} + +function walk(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map(walk); + } + const o = value as Record; + if (isAttachmentLikeObject(o)) { + const fn = o.filename as string; + const base = attachmentBasenameOnly(fn); + const next: Record = { ...o }; + delete next.uri; + delete next.url; + if (base !== fn.trim()) { + next.filename = base; + } + for (const key of Object.keys(next)) { + next[key] = walk(next[key]) as never; + } + return next; + } + const out: Record = {}; + for (const key of Object.keys(o)) { + out[key] = walk(o[key]); + } + return out; +} + +export function sanitizePortableAttachmentsInFormData( + data: Record, +): Record { + const clone = JSON.parse(JSON.stringify(data)) as Record; + return walk(clone) as Record; +} diff --git a/formulus-formplayer/src/mocks/webview-mock.ts b/formulus-formplayer/src/mocks/webview-mock.ts index 6f0d67fb9..4178e4ac3 100644 --- a/formulus-formplayer/src/mocks/webview-mock.ts +++ b/formulus-formplayer/src/mocks/webview-mock.ts @@ -328,7 +328,9 @@ class WebViewMock { this.showAudioSimulationPopup(fieldId); }); }, - getAttachmentUri: (_fileName: string): Promise => { + getAttachmentUri: ( + _fileRef: string | { filename?: string }, + ): Promise => { console.log( '[WebView Mock] getAttachmentUri (browser dev: no local files)', ); diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index 2bcff7571..8825b4ec8 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -17,7 +17,10 @@ import { } from '@mui/material'; import { PhotoCamera, Delete, Refresh } from '@mui/icons-material'; import FormulusClient from '../services/FormulusInterface'; -import { CameraResult } from '../types/FormulusInterfaceDefinition'; +import { + CameraResult, + CameraResultData, +} from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; @@ -27,22 +30,66 @@ const parsePx = (value: string): number => { }; /** - * True when `uri` points at another device's filesystem (e.g. Formulus Android - * after Synk pull). Those paths must not be used in img/src — resolve via - * `filename` + {@link FormulusClient.getAttachmentUri} instead. + * Basename for {@link FormulusClient.getAttachmentUri} from `photo.filename` + * (handles values that mistakenly include path segments). + */ +function photoAttachmentBasename( + data: Record | null, +): string | null { + if (!data || typeof data.filename !== 'string') { + return null; + } + const t = data.filename.trim(); + if (!t) { + return null; + } + const normalized = t.replace(/\\/g, '/'); + const last = normalized.split('/').pop()?.trim() ?? ''; + if (!last || last === '.' || last === '..' || last.includes('..')) { + return null; + } + return last; +} + +/** + * Subset of camera metadata kept on the observation (portable, no host paths or picker noise). */ -function isForeignDeviceFileUri(uri: string): boolean { - const u = uri.trim(); - if (!u) { - return false; +type PhotoObservationMetadata = Pick< + CameraResultData['metadata'], + 'width' | 'height' | 'size' | 'mimeType' | 'quality' +>; + +function observationPhotoMetadataFromCamera( + m: CameraResultData['metadata'], +): PhotoObservationMetadata { + return { + width: m.width, + height: m.height, + size: m.size, + mimeType: m.mimeType, + quality: m.quality, + }; +} + +/** Never pass another host's `file://` path to `` (e.g. stale bridge output or legacy data). */ +function webviewSafeImageSrc(url: string | null): string | null { + if (url == null || url === '') { + return null; } - if (u.includes('/data/user/') || u.includes('org.opendataensemble.formulus')) { - return true; + const u = url.trim(); + if (!u.startsWith('file://')) { + return u; } - if (u.includes('/var/mobile/') || u.includes('/Application/')) { - return true; + const lower = u.toLowerCase(); + if ( + lower.includes('/data/user/') || + lower.includes('org.opendataensemble.formulus') || + lower.includes('/var/mobile/') || + lower.includes('/application/') + ) { + return null; } - return false; + return u; } // Tester function to identify photo question types @@ -96,43 +143,20 @@ const PhotoQuestionRenderer: React.FC = ({ // Get the current photo data from the form data (now JSON format) const currentPhotoData = data || null; - // Prefer local uri (this device); else resolve basename via bridge (draft → committed → synced copy) + // Always resolve by basename so WebView never loads another device's file:// path. useEffect(() => { let cancelled = false; const run = async () => { console.log('Photo data changed:', currentPhotoData); - const rawUri = - typeof currentPhotoData?.uri === 'string' - ? currentPhotoData.uri - : null; - if ( - rawUri && - !isForeignDeviceFileUri(rawUri) - ) { - const u = rawUri; - const display = - u.startsWith('file://') || u.startsWith('http') ? u : `file://${u}`; - console.log('Setting photo URL from stored data:', display); - if (!cancelled) setPhotoUrl(display); - return; - } - if (currentPhotoData?.filename) { - const resolved = await formulusClient.current.getAttachmentUri( - currentPhotoData.filename, - ); - if (!cancelled) { - setPhotoUrl(resolved); - console.log( - 'Resolved photo from filename:', - currentPhotoData.filename, - resolved, - ); - } - return; - } + const base = photoAttachmentBasename( + currentPhotoData as Record | null, + ); + const resolved = await formulusClient.current.getAttachmentUri( + base ?? null, + ); if (!cancelled) { - console.log('No photo URI or filename, clearing photoUrl state'); - setPhotoUrl(null); + setPhotoUrl(webviewSafeImageSrc(resolved)); + console.log('Resolved photo display URL:', resolved); } }; void run(); @@ -159,22 +183,20 @@ const PhotoQuestionRenderer: React.FC = ({ // Check if the result was successful if (cameraResult.status === 'success' && cameraResult.data) { - // Store photo data in form - use file URI for display - const displayUri = cameraResult.data.uri; - + // Persist portable fields only — basename is stable across devices; avoid + // storing host-specific file paths in observation JSON. const photoData = { id: cameraResult.data.id, type: cameraResult.data.type, filename: cameraResult.data.filename, - uri: cameraResult.data.uri, timestamp: cameraResult.data.timestamp, - metadata: cameraResult.data.metadata, + metadata: observationPhotoMetadataFromCamera( + cameraResult.data.metadata, + ), }; console.log('Created photo data object for sync protocol:', { id: photoData.id, filename: photoData.filename, - uri: photoData.uri, - persistentStorage: photoData.metadata.persistentStorage, size: photoData.metadata.size, }); @@ -182,12 +204,11 @@ const PhotoQuestionRenderer: React.FC = ({ console.log('Updating form data with photo data...'); handleChange(path, photoData); - // Set the photo URL for display using the file URI - console.log( - 'Setting photo URL for display:', - displayUri.substring(0, 50) + '...', + const resolved = await formulusClient.current.getAttachmentUri( + photoData.filename, ); - setPhotoUrl(displayUri); + setPhotoUrl(webviewSafeImageSrc(resolved)); + console.log('Setting photo URL for display via getAttachmentUri:', resolved); // Clear any previous errors on successful photo capture console.log('Clearing error state after successful photo capture'); @@ -290,7 +311,6 @@ const PhotoQuestionRenderer: React.FC = ({ currentPhotoData, hasPhotoData: !!currentPhotoData, hasFilename: !!currentPhotoData?.filename, - hasUri: !!currentPhotoData?.uri, photoUrl, hasPhotoUrl: !!photoUrl, shouldShowThumbnail: !!( diff --git a/formulus-formplayer/src/services/FormulusInterface.ts b/formulus-formplayer/src/services/FormulusInterface.ts index 1b41d82b2..e8b69f508 100644 --- a/formulus-formplayer/src/services/FormulusInterface.ts +++ b/formulus-formplayer/src/services/FormulusInterface.ts @@ -8,6 +8,7 @@ */ import { + AttachmentDisplayDescriptor, FormulusInterface, CameraResult, QrcodeResult, @@ -120,12 +121,29 @@ class FormulusClient { } /** - * Resolve an attachment basename to a WebView-loadable URL (draft, committed, or pending upload). + * Resolve an attachment basename or photo descriptor to a WebView-loadable URL. */ - public async getAttachmentUri(fileName: string): Promise { + public async getAttachmentUri( + fileRef: string | AttachmentDisplayDescriptor | null | undefined, + ): Promise { + if (fileRef == null) { + return null; + } + if (typeof fileRef === 'string') { + if (!fileRef.trim()) { + return null; + } + } else { + const hasFn = + typeof fileRef.filename === 'string' && + fileRef.filename.trim() !== ''; + if (!hasFn) { + return null; + } + } await this.tryEnsureFormulus(); if (this.formulus) { - return this.formulus.getAttachmentUri(fileName); + return this.formulus.getAttachmentUri(fileRef); } console.warn('Formulus interface not available for getAttachmentUri'); return null; diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 0edfd07a3..c7bfcf680 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -106,6 +106,14 @@ export interface CameraResultData { }; } +/** + * Attachment reference for {@link FormulusInterface.getAttachmentUri} when not passing a basename string. + * Use `filename` only (observation JSON must not store file paths or `uri` — resolve display URLs via this API). + */ +export interface AttachmentDisplayDescriptor { + filename?: string; +} + /** * Audio-specific result data * @property {'audio'} type - Always 'audio' for audio results @@ -453,22 +461,23 @@ export interface FormulusInterface { getThemeMode(): Promise<'light' | 'dark' | 'system'>; /** - * Resolve a synced or camera-saved attachment to a WebView-loadable `file://` URL. + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). * - * Lookup order, first hit wins: + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: * 1. `attachments/draft/` — unsaved capture (formplayer preview) * 2. `attachments/synced/` — canonical committed / downloaded copy * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. * - * Legacy locations (`attachments/` and `attachments/pending_upload/`) - * are also checked as a fallback until the v2 folder-layout migration has - * finished on upgraded devices. Pass the basename only (e.g. `photo.filename` - * from observation data); path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). * - * @param fileName - Attachment file basename - * @returns `file://` URL if the file exists, otherwise `null` + * @param fileName - Basename string, or `{ filename? }` (never a stored `uri` — use this method to resolve URLs) + * @returns Display URL, or `null` if none */ - getAttachmentUri(fileName: string): Promise; + getAttachmentUri( + fileName: string | AttachmentDisplayDescriptor, + ): Promise; /** * Base `file://` URL for the canonical attachments directory (trailing slash). diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index a90df5ae7..0a6c34984 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -1,52 +1,38 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-04-09T07:22:39.750Z +// Last generated: 2026-04-17T14:59:47.429Z -(function () { +(function() { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return ( - globalThis.formulus || - window.formulus || - (typeof formulus !== 'undefined' ? formulus : undefined) - ); + return globalThis.formulus || window.formulus || (typeof formulus !== 'undefined' ? formulus : undefined); } function isFormulusAvailable() { const api = getFormulus(); - return ( - api && typeof api === 'object' && typeof api.getVersion === 'function' - ); + return api && typeof api === 'object' && typeof api.getVersion === 'function'; } // Idempotent guard to avoid double-initialization when scripts are reinjected - if (globalThis.__formulusBridgeInitialized) { + if ((globalThis).__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug( - 'Formulus bridge already initialized and functional. Skipping duplicate injection.', - ); + console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); return; } else { - console.warn( - 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', - ); + console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug( - 'Formulus interface already exists and is functional. Skipping injection.', - ); + console.debug('Formulus interface already exists and is functional. Skipping injection.'); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn( - 'Formulus interface exists but appears non-functional. Re-injecting...', - ); + console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); } // Helper function to handle callbacks @@ -62,7 +48,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -75,1541 +61,1200 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if ( - data.type === 'callback' && - data.callbackId && - callbacks[data.callbackId] - ) { + if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - if ( - data.type === 'onFormulusReady' && - globalThis.formulusCallbacks?.onFormulusReady - ) { + + + if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error( - 'Global handleMessage: Error processing message:', - e, - 'Raw event.data:', - event.data, - ); + console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getVersion callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getVersion_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + // getVersion: => Promise + getVersion: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getVersion_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getVersion' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getVersion', messageId, - }), - ); - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAvailableForms_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAvailableForms' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAvailableForms', messageId, - }), - ); - }); - }, - - // openFormplayer: formType, params, savedData, options? => Promise - openFormplayer: function (formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'openFormplayer callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'openFormplayer_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + openFormplayer: function(formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'openFormplayer_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'openFormplayer' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options, - }), - ); - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function (formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getObservations callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getObservations_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function(formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getObservations_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getObservations' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted, - }), - ); - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise - getObservationsByQuery: function (options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getObservationsByQuery_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + includeDeleted: includeDeleted + })); + + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + getObservationsByQuery: function(options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getObservationsByQuery' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options, - }), - ); - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function (formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'submitObservation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'submitObservation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function(formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'submitObservation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'submitObservation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData, - }), - ); - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function (observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'updateObservation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'updateObservation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + formType: formType, + finalData: finalData + })); + + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function(observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'updateObservation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'updateObservation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData, - }), - ); - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestCamera callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestCamera_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + finalData: finalData + })); + + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestCamera_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestCamera' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestLocation callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestLocation_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestLocation_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestLocation' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestFile callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestFile_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestFile_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestFile' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function (fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'launchIntent callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'launchIntent_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function(fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'launchIntent_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'launchIntent' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec, - }), - ); - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function (fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'callSubform callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'callSubform_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId, + intentSpec: intentSpec + })); + + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function(fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'callSubform_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'callSubform' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options, - }), - ); - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestAudio callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestAudio_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + options: options + })); + + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestAudio_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestAudio' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestQrcode callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestQrcode_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestQrcode_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestQrcode' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function (fieldId) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestBiometric callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestBiometric_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function(fieldId) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'requestBiometric_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestBiometric' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId, - }), - ); - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestConnectivityStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fieldId: fieldId + })); + + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestConnectivityStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestConnectivityStatus', messageId, - }), - ); - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestSyncStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'requestSyncStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestSyncStatus', messageId, - }), - ); - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function (fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'runLocalModel callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'runLocalModel_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function(fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'runLocalModel_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'runLocalModel' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input, - }), - ); - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getCurrentUser_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + input: input + })); + + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getCurrentUser' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getCurrentUser', messageId, - }), - ); - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getThemeMode callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getThemeMode_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getThemeMode_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getThemeMode' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getThemeMode', messageId, - }), - ); - }); - }, - - // getAttachmentUri: fileName: string => Promise - getAttachmentUri: function (fileName) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAttachmentUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function(fileName) { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAttachmentUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName, - }), - ); - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getAttachmentsUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + fileName: fileName + })); + + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getAttachmentsUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getAttachmentsUri', messageId, - }), - ); - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getCustomAppUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; } + if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getCustomAppUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getCustomAppUri', messageId, - }), - ); - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getFormSpecsUri_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); + + })); + + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function() { + return new Promise((resolve, reject) => { + const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = (event) => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object } else { - resolve(data.result); + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); + return; + } + if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } + } catch (e) { + console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } - } catch (e) { - console.error( - "'getFormSpecsUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ + }; + window.addEventListener('message', callback); + + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getFormSpecsUri', messageId, - }), - ); - }); - }, + + })); + + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - globalThis.__formulusBridgeInitialized = true; + (globalThis).__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now(), - }), - ); + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now() + })); } } // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'onFormulusReady', - }), - ); + globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'onFormulusReady' + })); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } + })(); diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index 0566c7efe..fb6b3ec89 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -1,16 +1,16 @@ /** * Formulus API Interface (JavaScript Version) - * + * * This file provides type information and documentation for the Formulus API * that's available in the WebView context as `globalThis.formulus`. - * + * * This file is auto-generated from FormulusInterfaceDefinition.ts - * Last generated: 2026-04-17 - * + * Last generated: 2026-04-17T14:59:47.704Z + * * @example * // In your JavaScript file: * /// - * + * * // Now you'll get autocompletion and type hints in IDEs that support JSDoc * globalThis.formulus.getVersion().then(version => { * console.log('Formulus version:', version); @@ -29,219 +29,214 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus API - * / - * @returns {Promise} The API version - */ - getVersion: function () {}, - - /** - * Get a list of available forms - * / - * @returns {Promise} Array of form information objects - */ - getAvailableForms: function () {}, - - /** - * Open Formplayer with the specified form - * / - * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization - * @param {Object} savedData - Previously saved form data (for editing) - * @returns {Promise} Promise that resolves when the form is completed/closed with result details - */ - openFormplayer: function (formType, params, savedData, options) {}, - - /** - * Get observations for a specific form - * / - * @param {string} formType - The identifier of the formtype - * @returns {Promise} Array of form observations - */ - getObservations: function (formType, isDraft, includeDeleted) {}, - - /** - * Get observations with optional WHERE clause filtering (for dynamic choice lists). - * Supports format: data.field = 'value' AND data.other = 'value' - * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * / - * @returns {Promise} Array of filtered observations - */ - getObservationsByQuery: function (options) {}, - + * Get the current version of the Formulus API + * / + * @returns {Promise} The API version + */ + getVersion: function() {}, + + /** + * Get a list of available forms + * / + * @returns {Promise} Array of form information objects + */ + getAvailableForms: function() {}, + + /** + * Open Formplayer with the specified form + * / + * @param {string} formType - The identifier of the formtype to open + * @param {Object} params - Additional parameters for form initialization + * @param {Object} savedData - Previously saved form data (for editing) + * @returns {Promise} Promise that resolves when the form is completed/closed with result details + */ + openFormplayer: function(formType, params, savedData, options) {}, + + /** + * Get observations for a specific form + * / + * @param {string} formType - The identifier of the formtype + * @returns {Promise} Array of form observations + */ + getObservations: function(formType, isDraft, includeDeleted) {}, + + /** + * Get observations with optional WHERE clause filtering (for dynamic choice lists). + * Supports format: data.field = 'value' AND data.other = 'value' + * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. + * / + * @returns {Promise} Array of filtered observations + */ + getObservationsByQuery: function(options) {}, + + /** + * Submit a completed form + * / + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to submit + * @returns {Promise} The observationId of the submitted form + */ + submitObservation: function(formType, finalData) {}, + + /** + * Update an existing form + * / + * @param {string} observationId - The identifier of the observation + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to update + * @returns {Promise} The observationId of the updated form + */ + updateObservation: function(observationId, formType, finalData) {}, + + /** + * Request camera access for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation + */ + requestCamera: function(fieldId) {}, + + /** + * Request location for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestLocation: function(fieldId) {}, + + /** + * Request file selection for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation + */ + requestFile: function(fieldId) {}, + + /** + * Launch an external intent + * / + * @param {string} fieldId - The ID of the field + * @param {Object} intentSpec - The intent specification + * @returns {Promise} + */ + launchIntent: function(fieldId, intentSpec) {}, + + /** + * Call a subform + * / + * @param {string} fieldId - The ID of the field + * @param {string} formType - The ID of the subform + * @param {Object} options - Additional options for the subform + * @returns {Promise} + */ + callSubform: function(fieldId, formType, options) {}, + + /** + * Request audio recording for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation + */ + requestAudio: function(fieldId) {}, + + /** + * Request QR code scanning for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation + */ + requestQrcode: function(fieldId) {}, + + /** + * Request biometric authentication + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestBiometric: function(fieldId) {}, + + /** + * Request the current connectivity status + * / + * @returns {Promise} + */ + requestConnectivityStatus: function() {}, + + /** + * Request the current sync status + * / + * @returns {Promise} + */ + requestSyncStatus: function() {}, + /** - * Submit a completed form - * / - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to submit - * @returns {Promise} The observationId of the submitted form - */ - submitObservation: function (formType, finalData) {}, - - /** - * Update an existing form - * / - * @param {string} observationId - The identifier of the observation - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to update - * @returns {Promise} The observationId of the updated form - */ - updateObservation: function (observationId, formType, finalData) {}, - - /** - * Request camera access for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation - */ - requestCamera: function (fieldId) {}, - - /** - * Request location for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestLocation: function (fieldId) {}, - - /** - * Request file selection for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation - */ - requestFile: function (fieldId) {}, - - /** - * Launch an external intent - * / - * @param {string} fieldId - The ID of the field - * @param {Object} intentSpec - The intent specification - * @returns {Promise} - */ - launchIntent: function (fieldId, intentSpec) {}, - - /** - * Call a subform - * / - * @param {string} fieldId - The ID of the field - * @param {string} formType - The ID of the subform - * @param {Object} options - Additional options for the subform - * @returns {Promise} - */ - callSubform: function (fieldId, formType, options) {}, - - /** - * Request audio recording for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation - */ - requestAudio: function (fieldId) {}, - - /** - * Request QR code scanning for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation - */ - requestQrcode: function (fieldId) {}, - - /** - * Request biometric authentication - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestBiometric: function (fieldId) {}, - - /** - * Request the current connectivity status - * / - * @returns {Promise} - */ - requestConnectivityStatus: function () {}, - - /** - * Request the current sync status - * / - * @returns {Promise} - */ - requestSyncStatus: function () {}, - - /** - * Run a local ML model - * / - * @param {string} fieldId - The ID of the field - * @param {string} modelId - The ID of the model to run - * @param {Object} input - The input data for the model - * @returns {Promise} - */ - runLocalModel: function (fieldId, modelId, input) {}, - - /** - * Get information about the currently authenticated user. - * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * / - * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} - */ - getCurrentUser: function () {}, - - /** - * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * / - * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. - */ - getThemeMode: function () {}, - - /** - * Resolve a synced or camera-saved attachment to a WebView-loadable `file://` URL. - * - * Lookup order, first hit wins: - * 1. `attachments/draft/` — unsaved capture (formplayer preview) - * 2. `attachments/synced/` — canonical committed / downloaded copy - * 3. `attachments/pending/` — queued for upload (fallback only) - * - * Legacy locations (`attachments/` and `attachments/pending_upload/`) - * are also checked as a fallback until the v2 folder-layout migration has - * finished on upgraded devices. Pass the basename only (e.g. `photo.filename` - * from observation data); path segments and ".." are rejected. - * - * / - * @returns {Promise} `file://` URL if the file exists, otherwise `null` - */ - getAttachmentUri: function (fileName) {}, - - /** - * Base `file://` URL for the canonical attachments directory (trailing slash). - * Returns the `synced/` subfolder — only committed/downloaded files are - * iterable from here. Drafts and the upload queue are excluded by design so - * custom apps can safely list this directory. - * - * **Breaking change (v2 layout):** this used to return the `attachments/` - * parent directory, which mixed committed files with `draft/` and - * `pending_upload/` subfolders. Custom apps that iterate this URL will now - * see only fully-committed attachments. - * - * / - * @returns {Promise} e.g. `file:///.../attachments/synced/` - */ - getAttachmentsUri: function () {}, - - /** - * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * / - * @returns {Promise} App directory URL for extensions, question_types, etc. - */ - getCustomAppUri: function () {}, - - /** - * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). - * Some bundles also use files under the custom app `forms/` subdirectory. - * / - * @returns {Promise} Forms directory URL - */ - getFormSpecsUri: function () {}, + * Run a local ML model + * / + * @param {string} fieldId - The ID of the field + * @param {string} modelId - The ID of the model to run + * @param {Object} input - The input data for the model + * @returns {Promise} + */ + runLocalModel: function(fieldId, modelId, input) {}, + + /** + * Get information about the currently authenticated user. + * When no one is logged in, resolves with `{ username: '' }` (does not reject). + * / + * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} + */ + getCurrentUser: function() {}, + + /** + * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. + * / + * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. + */ + getThemeMode: function() {}, + + /** + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). + * / + * @returns {Promise} Display URL, or `null` if none + */ + getAttachmentUri: function(fileName) {}, + + /** + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. + * / + * @returns {Promise} e.g. `file:///.../attachments/synced/` + */ + getAttachmentsUri: function() {}, + + /** + * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). + * / + * @returns {Promise} App directory URL for extensions, question_types, etc. + */ + getCustomAppUri: function() {}, + + /** + * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). + * Some bundles also use files under the custom app `forms/` subdirectory. + * / + * @returns {Promise} Forms directory URL + */ + getFormSpecsUri: function() {}, + }; // Make the API available globally in browser environments diff --git a/formulus/src/services/WebViewFileUrlResolver.ts b/formulus/src/services/WebViewFileUrlResolver.ts index 51519e52a..68156c2d9 100644 --- a/formulus/src/services/WebViewFileUrlResolver.ts +++ b/formulus/src/services/WebViewFileUrlResolver.ts @@ -86,6 +86,24 @@ export async function resolveAttachmentFileUrl( return null; } +/** + * Resolve a basename or `{ filename }` descriptor to a WebView-loadable URL. + * Callers must not pass stored `uri` values — only attachment basenames. + */ +export async function resolveAttachmentDisplayUri( + fileRef: string | { filename?: string }, +): Promise { + if (typeof fileRef === 'string') { + return resolveAttachmentFileUrl(fileRef); + } + const fname = + typeof fileRef.filename === 'string' ? fileRef.filename.trim() : ''; + if (fname) { + return resolveAttachmentFileUrl(fname); + } + return null; +} + /** * Base `file://` URL for the canonical attachment directory that custom apps * should iterate. Returns the `synced/` subdirectory — drafts and the upload diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 0edfd07a3..c7bfcf680 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -106,6 +106,14 @@ export interface CameraResultData { }; } +/** + * Attachment reference for {@link FormulusInterface.getAttachmentUri} when not passing a basename string. + * Use `filename` only (observation JSON must not store file paths or `uri` — resolve display URLs via this API). + */ +export interface AttachmentDisplayDescriptor { + filename?: string; +} + /** * Audio-specific result data * @property {'audio'} type - Always 'audio' for audio results @@ -453,22 +461,23 @@ export interface FormulusInterface { getThemeMode(): Promise<'light' | 'dark' | 'system'>; /** - * Resolve a synced or camera-saved attachment to a WebView-loadable `file://` URL. + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). * - * Lookup order, first hit wins: + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: * 1. `attachments/draft/` — unsaved capture (formplayer preview) * 2. `attachments/synced/` — canonical committed / downloaded copy * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. * - * Legacy locations (`attachments/` and `attachments/pending_upload/`) - * are also checked as a fallback until the v2 folder-layout migration has - * finished on upgraded devices. Pass the basename only (e.g. `photo.filename` - * from observation data); path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). * - * @param fileName - Attachment file basename - * @returns `file://` URL if the file exists, otherwise `null` + * @param fileName - Basename string, or `{ filename? }` (never a stored `uri` — use this method to resolve URLs) + * @returns Display URL, or `null` if none */ - getAttachmentUri(fileName: string): Promise; + getAttachmentUri( + fileName: string | AttachmentDisplayDescriptor, + ): Promise; /** * Base `file://` URL for the canonical attachments directory (trailing slash). diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index d486b6ee3..7c8839d8b 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -23,6 +23,7 @@ import { errorCodes, } from '@react-native-documents/picker'; import { + AttachmentDisplayDescriptor, FormInitData, FormCompletionResult, FormInfo, @@ -46,7 +47,7 @@ import { getAttachmentsDirectoryFileUrl, getCustomAppDirectoryFileUrl, getFormSpecsDirectoryFileUrl, - resolveAttachmentFileUrl, + resolveAttachmentDisplayUri, } from '../services/WebViewFileUrlResolver'; export type HandlerArgs = { @@ -1053,17 +1054,27 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { } }, onGetAttachmentUri: async (data: { - fileName?: string; - filename?: string; + fileName?: string | AttachmentDisplayDescriptor; + filename?: string | AttachmentDisplayDescriptor; }): Promise => { - const name = data?.fileName ?? data?.filename; - if (name == null || typeof name !== 'string') { + const ref = data?.fileName ?? data?.filename; + if (ref == null) { console.warn( 'FormulusMessageHandlers: onGetAttachmentUri missing fileName', ); return null; } - return resolveAttachmentFileUrl(name); + if (typeof ref === 'string' && !ref.trim()) { + return null; + } + if (typeof ref === 'object' && ref !== null && !Array.isArray(ref)) { + const hasFn = + typeof ref.filename === 'string' && ref.filename.trim() !== ''; + if (!hasFn) { + return null; + } + } + return resolveAttachmentDisplayUri(ref); }, onGetAttachmentsUri: async (): Promise => { return getAttachmentsDirectoryFileUrl(); diff --git a/formulus/src/webview/FormulusMessageHandlers.types.ts b/formulus/src/webview/FormulusMessageHandlers.types.ts index db3402dce..3eacd9b40 100644 --- a/formulus/src/webview/FormulusMessageHandlers.types.ts +++ b/formulus/src/webview/FormulusMessageHandlers.types.ts @@ -2,6 +2,7 @@ // Must match the injected interface in FormulusInterfaceDefinition.ts import { Observation } from '../database/models/Observation'; import { + AttachmentDisplayDescriptor, FormInitData, FormCompletionResult, FormInfo, @@ -68,8 +69,8 @@ export interface FormulusMessageHandlers { }>; onGetThemeMode?: () => Promise<'light' | 'dark' | 'system'>; onGetAttachmentUri?: (data: { - fileName?: string; - filename?: string; + fileName?: string | AttachmentDisplayDescriptor; + filename?: string | AttachmentDisplayDescriptor; }) => Promise; onGetAttachmentsUri?: () => Promise; onGetCustomAppUri?: () => Promise; From ee444d1462aa98ddb540fa4805b51ec61d925774 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:20:21 +0200 Subject: [PATCH 3/8] fix: linting --- desktop/eslint.config.js | 2 ++ desktop/public/formulus-injection.js | 3 ++- formulus/assets/webview/FormulusInjectionScript.js | 3 ++- formulus/scripts/generateInjectionScript.ts | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/desktop/eslint.config.js b/desktop/eslint.config.js index 607de6f5f..407019059 100644 --- a/desktop/eslint.config.js +++ b/desktop/eslint.config.js @@ -14,6 +14,8 @@ export default defineConfig([ 'src/generated/**', 'src-tauri/target/**', 'scripts/**', + // Copied formplayer build (large minified bundles); linting it can crash ESLint formatters. + 'public/formplayer_dist/**', ]), js.configs.recommended, ...tseslint.configs.recommended, diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 0a6c34984..7ca441631 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -6,7 +6,7 @@ // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || window.formulus || (typeof formulus !== 'undefined' ? formulus : undefined); + return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); } function isFormulusAvailable() { @@ -1244,6 +1244,7 @@ })); } } + globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 0a6c34984..7ca441631 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -6,7 +6,7 @@ // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || window.formulus || (typeof formulus !== 'undefined' ? formulus : undefined); + return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); } function isFormulusAvailable() { @@ -1244,6 +1244,7 @@ })); } } + globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { diff --git a/formulus/scripts/generateInjectionScript.ts b/formulus/scripts/generateInjectionScript.ts index e2ce9e81f..eb9afde0b 100644 --- a/formulus/scripts/generateInjectionScript.ts +++ b/formulus/scripts/generateInjectionScript.ts @@ -242,7 +242,7 @@ function generateInjectionScript(interfaceFilePath: string): string { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || window.formulus || (typeof formulus !== 'undefined' ? formulus : undefined); + return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); } function isFormulusAvailable() { @@ -340,6 +340,7 @@ function generateInjectionScript(interfaceFilePath: string): string { })); } } + globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { From 77ba6530f0d4d39086efa338d1bffef0172073a5 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:20:40 +0200 Subject: [PATCH 4/8] fix: pretty --- .../assets/webview/FormulusInjectionScript.js | 2440 ++++++++++------- formulus/assets/webview/formulus-api.js | 421 ++- 2 files changed, 1607 insertions(+), 1254 deletions(-) diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 7ca441631..110910003 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -2,37 +2,50 @@ // Do not edit directly - this file will be overwritten // Last generated: 2026-04-17T14:59:47.429Z -(function() { +(function () { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); + return ( + globalThis.formulus || + (typeof window !== 'undefined' ? window.formulus : undefined) + ); } function isFormulusAvailable() { const api = getFormulus(); - return api && typeof api === 'object' && typeof api.getVersion === 'function'; + return ( + api && typeof api === 'object' && typeof api.getVersion === 'function' + ); } // Idempotent guard to avoid double-initialization when scripts are reinjected - if ((globalThis).__formulusBridgeInitialized) { + if (globalThis.__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); + console.debug( + 'Formulus bridge already initialized and functional. Skipping duplicate injection.', + ); return; } else { - console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); + console.warn( + 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', + ); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug('Formulus interface already exists and is functional. Skipping injection.'); + console.debug( + 'Formulus interface already exists and is functional. Skipping injection.', + ); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); + console.warn( + 'Formulus interface exists but appears non-functional. Re-injecting...', + ); } // Helper function to handle callbacks @@ -48,7 +61,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -61,1201 +74,1542 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { + if ( + data.type === 'callback' && + data.callbackId && + callbacks[data.callbackId] + ) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - - if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { + + if ( + data.type === 'onFormulusReady' && + globalThis.formulusCallbacks?.onFormulusReady + ) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); + console.error( + 'Global handleMessage: Error processing message:', + e, + 'Raw event.data:', + event.data, + ); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + // getVersion: => Promise + getVersion: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getVersion callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getVersion_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getVersion_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getVersion' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getVersion', messageId, - - })); - - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAvailableForms_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAvailableForms' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAvailableForms', messageId, - - })); - - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise - openFormplayer: function(formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + openFormplayer: function (formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'openFormplayer callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'openFormplayer_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'openFormplayer_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'openFormplayer' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options - })); - - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function(formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function (formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservations callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservations_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservations_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservations' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted - })); - - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise - getObservationsByQuery: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + includeDeleted: includeDeleted, + }), + ); + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + getObservationsByQuery: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options - })); - - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function(formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function (formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'submitObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'submitObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'submitObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'submitObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData - })); - - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function(observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + formType: formType, + finalData: finalData, + }), + ); + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function (observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'updateObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'updateObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'updateObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'updateObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData - })); - - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + finalData: finalData, + }), + ); + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestCamera callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestCamera_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestCamera_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestCamera' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestFile callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestFile_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestFile_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestFile' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId - })); - - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function(fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function (fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'launchIntent callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'launchIntent_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'launchIntent_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'launchIntent' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec - })); - - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function(fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + intentSpec: intentSpec, + }), + ); + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function (fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'callSubform callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'callSubform_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'callSubform_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'callSubform' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options - })); - - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestAudio callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestAudio_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestAudio_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestAudio' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestQrcode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestQrcode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestQrcode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestBiometric_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestBiometric_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestBiometric' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestConnectivityStatus', messageId, - - })); - - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestSyncStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestSyncStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestSyncStatus', messageId, - - })); - - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function(fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'runLocalModel_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'runLocalModel_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'runLocalModel' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input - })); - - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentUser_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCurrentUser' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentUser', messageId, - - })); - - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getThemeMode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getThemeMode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getThemeMode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getThemeMode', messageId, - - })); - - }); - }, - - // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise - getAttachmentUri: function(fileName) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName - })); - - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentsUri', messageId, - - })); - - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCustomAppUri', messageId, - - })); - - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getFormSpecsUri', messageId, - - })); - - }); - }, + }), + ); + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - (globalThis).__formulusBridgeInitialized = true; + globalThis.__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now() - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now(), + }), + ); } } globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'onFormulusReady' - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'onFormulusReady', + }), + ); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } - })(); diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index fb6b3ec89..c32e55042 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -1,16 +1,16 @@ /** * Formulus API Interface (JavaScript Version) - * + * * This file provides type information and documentation for the Formulus API * that's available in the WebView context as `globalThis.formulus`. - * + * * This file is auto-generated from FormulusInterfaceDefinition.ts * Last generated: 2026-04-17T14:59:47.704Z - * + * * @example * // In your JavaScript file: * /// - * + * * // Now you'll get autocompletion and type hints in IDEs that support JSDoc * globalThis.formulus.getVersion().then(version => { * console.log('Formulus version:', version); @@ -29,214 +29,213 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus API - * / - * @returns {Promise} The API version - */ - getVersion: function() {}, - - /** - * Get a list of available forms - * / - * @returns {Promise} Array of form information objects - */ - getAvailableForms: function() {}, - - /** - * Open Formplayer with the specified form - * / - * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization - * @param {Object} savedData - Previously saved form data (for editing) - * @returns {Promise} Promise that resolves when the form is completed/closed with result details - */ - openFormplayer: function(formType, params, savedData, options) {}, - - /** - * Get observations for a specific form - * / - * @param {string} formType - The identifier of the formtype - * @returns {Promise} Array of form observations - */ - getObservations: function(formType, isDraft, includeDeleted) {}, - - /** - * Get observations with optional WHERE clause filtering (for dynamic choice lists). - * Supports format: data.field = 'value' AND data.other = 'value' - * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * / - * @returns {Promise} Array of filtered observations - */ - getObservationsByQuery: function(options) {}, - - /** - * Submit a completed form - * / - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to submit - * @returns {Promise} The observationId of the submitted form - */ - submitObservation: function(formType, finalData) {}, - - /** - * Update an existing form - * / - * @param {string} observationId - The identifier of the observation - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to update - * @returns {Promise} The observationId of the updated form - */ - updateObservation: function(observationId, formType, finalData) {}, - - /** - * Request camera access for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation - */ - requestCamera: function(fieldId) {}, - - /** - * Request location for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestLocation: function(fieldId) {}, - - /** - * Request file selection for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation - */ - requestFile: function(fieldId) {}, - - /** - * Launch an external intent - * / - * @param {string} fieldId - The ID of the field - * @param {Object} intentSpec - The intent specification - * @returns {Promise} - */ - launchIntent: function(fieldId, intentSpec) {}, - - /** - * Call a subform - * / - * @param {string} fieldId - The ID of the field - * @param {string} formType - The ID of the subform - * @param {Object} options - Additional options for the subform - * @returns {Promise} - */ - callSubform: function(fieldId, formType, options) {}, - - /** - * Request audio recording for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation - */ - requestAudio: function(fieldId) {}, - - /** - * Request QR code scanning for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation - */ - requestQrcode: function(fieldId) {}, - - /** - * Request biometric authentication - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestBiometric: function(fieldId) {}, - - /** - * Request the current connectivity status - * / - * @returns {Promise} - */ - requestConnectivityStatus: function() {}, - - /** - * Request the current sync status - * / - * @returns {Promise} - */ - requestSyncStatus: function() {}, - + * Get the current version of the Formulus API + * / + * @returns {Promise} The API version + */ + getVersion: function () {}, + + /** + * Get a list of available forms + * / + * @returns {Promise} Array of form information objects + */ + getAvailableForms: function () {}, + + /** + * Open Formplayer with the specified form + * / + * @param {string} formType - The identifier of the formtype to open + * @param {Object} params - Additional parameters for form initialization + * @param {Object} savedData - Previously saved form data (for editing) + * @returns {Promise} Promise that resolves when the form is completed/closed with result details + */ + openFormplayer: function (formType, params, savedData, options) {}, + + /** + * Get observations for a specific form + * / + * @param {string} formType - The identifier of the formtype + * @returns {Promise} Array of form observations + */ + getObservations: function (formType, isDraft, includeDeleted) {}, + + /** + * Get observations with optional WHERE clause filtering (for dynamic choice lists). + * Supports format: data.field = 'value' AND data.other = 'value' + * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. + * / + * @returns {Promise} Array of filtered observations + */ + getObservationsByQuery: function (options) {}, + /** - * Run a local ML model - * / - * @param {string} fieldId - The ID of the field - * @param {string} modelId - The ID of the model to run - * @param {Object} input - The input data for the model - * @returns {Promise} - */ - runLocalModel: function(fieldId, modelId, input) {}, - - /** - * Get information about the currently authenticated user. - * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * / - * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} - */ - getCurrentUser: function() {}, - - /** - * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * / - * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. - */ - getThemeMode: function() {}, - - /** - * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). - * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: - * 1. `attachments/draft/` — unsaved capture (formplayer preview) - * 2. `attachments/synced/` — canonical committed / downloaded copy - * 3. `attachments/pending/` — queued for upload (fallback only) - * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. - * Path segments and ".." are rejected. - * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). - * / - * @returns {Promise} Display URL, or `null` if none - */ - getAttachmentUri: function(fileName) {}, - - /** - * Base `file://` URL for the canonical attachments directory (trailing slash). - * Returns the `synced/` subfolder — only committed/downloaded files are - * iterable from here. Drafts and the upload queue are excluded by design so - * custom apps can safely list this directory. - * **Breaking change (v2 layout):** this used to return the `attachments/` - * parent directory, which mixed committed files with `draft/` and - * `pending_upload/` subfolders. Custom apps that iterate this URL will now - * see only fully-committed attachments. - * / - * @returns {Promise} e.g. `file:///.../attachments/synced/` - */ - getAttachmentsUri: function() {}, - - /** - * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * / - * @returns {Promise} App directory URL for extensions, question_types, etc. - */ - getCustomAppUri: function() {}, - - /** - * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). - * Some bundles also use files under the custom app `forms/` subdirectory. - * / - * @returns {Promise} Forms directory URL - */ - getFormSpecsUri: function() {}, - + * Submit a completed form + * / + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to submit + * @returns {Promise} The observationId of the submitted form + */ + submitObservation: function (formType, finalData) {}, + + /** + * Update an existing form + * / + * @param {string} observationId - The identifier of the observation + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to update + * @returns {Promise} The observationId of the updated form + */ + updateObservation: function (observationId, formType, finalData) {}, + + /** + * Request camera access for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation + */ + requestCamera: function (fieldId) {}, + + /** + * Request location for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestLocation: function (fieldId) {}, + + /** + * Request file selection for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation + */ + requestFile: function (fieldId) {}, + + /** + * Launch an external intent + * / + * @param {string} fieldId - The ID of the field + * @param {Object} intentSpec - The intent specification + * @returns {Promise} + */ + launchIntent: function (fieldId, intentSpec) {}, + + /** + * Call a subform + * / + * @param {string} fieldId - The ID of the field + * @param {string} formType - The ID of the subform + * @param {Object} options - Additional options for the subform + * @returns {Promise} + */ + callSubform: function (fieldId, formType, options) {}, + + /** + * Request audio recording for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation + */ + requestAudio: function (fieldId) {}, + + /** + * Request QR code scanning for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation + */ + requestQrcode: function (fieldId) {}, + + /** + * Request biometric authentication + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestBiometric: function (fieldId) {}, + + /** + * Request the current connectivity status + * / + * @returns {Promise} + */ + requestConnectivityStatus: function () {}, + + /** + * Request the current sync status + * / + * @returns {Promise} + */ + requestSyncStatus: function () {}, + + /** + * Run a local ML model + * / + * @param {string} fieldId - The ID of the field + * @param {string} modelId - The ID of the model to run + * @param {Object} input - The input data for the model + * @returns {Promise} + */ + runLocalModel: function (fieldId, modelId, input) {}, + + /** + * Get information about the currently authenticated user. + * When no one is logged in, resolves with `{ username: '' }` (does not reject). + * / + * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} + */ + getCurrentUser: function () {}, + + /** + * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. + * / + * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. + */ + getThemeMode: function () {}, + + /** + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). + * / + * @returns {Promise} Display URL, or `null` if none + */ + getAttachmentUri: function (fileName) {}, + + /** + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. + * / + * @returns {Promise} e.g. `file:///.../attachments/synced/` + */ + getAttachmentsUri: function () {}, + + /** + * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). + * / + * @returns {Promise} App directory URL for extensions, question_types, etc. + */ + getCustomAppUri: function () {}, + + /** + * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). + * Some bundles also use files under the custom app `forms/` subdirectory. + * / + * @returns {Promise} Forms directory URL + */ + getFormSpecsUri: function () {}, }; // Make the API available globally in browser environments From 9ad27aa8c5d5422a0ca9c59ff1a36f4865d277b7 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:21:50 +0200 Subject: [PATCH 5/8] fix: pretty --- formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx | 5 ++++- formulus-formplayer/src/services/FormulusInterface.ts | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index 8825b4ec8..b8f0ff83c 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -208,7 +208,10 @@ const PhotoQuestionRenderer: React.FC = ({ photoData.filename, ); setPhotoUrl(webviewSafeImageSrc(resolved)); - console.log('Setting photo URL for display via getAttachmentUri:', resolved); + console.log( + 'Setting photo URL for display via getAttachmentUri:', + resolved, + ); // Clear any previous errors on successful photo capture console.log('Clearing error state after successful photo capture'); diff --git a/formulus-formplayer/src/services/FormulusInterface.ts b/formulus-formplayer/src/services/FormulusInterface.ts index e8b69f508..b6b2632cf 100644 --- a/formulus-formplayer/src/services/FormulusInterface.ts +++ b/formulus-formplayer/src/services/FormulusInterface.ts @@ -135,8 +135,7 @@ class FormulusClient { } } else { const hasFn = - typeof fileRef.filename === 'string' && - fileRef.filename.trim() !== ''; + typeof fileRef.filename === 'string' && fileRef.filename.trim() !== ''; if (!hasFn) { return null; } From 8d205a662ce8166aa8edbf9761df5d54f70db783 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:29:16 +0200 Subject: [PATCH 6/8] fix: a different kind of pretty --- desktop/public/formulus-injection.js | 2440 +++++++++++++--------- desktop/src/lib/sanitizeFormSavedData.ts | 3 +- desktop/src/store/useCustodianStore.ts | 5 +- 3 files changed, 1399 insertions(+), 1049 deletions(-) diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 7ca441631..110910003 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -2,37 +2,50 @@ // Do not edit directly - this file will be overwritten // Last generated: 2026-04-17T14:59:47.429Z -(function() { +(function () { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); + return ( + globalThis.formulus || + (typeof window !== 'undefined' ? window.formulus : undefined) + ); } function isFormulusAvailable() { const api = getFormulus(); - return api && typeof api === 'object' && typeof api.getVersion === 'function'; + return ( + api && typeof api === 'object' && typeof api.getVersion === 'function' + ); } // Idempotent guard to avoid double-initialization when scripts are reinjected - if ((globalThis).__formulusBridgeInitialized) { + if (globalThis.__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); + console.debug( + 'Formulus bridge already initialized and functional. Skipping duplicate injection.', + ); return; } else { - console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); + console.warn( + 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', + ); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug('Formulus interface already exists and is functional. Skipping injection.'); + console.debug( + 'Formulus interface already exists and is functional. Skipping injection.', + ); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); + console.warn( + 'Formulus interface exists but appears non-functional. Re-injecting...', + ); } // Helper function to handle callbacks @@ -48,7 +61,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -61,1201 +74,1542 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { + if ( + data.type === 'callback' && + data.callbackId && + callbacks[data.callbackId] + ) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - - if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { + + if ( + data.type === 'onFormulusReady' && + globalThis.formulusCallbacks?.onFormulusReady + ) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); + console.error( + 'Global handleMessage: Error processing message:', + e, + 'Raw event.data:', + event.data, + ); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + // getVersion: => Promise + getVersion: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getVersion callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getVersion_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getVersion_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getVersion' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getVersion', messageId, - - })); - - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAvailableForms_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAvailableForms' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAvailableForms', messageId, - - })); - - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise - openFormplayer: function(formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + openFormplayer: function (formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'openFormplayer callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'openFormplayer_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'openFormplayer_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'openFormplayer' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options - })); - - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function(formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function (formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservations callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservations_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservations_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservations' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted - })); - - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise - getObservationsByQuery: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + includeDeleted: includeDeleted, + }), + ); + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + getObservationsByQuery: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options - })); - - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function(formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function (formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'submitObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'submitObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'submitObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'submitObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData - })); - - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function(observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + formType: formType, + finalData: finalData, + }), + ); + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function (observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'updateObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'updateObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'updateObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'updateObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData - })); - - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + finalData: finalData, + }), + ); + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestCamera callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestCamera_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestCamera_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestCamera' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestFile callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestFile_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestFile_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestFile' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId - })); - - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function(fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function (fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'launchIntent callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'launchIntent_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'launchIntent_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'launchIntent' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec - })); - - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function(fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + intentSpec: intentSpec, + }), + ); + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function (fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'callSubform callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'callSubform_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'callSubform_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'callSubform' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options - })); - - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestAudio callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestAudio_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestAudio_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestAudio' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestQrcode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestQrcode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestQrcode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestBiometric_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestBiometric_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestBiometric' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestConnectivityStatus', messageId, - - })); - - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestSyncStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestSyncStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestSyncStatus', messageId, - - })); - - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function(fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'runLocalModel_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'runLocalModel_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'runLocalModel' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input - })); - - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentUser_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCurrentUser' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentUser', messageId, - - })); - - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getThemeMode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getThemeMode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getThemeMode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getThemeMode', messageId, - - })); - - }); - }, - - // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise - getAttachmentUri: function(fileName) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName - })); - - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentsUri', messageId, - - })); - - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCustomAppUri', messageId, - - })); - - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getFormSpecsUri', messageId, - - })); - - }); - }, + }), + ); + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - (globalThis).__formulusBridgeInitialized = true; + globalThis.__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now() - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now(), + }), + ); } } globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'onFormulusReady' - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'onFormulusReady', + }), + ); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } - })(); diff --git a/desktop/src/lib/sanitizeFormSavedData.ts b/desktop/src/lib/sanitizeFormSavedData.ts index 177cae736..96ab51ea8 100644 --- a/desktop/src/lib/sanitizeFormSavedData.ts +++ b/desktop/src/lib/sanitizeFormSavedData.ts @@ -5,8 +5,7 @@ * `getAttachmentUri(basename)`. */ -const ATTACHMENT_NAME_LIKE = - /\.(jpe?g|png|gif|bmp|webp|pdf|docx?)$/i; +const ATTACHMENT_NAME_LIKE = /\.(jpe?g|png|gif|bmp|webp|pdf|docx?)$/i; function attachmentBasenameOnly(raw: string): string { const t = raw.trim().replace(/\\/g, '/'); diff --git a/desktop/src/store/useCustodianStore.ts b/desktop/src/store/useCustodianStore.ts index 9b5e8924a..47f430ab4 100644 --- a/desktop/src/store/useCustodianStore.ts +++ b/desktop/src/store/useCustodianStore.ts @@ -141,10 +141,7 @@ async function pullSyncWithAttachments( attachmentsDownloaded += 1; } catch (e) { attachmentsFailed += 1; - console.error( - `Attachment download failed (${op.attachment_id}):`, - e, - ); + console.error(`Attachment download failed (${op.attachment_id}):`, e); } } else if (op.operation === 'delete') { await tauriClient.removeWorkspaceAttachment(op.attachment_id); From c1d40176dbdfd4b701692728cb7bf80994f0b8b0 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:35:16 +0200 Subject: [PATCH 7/8] chore: be prettier now --- desktop/package.json | 4 +- desktop/src-tauri/src/lib.rs | 24 +- desktop/src/App.css | 36 +- formulus-formplayer/.storybook/main.ts | 24 +- formulus-formplayer/.storybook/preview.tsx | 56 +- formulus-formplayer/AGENTS.md | 32 +- formulus-formplayer/README.md | 54 +- formulus-formplayer/eslint.config.js | 103 +- formulus-formplayer/package.json | 4 +- formulus-formplayer/public/formulus-load.js | 2 +- formulus-formplayer/vite.config.ts | 7 +- formulus/package.json | 4 +- synkronus-portal/.prettierignore | 4 + synkronus-portal/LLM_context.md | 34 +- synkronus-portal/README.md | 2 +- synkronus-portal/package.json | 4 +- synkronus-portal/src/components/Login.css | 40 +- .../src/components/ThemeSwitcher.css | 115 +- synkronus-portal/src/index.css | 45 +- synkronus-portal/src/pages/Dashboard.css | 1067 ++++++++++------- 20 files changed, 968 insertions(+), 693 deletions(-) create mode 100644 synkronus-portal/.prettierignore diff --git a/desktop/package.json b/desktop/package.json index 5cb70c31b..4fecf6e24 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -15,8 +15,8 @@ "copy:formplayer": "node ./scripts/copy-formplayer-to-desktop.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier \"**/*.{js,jsx,ts,tsx,json,md}\" --write", - "format:check": "prettier \"**/*.{js,jsx,ts,tsx,json,md}\" --check", + "format": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --write", + "format:check": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --check", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2e217117a..0c1352ead 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -547,11 +547,8 @@ fn migrate_repository_generation_fresh_install_defaults( if repo != 1 || obs_ver != 0 || att_ver != 0 { return Ok(()); } - let obs_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM observations", - [], - |row| row.get(0), - )?; + let obs_count: i64 = + conn.query_row("SELECT COUNT(*) FROM observations", [], |row| row.get(0))?; if obs_count > 0 { return Ok(()); } @@ -1866,10 +1863,7 @@ async fn download_workspace_attachment_from_url( .await .map_err(|e| format!("attachment request failed: {e}"))?; if !res.status().is_success() { - return Err(format!( - "attachment download failed: HTTP {}", - res.status() - )); + return Err(format!("attachment download failed: HTTP {}", res.status())); } let bytes = res .bytes() @@ -2658,10 +2652,8 @@ mod tests { #[test] fn resolve_attachment_prefers_draft_over_synced() { - let base = std::env::temp_dir().join(format!( - "ode_attach_test_draft_{}", - std::process::id() - )); + let base = + std::env::temp_dir().join(format!("ode_attach_test_draft_{}", std::process::id())); let _ = fs::remove_dir_all(&base); fs::create_dir_all(base.join("attachments/draft")).unwrap(); fs::create_dir_all(base.join("attachments/synced")).unwrap(); @@ -2674,10 +2666,8 @@ mod tests { #[test] fn resolve_attachment_falls_back_to_legacy_flat_root() { - let base = std::env::temp_dir().join(format!( - "ode_attach_test_legacy_{}", - std::process::id() - )); + let base = + std::env::temp_dir().join(format!("ode_attach_test_legacy_{}", std::process::id())); let _ = fs::remove_dir_all(&base); fs::create_dir_all(base.join("attachments")).unwrap(); fs::write(base.join("attachments/b.jpg"), b"x").unwrap(); diff --git a/desktop/src/App.css b/desktop/src/App.css index 9972b851d..9a9d1499b 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -1,8 +1,12 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0'); :root { - font-family: Inter, system-ui, -apple-system, sans-serif; + font-family: + Inter, + system-ui, + -apple-system, + sans-serif; color: #d9e3fd; background-color: #0b1326; line-height: 1.4; @@ -29,7 +33,11 @@ body { } .material-symbols-outlined { - font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; } .app { @@ -57,7 +65,7 @@ body { .brand h1 { margin: 0; - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; font-size: 1.25rem; letter-spacing: 0.03em; } @@ -101,7 +109,7 @@ body { padding: 0.55rem 0.65rem; border-radius: 0.45rem; transition: all 120ms ease-in-out; - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; } .nav-link:hover { @@ -355,7 +363,7 @@ body { } .page-header h2 { - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; margin: 0; } @@ -396,7 +404,7 @@ body { margin: 0.35rem 0; font-size: 1.8rem; font-weight: 700; - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; } .metric.warn { @@ -760,7 +768,7 @@ button.secondary.danger { margin: 0 auto 0.5rem; } -.import-dropzone input[type="file"] { +.import-dropzone input[type='file'] { margin-top: 1rem; width: auto; max-width: 100%; @@ -799,7 +807,7 @@ button.secondary.danger { } .filter-chip { - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; font-size: 0.78rem; padding: 0.35rem 0.65rem; border-radius: 999px; @@ -884,7 +892,7 @@ button.secondary.danger { align-items: center; } -.observation-form .field-row-checkbox input[type="checkbox"] { +.observation-form .field-row-checkbox input[type='checkbox'] { width: auto; justify-self: start; } @@ -933,7 +941,7 @@ button.secondary.danger { .mode-switch-btn { flex: 1; - font-family: "Space Grotesk", sans-serif; + font-family: 'Space Grotesk', sans-serif; font-size: 0.8rem; padding: 0.4rem 0.65rem; border: none; @@ -941,7 +949,9 @@ button.secondary.danger { cursor: pointer; background: transparent; color: #90a3cb; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .mode-switch-btn:hover { diff --git a/formulus-formplayer/.storybook/main.ts b/formulus-formplayer/.storybook/main.ts index df84cb5e5..d145d3bb6 100644 --- a/formulus-formplayer/.storybook/main.ts +++ b/formulus-formplayer/.storybook/main.ts @@ -1,12 +1,12 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-docs'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -}; - -export default config; +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +}; + +export default config; diff --git a/formulus-formplayer/.storybook/preview.tsx b/formulus-formplayer/.storybook/preview.tsx index 2a12aa9b3..f8e404d95 100644 --- a/formulus-formplayer/.storybook/preview.tsx +++ b/formulus-formplayer/.storybook/preview.tsx @@ -1,28 +1,28 @@ -import React from 'react'; -import type { Preview } from '@storybook/react-vite'; -import { ThemeProvider, CssBaseline } from '@mui/material'; -import { theme } from '../src/theme/theme'; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - layout: 'centered', - }, - decorators: [ - (Story) => ( - - -
- -
-
- ), - ], -}; - -export default preview; +import React from 'react'; +import type { Preview } from '@storybook/react-vite'; +import { ThemeProvider, CssBaseline } from '@mui/material'; +import { theme } from '../src/theme/theme'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: 'centered', + }, + decorators: [ + Story => ( + + +
+ +
+
+ ), + ], +}; + +export default preview; diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index 81799060d..b9c626e87 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -39,18 +39,18 @@ This file gives AI assistants and developers enough context to work effectively ## Source layout (high level) -| Area | Purpose | -|------|--------| -| `src/App.tsx` | Main app: JsonForms setup, renderer/cell registration, theme, init from `FormInitData`. | -| `src/index.tsx` | Entry: mounts React app; exposes `React` and `MaterialUI` on `window` for custom question type renderers. | -| `src/renderers/*` | JSON Forms **renderers** (e.g. signature, photo, file, GPS, swipe layout, finalize). Each has a **tester** (when to use) and a **component**. | -| `src/theme/` | MUI theme from `@ode/tokens` via `tokens-adapter.ts`; material wrappers for consistent look. | -| `src/services/` | `FormulusInterface.ts` (bridge client), `DraftService`, `ExtensionsLoader`, custom question type/validator loaders and registries. | -| `src/types/` | `FormulusInterfaceDefinition.ts` (synced from formulus), `CustomQuestionTypeContract.ts`, etc. | -| `src/components/` | Shared UI (e.g. `QuestionShell`, `FormLayout`, `DraftSelector`). | -| `src/builtinExtensions.ts` | Built-in extension functions (e.g. `getDynamicChoiceList`) used in forms. | -| `src/mocks/` | `webview-mock.ts` and `DevTestbed` for local dev without RN. | -| `scripts/` | `sync-interface.js`, `copy-to-rn.js`, `clean-rn-assets.js`. | +| Area | Purpose | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.tsx` | Main app: JsonForms setup, renderer/cell registration, theme, init from `FormInitData`. | +| `src/index.tsx` | Entry: mounts React app; exposes `React` and `MaterialUI` on `window` for custom question type renderers. | +| `src/renderers/*` | JSON Forms **renderers** (e.g. signature, photo, file, GPS, swipe layout, finalize). Each has a **tester** (when to use) and a **component**. | +| `src/theme/` | MUI theme from `@ode/tokens` via `tokens-adapter.ts`; material wrappers for consistent look. | +| `src/services/` | `FormulusInterface.ts` (bridge client), `DraftService`, `ExtensionsLoader`, custom question type/validator loaders and registries. | +| `src/types/` | `FormulusInterfaceDefinition.ts` (synced from formulus), `CustomQuestionTypeContract.ts`, etc. | +| `src/components/` | Shared UI (e.g. `QuestionShell`, `FormLayout`, `DraftSelector`). | +| `src/builtinExtensions.ts` | Built-in extension functions (e.g. `getDynamicChoiceList`) used in forms. | +| `src/mocks/` | `webview-mock.ts` and `DevTestbed` for local dev without RN. | +| `scripts/` | `sync-interface.js`, `copy-to-rn.js`, `clean-rn-assets.js`. | ## Key technical constraints @@ -65,10 +65,10 @@ This file gives AI assistants and developers enough context to work effectively Add a renderer in `src/renderers/` with a **tester** (e.g. `formatIs('myFormat')`) and component; register it in `App.tsx` (renderers array). If it needs a new AJV format, register it where other formats are registered in `App.tsx`. - **New question type (custom / from Synkronus)** Custom types are loaded by `CustomQuestionTypeLoader` from the manifest; they must comply with `CustomQuestionTypeContract.ts` and export a default component. No change in formplayer code needed for new custom types that follow the contract. -- **New native capability (e.g. new “requestX” from RN)** - 1) Extend the contract in **formulus** (`FormulusInterfaceDefinition.ts`). - 2) Run `npm run sync-interface` in formulus-formplayer. - 3) Implement the client side in `FormulusInterface.ts` and use it in the relevant renderer or service. +- **New native capability (e.g. new “requestX” from RN)** + 1. Extend the contract in **formulus** (`FormulusInterfaceDefinition.ts`). + 2. Run `npm run sync-interface` in formulus-formplayer. + 3. Implement the client side in `FormulusInterface.ts` and use it in the relevant renderer or service. - **Build / bundle issues** Keep **one** main bundle; avoid dynamic imports that create extra chunks unless you’ve verified loading under file:// in the RN WebView. Keep `base: './'` and the no-crossorigin plugin. diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index e3d2233d7..123e8c43c 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -3,19 +3,23 @@ This app implements the core functionality to render and submit forms to Formulus (which can then sync with Synkronus). # Usage in custom apps + Primarily the formplayer exposes a javascript interface, that is injected into the custom app and can be used to render forms based on the jsonform spec's provided by formulus (formulus downloads the jsonform spec's and question_types from synkronus). Likewise, all of the question_types provided are loaded into the formplayer at initialization (by the formulus app) and can thus be used in the forms. ## Responsibility of the Formulus Formplayer -The formplayer is solely responsible for + +The formplayer is solely responsible for + - rendering the forms - - create new observations - - edit existing observations - - validate form responses + - create new observations + - edit existing observations + - validate form responses - loading previously saved data (if the form is opened with a valid observation_id) - submitting the forms to Formulus (either as draft or final) - (soft-)deleting observations ## Development setup + This project depends on `@ode/tokens` (local `packages/tokens`). On a fresh clone or new branch, install in order: 1. From repo root: `cd packages/tokens && npm install` @@ -24,7 +28,8 @@ This project depends on `@ode/tokens` (local `packages/tokens`). On a fresh clon If you run `npm install` only in formulus-formplayer, the tokens package’s `prepare` script may fail with "Cannot find module 'style-dictionary'" until tokens has its own dependencies installed. ## Building this project -Use 'npm run build:rn' to build the project. This will build the project and copy the build to the formulus app. + +Use 'npm run build:rn' to build the project. This will build the project and copy the build to the formulus app. ## Javascript interface @@ -42,26 +47,27 @@ window.formulus.formplayer = { ### addObservation ```javascript -window.formulus.addObservation(formType, initializationData) +window.formulus.addObservation(formType, initializationData); ``` + formType: The type of the form to be rendered. Notice that formulus will always use the latest version of a form to render the form. initializationData: An object containing any initialization data that should be passed to the form - ### editObservation ```javascript -window.formulus.editObservation(formType, observationId) +window.formulus.editObservation(formType, observationId); ``` + formType: The type of the form to be rendered. Editing an existing observation will always use the version of the form that was used to create the observation. observationId: The id of the observation to be edited - ### deleteObservation ```javascript -window.formulus.deleteObservation(formType, observationId) +window.formulus.deleteObservation(formType, observationId); ``` + formType: The type of the form to be rendered observationId: The id of the observation to be deleted @@ -75,36 +81,20 @@ The React Native host passes `FormInitData` into the WebView (including `params` - **Sanitization**: When the schema defines non-empty root `properties`, loaded and submitted data are filtered to those keys plus `locale` (so older polluted rows are cleaned on edit/save). Schemas with missing or empty root `properties` pass data through unchanged. ## Initialization + The formulus formplayer object will be initialized by the formulus app. The formulus app will inject the initialized formulus object into the custom app, hence **the custom app does not need to do anything to initialize the formulus object**. ```javascript -new formulus.formplayer( - config -) +new formulus.formplayer(config); ``` config: An object containing the configuration for the formulus formplayer object. The config object should have the following properties: + - renderers: An array of renderers to be used by the formplayer. Renderers are container components responsible for rendering the form. -- cells: An array of cells to be used by the formplayer. Cells maps to `question types` and are components responsible for handling specific input types - e.g. text input cell, date input cell, etc. Core formulus provides the following cells: - - text cell - - date cell - - time cell - - datetime cell - - number cell - - boolean cell - - select cell - - select multiple cell - - file cell - - image cell - - signature cell - - barcode cell - - qr code cell - - location cell -Any other cells, either custom developed or provided by the community, will be included as well once they are downloaded from synkronus as part of the normal sync process. +- cells: An array of cells to be used by the formplayer. Cells maps to `question types` and are components responsible for handling specific input types - e.g. text input cell, date input cell, etc. Core formulus provides the following cells: - text cell - date cell - time cell - datetime cell - number cell - boolean cell - select cell - select multiple cell - file cell - image cell - signature cell - barcode cell - qr code cell - location cell + Any other cells, either custom developed or provided by the community, will be included as well once they are downloaded from synkronus as part of the normal sync process. - formSpecs: An array of jsonform formSpecs to be used by the formplayer wrapped in an envelope object: `{formType: string, version: string, spec: any}` - - ## Available `npm` scripts In the project directory, you can run: @@ -147,4 +137,4 @@ flowchart LR %% Message bridging FP -->|postMessage events| SM CA -->|postMessage commands| SM -``` \ No newline at end of file +``` diff --git a/formulus-formplayer/eslint.config.js b/formulus-formplayer/eslint.config.js index 737e31c19..7d72691fe 100644 --- a/formulus-formplayer/eslint.config.js +++ b/formulus-formplayer/eslint.config.js @@ -1,48 +1,55 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from "eslint-plugin-storybook"; - -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import reactPlugin from 'eslint-plugin-react'; -import hooksPlugin from 'eslint-plugin-react-hooks'; -import prettierConfig from 'eslint-config-prettier'; -import globals from 'globals'; -import { defineConfig, globalIgnores } from 'eslint/config'; - -export default defineConfig([globalIgnores([ - '**/node_modules/**', - '**/build/**', - '**/coverage/**', - '**/__tests__/**', - '**/scripts/**', -]), js.configs.recommended, ...tseslint.configs.recommended, { - files: ['**/*.{js,jsx,ts,tsx}'], - plugins: { - react: reactPlugin, - 'react-hooks': hooksPlugin, - }, - languageOptions: { - globals: { - ...globals.browser, - ...globals.es2021, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - rules: { - ...hooksPlugin.configs.recommended.rules, - 'react/react-in-jsx-scope': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - varsIgnorePattern: '^_', - argsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, -}, prettierConfig, ...storybook.configs["flat/recommended"]]); +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from 'eslint-plugin-storybook'; + +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import hooksPlugin from 'eslint-plugin-react-hooks'; +import prettierConfig from 'eslint-config-prettier'; +import globals from 'globals'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores([ + '**/node_modules/**', + '**/build/**', + '**/coverage/**', + '**/__tests__/**', + '**/scripts/**', + ]), + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{js,jsx,ts,tsx}'], + plugins: { + react: reactPlugin, + 'react-hooks': hooksPlugin, + }, + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2021, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + ...hooksPlugin.configs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + prettierConfig, + ...storybook.configs['flat/recommended'], +]); diff --git a/formulus-formplayer/package.json b/formulus-formplayer/package.json index 8bd15da8e..10a7b03a1 100644 --- a/formulus-formplayer/package.json +++ b/formulus-formplayer/package.json @@ -46,8 +46,8 @@ "test": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier \"src/**/*.{js,jsx,ts,tsx,json,css,md}\" --write", - "format:check": "prettier \"src/**/*.{js,jsx,ts,tsx,json,css,md}\" --check", + "format": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --write", + "format:check": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --check", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/formulus-formplayer/public/formulus-load.js b/formulus-formplayer/public/formulus-load.js index 4632dab5c..3a1a65765 100644 --- a/formulus-formplayer/public/formulus-load.js +++ b/formulus-formplayer/public/formulus-load.js @@ -88,7 +88,7 @@ isDraft: options.isDraft, includeDeleted: options.includeDeleted, whereClause: options.whereClause, - }) + }), ); }); }; diff --git a/formulus-formplayer/vite.config.ts b/formulus-formplayer/vite.config.ts index ad7a056e2..002ceea53 100644 --- a/formulus-formplayer/vite.config.ts +++ b/formulus-formplayer/vite.config.ts @@ -11,9 +11,7 @@ function removeCrossOriginForWebView() { return { name: 'remove-crossorigin-for-webview', transformIndexHtml(html: string) { - return html - .replace(/\s+crossorigin/g, '') - .replace(/crossorigin\s+/g, ''); + return html.replace(/\s+crossorigin/g, '').replace(/crossorigin\s+/g, ''); }, }; } @@ -43,7 +41,8 @@ export default defineConfig({ if ( warning.code === 'EVAL' || (warning.message && warning.message.includes('ExtensionsLoader')) || - (warning.code === 'UNUSED_EXTERNAL_IMPORT' && warning.source?.includes('formulus-load.js')) + (warning.code === 'UNUSED_EXTERNAL_IMPORT' && + warning.source?.includes('formulus-load.js')) ) { return; } diff --git a/formulus/package.json b/formulus/package.json index d0eb9972a..878fa37dd 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -10,8 +10,8 @@ "ios": "react-native run-ios", "lint": "eslint . --max-warnings 9999", "lint:fix": "eslint . --fix --max-warnings 9999", - "format": "npx prettier \"**/*.{js,jsx,ts,tsx,json,md}\" --write", - "format:check": "npx prettier \"**/*.{js,jsx,ts,tsx,json,md}\" --check", + "format": "npx prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --write", + "format:check": "npx prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --check", "start": "react-native start", "test": "jest", "generate": "ts-node --project scripts/tsconfig.json scripts/generateInjectionScript.ts", diff --git a/synkronus-portal/.prettierignore b/synkronus-portal/.prettierignore new file mode 100644 index 000000000..0961225c7 --- /dev/null +++ b/synkronus-portal/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +src/api/synkronus/generated diff --git a/synkronus-portal/LLM_context.md b/synkronus-portal/LLM_context.md index cba6a4359..f265228be 100644 --- a/synkronus-portal/LLM_context.md +++ b/synkronus-portal/LLM_context.md @@ -68,6 +68,7 @@ Centralized HTTP client for all API requests: ### Protected Routes `ProtectedRoute` component: + - Checks `isAuthenticated` from `AuthContext`. - Automatically refreshes tokens if they expire soon (< 5 minutes). - Redirects to login if not authenticated. @@ -78,18 +79,20 @@ Centralized HTTP client for all API requests: When adding a new feature that calls the Synkronus API: 1. **Add TypeScript types** (if needed) in `src/types/`: + ```typescript // src/types/feature.ts export interface FeatureRequest { - field: string + field: string; } - + export interface FeatureResponse { - result: string + result: string; } ``` 2. **Add API method** in `src/services/api.ts`: + ```typescript async getFeature(id: string): Promise { return this.get(`/feature/${id}`) @@ -97,20 +100,22 @@ When adding a new feature that calls the Synkronus API: ``` 3. **Use in component**: + ```typescript - import { api } from '../services/api' - - const data = await api.getFeature('123') + import { api } from '../services/api'; + + const data = await api.getFeature('123'); ``` ## Pattern: Adding a New Page/Component 1. **Create component file** in `src/pages/` or `src/components/`: + ```typescript // src/pages/NewPage.tsx import { useAuth } from '../contexts/AuthContext' import './NewPage.css' - + export function NewPage() { const { user } = useAuth() return
New Page Content
@@ -118,6 +123,7 @@ When adding a new feature that calls the Synkronus API: ``` 2. **Create CSS file** (if needed): + ```css /* src/pages/NewPage.css */ .new-page { @@ -126,9 +132,10 @@ When adding a new feature that calls the Synkronus API: ``` 3. **Add to App.tsx** (if it's a protected route): + ```typescript import { NewPage } from './pages/NewPage' - + @@ -143,11 +150,11 @@ import { useAuth } from '../contexts/AuthContext' function MyComponent() { const { user, isAuthenticated, logout } = useAuth() - + if (!isAuthenticated) { return
Not logged in
} - + return (

Welcome, {user?.username}

@@ -166,6 +173,7 @@ The `vite.config.ts` proxies `/api/*` and `/health` to the backend without rewri ### Production (Nginx) The `Dockerfile` includes nginx configuration that: + - Serves the built React app from `/usr/share/nginx/html` - Proxies `/api/*` to the demo backend with the same URI (e.g. `/api/auth/login` → backend `/api/auth/login`) - Proxies `GET /health` to the backend for the apex health check @@ -193,6 +201,7 @@ The `Dockerfile` includes nginx configuration that: ### Database Initialization The `init-db.sh` script: + - Creates `synkronus_user` with password `dev_password_change_in_production` - Grants all privileges on the `synkronus` database - Runs automatically on first database initialization @@ -210,9 +219,9 @@ Components should catch errors and display them to users: ```typescript try { - await api.someMethod() + await api.someMethod(); } catch (error) { - setError(error instanceof Error ? error.message : 'An error occurred') + setError(error instanceof Error ? error.message : 'An error occurred'); } ``` @@ -256,4 +265,3 @@ When extending the portal, LLM agents should: - Document new features or endpoints Following this guide should allow LLM agents to add new features that are consistent with the existing synkronus-portal architecture and patterns. - diff --git a/synkronus-portal/README.md b/synkronus-portal/README.md index 849210aee..3e4eefc77 100644 --- a/synkronus-portal/README.md +++ b/synkronus-portal/README.md @@ -1 +1 @@ -# Synkronus PortalFrontend service for Synkronus, built with React + TypeScript + Vite.## OverviewThe Synkronus Portal provides a web-based interface for managing app bundles, users, observations, and data exports. It supports both development (hot reload) and production (optimized build) modes.## PrerequisitesBefore starting, ensure you have:### For Docker-based Setup (Recommended)- **Docker** (version 20.10+) and **Docker Compose** (version 2.0+) - Check: `docker --version` and `docker compose version`- **Git** (for cloning the repository)- **4GB+ free disk space** (for Docker images and volumes)**Note for Windows users:** Ensure WSL2 is enabled if using Docker Desktop.### For Dockerless Development Setup- **Node.js** 20+ and **npm** or **yarn**- **Go** 1.22+ (for running the backend API)- **PostgreSQL** 17+ (installed locally or accessible)- **Git** (for cloning the repository)---## Quick Reference| Mode | Command | URL | Hot Reload | Docker Required ||------|---------|-----|------------|-----------------|| **Production** | `docker compose up -d --build` | http://localhost:5173 | ❌ No | ✅ Yes || **Development (Docker)** | `docker compose up -d postgres synkronus`
`npm run dev` | http://localhost:5174 | ✅ Yes | ✅ Partial || **Development (Dockerless)** | See [Dockerless Setup](#dockerless-development-setup) | http://localhost:5174 | ✅ Yes | ❌ No |**Default Login Credentials:**- Username: `admin`- Password: `admin`---## Quick Start (First Time)**For users who just want to get it running:**### Option 1: Production Mode (Docker - Easiest)1. **Navigate to directory:** ```bash cd synkronus-portal ```2. **Start everything:** ```bash docker compose up -d --build ```3. **Wait ~30 seconds** for services to start4. **Open in browser:** - Portal: http://localhost:5173 - Login with: `admin` / `admin`That's it! 🎉### Option 2: Development Mode (Dockerless - No Docker Required)See the [Dockerless Development Setup](#dockerless-development-setup) section below for complete instructions.---## Production Mode (Optimized Build)**Step 1:** Navigate to the portal directory```bashcd synkronus-portal```**Step 2:** Build and start all services in production mode```bash# Option 1: Build and start in one command (recommended)docker compose up -d --build# Option 2: Build first, then start (if you prefer separate steps)docker compose builddocker compose up -d```**Note:** The `--build` flag ensures the frontend is built before starting. If you skip building, Docker will build automatically, but it's better to be explicit.**Step 3:** Wait for services to start (about 10-30 seconds)```bash# Check service statusdocker compose ps# View logs if neededdocker compose logs -f```**Step 4:** Access the portal- **Frontend Portal**: http://localhost:5173 (Nginx serving optimized production build)- **Backend API**: http://localhost:8080- **PostgreSQL**: localhost:5432- **Swagger UI**: http://localhost:8080/openapi/swagger-ui.html**Production Mode Features:**- ✅ Optimized production build (minified, tree-shaken)- ✅ Static file serving via Nginx (fast, efficient)- ✅ Persistent data storage (survives container restarts)- ✅ Production-ready performance- ❌ No hot reload (requires rebuild for changes)**To stop production mode:**```bashdocker compose down```**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe.---## Development Mode (Hot Reload)### Development with Docker (Partial Docker)**Step 1:** Navigate to the portal directory```bashcd synkronus-portal```**Step 2:** Start backend services (PostgreSQL + API)```bash# Start only backend services (postgres + synkronus API)docker compose up -d postgres synkronus```**Step 3:** Wait for backend to be ready (about 10-20 seconds)```bash# Check backend healthcurl http://localhost:8080/health# Should return: OK```**Step 4:** Install dependencies (if not already done)```bashnpm install```**Step 5:** Start the Vite dev server```bashnpm run dev```**Step 6:** Access the portal- **Frontend Portal**: http://localhost:5174 (Vite dev server with hot reload)- **Backend API**: http://localhost:8080 (already running from Step 2)**Development Mode Features:**- ✅ Hot Module Replacement (HMR) - instant code updates without page refresh- ✅ Fast refresh - React components update instantly- ✅ Source maps for debugging- ✅ Same persistent storage as production - data is shared- ✅ Full debugging support in browser DevTools- ✅ Real-time error overlay in browser**To stop development mode:**```bash# Stop Vite dev server: Press Ctrl+C in the terminal running npm run dev# Stop backend servicesdocker compose down```**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe.---## Dockerless Development Setup**Perfect for developers who prefer not to use Docker or want a fully local development environment.**### Prerequisites- **Node.js** 20+ and **npm**- **Go** 1.22+ ([Install Go](https://go.dev/doc/install))- **PostgreSQL** 17+ ([Install PostgreSQL](https://www.postgresql.org/download/))- **Git**### Step 1: Set Up PostgreSQL Database**Create the database and user:**```bash# Connect to PostgreSQL (as superuser)psql -U postgres# In PostgreSQL prompt, run:CREATE DATABASE synkronus;CREATE USER synkronus_user WITH PASSWORD 'dev_password_change_in_production';GRANT ALL PRIVILEGES ON DATABASE synkronus TO synkronus_user;# Connect to synkronus database\c synkronus# Grant schema privilegesGRANT ALL ON SCHEMA public TO synkronus_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO synkronus_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO synkronus_user;# Exit\q```**Windows PowerShell alternative:**```powershell# Using psql command line$env:PGPASSWORD='postgres'; psql -U postgres -c "CREATE DATABASE synkronus;"$env:PGPASSWORD='postgres'; psql -U postgres -c "CREATE USER synkronus_user WITH PASSWORD 'dev_password_change_in_production';"$env:PGPASSWORD='postgres'; psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE synkronus TO synkronus_user;"```### Step 2: Set Up Backend API (Synkronus)**Navigate to synkronus directory:**```bashcd ../synkronus```**Install dependencies:**```bashgo mod download```**Create environment file (`.env` in synkronus directory):**```bash# Create .env filecat > .env << EOFPORT=8080LOG_LEVEL=debugDB_CONNECTION=postgres://synkronus_user:dev_password_change_in_production@localhost:5432/synkronus?sslmode=disableJWT_SECRET=dev_jwt_secret_change_in_production_32charsADMIN_USERNAME=adminADMIN_PASSWORD=adminAPP_BUNDLE_PATH=./data/app-bundlesMAX_VERSIONS_KEPT=5EOF```**Windows PowerShell alternative:**```powershell@"PORT=8080LOG_LEVEL=debugDB_CONNECTION=postgres://synkronus_user:dev_password_change_in_production@localhost:5432/synkronus?sslmode=disableJWT_SECRET=dev_jwt_secret_change_in_production_32charsADMIN_USERNAME=adminADMIN_PASSWORD=adminAPP_BUNDLE_PATH=./data/app-bundlesMAX_VERSIONS_KEPT=5"@ | Out-File -FilePath .env -Encoding utf8```**Create app bundles directory:**```bashmkdir -p data/app-bundles```**Run the backend API:**```bash# Option 1: Run directly (recommended for development)go run cmd/synkronus/main.go# Option 2: Build and rungo build -o bin/synkronus cmd/synkronus/main.go./bin/synkronus```**Verify backend is running:**```bash# In another terminalcurl http://localhost:8080/health# Should return: OK```### Step 3: Set Up Frontend Portal**Navigate to portal directory:**```bashcd ../synkronus-portal```**Install dependencies:**```bashnpm install```**Start the Vite dev server:**```bashnpm run dev```**Access the portal:**- **Frontend Portal**: http://localhost:5174- **Backend API**: http://localhost:8080- **Login**: `admin` / `admin`### Step 4: Verify Everything Works1. **Check backend health:** ```bash curl http://localhost:8080/health # Should return: OK ```2. **Open frontend in browser:** - Navigate to http://localhost:5174 - You should see the login page3. **Test login:** - Username: `admin` - Password: `admin` - Should successfully log in and show the dashboard### Stopping Dockerless Development1. **Stop frontend:** Press `Ctrl+C` in the terminal running `npm run dev`2. **Stop backend:** Press `Ctrl+C` in the terminal running the Go server### Troubleshooting Dockerless Setup**Backend won't start:**- Verify PostgreSQL is running: `psql -U postgres -c "SELECT version();"`- Check database connection string in `.env` file- Ensure database and user were created correctly**Frontend can't connect to backend:**- Verify backend is running on port 8080: `curl http://localhost:8080/health`- Check `vite.config.ts` proxy configuration- Ensure no firewall is blocking localhost connections**Database connection errors:**- Verify PostgreSQL is running- Check connection string format: `postgres://user:password@host:port/database`- Ensure user has proper permissions---## Verification ChecklistAfter setting up (any mode), verify everything is working:1. **Check service status (Docker mode):** ```bash docker compose ps ``` All services should show "Up" status. Health checks may show "starting" for the first 30-60 seconds - this is normal.2. **Test API health:** ```bash curl http://localhost:8080/health ``` Should return: `OK`3. **Test frontend:** - Open http://localhost:5173 (production) or http://localhost:5174 (development) in your browser - Should show the login page4. **Test login:** - Username: `admin` - Password: `admin` - Should successfully authenticate and show dashboard5. **Check logs (if issues):** ```bash # Docker mode docker compose logs -f # Dockerless mode - check terminal output ```---## Common Issues### "cannot execute: required file not found" (init-db.sh)**Symptom:** PostgreSQL container fails to initialize database user.**Cause:** Windows line endings (CRLF) in shell scripts.**Solution:**```powershell# Convert line endings (PowerShell)$content = Get-Content init-db.sh -Raw$content = $content -replace "`r`n", "`n"[System.IO.File]::WriteAllText((Resolve-Path init-db.sh), $content, [System.Text.UTF8Encoding]::new($false))# Then restartdocker compose down -vdocker compose up -d --build```**Prevention:** The `.gitattributes` file enforces LF line endings. If you're on Windows, ensure Git is configured correctly:```bashgit config core.autocrlf false```### Port Already in Use**Production Mode (Port 5173):**```bash# Edit docker-compose.yml and change the port mapping:ports: - "5173:80" # Change 5173 to your desired port```**Development Mode (Port 5174):**```bash# Edit vite.config.ts and change the port:server: { port: 5174, # Change to your desired port}```**Backend API (Port 8080):**```bash# Docker mode: Edit docker-compose.ymlports: - "8080:8080" # Change 8080 to your desired port# Dockerless mode: Edit .env file in synkronus directoryPORT=8080 # Change to your desired port```### Health Checks Show "Unhealthy" or "Starting"**This is normal!** Health checks can take 30-60 seconds to pass on first startup. As long as:- Services show "Up" status- `curl http://localhost:8080/health` returns `OK`- Frontend loads in browserThen everything is working correctly. The health check status will update to "healthy" after a few cycles.### Hot Reload Not Working (Development Mode)1. Ensure you're running `npm run dev` (not Docker for frontend)2. Check that Vite is running on port 51743. Verify the browser is connected to the correct port (http://localhost:5174)4. Check browser console for HMR connection errors5. Try hard refresh (Ctrl+Shift+R or Cmd+Shift+R)6. **Windows users:** Ensure file watching is enabled. If using WSL2, files should be in the WSL filesystem, not Windows filesystem.### API Connection Issues1. **Docker mode:** - Verify backend is running: `docker compose ps` - Check backend logs: `docker compose logs synkronus` - Test API directly: `curl http://localhost:8080/health`2. **Dockerless mode:** - Verify backend process is running (check terminal) - Check backend logs in terminal output - Test API directly: `curl http://localhost:8080/health` - Verify PostgreSQL is running and accessible### App Bundles Not PersistingIf app bundles disappear after restarting containers:1. **Verify the volume exists (Docker mode):** ```bash docker volume ls | grep app-bundles ```2. **Check if bundles are in the volume:** ```bash # If containers are running docker compose exec synkronus ls -la /app/data/app-bundles # If containers are stopped docker run --rm -v synkronus-portal_app-bundles:/data alpine ls -la /data ```3. **Verify volume is mounted correctly:** ```bash docker compose config | grep -A 5 app-bundles ```4. **Check backend logs for app bundle initialization:** ```bash docker compose logs synkronus | grep -i "app bundle\|bundle path" ```5. **Ensure you're not using `docker compose down -v`:** - Use `docker compose down` (preserves volumes) ✅ - Avoid `docker compose down -v` (deletes volumes) ❌**Note:** App bundles are stored in the `app-bundles` volume (Docker) or `./data/app-bundles` directory (Dockerless). This persists across restarts. If bundles are missing, check that:- The volume/directory wasn't accidentally deleted- The backend has proper permissions to read/write- The `APP_BUNDLE_PATH` environment variable is set correctly### Windows-Specific Issues**Line Endings:**- Git should handle this automatically with `.gitattributes`- If issues persist: `git config core.autocrlf false`**File Watching (Dockerless):**- If hot reload doesn't work, files may need to be in WSL filesystem- Or use polling mode in `vite.config.ts` (already configured)**PowerShell vs Bash:**- Most commands work in both- Use backticks for line continuation in PowerShell: `` ` ``---## Architecture### Development Mode (Docker)```┌─────────────────────────────────────────────────────────────┐│ Development Environment (docker-compose.yml) │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ synkronus-portal │ │ synkronus-api │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • Vite Dev │◄────►│ • Go Server │ ││ │ • Port 5174 │ │ • Port 8080 │ ││ │ • Hot Reload │ │ • App Bundles │ ││ │ • Source Mounted │ │ • PostgreSQL │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ Port 5432 │ ││ │ Persistent DB │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘```### Development Mode (Dockerless)```┌─────────────────────────────────────────────────────────────┐│ Dockerless Development Environment │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ Vite Dev Server │ │ Go API Server │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • npm run dev │◄────►│ • go run │ ││ │ • Port 5174 │ │ • Port 8080 │ ││ │ • Hot Reload │ │ • App Bundles │ ││ │ • Local Files │ │ • Local Files │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ (Local Install) │ ││ │ Port 5432 │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘```### Production Mode```┌─────────────────────────────────────────────────────────────┐│ Production Environment (docker-compose.yml) │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ synkronus-portal │ │ synkronus-api │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • Nginx │◄────►│ • Go Server │ ││ │ • Static Files │ │ • Port 8080 │ ││ │ • Port 5173 │ │ • App Bundles │ ││ │ • Optimized │ │ • PostgreSQL │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ Port 5432 │ ││ │ Persistent DB │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘```---## API Proxy Configuration### Development ModeThe Vite dev server automatically proxies `/api/*` requests to the backend:- **Frontend → Backend**: `/api/*` → `http://localhost:8080/*` (via Vite proxy)- **Configuration**: See `vite.config.ts`### Production ModeNginx proxies `/api/*` requests to the backend:- **Frontend → Backend**: `/api/*` → `http://synkronus:8080/*` (via Nginx)- **Configuration**: See `Dockerfile` nginx config---## Storage Persistence### Docker ModeBoth development and production modes use the **same named Docker volumes**, ensuring your data persists across:- Container restarts- Mode switches (dev ↔ prod)- Container removal (with `docker compose down`)- System reboots**Volumes:**- **postgres-data**: PostgreSQL database files (users, observations, app bundles metadata)- **app-bundles**: App bundle ZIP files and versions (stored at `/app/data/app-bundles` in the container)**Important:** App bundles are stored in **both** places:- **Files**: Actual ZIP files and extracted content in the `app-bundles` volume- **Database**: Metadata about bundles (versions, manifest info) in the `postgres-data` volumeBoth volumes must persist for app bundles to work correctly after restart.**Volume Persistence Guarantee:**✅ **Volumes are NOT deleted when you:**- Stop containers: `docker compose down`- Restart containers: `docker compose restart`- Switch between dev/prod modes- Rebuild containers: `docker compose build`⚠️ **Volumes ARE deleted ONLY when you:**- Explicitly use: `docker compose down -v` (the `-v` flag removes volumes)- Manually delete: `docker volume rm `### Dockerless ModeData is stored in local directories:- **PostgreSQL**: Uses your local PostgreSQL data directory (configured during PostgreSQL installation)- **App Bundles**: Stored in `../synkronus/data/app-bundles` directory**Backup Recommendations:**- Regularly backup your PostgreSQL database- Backup the `data/app-bundles` directory---## Stopping Services### Docker Mode**Safe Stop (Preserves Data):**```bash# Stop all services - VOLUMES ARE PRESERVED ✅docker compose down```This command:- ✅ Stops all containers- ✅ Removes containers- ✅ **Keeps all volumes** (your data is safe!)- ✅ Removes networks**Complete Removal (⚠️ DELETES ALL DATA):**```bash# Stop services AND delete volumes - ⚠️ THIS DELETES ALL DATA!docker compose down -v```**⚠️ WARNING:** The `-v` flag removes volumes, which will:- Delete all database data (users, observations, etc.)- Delete all uploaded app bundles- **This action cannot be undone!****Restarting Services:**```bash# Start services (volumes are automatically reattached)docker compose up -d```Your data will be exactly as you left it!### Dockerless Mode**Stop Frontend:**- Press `Ctrl+C` in the terminal running `npm run dev`**Stop Backend:**- Press `Ctrl+C` in the terminal running the Go server**Stop PostgreSQL:**- Use your system's service manager (e.g., `systemctl stop postgresql` on Linux)---## Default Credentials- **Admin username**: `admin`- **Admin password**: `admin`**⚠️ Warning**: These are development credentials only. Change them before production use.---## Switching Between Modes### From Production to Development (Docker)1. Stop production containers: ```bash docker compose down ```2. Start backend services: ```bash docker compose up -d postgres synkronus ```3. Start dev server: ```bash npm run dev ```### From Development to Production (Docker)1. Stop Vite dev server (Ctrl+C)2. Stop backend containers: ```bash docker compose down ```3. Start production mode: ```bash docker compose up -d --build ```**Important:** Your data (database, app bundles) persists when switching between modes because both use the same Docker volumes.### From Docker to Dockerless (or vice versa)**Note:** Data is not automatically shared between Docker and Dockerless modes. You'll need to:- Export data from one mode- Import into the other mode- Or use the same PostgreSQL instance for both---## Building for Production### First Time SetupFor the first time, or after code changes:```bash# Build and start (recommended - does both in one command)docker compose up -d --build# Or build first, then start (if you prefer separate steps)docker compose builddocker compose up -d```### Rebuilding After Code ChangesIf you've made changes to the frontend code:```bash# Rebuild just the portal imagedocker compose build synkronus-portal# Restart the portal servicedocker compose up -d synkronus-portal```**Note:** The `--build` flag in `docker compose up -d --build` will:- Build images if they don't exist- Rebuild images if the Dockerfile or source code changed- Start all services after buildingThis is the easiest way to ensure everything is up-to-date!---## Environment Variables### Development- `VITE_API_URL`: Backend API URL (default: uses `/api` proxy)- `DOCKER_ENV`: Set to `true` when running in Docker### Production- `VITE_API_URL`: Backend API URL (default: `http://localhost:8080`)### Backend (Dockerless)See `../synkronus/README.md` for complete backend environment variable documentation.---## See Also- [SETUP_ANALYSIS.md](./SETUP_ANALYSIS.md) - Detailed setup analysis and recommendations- [../synkronus/README.md](../synkronus/README.md) - Backend API documentation- [../synkronus/DEPLOYMENT.md](../synkronus/DEPLOYMENT.md) - Production deployment guide \ No newline at end of file +# Synkronus PortalFrontend service for Synkronus, built with React + TypeScript + Vite.## OverviewThe Synkronus Portal provides a web-based interface for managing app bundles, users, observations, and data exports. It supports both development (hot reload) and production (optimized build) modes.## PrerequisitesBefore starting, ensure you have:### For Docker-based Setup (Recommended)- **Docker** (version 20.10+) and **Docker Compose** (version 2.0+) - Check: `docker --version` and `docker compose version`- **Git** (for cloning the repository)- **4GB+ free disk space** (for Docker images and volumes)**Note for Windows users:** Ensure WSL2 is enabled if using Docker Desktop.### For Dockerless Development Setup- **Node.js** 20+ and **npm** or **yarn**- **Go** 1.22+ (for running the backend API)- **PostgreSQL** 17+ (installed locally or accessible)- **Git** (for cloning the repository)---## Quick Reference| Mode | Command | URL | Hot Reload | Docker Required ||------|---------|-----|------------|-----------------|| **Production** | `docker compose up -d --build` | http://localhost:5173 | ❌ No | ✅ Yes || **Development (Docker)** | `docker compose up -d postgres synkronus`
`npm run dev` | http://localhost:5174 | ✅ Yes | ✅ Partial || **Development (Dockerless)** | See [Dockerless Setup](#dockerless-development-setup) | http://localhost:5174 | ✅ Yes | ❌ No |**Default Login Credentials:**- Username: `admin`- Password: `admin`---## Quick Start (First Time)**For users who just want to get it running:**### Option 1: Production Mode (Docker - Easiest)1. **Navigate to directory:** `bash cd synkronus-portal `2. **Start everything:** `bash docker compose up -d --build `3. **Wait ~30 seconds** for services to start4. **Open in browser:** - Portal: http://localhost:5173 - Login with: `admin` / `admin`That's it! 🎉### Option 2: Development Mode (Dockerless - No Docker Required)See the [Dockerless Development Setup](#dockerless-development-setup) section below for complete instructions.---## Production Mode (Optimized Build)**Step 1:** Navigate to the portal directory`bashcd synkronus-portal`**Step 2:** Build and start all services in production mode`bash# Option 1: Build and start in one command (recommended)docker compose up -d --build# Option 2: Build first, then start (if you prefer separate steps)docker compose builddocker compose up -d`**Note:** The `--build` flag ensures the frontend is built before starting. If you skip building, Docker will build automatically, but it's better to be explicit.**Step 3:** Wait for services to start (about 10-30 seconds)`bash# Check service statusdocker compose ps# View logs if neededdocker compose logs -f`**Step 4:** Access the portal- **Frontend Portal**: http://localhost:5173 (Nginx serving optimized production build)- **Backend API**: http://localhost:8080- **PostgreSQL**: localhost:5432- **Swagger UI**: http://localhost:8080/openapi/swagger-ui.html**Production Mode Features:**- ✅ Optimized production build (minified, tree-shaken)- ✅ Static file serving via Nginx (fast, efficient)- ✅ Persistent data storage (survives container restarts)- ✅ Production-ready performance- ❌ No hot reload (requires rebuild for changes)**To stop production mode:**`bashdocker compose down`**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe.---## Development Mode (Hot Reload)### Development with Docker (Partial Docker)**Step 1:** Navigate to the portal directory`bashcd synkronus-portal`**Step 2:** Start backend services (PostgreSQL + API)`bash# Start only backend services (postgres + synkronus API)docker compose up -d postgres synkronus`**Step 3:** Wait for backend to be ready (about 10-20 seconds)`bash# Check backend healthcurl http://localhost:8080/health# Should return: OK`**Step 4:** Install dependencies (if not already done)`bashnpm install`**Step 5:** Start the Vite dev server`bashnpm run dev`**Step 6:** Access the portal- **Frontend Portal**: http://localhost:5174 (Vite dev server with hot reload)- **Backend API**: http://localhost:8080 (already running from Step 2)**Development Mode Features:**- ✅ Hot Module Replacement (HMR) - instant code updates without page refresh- ✅ Fast refresh - React components update instantly- ✅ Source maps for debugging- ✅ Same persistent storage as production - data is shared- ✅ Full debugging support in browser DevTools- ✅ Real-time error overlay in browser**To stop development mode:**`bash# Stop Vite dev server: Press Ctrl+C in the terminal running npm run dev# Stop backend servicesdocker compose down`**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe.---## Dockerless Development Setup**Perfect for developers who prefer not to use Docker or want a fully local development environment.**### Prerequisites- **Node.js** 20+ and **npm**- **Go** 1.22+ ([Install Go](https://go.dev/doc/install))- **PostgreSQL** 17+ ([Install PostgreSQL](https://www.postgresql.org/download/))- **Git**### Step 1: Set Up PostgreSQL Database**Create the database and user:**`bash# Connect to PostgreSQL (as superuser)psql -U postgres# In PostgreSQL prompt, run:CREATE DATABASE synkronus;CREATE USER synkronus_user WITH PASSWORD 'dev_password_change_in_production';GRANT ALL PRIVILEGES ON DATABASE synkronus TO synkronus_user;# Connect to synkronus database\c synkronus# Grant schema privilegesGRANT ALL ON SCHEMA public TO synkronus_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO synkronus_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO synkronus_user;# Exit\q`**Windows PowerShell alternative:**`powershell# Using psql command line$env:PGPASSWORD='postgres'; psql -U postgres -c "CREATE DATABASE synkronus;"$env:PGPASSWORD='postgres'; psql -U postgres -c "CREATE USER synkronus_user WITH PASSWORD 'dev_password_change_in_production';"$env:PGPASSWORD='postgres'; psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE synkronus TO synkronus_user;"`### Step 2: Set Up Backend API (Synkronus)**Navigate to synkronus directory:**`bashcd ../synkronus`**Install dependencies:**`bashgo mod download`**Create environment file (`.env` in synkronus directory):**`bash# Create .env filecat > .env << EOFPORT=8080LOG_LEVEL=debugDB_CONNECTION=postgres://synkronus_user:dev_password_change_in_production@localhost:5432/synkronus?sslmode=disableJWT_SECRET=dev_jwt_secret_change_in_production_32charsADMIN_USERNAME=adminADMIN_PASSWORD=adminAPP_BUNDLE_PATH=./data/app-bundlesMAX_VERSIONS_KEPT=5EOF`**Windows PowerShell alternative:**`powershell@"PORT=8080LOG_LEVEL=debugDB_CONNECTION=postgres://synkronus_user:dev_password_change_in_production@localhost:5432/synkronus?sslmode=disableJWT_SECRET=dev_jwt_secret_change_in_production_32charsADMIN_USERNAME=adminADMIN_PASSWORD=adminAPP_BUNDLE_PATH=./data/app-bundlesMAX_VERSIONS_KEPT=5"@ | Out-File -FilePath .env -Encoding utf8`**Create app bundles directory:**`bashmkdir -p data/app-bundles`**Run the backend API:**`bash# Option 1: Run directly (recommended for development)go run cmd/synkronus/main.go# Option 2: Build and rungo build -o bin/synkronus cmd/synkronus/main.go./bin/synkronus`**Verify backend is running:**`bash# In another terminalcurl http://localhost:8080/health# Should return: OK`### Step 3: Set Up Frontend Portal**Navigate to portal directory:**`bashcd ../synkronus-portal`**Install dependencies:**`bashnpm install`**Start the Vite dev server:**`bashnpm run dev`**Access the portal:**- **Frontend Portal**: http://localhost:5174- **Backend API**: http://localhost:8080- **Login**: `admin` / `admin`### Step 4: Verify Everything Works1. **Check backend health:** `bash curl http://localhost:8080/health # Should return: OK `2. **Open frontend in browser:** - Navigate to http://localhost:5174 - You should see the login page3. **Test login:** - Username: `admin` - Password: `admin` - Should successfully log in and show the dashboard### Stopping Dockerless Development1. **Stop frontend:** Press `Ctrl+C` in the terminal running `npm run dev`2. **Stop backend:** Press `Ctrl+C` in the terminal running the Go server### Troubleshooting Dockerless Setup**Backend won't start:**- Verify PostgreSQL is running: `psql -U postgres -c "SELECT version();"`- Check database connection string in `.env` file- Ensure database and user were created correctly**Frontend can't connect to backend:**- Verify backend is running on port 8080: `curl http://localhost:8080/health`- Check `vite.config.ts` proxy configuration- Ensure no firewall is blocking localhost connections**Database connection errors:**- Verify PostgreSQL is running- Check connection string format: `postgres://user:password@host:port/database`- Ensure user has proper permissions---## Verification ChecklistAfter setting up (any mode), verify everything is working:1. **Check service status (Docker mode):** `bash docker compose ps ` All services should show "Up" status. Health checks may show "starting" for the first 30-60 seconds - this is normal.2. **Test API health:** `bash curl http://localhost:8080/health ` Should return: `OK`3. **Test frontend:** - Open http://localhost:5173 (production) or http://localhost:5174 (development) in your browser - Should show the login page4. **Test login:** - Username: `admin` - Password: `admin` - Should successfully authenticate and show dashboard5. **Check logs (if issues):** `bash # Docker mode docker compose logs -f # Dockerless mode - check terminal output `---## Common Issues### "cannot execute: required file not found" (init-db.sh)**Symptom:** PostgreSQL container fails to initialize database user.**Cause:** Windows line endings (CRLF) in shell scripts.**Solution:**``powershell# Convert line endings (PowerShell)$content = Get-Content init-db.sh -Raw$content = $content -replace "`r`n", "`n"[System.IO.File]::WriteAllText((Resolve-Path init-db.sh), $content, [System.Text.UTF8Encoding]::new($false))# Then restartdocker compose down -vdocker compose up -d --build``**Prevention:** The `.gitattributes` file enforces LF line endings. If you're on Windows, ensure Git is configured correctly:`bashgit config core.autocrlf false`### Port Already in Use**Production Mode (Port 5173):**`bash# Edit docker-compose.yml and change the port mapping:ports: - "5173:80" # Change 5173 to your desired port`**Development Mode (Port 5174):**`bash# Edit vite.config.ts and change the port:server: { port: 5174, # Change to your desired port}`**Backend API (Port 8080):**`bash# Docker mode: Edit docker-compose.ymlports: - "8080:8080" # Change 8080 to your desired port# Dockerless mode: Edit .env file in synkronus directoryPORT=8080 # Change to your desired port`### Health Checks Show "Unhealthy" or "Starting"**This is normal!** Health checks can take 30-60 seconds to pass on first startup. As long as:- Services show "Up" status- `curl http://localhost:8080/health` returns `OK`- Frontend loads in browserThen everything is working correctly. The health check status will update to "healthy" after a few cycles.### Hot Reload Not Working (Development Mode)1. Ensure you're running `npm run dev` (not Docker for frontend)2. Check that Vite is running on port 51743. Verify the browser is connected to the correct port (http://localhost:5174)4. Check browser console for HMR connection errors5. Try hard refresh (Ctrl+Shift+R or Cmd+Shift+R)6. **Windows users:** Ensure file watching is enabled. If using WSL2, files should be in the WSL filesystem, not Windows filesystem.### API Connection Issues1. **Docker mode:** - Verify backend is running: `docker compose ps` - Check backend logs: `docker compose logs synkronus` - Test API directly: `curl http://localhost:8080/health`2. **Dockerless mode:** - Verify backend process is running (check terminal) - Check backend logs in terminal output - Test API directly: `curl http://localhost:8080/health` - Verify PostgreSQL is running and accessible### App Bundles Not PersistingIf app bundles disappear after restarting containers:1. **Verify the volume exists (Docker mode):** `bash docker volume ls | grep app-bundles `2. **Check if bundles are in the volume:** `bash # If containers are running docker compose exec synkronus ls -la /app/data/app-bundles # If containers are stopped docker run --rm -v synkronus-portal_app-bundles:/data alpine ls -la /data `3. **Verify volume is mounted correctly:** `bash docker compose config | grep -A 5 app-bundles `4. **Check backend logs for app bundle initialization:** `bash docker compose logs synkronus | grep -i "app bundle\|bundle path" `5. **Ensure you're not using `docker compose down -v`:** - Use `docker compose down` (preserves volumes) ✅ - Avoid `docker compose down -v` (deletes volumes) ❌**Note:** App bundles are stored in the `app-bundles` volume (Docker) or `./data/app-bundles` directory (Dockerless). This persists across restarts. If bundles are missing, check that:- The volume/directory wasn't accidentally deleted- The backend has proper permissions to read/write- The `APP_BUNDLE_PATH` environment variable is set correctly### Windows-Specific Issues**Line Endings:**- Git should handle this automatically with `.gitattributes`- If issues persist: `git config core.autocrlf false`**File Watching (Dockerless):**- If hot reload doesn't work, files may need to be in WSL filesystem- Or use polling mode in `vite.config.ts` (already configured)**PowerShell vs Bash:**- Most commands work in both- Use backticks for line continuation in PowerShell: `` ` ``---## Architecture### Development Mode (Docker)`┌─────────────────────────────────────────────────────────────┐│ Development Environment (docker-compose.yml) │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ synkronus-portal │ │ synkronus-api │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • Vite Dev │◄────►│ • Go Server │ ││ │ • Port 5174 │ │ • Port 8080 │ ││ │ • Hot Reload │ │ • App Bundles │ ││ │ • Source Mounted │ │ • PostgreSQL │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ Port 5432 │ ││ │ Persistent DB │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘`### Development Mode (Dockerless)`┌─────────────────────────────────────────────────────────────┐│ Dockerless Development Environment │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ Vite Dev Server │ │ Go API Server │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • npm run dev │◄────►│ • go run │ ││ │ • Port 5174 │ │ • Port 8080 │ ││ │ • Hot Reload │ │ • App Bundles │ ││ │ • Local Files │ │ • Local Files │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ (Local Install) │ ││ │ Port 5432 │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘`### Production Mode`┌─────────────────────────────────────────────────────────────┐│ Production Environment (docker-compose.yml) │├─────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ synkronus-portal │ │ synkronus-api │ ││ │ (Frontend) │ │ (Backend) │ ││ │ │ │ │ ││ │ • Nginx │◄────►│ • Go Server │ ││ │ • Static Files │ │ • Port 8080 │ ││ │ • Port 5173 │ │ • App Bundles │ ││ │ • Optimized │ │ • PostgreSQL │ ││ └──────────────────┘ └────────┬───────────┘ ││ │ │ ││ │ │ ││ └───────────────────────────┼───────────────────────┘│ │ ││ ┌────────▼──────────┐ ││ │ PostgreSQL │ ││ │ Port 5432 │ ││ │ Persistent DB │ ││ └───────────────────┘ ││ │└─────────────────────────────────────────────────────────────┘`---## API Proxy Configuration### Development ModeThe Vite dev server automatically proxies `/api/*` requests to the backend:- **Frontend → Backend**: `/api/*` → `http://localhost:8080/*` (via Vite proxy)- **Configuration**: See `vite.config.ts`### Production ModeNginx proxies `/api/*` requests to the backend:- **Frontend → Backend**: `/api/*` → `http://synkronus:8080/*` (via Nginx)- **Configuration**: See `Dockerfile` nginx config---## Storage Persistence### Docker ModeBoth development and production modes use the **same named Docker volumes**, ensuring your data persists across:- Container restarts- Mode switches (dev ↔ prod)- Container removal (with `docker compose down`)- System reboots**Volumes:**- **postgres-data**: PostgreSQL database files (users, observations, app bundles metadata)- **app-bundles**: App bundle ZIP files and versions (stored at `/app/data/app-bundles` in the container)**Important:** App bundles are stored in **both** places:- **Files**: Actual ZIP files and extracted content in the `app-bundles` volume- **Database**: Metadata about bundles (versions, manifest info) in the `postgres-data` volumeBoth volumes must persist for app bundles to work correctly after restart.**Volume Persistence Guarantee:**✅ **Volumes are NOT deleted when you:**- Stop containers: `docker compose down`- Restart containers: `docker compose restart`- Switch between dev/prod modes- Rebuild containers: `docker compose build`⚠️ **Volumes ARE deleted ONLY when you:**- Explicitly use: `docker compose down -v` (the `-v` flag removes volumes)- Manually delete: `docker volume rm `### Dockerless ModeData is stored in local directories:- **PostgreSQL**: Uses your local PostgreSQL data directory (configured during PostgreSQL installation)- **App Bundles**: Stored in `../synkronus/data/app-bundles` directory**Backup Recommendations:**- Regularly backup your PostgreSQL database- Backup the `data/app-bundles` directory---## Stopping Services### Docker Mode**Safe Stop (Preserves Data):**`bash# Stop all services - VOLUMES ARE PRESERVED ✅docker compose down`This command:- ✅ Stops all containers- ✅ Removes containers- ✅ **Keeps all volumes** (your data is safe!)- ✅ Removes networks**Complete Removal (⚠️ DELETES ALL DATA):**`bash# Stop services AND delete volumes - ⚠️ THIS DELETES ALL DATA!docker compose down -v`**⚠️ WARNING:** The `-v` flag removes volumes, which will:- Delete all database data (users, observations, etc.)- Delete all uploaded app bundles- **This action cannot be undone!\*\***Restarting Services:**`bash# Start services (volumes are automatically reattached)docker compose up -d`Your data will be exactly as you left it!### Dockerless Mode**Stop Frontend:**- Press `Ctrl+C` in the terminal running `npm run dev`**Stop Backend:**- Press `Ctrl+C` in the terminal running the Go server**Stop PostgreSQL:**- Use your system's service manager (e.g., `systemctl stop postgresql` on Linux)---## Default Credentials- **Admin username**: `admin`- **Admin password**: `admin`**⚠️ Warning**: These are development credentials only. Change them before production use.---## Switching Between Modes### From Production to Development (Docker)1. Stop production containers: `bash docker compose down `2. Start backend services: `bash docker compose up -d postgres synkronus `3. Start dev server: `bash npm run dev `### From Development to Production (Docker)1. Stop Vite dev server (Ctrl+C)2. Stop backend containers: `bash docker compose down `3. Start production mode: `bash docker compose up -d --build `**Important:** Your data (database, app bundles) persists when switching between modes because both use the same Docker volumes.### From Docker to Dockerless (or vice versa)**Note:** Data is not automatically shared between Docker and Dockerless modes. You'll need to:- Export data from one mode- Import into the other mode- Or use the same PostgreSQL instance for both---## Building for Production### First Time SetupFor the first time, or after code changes:`bash# Build and start (recommended - does both in one command)docker compose up -d --build# Or build first, then start (if you prefer separate steps)docker compose builddocker compose up -d`### Rebuilding After Code ChangesIf you've made changes to the frontend code:`bash# Rebuild just the portal imagedocker compose build synkronus-portal# Restart the portal servicedocker compose up -d synkronus-portal`**Note:\*\* The `--build` flag in `docker compose up -d --build` will:- Build images if they don't exist- Rebuild images if the Dockerfile or source code changed- Start all services after buildingThis is the easiest way to ensure everything is up-to-date!---## Environment Variables### Development- `VITE_API_URL`: Backend API URL (default: uses `/api` proxy)- `DOCKER_ENV`: Set to `true` when running in Docker### Production- `VITE_API_URL`: Backend API URL (default: `http://localhost:8080`)### Backend (Dockerless)See `../synkronus/README.md` for complete backend environment variable documentation.---## See Also- [SETUP_ANALYSIS.md](./SETUP_ANALYSIS.md) - Detailed setup analysis and recommendations- [../synkronus/README.md](../synkronus/README.md) - Backend API documentation- [../synkronus/DEPLOYMENT.md](../synkronus/DEPLOYMENT.md) - Production deployment guide diff --git a/synkronus-portal/package.json b/synkronus-portal/package.json index 793b70d04..218c85526 100644 --- a/synkronus-portal/package.json +++ b/synkronus-portal/package.json @@ -11,8 +11,8 @@ "generate:api": "npx --yes @openapitools/openapi-generator-cli generate -i ../synkronus/openapi/synkronus.yaml -g typescript-axios -o src/api/synkronus/generated \"--additional-properties=supportsES6=true,useSingleRequestParameter=true,modelPropertyNaming=original\" && npm run format", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "npx prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", - "format:check": "npx prettier \"**/*.{js,jsx,ts,tsx,json}\" --check", + "format": "npx prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --write", + "format:check": "npx prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --check", "preview": "vite preview" }, "dependencies": { diff --git a/synkronus-portal/src/components/Login.css b/synkronus-portal/src/components/Login.css index cddfad958..2b6fa1551 100644 --- a/synkronus-portal/src/components/Login.css +++ b/synkronus-portal/src/components/Login.css @@ -20,8 +20,16 @@ width: 200%; height: 200%; background: - radial-gradient(circle at 25% 35%, var(--color-brand-primary-500) 0%, transparent 35%), - radial-gradient(circle at 75% 65%, var(--color-brand-secondary-500) 0%, transparent 35%); + radial-gradient( + circle at 25% 35%, + var(--color-brand-primary-500) 0%, + transparent 35% + ), + radial-gradient( + circle at 75% 65%, + var(--color-brand-secondary-500) 0%, + transparent 35% + ); opacity: var(--opacity-6); animation: backgroundPulse 25s ease-in-out infinite; pointer-events: none; @@ -30,7 +38,7 @@ background-blend-mode: overlay; } -[data-theme="light"] .login-container::before { +[data-theme='light'] .login-container::before { background-color: var(--color-semantic-ui-overlay-light); } @@ -43,7 +51,10 @@ bottom: -10%; width: 120%; height: 120%; - background-image: var(--login-bg-image, url('../assets/dashboard-background.png')); + background-image: var( + --login-bg-image, + url('../assets/dashboard-background.png') + ); background-size: cover; background-position: center; background-repeat: no-repeat; @@ -52,17 +63,19 @@ opacity: var(--opacity-70); z-index: 0; pointer-events: none; - transition: filter var(--duration-normal) var(--easing-ease-out), + transition: + filter var(--duration-normal) var(--easing-ease-out), opacity var(--duration-normal) var(--easing-ease-out); } -[data-theme="light"] .login-container::after { +[data-theme='light'] .login-container::after { filter: blur(var(--filter-blur-16)) brightness(1) saturate(1); opacity: var(--opacity-70); } @keyframes backgroundPulse { - 0%, 100% { + 0%, + 100% { transform: translate(0, 0) scale(1); opacity: var(--opacity-6); } @@ -80,7 +93,9 @@ padding: var(--spacing-8); width: 100%; max-width: 420px; - box-shadow: var(--shadow-portal-lg), inset 0 1px 0 var(--color-neutral-white-alpha-5); + box-shadow: + var(--shadow-portal-lg), + inset 0 1px 0 var(--color-neutral-white-alpha-5); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); position: relative; z-index: 1; @@ -115,7 +130,8 @@ border: var(--border-width-thin) solid var(--color-brand-primary-alpha-40); background: var(--color-background-card); filter: drop-shadow(0 4px 12px var(--color-brand-primary-alpha-30)); - transition: filter var(--duration-normal) var(--easing-ease-out), + transition: + filter var(--duration-normal) var(--easing-ease-out), border-color var(--duration-normal) var(--easing-ease-out); } @@ -123,7 +139,7 @@ filter: drop-shadow(0 0 16px var(--color-brand-primary-alpha-60)); } -[data-theme="light"] .login-logo { +[data-theme='light'] .login-logo { border-color: var(--color-brand-primary-500); } @@ -237,7 +253,7 @@ animation: slideDown var(--duration-normal) var(--easing-ease-out); } -[data-theme="light"] .error-message { +[data-theme='light'] .error-message { background: var(--color-semantic-error-50); color: var(--color-semantic-error-600); } @@ -274,6 +290,6 @@ text-transform: uppercase; } -[data-theme="light"] .version-text { +[data-theme='light'] .version-text { color: var(--color-text-tertiary-light); } diff --git a/synkronus-portal/src/components/ThemeSwitcher.css b/synkronus-portal/src/components/ThemeSwitcher.css index e2f2ed288..97d7cb8b8 100644 --- a/synkronus-portal/src/components/ThemeSwitcher.css +++ b/synkronus-portal/src/components/ThemeSwitcher.css @@ -30,7 +30,8 @@ position: absolute; inset: -1px; border-radius: var(--border-radius-md); - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-5) 15%, var(--color-brand-primary-alpha-10) 30%, @@ -40,9 +41,11 @@ var(--color-brand-primary-alpha-30) 90%, var(--color-brand-primary-alpha-30) 100% ); - mask: linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -124,7 +127,8 @@ display: none; font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); - transition: color var(--duration-normal) var(--easing-ease-out), + transition: + color var(--duration-normal) var(--easing-ease-out), transform var(--duration-slow) var(--easing-ease-in-out); margin-left: var(--spacing-2); transform: translateY(0); @@ -146,7 +150,8 @@ } @keyframes arrowFloat { - 0%, 100% { + 0%, + 100% { transform: translateY(0); } 50% { @@ -154,11 +159,14 @@ } } -[data-theme="light"] .mobile-menu .theme-switcher-arrow.mobile-only { +[data-theme='light'] .mobile-menu .theme-switcher-arrow.mobile-only { color: var(--color-brand-primary-600); } -[data-theme="light"] .mobile-menu .theme-switcher-button:hover .theme-switcher-arrow.mobile-only { +[data-theme='light'] + .mobile-menu + .theme-switcher-button:hover + .theme-switcher-arrow.mobile-only { color: var(--color-neutral-white) !important; } @@ -171,10 +179,13 @@ backdrop-filter: blur(40px) saturate(180%); -webkit-backdrop-filter: blur(40px) saturate(180%); border: none; - border-left: var(--border-width-thin) solid var(--color-brand-primary-alpha-30); + border-left: var(--border-width-thin) solid + var(--color-brand-primary-alpha-30); border-right: none; border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-portal-lg), inset 0 1px 0 var(--color-neutral-white-alpha-5); + box-shadow: + var(--shadow-portal-lg), + inset 0 1px 0 var(--color-neutral-white-alpha-5); padding: var(--spacing-2); z-index: var(--z-index-dropdown); animation: slideDownFade var(--duration-normal) var(--easing-ease-out); @@ -193,8 +204,10 @@ pointer-events: none; z-index: 0; background: - linear-gradient(to right, var(--color-brand-primary-alpha-30), transparent) 0 0 / 100% var(--border-width-thin) no-repeat, - linear-gradient(to right, var(--color-brand-primary-alpha-30), transparent) 0 100% / 100% var(--border-width-thin) no-repeat; + linear-gradient(to right, var(--color-brand-primary-alpha-30), transparent) + 0 0 / 100% var(--border-width-thin) no-repeat, + linear-gradient(to right, var(--color-brand-primary-alpha-30), transparent) + 0 100% / 100% var(--border-width-thin) no-repeat; background-origin: border-box; } @@ -205,7 +218,8 @@ right: 0; left: auto; border: none; - border-left: var(--border-width-thin) solid var(--color-brand-primary-alpha-30); + border-left: var(--border-width-thin) solid + var(--color-brand-primary-alpha-30); border-right: none; border-radius: 0 0 0 var(--border-radius-xl); } @@ -215,7 +229,8 @@ position: absolute; inset: -1px; border-radius: 0 0 0 var(--border-radius-xl); - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-neutral-white-alpha-30) 0%, var(--color-neutral-white-alpha-30) 10%, var(--color-neutral-white-alpha-25) 25%, @@ -225,9 +240,11 @@ var(--color-neutral-white-alpha-5) 85%, transparent 100% ); - mask: linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -304,14 +321,15 @@ flex-shrink: 0; } -[data-theme="light"] .theme-switcher-button { +[data-theme='light'] .theme-switcher-button { background: transparent !important; border: none !important; color: var(--color-brand-primary-600); } -[data-theme="light"] .theme-switcher-button::after { - background: linear-gradient(90deg, +[data-theme='light'] .theme-switcher-button::after { + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-3) 15%, var(--color-brand-primary-alpha-6) 30%, @@ -323,81 +341,94 @@ ); } -[data-theme="light"] .theme-switcher-button:hover { +[data-theme='light'] .theme-switcher-button:hover { background: var(--color-brand-primary-500) !important; border: none !important; } -[data-theme="light"] .theme-switcher-button:hover::after { +[data-theme='light'] .theme-switcher-button:hover::after { display: none; } @media (min-width: 1025px) { - [data-theme="light"] .user-info .theme-switcher-button.open { + [data-theme='light'] .user-info .theme-switcher-button.open { background: transparent !important; border: none !important; } - [data-theme="light"] .user-info .theme-switcher-button.open::after { + [data-theme='light'] .user-info .theme-switcher-button.open::after { opacity: 1 !important; } - [data-theme="light"] .user-info .theme-switcher-button.open:hover { + [data-theme='light'] .user-info .theme-switcher-button.open:hover { background: var(--color-brand-primary-500) !important; border: none !important; } - [data-theme="light"] .user-info .theme-switcher-button.open:hover::after { + [data-theme='light'] .user-info .theme-switcher-button.open:hover::after { display: none; } - [data-theme="light"] .user-info .theme-switcher-button.open:hover .theme-switcher-icon { + [data-theme='light'] + .user-info + .theme-switcher-button.open:hover + .theme-switcher-icon { color: var(--color-neutral-white) !important; } } -[data-theme="light"] .mobile-menu .theme-switcher-button.open { +[data-theme='light'] .mobile-menu .theme-switcher-button.open { background: transparent !important; border: none !important; } -[data-theme="light"] .mobile-menu .theme-switcher-button.open::after { +[data-theme='light'] .mobile-menu .theme-switcher-button.open::after { opacity: 1 !important; } -[data-theme="light"] .theme-switcher-icon { +[data-theme='light'] .theme-switcher-icon { color: var(--color-brand-primary-600); transition: color var(--duration-normal) var(--easing-ease-out); } -[data-theme="light"] .theme-switcher-button:hover .theme-switcher-icon { +[data-theme='light'] .theme-switcher-button:hover .theme-switcher-icon { color: var(--color-neutral-white) !important; } -[data-theme="light"] .theme-switcher-dropdown { +[data-theme='light'] .theme-switcher-dropdown { background: var(--color-semantic-ui-surface-dropdown-light); border-left: var(--border-width-thin) solid var(--color-brand-primary-400); border-right: none; - box-shadow: 0 8px 32px var(--color-neutral-black-alpha-10), + box-shadow: + 0 8px 32px var(--color-neutral-black-alpha-10), inset 0 1px 0 var(--color-neutral-black-alpha-3); } -[data-theme="light"] .theme-switcher-dropdown::before { +[data-theme='light'] .theme-switcher-dropdown::before { background: - linear-gradient(to right, var(--color-brand-primary-400), transparent) 0 0 / 100% var(--border-width-thin) no-repeat, - linear-gradient(to right, var(--color-brand-primary-400), transparent) 0 100% / 100% var(--border-width-thin) no-repeat; + linear-gradient(to right, var(--color-brand-primary-400), transparent) 0 0 / + 100% var(--border-width-thin) no-repeat, + linear-gradient(to right, var(--color-brand-primary-400), transparent) 0 + 100% / 100% var(--border-width-thin) no-repeat; } @media (min-width: 1025px) { - [data-theme="light"] .user-info .theme-switcher-container .theme-switcher-dropdown { + [data-theme='light'] + .user-info + .theme-switcher-container + .theme-switcher-dropdown { border: none; border-left: var(--border-width-thin) solid var(--color-brand-primary-400); border-right: none; } - [data-theme="light"] .user-info .theme-switcher-container .theme-switcher-dropdown::after { + [data-theme='light'] + .user-info + .theme-switcher-container + .theme-switcher-dropdown::after { border-radius: 0 0 0 var(--border-radius-xl); - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-brand-primary-alpha-20) 0%, var(--color-brand-primary-alpha-20) 10%, var(--color-brand-primary-alpha-18) 25%, @@ -410,23 +441,23 @@ } } -[data-theme="light"] .theme-option { +[data-theme='light'] .theme-option { color: var(--color-text-primary); } -[data-theme="light"] .theme-option:hover { +[data-theme='light'] .theme-option:hover { background: var(--color-neutral-black-alpha-3); } -[data-theme="light"] .theme-option.active { +[data-theme='light'] .theme-option.active { background: var(--color-brand-primary-alpha-8); border-color: var(--color-brand-primary-alpha-20); } -[data-theme="light"] .theme-option-name { +[data-theme='light'] .theme-option-name { color: var(--color-text-primary); } -[data-theme="light"] .theme-option-description { +[data-theme='light'] .theme-option-description { color: var(--color-text-secondary); } diff --git a/synkronus-portal/src/index.css b/synkronus-portal/src/index.css index 8f790bf42..43767a1f9 100644 --- a/synkronus-portal/src/index.css +++ b/synkronus-portal/src/index.css @@ -22,17 +22,37 @@ } /* Light theme: override with theme-light tokens */ -[data-theme="light"] { - --color-semantic-theme-background-base: var(--color-semantic-theme-light-background-base); - --color-semantic-theme-background-elevated: var(--color-semantic-theme-light-background-elevated); - --color-semantic-theme-background-overlay: var(--color-semantic-theme-light-background-overlay); - --color-semantic-theme-background-card: var(--color-semantic-theme-light-background-card); - --color-semantic-theme-text-primary: var(--color-semantic-theme-light-text-primary); - --color-semantic-theme-text-secondary: var(--color-semantic-theme-light-text-secondary); - --color-semantic-theme-text-tertiary: var(--color-semantic-theme-light-text-tertiary); - --color-semantic-theme-text-disabled: var(--color-semantic-theme-light-text-disabled); - --color-semantic-theme-border-default: var(--color-semantic-theme-light-border-default); - --color-semantic-theme-border-hover: var(--color-semantic-theme-light-border-hover); +[data-theme='light'] { + --color-semantic-theme-background-base: var( + --color-semantic-theme-light-background-base + ); + --color-semantic-theme-background-elevated: var( + --color-semantic-theme-light-background-elevated + ); + --color-semantic-theme-background-overlay: var( + --color-semantic-theme-light-background-overlay + ); + --color-semantic-theme-background-card: var( + --color-semantic-theme-light-background-card + ); + --color-semantic-theme-text-primary: var( + --color-semantic-theme-light-text-primary + ); + --color-semantic-theme-text-secondary: var( + --color-semantic-theme-light-text-secondary + ); + --color-semantic-theme-text-tertiary: var( + --color-semantic-theme-light-text-tertiary + ); + --color-semantic-theme-text-disabled: var( + --color-semantic-theme-light-text-disabled + ); + --color-semantic-theme-border-default: var( + --color-semantic-theme-light-border-default + ); + --color-semantic-theme-border-hover: var( + --color-semantic-theme-light-border-hover + ); } body { @@ -44,7 +64,8 @@ body { padding: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: background-color var(--duration-normal) var(--easing-ease-out), + transition: + background-color var(--duration-normal) var(--easing-ease-out), color var(--duration-normal) var(--easing-ease-out); } diff --git a/synkronus-portal/src/pages/Dashboard.css b/synkronus-portal/src/pages/Dashboard.css index 06d8993ee..99251d393 100644 --- a/synkronus-portal/src/pages/Dashboard.css +++ b/synkronus-portal/src/pages/Dashboard.css @@ -10,8 +10,8 @@ .dashboard kbd, .dashboard samp, .dashboard .mono, -.dashboard [class*="mono"], -.dashboard [class*="code"] { +.dashboard [class*='mono'], +.dashboard [class*='code'] { font-family: var(--font-family-mono) !important; } @@ -22,7 +22,8 @@ overflow-x: hidden; color: var(--color-text-primary); background-color: var(--color-background-base); - transition: background-color var(--duration-normal) var(--easing-ease-out), + transition: + background-color var(--duration-normal) var(--easing-ease-out), color var(--duration-normal) var(--easing-ease-out); } @@ -32,7 +33,8 @@ body, html { scrollbar-width: thin; - scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) var(--color-semantic-ui-scrollbar-track); + scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) + var(--color-semantic-ui-scrollbar-track); } .dashboard::-webkit-scrollbar, @@ -80,7 +82,7 @@ html::-webkit-scrollbar-thumb:hover { z-index: 1; } -[data-theme="light"] .dashboard::before { +[data-theme='light'] .dashboard::before { background-color: var(--color-semantic-ui-overlay-light); } @@ -94,7 +96,10 @@ html::-webkit-scrollbar-thumb:hover { bottom: -10%; width: 120%; height: 120%; - background-image: var(--dashboard-bg-image, url('../assets/dashboard-background.png')); + background-image: var( + --dashboard-bg-image, + url('../assets/dashboard-background.png') + ); background-size: cover; background-position: center; background-repeat: no-repeat; @@ -103,11 +108,12 @@ html::-webkit-scrollbar-thumb:hover { opacity: var(--opacity-70); z-index: 0; pointer-events: none; - transition: filter var(--duration-normal) var(--easing-ease-out), + transition: + filter var(--duration-normal) var(--easing-ease-out), opacity var(--duration-normal) var(--easing-ease-out); } -[data-theme="light"] .dashboard::after { +[data-theme='light'] .dashboard::after { filter: blur(var(--filter-blur-18)) brightness(1) saturate(1); opacity: var(--opacity-70); } @@ -123,7 +129,9 @@ html::-webkit-scrollbar-thumb:hover { backdrop-filter: blur(var(--filter-blur-40)) saturate(180%); -webkit-backdrop-filter: blur(var(--filter-blur-40)) saturate(180%); border-bottom: var(--border-width-thin) solid var(--color-border-default); - box-shadow: var(--shadow-portal-md), inset 0 1px 0 var(--color-neutral-white-alpha-5); + box-shadow: + var(--shadow-portal-md), + inset 0 1px 0 var(--color-neutral-white-alpha-5); padding: 0; transition: all var(--duration-normal) var(--easing-ease-out); opacity: 1; @@ -131,11 +139,13 @@ html::-webkit-scrollbar-thumb:hover { will-change: transform; } -[data-theme="light"] .dashboard-header { - box-shadow: var(--shadow-portal-sm), inset 0 1px 0 var(--color-neutral-black-alpha-3); +[data-theme='light'] .dashboard-header { + box-shadow: + var(--shadow-portal-sm), + inset 0 1px 0 var(--color-neutral-black-alpha-3); } -[data-theme="light"] .dashboard-header.scrolled { +[data-theme='light'] .dashboard-header.scrolled { opacity: 1; background: var(--color-background-elevated); backdrop-filter: blur(var(--filter-blur-50)) saturate(200%); @@ -167,7 +177,8 @@ html::-webkit-scrollbar-thumb:hover { border: var(--border-width-thin) solid var(--color-brand-primary-alpha-40); background: var(--color-background-card); filter: drop-shadow(var(--filter-drop-shadow-glow-sm)); - transition: filter var(--duration-normal) var(--easing-ease-out), + transition: + filter var(--duration-normal) var(--easing-ease-out), border-color var(--duration-normal) var(--easing-ease-out); } @@ -175,7 +186,7 @@ html::-webkit-scrollbar-thumb:hover { filter: drop-shadow(var(--filter-drop-shadow-glow-md)); } -[data-theme="light"] .logo-icon { +[data-theme='light'] .logo-icon { border-color: var(--color-brand-primary-500); } @@ -189,8 +200,10 @@ html::-webkit-scrollbar-thumb:hover { } /* Use darker green shade for "Synkronus Portal" text in light mode */ -[data-theme="light"] .dashboard-header h1 { - color: var(--color-brand-primary-600) !important; /* Darker green for light mode */ +[data-theme='light'] .dashboard-header h1 { + color: var( + --color-brand-primary-600 + ) !important; /* Darker green for light mode */ } .user-info { @@ -225,7 +238,8 @@ html::-webkit-scrollbar-thumb:hover { position: absolute; inset: -1px; border-radius: 8px; - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-5) 15%, var(--color-brand-primary-alpha-10) 30%, @@ -235,11 +249,11 @@ html::-webkit-scrollbar-thumb:hover { var(--color-brand-primary-alpha-30) 90%, var(--color-brand-primary-alpha-30) 100% ); - mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -260,7 +274,9 @@ html::-webkit-scrollbar-thumb:hover { } .mobile-menu-toggle:hover { - background: var(--color-brand-primary-500) !important; /* Keep current hover */ + background: var( + --color-brand-primary-500 + ) !important; /* Keep current hover */ border: var(--border-width-thin) solid var(--color-brand-primary-500) !important; transform: scale(1.05); } @@ -291,13 +307,14 @@ html::-webkit-scrollbar-thumb:hover { transform: translateY(-7px) rotate(-45deg); } -[data-theme="light"] .mobile-menu-toggle { +[data-theme='light'] .mobile-menu-toggle { background: transparent !important; border: none !important; } -[data-theme="light"] .mobile-menu-toggle::after { - background: linear-gradient(90deg, +[data-theme='light'] .mobile-menu-toggle::after { + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-3) 15%, var(--color-brand-primary-alpha-6) 30%, @@ -309,16 +326,16 @@ html::-webkit-scrollbar-thumb:hover { ); } -[data-theme="light"] .mobile-menu-toggle:hover { +[data-theme='light'] .mobile-menu-toggle:hover { background: var(--color-brand-primary-500) !important; border: var(--border-width-thin) solid var(--color-brand-primary-500) !important; } -[data-theme="light"] .mobile-menu-toggle:hover::after { +[data-theme='light'] .mobile-menu-toggle:hover::after { display: none; } -[data-theme="light"] .mobile-menu-toggle:hover .hamburger-line { +[data-theme='light'] .mobile-menu-toggle:hover .hamburger-line { background: var(--color-neutral-white) !important; } @@ -374,7 +391,10 @@ html::-webkit-scrollbar-thumb:hover { z-index: 10000; max-height: 0; overflow: visible; - transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: + max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease, + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); opacity: 0; transform: translateX(100%); } @@ -385,7 +405,8 @@ html::-webkit-scrollbar-thumb:hover { position: absolute; inset: -1px; border-radius: 0 0 0 16px; /* Straight top-left, more curved bottom-left */ - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-neutral-white-alpha-30) 0%, var(--color-neutral-white-alpha-30) 10%, var(--color-neutral-white-alpha-25) 25%, @@ -395,11 +416,11 @@ html::-webkit-scrollbar-thumb:hover { var(--color-neutral-white-alpha-5) 85%, transparent 100% ); - mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -412,7 +433,10 @@ html::-webkit-scrollbar-thumb:hover { max-height: 500px; opacity: 1; transform: translateX(0); - transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: + max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease, + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } /* Expand mobile menu when themes dropdown is open */ @@ -473,7 +497,7 @@ html::-webkit-scrollbar-thumb:hover { font-weight: var(--font-weight-medium); } -[data-theme="light"] .mobile-menu-welcome-text { +[data-theme='light'] .mobile-menu-welcome-text { color: var(--color-text-secondary); } @@ -537,7 +561,8 @@ html::-webkit-scrollbar-thumb:hover { position: absolute; inset: -1px; border-radius: 8px; - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-5) 15%, var(--color-brand-primary-alpha-10) 30%, @@ -547,11 +572,11 @@ html::-webkit-scrollbar-thumb:hover { var(--color-brand-primary-alpha-30) 90%, var(--color-brand-primary-alpha-30) 100% ); - mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -561,7 +586,6 @@ html::-webkit-scrollbar-thumb:hover { border-width: 1px; } - .mobile-menu .theme-switcher-button:hover { background: var(--color-brand-primary-500) !important; border: var(--border-width-thin) solid var(--color-brand-primary-500) !important; @@ -581,14 +605,15 @@ html::-webkit-scrollbar-thumb:hover { color: var(--color-neutral-white) !important; } -[data-theme="light"] .mobile-menu .theme-switcher-button { +[data-theme='light'] .mobile-menu .theme-switcher-button { background: transparent !important; border: none !important; color: var(--color-brand-primary-600) !important; } -[data-theme="light"] .mobile-menu .theme-switcher-button::after { - background: linear-gradient(90deg, +[data-theme='light'] .mobile-menu .theme-switcher-button::after { + background: linear-gradient( + 90deg, transparent 0%, var(--color-brand-primary-alpha-3) 15%, var(--color-brand-primary-alpha-6) 30%, @@ -600,21 +625,24 @@ html::-webkit-scrollbar-thumb:hover { ); } -[data-theme="light"] .mobile-menu .theme-switcher-button:hover { +[data-theme='light'] .mobile-menu .theme-switcher-button:hover { background: var(--color-brand-primary-500) !important; border: var(--border-width-thin) solid var(--color-brand-primary-500) !important; } -[data-theme="light"] .mobile-menu .theme-switcher-button:hover::after { +[data-theme='light'] .mobile-menu .theme-switcher-button:hover::after { opacity: 0; } /* Keep border visible when dropdown is open in mobile menu (light theme) */ -[data-theme="light"] .mobile-menu .theme-switcher-button.open::after { +[data-theme='light'] .mobile-menu .theme-switcher-button.open::after { opacity: 1 !important; } -[data-theme="light"] .mobile-menu .theme-switcher-button:hover .theme-switcher-icon { +[data-theme='light'] + .mobile-menu + .theme-switcher-button:hover + .theme-switcher-icon { color: var(--color-neutral-white) !important; } @@ -648,7 +676,8 @@ html::-webkit-scrollbar-thumb:hover { position: absolute; inset: -1px; border-radius: 8px; - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-neutral-white-alpha-30) 0%, var(--color-neutral-white-alpha-30) 10%, var(--color-neutral-white-alpha-25) 25%, @@ -658,11 +687,11 @@ html::-webkit-scrollbar-thumb:hover { var(--color-neutral-white-alpha-5) 85%, transparent 100% ); - mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -689,14 +718,15 @@ html::-webkit-scrollbar-thumb:hover { opacity: 0; } -[data-theme="light"] .mobile-menu-logout-button { +[data-theme='light'] .mobile-menu-logout-button { background: transparent !important; border: none !important; color: var(--color-semantic-theme-light-text-secondary) !important; } -[data-theme="light"] .mobile-menu-logout-button::after { - background: linear-gradient(90deg, +[data-theme='light'] .mobile-menu-logout-button::after { + background: linear-gradient( + 90deg, var(--color-neutral-black-alpha-20) 0%, var(--color-neutral-black-alpha-20) 10%, var(--color-neutral-black-alpha-18) 25%, @@ -708,12 +738,12 @@ html::-webkit-scrollbar-thumb:hover { ); } -[data-theme="light"] .mobile-menu-logout-button:hover { +[data-theme='light'] .mobile-menu-logout-button:hover { background: var(--color-semantic-error-alpha-15) !important; border: var(--border-width-thin) solid var(--color-semantic-error-500) !important; } -[data-theme="light"] .mobile-menu-logout-button:hover::after { +[data-theme='light'] .mobile-menu-logout-button:hover::after { opacity: 0; } @@ -735,15 +765,16 @@ html::-webkit-scrollbar-thumb:hover { animation: slideDownFade 0.3s cubic-bezier(0.4, 0, 0.2, 1); } -[data-theme="light"] .mobile-menu { +[data-theme='light'] .mobile-menu { background: var(--color-background-elevated); border: none; box-shadow: var(--shadow-portal-lg-light); } -[data-theme="light"] .mobile-menu::after { +[data-theme='light'] .mobile-menu::after { border-radius: 0 0 0 16px; /* Straight top-left, more curved bottom-left */ - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-neutral-black-alpha-20) 0%, var(--color-neutral-black-alpha-20) 10%, var(--color-neutral-black-alpha-18) 25%, @@ -755,12 +786,12 @@ html::-webkit-scrollbar-thumb:hover { ); } -[data-theme="light"] .mobile-menu-action-item { +[data-theme='light'] .mobile-menu-action-item { background: transparent; border: none; } -[data-theme="light"] .mobile-menu-action-item:hover { +[data-theme='light'] .mobile-menu-action-item:hover { background: transparent; border: none; } @@ -770,19 +801,19 @@ html::-webkit-scrollbar-thumb:hover { .mobile-menu-toggle { display: flex; } - + .mobile-menu { display: block; } - + .dashboard-tabs { display: none; } - + .user-info { gap: 12px; } - + /* Hide welcome text, badge, theme switcher, and logout button on small screens */ .welcome-text, .user-details, @@ -791,11 +822,11 @@ html::-webkit-scrollbar-thumb:hover { .user-info .ode-badge { display: none !important; } - + .user-info { gap: 0; } - + .mobile-menu-toggle { display: flex !important; } @@ -810,7 +841,7 @@ html::-webkit-scrollbar-thumb:hover { justify-content: center; /* Center content horizontally */ position: relative; /* For absolute positioning of logo and button */ } - + .logo-section { margin-left: 12px; /* Push logo away from left edge (same distance as hamburger from right) */ position: absolute; @@ -818,7 +849,7 @@ html::-webkit-scrollbar-thumb:hover { display: flex; align-items: center; } - + /* Center the h1 text in the navbar */ .logo-section h1 { font-size: var(--font-size-xl); @@ -830,28 +861,28 @@ html::-webkit-scrollbar-thumb:hover { margin: 0; z-index: 1; } - + /* Keep logo icon visible but position it on the left */ .logo-section .logo-icon { display: block; } - + .user-info { gap: 8px; position: absolute; right: 0; } - + /* Push hamburger button away from right edge */ .mobile-menu-toggle { margin-right: 12px; } - + /* Adjust mobile menu position - lower it with space so top border is visible */ .mobile-menu { top: 70px !important; /* Lower than navbar height (60px) with 8px gap to show top border */ } - + /* Adjust backdrop position */ .mobile-menu-backdrop::after { top: 60px; /* Match the new navbar height */ @@ -861,9 +892,11 @@ html::-webkit-scrollbar-thumb:hover { /* Also increase navbar thickness on medium screens */ @media (max-width: 1024px) { .dashboard-content { - padding-top: calc(60px + var(--spacing-8)) !important; /* Mobile navbar height (60px) + spacing matching gap between tabs and content */ + padding-top: calc( + 60px + var(--spacing-8) + ) !important; /* Mobile navbar height (60px) + spacing matching gap between tabs and content */ } - + .header-content { padding: 8px 24px; /* Padding to make navbar ~60px total (button 44px + 8px top + 8px bottom = 60px) */ min-height: 60px; /* Ensure minimum height */ @@ -872,7 +905,7 @@ html::-webkit-scrollbar-thumb:hover { justify-content: center; /* Center content horizontally */ position: relative; /* For absolute positioning of logo and button */ } - + .logo-section { margin-left: 12px; /* Push logo away from left edge (same distance as hamburger from right) */ position: absolute; @@ -880,7 +913,7 @@ html::-webkit-scrollbar-thumb:hover { display: flex; align-items: center; } - + /* Center the h1 text in the navbar */ .logo-section h1 { position: fixed; @@ -892,27 +925,27 @@ html::-webkit-scrollbar-thumb:hover { z-index: 1; font-size: var(--font-size-xl); } - + /* Keep logo icon visible but position it on the left */ .logo-section .logo-icon { display: block; } - + .user-info { position: absolute; right: 0; } - + /* Push hamburger button away from right edge */ .mobile-menu-toggle { margin-right: 12px; } - + /* Adjust mobile menu position - lower it with space so top border is visible */ .mobile-menu { top: 68px !important; /* Lower than navbar height (60px) with 8px gap to show top border */ } - + /* Adjust backdrop position */ .mobile-menu-backdrop::after { top: 60px; /* Match the new navbar height */ @@ -932,7 +965,6 @@ html::-webkit-scrollbar-thumb:hover { text-transform: uppercase; letter-spacing: var(--font-letter-spacing-wider); font-weight: var(--font-weight-medium); - } .username { @@ -956,19 +988,31 @@ html::-webkit-scrollbar-thumb:hover { } .role-badge.role-admin { - background: linear-gradient(135deg, var(--color-semantic-error-500) 0%, var(--color-semantic-error-600) 100%); + background: linear-gradient( + 135deg, + var(--color-semantic-error-500) 0%, + var(--color-semantic-error-600) 100% + ); color: var(--color-neutral-white); box-shadow: var(--shadow-portal-role-error); } .role-badge.role-read-write { - background: linear-gradient(135deg, var(--color-semantic-success-500) 0%, var(--color-semantic-success-600) 100%); + background: linear-gradient( + 135deg, + var(--color-semantic-success-500) 0%, + var(--color-semantic-success-600) 100% + ); color: var(--color-neutral-white); box-shadow: 0 2px 12px var(--color-semantic-success-alpha-30); } .role-badge.role-read-only { - background: linear-gradient(135deg, var(--color-semantic-info-500) 0%, var(--color-semantic-info-600) 100%); + background: linear-gradient( + 135deg, + var(--color-semantic-info-500) 0%, + var(--color-semantic-info-600) 100% + ); color: var(--color-neutral-white); box-shadow: var(--shadow-portal-role-info); } @@ -994,7 +1038,8 @@ html::-webkit-scrollbar-thumb:hover { position: absolute; inset: -1px; border-radius: 8px; - background: linear-gradient(90deg, + background: linear-gradient( + 90deg, var(--color-neutral-white-alpha-30) 0%, var(--color-neutral-white-alpha-30) 10%, var(--color-neutral-white-alpha-25) 25%, @@ -1004,11 +1049,11 @@ html::-webkit-scrollbar-thumb:hover { var(--color-neutral-white-alpha-5) 85%, transparent 100% ); - mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); - -webkit-mask: - linear-gradient(var(--color-neutral-white) 0 0) content-box, + -webkit-mask: + linear-gradient(var(--color-neutral-white) 0 0) content-box, linear-gradient(var(--color-neutral-white) 0 0); mask-composite: exclude; -webkit-mask-composite: destination-out; @@ -1025,7 +1070,12 @@ html::-webkit-scrollbar-thumb:hover { left: -100%; width: 100%; height: 100%; - background: linear-gradient(90deg, transparent, var(--color-semantic-error-alpha-20), transparent); + background: linear-gradient( + 90deg, + transparent, + var(--color-semantic-error-alpha-20), + transparent + ); transition: left 0.5s ease; } @@ -1049,7 +1099,9 @@ html::-webkit-scrollbar-thumb:hover { max-width: 1600px; margin: 0 auto; padding: var(--spacing-10); - padding-top: calc(80px + var(--spacing-8)); /* Navbar height + spacing matching gap between tabs and content */ + padding-top: calc( + 80px + var(--spacing-8) + ); /* Navbar height + spacing matching gap between tabs and content */ position: relative; z-index: 1; } @@ -1066,7 +1118,7 @@ html::-webkit-scrollbar-thumb:hover { padding: var(--spacing-2); border-radius: var(--border-radius-xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-15); - box-shadow: + box-shadow: 0 4px 24px var(--color-neutral-black-alpha-30), inset 0 1px 0 var(--color-neutral-white-alpha-5); position: relative; @@ -1257,10 +1309,12 @@ html::-webkit-scrollbar-thumb:hover { backdrop-filter: blur(var(--filter-blur-40)) saturate(180%); -webkit-backdrop-filter: blur(var(--filter-blur-40)) saturate(180%); padding: var(--spacing-10); - padding-bottom: calc(var(--spacing-10) + var(--spacing-8)); /* Extra bottom spacing */ + padding-bottom: calc( + var(--spacing-10) + var(--spacing-8) + ); /* Extra bottom spacing */ border-radius: var(--border-radius-2xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); - box-shadow: + box-shadow: 0 8px 32px var(--color-neutral-black-alpha-40), inset 0 1px 0 var(--color-neutral-white-alpha-5); min-height: 500px; @@ -1296,7 +1350,6 @@ html::-webkit-scrollbar-thumb:hover { position: relative; display: inline-block; text-align: center; - } .section-title h2::after { @@ -1322,7 +1375,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .section-subtitle { +[data-theme='light'] .section-subtitle { color: var(--color-text-secondary); } @@ -1369,7 +1422,7 @@ html::-webkit-scrollbar-thumb:hover { cursor: pointer; font-size: var(--font-size-sm) !important; font-weight: var(--font-weight-semibold); - + transition: all var(--duration-normal) var(--easing-ease-out); display: flex; align-items: center; @@ -1435,11 +1488,11 @@ html::-webkit-scrollbar-thumb:hover { } /* Auto-activate text - darker color in light mode */ -[data-theme="light"] .auto-activate-toggle { +[data-theme='light'] .auto-activate-toggle { color: var(--color-text-primary) !important; /* Darker color, not white */ } -.auto-activate-toggle input[type="checkbox"] { +.auto-activate-toggle input[type='checkbox'] { cursor: pointer; accent-color: var(--color-brand-primary-500); } @@ -1474,7 +1527,6 @@ html::-webkit-scrollbar-thumb:hover { color: var(--color-brand-primary-300); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); - } /* Stats Grid - Minimal Futuristic - Centered */ @@ -1497,7 +1549,7 @@ html::-webkit-scrollbar-thumb:hover { .stats-grid { grid-template-columns: repeat(2, 1fr); } - + .stats-grid .stat-card:nth-child(3) { grid-column: 1 / -1; justify-self: center; @@ -1512,7 +1564,7 @@ html::-webkit-scrollbar-thumb:hover { padding: var(--spacing-6); border-radius: var(--border-radius-2xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); - box-shadow: + box-shadow: 0 8px 32px var(--color-neutral-black-alpha-40), inset 0 1px 0 var(--color-neutral-white-alpha-5); transition: all var(--duration-slow) var(--easing-ease-out); @@ -1535,7 +1587,7 @@ html::-webkit-scrollbar-thumb:hover { .stat-card:hover { transform: translateY(-4px); border-color: var(--color-brand-primary-alpha-40); - box-shadow: + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-40), 0 0 0 1px var(--color-brand-primary-alpha-30), inset 0 1px 0 var(--color-neutral-white-alpha-10); @@ -1592,7 +1644,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .stat-content h3 { +[data-theme='light'] .stat-content h3 { color: var(--color-text-primary); font-weight: var(--font-weight-light); } @@ -1607,7 +1659,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .stat-value { +[data-theme='light'] .stat-value { color: var(--color-text-primary); } @@ -1627,7 +1679,7 @@ html::-webkit-scrollbar-thumb:hover { position: relative; z-index: 2; color: var(--color-neutral-white); - box-shadow: + box-shadow: 0 4px 16px var(--color-neutral-black-alpha-20), inset 0 1px 0 var(--color-neutral-white-alpha-5); transition: all var(--duration-slow) var(--easing-ease-out); @@ -1647,11 +1699,10 @@ html::-webkit-scrollbar-thumb:hover { text-align: center; } - .welcome-card:hover { transform: translateY(-4px); border-color: var(--color-brand-primary-alpha-40); - box-shadow: + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-40), 0 0 0 1px var(--color-brand-primary-alpha-30), inset 0 1px 0 var(--color-neutral-white-alpha-10); @@ -1669,12 +1720,12 @@ html::-webkit-scrollbar-thumb:hover { } /* Keep rocket icon green in light mode */ -[data-theme="light"] .welcome-icon { +[data-theme='light'] .welcome-icon { color: var(--color-brand-primary-400) !important; } /* Ensure rocket icon SVG stays green in light mode */ -[data-theme="light"] .welcome-icon svg { +[data-theme='light'] .welcome-icon svg { color: var(--color-brand-primary-400) !important; } @@ -1721,7 +1772,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .welcome-card h3 { +[data-theme='light'] .welcome-card h3 { color: var(--color-text-primary); font-weight: var(--font-weight-regular); } @@ -1735,7 +1786,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .welcome-card p { +[data-theme='light'] .welcome-card p { color: var(--color-text-primary); } @@ -1858,7 +1909,7 @@ html::-webkit-scrollbar-thumb:hover { color: var(--color-text-tertiary); } -[data-theme="light"] .last-activity-card { +[data-theme='light'] .last-activity-card { border-color: var(--color-brand-primary-alpha-30); } @@ -1878,7 +1929,7 @@ html::-webkit-scrollbar-thumb:hover { padding: var(--spacing-8); border-radius: var(--border-radius-2xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); - box-shadow: + box-shadow: 0 4px 16px var(--color-neutral-black-alpha-20), inset 0 1px 0 var(--color-neutral-white-alpha-5); transition: all var(--duration-slow) var(--easing-ease-out); @@ -1906,7 +1957,7 @@ html::-webkit-scrollbar-thumb:hover { .bundle-card:hover { transform: translateY(-4px); border-color: var(--color-brand-primary-alpha-40); - box-shadow: + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-40), 0 0 0 1px var(--color-brand-primary-alpha-30), inset 0 1px 0 var(--color-neutral-white-alpha-10); @@ -1957,7 +2008,6 @@ html::-webkit-scrollbar-thumb:hover { font-weight: var(--font-weight-medium); letter-spacing: -0.3px; text-align: center; - } .bundle-meta { @@ -1987,7 +2037,6 @@ html::-webkit-scrollbar-thumb:hover { border-radius: var(--border-radius-md) !important; border: none !important; box-shadow: none !important; - } .bundle-action-btn:hover:not(:disabled) { @@ -2021,7 +2070,6 @@ html::-webkit-scrollbar-thumb:hover { border: none !important; color: var(--color-neutral-white); border-radius: var(--border-radius-md) !important; - } .view-manifest-btn:hover:not(:disabled) { @@ -2031,20 +2079,24 @@ html::-webkit-scrollbar-thumb:hover { } /* View Current Manifest button - same color as border, green hover in light mode */ -[data-theme="light"] .view-manifest-btn { +[data-theme='light'] .view-manifest-btn { color: var(--color-neutral-600) !important; /* Same color as border */ background: transparent !important; } -[data-theme="light"] .view-manifest-btn:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ - color: var(--color-neutral-white) !important; /* White text and icon on hover */ +[data-theme='light'] .view-manifest-btn:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ + color: var( + --color-neutral-white + ) !important; /* White text and icon on hover */ transform: translateY(-1px); } /* View Current Manifest button icon - white on hover */ -[data-theme="light"] .view-manifest-btn:hover:not(:disabled) svg, -[data-theme="light"] .view-manifest-btn:hover:not(:disabled) svg * { +[data-theme='light'] .view-manifest-btn:hover:not(:disabled) svg, +[data-theme='light'] .view-manifest-btn:hover:not(:disabled) svg * { color: var(--color-neutral-white) !important; } @@ -2128,7 +2180,7 @@ html::-webkit-scrollbar-thumb:hover { -webkit-backdrop-filter: blur(var(--filter-blur-40)) saturate(180%); border-radius: var(--border-radius-2xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); - box-shadow: + box-shadow: 0 4px 16px var(--color-neutral-black-alpha-15), inset 0 1px 0 var(--color-neutral-white-alpha-5); overflow: hidden; @@ -2150,7 +2202,8 @@ html::-webkit-scrollbar-thumb:hover { width: 100%; max-width: 100% !important; border-radius: var(--border-radius-2xl) var(--border-radius-2xl) 0 0; - border-bottom: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); + border-bottom: var(--border-width-thin) solid + var(--color-brand-primary-alpha-20); background: transparent; box-sizing: border-box; } @@ -2211,20 +2264,20 @@ html::-webkit-scrollbar-thumb:hover { background: var(--color-semantic-ui-input-fill-dark) !important; } -.users-table-container .search-input input[style*="border"], -.users-table-container .ode-input input[style*="border"] { +.users-table-container .search-input input[style*='border'], +.users-table-container .ode-input input[style*='border'] { border-color: var(--color-brand-primary-alpha-20) !important; border-width: var(--border-width-thin) !important; border-bottom: none !important; border-radius: var(--border-radius-2xl) var(--border-radius-2xl) 0 0 !important; } -.users-table-container .search-input input[style*="border"]:focus, -.users-table-container .ode-input input[style*="border"]:focus, -.users-table-container .search-input input[style*="border"]:focus-visible, -.users-table-container .ode-input input[style*="border"]:focus-visible, -.users-table-container .search-input:focus-within input[style*="border"], -.users-table-container .ode-input:focus-within input[style*="border"] { +.users-table-container .search-input input[style*='border']:focus, +.users-table-container .ode-input input[style*='border']:focus, +.users-table-container .search-input input[style*='border']:focus-visible, +.users-table-container .ode-input input[style*='border']:focus-visible, +.users-table-container .search-input:focus-within input[style*='border'], +.users-table-container .ode-input:focus-within input[style*='border'] { border: var(--border-width-medium) solid var(--color-brand-primary-500) !important; border-bottom: none !important; border-radius: var(--border-radius-2xl) var(--border-radius-2xl) 0 0 !important; @@ -2313,7 +2366,8 @@ html::-webkit-scrollbar-thumb:hover { /* Firefox scrollbar */ .users-table-container { scrollbar-width: thin; - scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) var(--color-semantic-ui-scrollbar-track); + scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) + var(--color-semantic-ui-scrollbar-track); } .users-table { @@ -2335,7 +2389,6 @@ html::-webkit-scrollbar-thumb:hover { font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; - } .users-table th.actions-column { @@ -2396,7 +2449,7 @@ html::-webkit-scrollbar-thumb:hover { font-size: 14px; } -[data-theme="light"] .user-name { +[data-theme='light'] .user-name { color: var(--color-neutral-900) !important; } @@ -2405,7 +2458,7 @@ html::-webkit-scrollbar-thumb:hover { font-size: var(--font-size-xs); } -[data-theme="light"] .created-date { +[data-theme='light'] .created-date { color: var(--color-neutral-600) !important; } @@ -2426,7 +2479,7 @@ html::-webkit-scrollbar-thumb:hover { cursor: pointer; transition: all var(--duration-normal) var(--easing-ease-out); font-size: var(--font-size-xs); - + font-weight: var(--font-weight-medium); display: flex; align-items: center; @@ -2473,9 +2526,7 @@ html::-webkit-scrollbar-thumb:hover { transform: translateY(-1px); } -.view-btn - -.view-btn:hover:not(:disabled) { +.view-btn .view-btn:hover:not(:disabled) { background: var(--color-semantic-info-alpha-20); border-color: var(--color-semantic-info-500); } @@ -2535,7 +2586,8 @@ html::-webkit-scrollbar-thumb:hover { /* Firefox scrollbar */ .table-container { scrollbar-width: thin; - scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) var(--color-semantic-ui-scrollbar-track); + scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) + var(--color-semantic-ui-scrollbar-track); } /* System Info - inherits styles from tab-content */ @@ -2564,7 +2616,7 @@ html::-webkit-scrollbar-thumb:hover { padding: var(--spacing-8); border-radius: var(--border-radius-2xl); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-20); - box-shadow: + box-shadow: 0 8px 32px var(--color-neutral-black-alpha-40), inset 0 1px 0 var(--color-neutral-white-alpha-5); display: flex; @@ -2585,20 +2637,23 @@ html::-webkit-scrollbar-thumb:hover { .info-card:hover { transform: translateY(-4px); border-color: var(--color-brand-primary-alpha-40); - box-shadow: + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-40), 0 0 0 1px var(--color-brand-primary-alpha-30), inset 0 1px 0 var(--color-neutral-white-alpha-10); } /* Info card hover - same as stat card hover in light mode */ -[data-theme="light"] .info-card:hover { +[data-theme='light'] .info-card:hover { transform: translateY(-4px); - border-color: var(--color-brand-primary-alpha-60); /* Darker border for light mode */ - box-shadow: + border-color: var( + --color-brand-primary-alpha-60 + ); /* Darker border for light mode */ + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-20), - 0 0 0 1px var(--color-brand-primary-alpha-50), /* Darker border highlight */ - inset 0 1px 0 var(--color-neutral-black-alpha-5); + 0 0 0 1px var(--color-brand-primary-alpha-50), + /* Darker border highlight */ inset 0 1px 0 + var(--color-neutral-black-alpha-5); } .info-icon { @@ -2620,12 +2675,12 @@ html::-webkit-scrollbar-thumb:hover { transform: scale(1.1) rotate(5deg); } -[data-theme="light"] .info-icon { +[data-theme='light'] .info-icon { color: var(--color-brand-primary-500); filter: drop-shadow(var(--filter-drop-shadow-brand-sm)); } -[data-theme="light"] .info-card:hover .info-icon { +[data-theme='light'] .info-card:hover .info-icon { filter: drop-shadow(var(--filter-drop-shadow-brand-md)); } @@ -2648,7 +2703,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .info-content h3 { +[data-theme='light'] .info-content h3 { color: var(--color-text-secondary); font-weight: var(--font-weight-regular); } @@ -2687,8 +2742,12 @@ html::-webkit-scrollbar-thumb:hover { } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .modal-content { @@ -2704,7 +2763,8 @@ html::-webkit-scrollbar-thumb:hover { overflow-y: auto; animation: slideUpModal var(--duration-normal) var(--easing-ease-out); scrollbar-width: thin; - scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) var(--color-semantic-ui-scrollbar-track); + scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) + var(--color-semantic-ui-scrollbar-track); } .modal-content::-webkit-scrollbar { @@ -2795,17 +2855,37 @@ html::-webkit-scrollbar-thumb:hover { transform: none; } -[data-theme="light"] .modal-create-user .modal-actions button:hover:not(:disabled) { +[data-theme='light'] + .modal-create-user + .modal-actions + button:hover:not(:disabled) { background: var(--color-brand-primary-500) !important; color: var(--color-neutral-white) !important; border-color: var(--color-brand-primary-500) !important; transform: translateY(-1px); } -[data-theme="light"] .modal-create-user .modal-actions button:hover:not(:disabled) span, -[data-theme="light"] .modal-create-user .modal-actions button:hover:not(:disabled) *, -[data-theme="light"] .modal-create-user .modal-actions button:hover:not(:disabled) svg, -[data-theme="light"] .modal-create-user .modal-actions button:hover:not(:disabled) svg * { +[data-theme='light'] + .modal-create-user + .modal-actions + button:hover:not(:disabled) + span, +[data-theme='light'] + .modal-create-user + .modal-actions + button:hover:not(:disabled) + *, +[data-theme='light'] + .modal-create-user + .modal-actions + button:hover:not(:disabled) + svg, +[data-theme='light'] + .modal-create-user + .modal-actions + button:hover:not(:disabled) + svg + * { color: var(--color-neutral-white) !important; } @@ -2821,7 +2901,8 @@ html::-webkit-scrollbar-thumb:hover { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; - scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) var(--color-semantic-ui-scrollbar-track); + scrollbar-color: var(--color-semantic-ui-scrollbar-thumb) + var(--color-semantic-ui-scrollbar-track); animation: slideUpRoleMenu var(--duration-normal) var(--easing-ease-out); } @@ -2882,7 +2963,8 @@ html::-webkit-scrollbar-thumb:hover { justify-content: space-between; align-items: center; padding: var(--spacing-6); - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-10); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-10); } .modal-header h2 { @@ -2914,11 +2996,11 @@ html::-webkit-scrollbar-thumb:hover { transform: rotate(90deg); } -[data-theme="light"] .modal-close { +[data-theme='light'] .modal-close { color: var(--color-text-primary); } -[data-theme="light"] .modal-close:hover { +[data-theme='light'] .modal-close:hover { color: var(--color-neutral-900); background: var(--color-neutral-black-alpha-10); transform: rotate(90deg); @@ -2936,7 +3018,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .modal-body p { +[data-theme='light'] .modal-body p { color: var(--color-text-primary); } @@ -2950,7 +3032,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .modal-body strong { +[data-theme='light'] .modal-body strong { color: var(--color-text-primary); } @@ -2995,7 +3077,10 @@ html::-webkit-scrollbar-thumb:hover { display: flex; align-items: center; justify-content: space-between; - transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; + transition: + background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease; transition: all var(--duration-normal) var(--easing-ease-out); min-height: var(--touch-target-comfortable); box-shadow: var(--shadow-portal-xs); @@ -3011,7 +3096,7 @@ html::-webkit-scrollbar-thumb:hover { outline: none; border-color: var(--color-brand-primary-500); background: var(--color-semantic-ui-surface-dropdown-dark); - box-shadow: + box-shadow: 0 0 0 3px var(--color-brand-primary-alpha-20), 0 4px 12px var(--color-neutral-black-alpha-30); } @@ -3042,7 +3127,7 @@ html::-webkit-scrollbar-thumb:hover { -webkit-backdrop-filter: blur(var(--filter-blur-20)); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-30); border-radius: var(--border-radius-md); - box-shadow: + box-shadow: 0 8px 32px var(--color-neutral-black-alpha-50), 0 4px 16px var(--color-neutral-black-alpha-30); z-index: 1000; @@ -3066,7 +3151,8 @@ html::-webkit-scrollbar-thumb:hover { padding: var(--spacing-4) var(--spacing-5); background: transparent; border: none; - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-5); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-5); color: var(--color-neutral-white); cursor: pointer; display: flex; @@ -3109,11 +3195,11 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .role-name { +[data-theme='light'] .role-name { color: var(--color-text-primary) !important; } -[data-theme="light"] .role-description { +[data-theme='light'] .role-description { color: var(--color-text-secondary) !important; } @@ -3131,17 +3217,17 @@ html::-webkit-scrollbar-thumb:hover { font-size: var(--font-size-base) !important; } -[data-theme="light"] .modal-input input { +[data-theme='light'] .modal-input input { background: var(--color-background-card) !important; border-color: var(--color-border-default) !important; color: var(--color-text-primary) !important; } -[data-theme="light"] .modal-input input::placeholder { +[data-theme='light'] .modal-input input::placeholder { color: var(--color-text-tertiary) !important; } -[data-theme="light"] .modal-input input:focus { +[data-theme='light'] .modal-input input:focus { background: var(--color-background-elevated) !important; border-color: var(--color-brand-primary-500) !important; } @@ -3153,7 +3239,7 @@ html::-webkit-scrollbar-thumb:hover { .modal-input input:focus { border-color: var(--color-brand-primary-500) !important; background: var(--color-semantic-ui-surface-dropdown-dark) !important; - box-shadow: + box-shadow: 0 0 0 3px var(--color-brand-primary-alpha-20), 0 4px 12px var(--color-neutral-black-alpha-30) !important; outline: none !important; @@ -3171,7 +3257,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .modal-input label { +[data-theme='light'] .modal-input label { color: var(--color-text-primary) !important; } @@ -3191,12 +3277,20 @@ html::-webkit-scrollbar-thumb:hover { } .delete-confirm-btn { - background: linear-gradient(135deg, var(--color-semantic-error-500) 0%, var(--color-semantic-error-600) 100%); + background: linear-gradient( + 135deg, + var(--color-semantic-error-500) 0%, + var(--color-semantic-error-600) 100% + ); color: var(--color-neutral-white); } .activate-confirm-btn { - background: linear-gradient(135deg, var(--color-semantic-success-500) 0%, var(--color-semantic-success-600) 100%); + background: linear-gradient( + 135deg, + var(--color-semantic-success-500) 0%, + var(--color-semantic-success-600) 100% + ); color: var(--color-neutral-white); } @@ -3211,7 +3305,8 @@ html::-webkit-scrollbar-thumb:hover { justify-content: space-between; align-items: center; padding: var(--spacing-3) 0; - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-5); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-5); } .info-row:last-child { @@ -3258,7 +3353,8 @@ html::-webkit-scrollbar-thumb:hover { .files-table thead { background: var(--color-brand-primary-alpha-10); - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-10); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-10); } .files-table th { @@ -3276,7 +3372,8 @@ html::-webkit-scrollbar-thumb:hover { } .files-table tbody tr { - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-5); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-5); } .files-table tbody tr:last-child { @@ -3340,7 +3437,8 @@ html::-webkit-scrollbar-thumb:hover { color: rgba(255, 255, 255, var(--opacity-80)); font-size: var(--font-size-sm); font-family: var(--font-family-mono); - border-bottom: var(--border-width-thin) solid var(--color-neutral-white-alpha-5); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-5); } .changes-list li:last-child { @@ -3383,7 +3481,9 @@ html::-webkit-scrollbar-thumb:hover { } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .empty-icon { @@ -3484,7 +3584,6 @@ html::-webkit-scrollbar-thumb:hover { min-height: auto !important; border-radius: var(--border-radius-md) !important; border: none !important; - } /* Responsive adjustments */ @@ -3497,13 +3596,19 @@ html::-webkit-scrollbar-thumb:hover { .dashboard-content { padding: var(--spacing-6); - padding-top: calc(60px + var(--spacing-8)); /* Mobile navbar height (60px) + spacing matching gap between tabs and content */ + padding-top: calc( + 60px + var(--spacing-8) + ); /* Mobile navbar height (60px) + spacing matching gap between tabs and content */ } .tab-content { padding: var(--spacing-6); - padding-bottom: calc(var(--spacing-6) + var(--spacing-8)); /* Extra bottom spacing on mobile */ - margin-bottom: var(--spacing-8); /* Additional margin from bottom edge on mobile */ + padding-bottom: calc( + var(--spacing-6) + var(--spacing-8) + ); /* Extra bottom spacing on mobile */ + margin-bottom: var( + --spacing-8 + ); /* Additional margin from bottom edge on mobile */ } .dashboard-tabs { @@ -3575,7 +3680,7 @@ html::-webkit-scrollbar-thumb:hover { } /* User search empty heading - darker color in light mode */ -[data-theme="light"] .user-search-empty h3 { +[data-theme='light'] .user-search-empty h3 { color: var(--color-text-primary) !important; /* Darker color in light mode */ } @@ -3586,7 +3691,7 @@ html::-webkit-scrollbar-thumb:hover { } /* User search empty text - darker color in light mode */ -[data-theme="light"] .user-search-empty p { +[data-theme='light'] .user-search-empty p { color: var(--color-text-primary) !important; /* Darker color in light mode */ } @@ -3607,7 +3712,7 @@ html::-webkit-scrollbar-thumb:hover { transition: color 0.3s ease; } -[data-theme="light"] .system-info-title { +[data-theme='light'] .system-info-title { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } @@ -3620,7 +3725,7 @@ html::-webkit-scrollbar-thumb:hover { font-weight: var(--font-weight-semibold); } -[data-theme="light"] .health-status-title { +[data-theme='light'] .health-status-title { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } @@ -3646,7 +3751,7 @@ html::-webkit-scrollbar-thumb:hover { } /* Cancel / Close buttons in modals (except Create User) – match modal close (X): same color and hover */ -.modal-content:not(.modal-create-user) .modal-actions button[type="button"], +.modal-content:not(.modal-create-user) .modal-actions button[type='button'], .modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral { padding: var(--spacing-3) var(--spacing-6) !important; background: transparent !important; @@ -3658,35 +3763,67 @@ html::-webkit-scrollbar-thumb:hover { transition: all var(--duration-fast) var(--easing-ease-out) !important; } -.modal-content:not(.modal-create-user) .modal-actions button[type="button"]:hover:not(:disabled), -.modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral:hover:not(:disabled) { +.modal-content:not(.modal-create-user) + .modal-actions + button[type='button']:hover:not(:disabled), +.modal-content:not(.modal-create-user) + .modal-actions + .ode-button--neutral:hover:not(:disabled) { color: var(--color-neutral-white) !important; background: var(--color-brand-primary-500) !important; border-color: var(--color-brand-primary-500) !important; transform: translateY(-1px) !important; } -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions button[type="button"], -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral { +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + button[type='button'], +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + .ode-button--neutral { color: var(--color-text-primary) !important; } -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions button[type="button"]:hover:not(:disabled), -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral:hover:not(:disabled) { +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + button[type='button']:hover:not(:disabled), +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + .ode-button--neutral:hover:not(:disabled) { color: var(--color-neutral-white) !important; background: var(--color-brand-primary-500) !important; border-color: var(--color-brand-primary-500) !important; } -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions button[type="button"]:hover:not(:disabled) span, -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions button[type="button"]:hover:not(:disabled) *, -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral:hover:not(:disabled) span, -[data-theme="light"] .modal-content:not(.modal-create-user) .modal-actions .ode-button--neutral:hover:not(:disabled) * { +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + button[type='button']:hover:not(:disabled) + span, +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + button[type='button']:hover:not(:disabled) + *, +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + .ode-button--neutral:hover:not(:disabled) + span, +[data-theme='light'] + .modal-content:not(.modal-create-user) + .modal-actions + .ode-button--neutral:hover:not(:disabled) + * { color: var(--color-neutral-white) !important; } /* Create User modal – restore original button styles (visible background and border); hover stays green for both via rules below */ -.modal-create-user .modal-actions button[type="button"], +.modal-create-user .modal-actions button[type='button'], .modal-create-user .modal-actions .ode-button--neutral { padding: var(--spacing-2) var(--spacing-4) !important; background: var(--color-neutral-white-alpha-5) !important; @@ -3698,8 +3835,8 @@ html::-webkit-scrollbar-thumb:hover { transition: all var(--duration-normal) var(--easing-ease-out) !important; } -[data-theme="light"] .modal-create-user .modal-actions button[type="button"], -[data-theme="light"] .modal-create-user .modal-actions .ode-button--neutral { +[data-theme='light'] .modal-create-user .modal-actions button[type='button'], +[data-theme='light'] .modal-create-user .modal-actions .ode-button--neutral { background: var(--color-neutral-black-alpha-5) !important; border-color: var(--color-border-default) !important; color: var(--color-text-primary) !important; @@ -3733,164 +3870,166 @@ html::-webkit-scrollbar-thumb:hover { LIGHT THEME OVERRIDES ============================================ */ -[data-theme="light"] .dashboard { +[data-theme='light'] .dashboard { background-color: var(--color-background-base); } -[data-theme="light"] .dashboard-header { +[data-theme='light'] .dashboard-header { background: var(--color-background-elevated); border-bottom-color: var(--color-border-default); } -[data-theme="light"] .welcome-text, -[data-theme="light"] .section-subtitle, -[data-theme="light"] .empty-state p, -[data-theme="light"] .loading-state p, -[data-theme="light"] .info-content p, -[data-theme="light"] .stat-label, -[data-theme="light"] .bundle-info p, -[data-theme="light"] .user-info p, -[data-theme="light"] .geolocation-info, -[data-theme="light"] .system-info-text, -[data-theme="light"] .health-status-text, -[data-theme="light"] .modal-body p, -[data-theme="light"] .form-group label, -[data-theme="light"] .search-input::placeholder, -[data-theme="light"] td, -[data-theme="light"] .bundle-version, -[data-theme="light"] .bundle-date { +[data-theme='light'] .welcome-text, +[data-theme='light'] .section-subtitle, +[data-theme='light'] .empty-state p, +[data-theme='light'] .loading-state p, +[data-theme='light'] .info-content p, +[data-theme='light'] .stat-label, +[data-theme='light'] .bundle-info p, +[data-theme='light'] .user-info p, +[data-theme='light'] .geolocation-info, +[data-theme='light'] .system-info-text, +[data-theme='light'] .health-status-text, +[data-theme='light'] .modal-body p, +[data-theme='light'] .form-group label, +[data-theme='light'] .search-input::placeholder, +[data-theme='light'] td, +[data-theme='light'] .bundle-version, +[data-theme='light'] .bundle-date { color: var(--color-text-secondary); } /* Keep table action buttons (Delete, Reset Password) with dark mode colors in light mode */ -[data-theme="light"] .table-action-btn { +[data-theme='light'] .table-action-btn { color: var(--color-neutral-white) !important; } -[data-theme="light"] .delete-btn { +[data-theme='light'] .delete-btn { color: var(--color-neutral-white) !important; } -[data-theme="light"] .reset-password-btn { +[data-theme='light'] .reset-password-btn { color: var(--color-neutral-white) !important; } -[data-theme="light"] .section-title h2, -[data-theme="light"] .welcome-card h3, -[data-theme="light"] .stat-content h3, -[data-theme="light"] .bundle-info h3, -[data-theme="light"] .empty-state h3, -[data-theme="light"] .info-content h3, -[data-theme="light"] .user-info h3, -[data-theme="light"] .health-status-section h3, -[data-theme="light"] .user-search-empty h3, -[data-theme="light"] .modal-header h2, -[data-theme="light"] .files-list h3, -[data-theme="light"] .files-table th, -[data-theme="light"] .changes-section h3, -[data-theme="light"] .users-table th, -[data-theme="light"] .bundle-version-label, -[data-theme="light"] .bundle-date-label { +[data-theme='light'] .section-title h2, +[data-theme='light'] .welcome-card h3, +[data-theme='light'] .stat-content h3, +[data-theme='light'] .bundle-info h3, +[data-theme='light'] .empty-state h3, +[data-theme='light'] .info-content h3, +[data-theme='light'] .user-info h3, +[data-theme='light'] .health-status-section h3, +[data-theme='light'] .user-search-empty h3, +[data-theme='light'] .modal-header h2, +[data-theme='light'] .files-list h3, +[data-theme='light'] .files-table th, +[data-theme='light'] .changes-section h3, +[data-theme='light'] .users-table th, +[data-theme='light'] .bundle-version-label, +[data-theme='light'] .bundle-date-label { color: var(--color-text-primary); } -[data-theme="light"] .section-card, -[data-theme="light"] .tab-content, -[data-theme="light"] .search-bar, -[data-theme="light"] .role-dropdown-menu { +[data-theme='light'] .section-card, +[data-theme='light'] .tab-content, +[data-theme='light'] .search-bar, +[data-theme='light'] .role-dropdown-menu { background: var(--color-background-card); border-color: var(--color-border-default); } /* Modal content – solid background and green border in light mode */ -[data-theme="light"] .modal-content { +[data-theme='light'] .modal-content { background: var(--color-neutral-white); border: var(--border-width-medium) solid var(--color-brand-primary-500); } /* Different shades for containers in light mode - similar to dark mode hierarchy */ -[data-theme="light"] .welcome-card { +[data-theme='light'] .welcome-card { background: var(--color-brand-primary-alpha-8); border-color: var(--color-brand-primary-alpha-20); } -[data-theme="light"] .stat-card { +[data-theme='light'] .stat-card { background: var(--color-neutral-black-alpha-5); border-color: var(--color-border-default); } /* First three stat cards (System Status, User Role, Username) - white but slightly transparent in light mode (like dark mode transparency) */ -[data-theme="light"] .stat-card:nth-child(1) { +[data-theme='light'] .stat-card:nth-child(1) { background: var(--color-semantic-ui-surface-dropdown-light); } -[data-theme="light"] .stat-card:nth-child(2) { +[data-theme='light'] .stat-card:nth-child(2) { background: var(--color-semantic-ui-surface-dropdown-light); } -[data-theme="light"] .stat-card:nth-child(3) { +[data-theme='light'] .stat-card:nth-child(3) { background: var(--color-semantic-ui-surface-dropdown-light); } /* All four cards (three stat cards + welcome card) - same hover effect with darker border highlight in light mode */ -[data-theme="light"] .stat-card:hover, -[data-theme="light"] .welcome-card:hover { +[data-theme='light'] .stat-card:hover, +[data-theme='light'] .welcome-card:hover { transform: translateY(-4px); - border-color: var(--color-brand-primary-alpha-60); /* Darker border for light mode */ - box-shadow: + border-color: var( + --color-brand-primary-alpha-60 + ); /* Darker border for light mode */ + box-shadow: 0 12px 40px var(--color-neutral-black-alpha-20), - 0 0 0 1px var(--color-brand-primary-alpha-50), /* Darker border highlight */ - inset 0 1px 0 var(--color-neutral-black-alpha-5); + 0 0 0 1px var(--color-brand-primary-alpha-50), + /* Darker border highlight */ inset 0 1px 0 + var(--color-neutral-black-alpha-5); } -[data-theme="light"] .bundle-card { +[data-theme='light'] .bundle-card { background: var(--color-neutral-black-alpha-5); border-color: var(--color-border-default); } -[data-theme="light"] .bundle-card:nth-child(odd) { +[data-theme='light'] .bundle-card:nth-child(odd) { background: var(--color-brand-primary-alpha-5); } - /* Section container backgrounds - different shades in light mode to create depth */ -[data-theme="light"] .app-bundles-section { +[data-theme='light'] .app-bundles-section { background: var(--color-brand-primary-alpha-3); border-radius: var(--border-radius-2xl); padding: var(--spacing-6); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-10); } -[data-theme="light"] .app-bundles-section .bundles-grid { +[data-theme='light'] .app-bundles-section .bundles-grid { background: var(--color-neutral-black-alpha-5); padding: var(--spacing-6); border-radius: var(--border-radius-lg); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-8); } -[data-theme="light"] .users-section { +[data-theme='light'] .users-section { background: var(--color-brand-primary-alpha-3); border-radius: var(--border-radius-2xl); padding: var(--spacing-6); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-10); } -[data-theme="light"] .users-table-container { +[data-theme='light'] .users-table-container { background: var(--color-neutral-black-alpha-5); border-color: var(--color-brand-primary-alpha-15); border: var(--border-width-thin) solid var(--color-brand-primary-alpha-15); box-shadow: var(--shadow-portal-dropdown) !important; } -[data-theme="light"] .users-table-container .search-bar { +[data-theme='light'] .users-table-container .search-bar { border-bottom-color: var(--color-border-default); } -[data-theme="light"] .users-table-section { +[data-theme='light'] .users-table-section { border-top-color: var(--color-border-default); } -[data-theme="light"] .users-table-container .table-container { +[data-theme='light'] .users-table-container .table-container { border-color: var(--color-border-default); background: var(--color-background-card); } @@ -3901,7 +4040,7 @@ html::-webkit-scrollbar-thumb:hover { display: none; } -[data-theme="light"] .system-section { +[data-theme='light'] .system-section { background: var(--color-brand-primary-alpha-3); border-radius: var(--border-radius-2xl); padding: var(--spacing-6); @@ -3909,458 +4048,518 @@ html::-webkit-scrollbar-thumb:hover { } /* Info cards - same white/light grey background as stat cards in light mode */ -[data-theme="light"] .info-card { - background: var(--color-semantic-ui-surface-dropdown-light); /* White but slightly transparent, matching stat cards */ +[data-theme='light'] .info-card { + background: var( + --color-semantic-ui-surface-dropdown-light + ); /* White but slightly transparent, matching stat cards */ border-color: var(--color-border-default); } -[data-theme="light"] .section-card, -[data-theme="light"] .welcome-card, -[data-theme="light"] .stat-card, -[data-theme="light"] .bundle-card, -[data-theme="light"] .info-card, -[data-theme="light"] .modal-content { - box-shadow: +[data-theme='light'] .section-card, +[data-theme='light'] .welcome-card, +[data-theme='light'] .stat-card, +[data-theme='light'] .bundle-card, +[data-theme='light'] .info-card, +[data-theme='light'] .modal-content { + box-shadow: 0 2px 8px var(--color-neutral-black-alpha-8), inset 0 1px 0 var(--color-neutral-black-alpha-3); } -[data-theme="light"] input[type="text"], -[data-theme="light"] input[type="password"], -[data-theme="light"] input[type="email"], -[data-theme="light"] textarea, -[data-theme="light"] select, -[data-theme="light"] .search-input input, -[data-theme="light"] .form-group input, -[data-theme="light"] .form-group textarea, -[data-theme="light"] .form-group select, -[data-theme="light"] .role-select-button { +[data-theme='light'] input[type='text'], +[data-theme='light'] input[type='password'], +[data-theme='light'] input[type='email'], +[data-theme='light'] textarea, +[data-theme='light'] select, +[data-theme='light'] .search-input input, +[data-theme='light'] .form-group input, +[data-theme='light'] .form-group textarea, +[data-theme='light'] .form-group select, +[data-theme='light'] .role-select-button { background: var(--color-background-card); border-color: var(--color-border-default); color: var(--color-text-primary); } -[data-theme="light"] input:focus, -[data-theme="light"] textarea:focus, -[data-theme="light"] select:focus, -[data-theme="light"] .search-input input:focus, -[data-theme="light"] .form-group input:focus { +[data-theme='light'] input:focus, +[data-theme='light'] textarea:focus, +[data-theme='light'] select:focus, +[data-theme='light'] .search-input input:focus, +[data-theme='light'] .form-group input:focus { border-color: var(--color-brand-primary-500); background: var(--color-background-elevated); } -[data-theme="light"] .users-table { +[data-theme='light'] .users-table { background: var(--color-background-elevated); } -[data-theme="light"] .users-table th { +[data-theme='light'] .users-table th { background: var(--color-background-overlay); border-bottom-color: var(--color-border-default); color: var(--color-neutral-900) !important; } -[data-theme="light"] .users-table td { +[data-theme='light'] .users-table td { border-bottom-color: var(--color-border-default); color: var(--color-neutral-800) !important; } -[data-theme="light"] .users-table tr:hover { +[data-theme='light'] .users-table tr:hover { background: var(--color-background-card); } -[data-theme="light"] .modal-overlay { +[data-theme='light'] .modal-overlay { background: var(--color-neutral-black-alpha-50); } -[data-theme="light"] .logout-button { +[data-theme='light'] .logout-button { background: var(--color-semantic-error-alpha-15); border-color: var(--color-semantic-error-alpha-30); - color: var(--color-semantic-error-600, #DC2626); + color: var(--color-semantic-error-600, #dc2626); } -[data-theme="light"] .logout-button:hover { +[data-theme='light'] .logout-button:hover { background: var(--color-semantic-error-alpha-15) !important; border: var(--border-width-thin) solid var(--color-semantic-error-500) !important; } -[data-theme="light"] .logout-button:hover::after { +[data-theme='light'] .logout-button:hover::after { opacity: 0; } /* Tab buttons keep dark mode styles in light mode - exact same colors */ /* Navigation links container - slightly transparent white background in light mode only */ -[data-theme="light"] .dashboard-tabs { +[data-theme='light'] .dashboard-tabs { background: var(--color-neutral-white-alpha-90) !important; } -[data-theme="light"] .dashboard-tabs button.tab-button { - color: var(--color-brand-primary-500) !important; /* Green text - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button { + color: var( + --color-brand-primary-500 + ) !important; /* Green text - same as dark mode */ background: transparent !important; /* Same as dark mode */ } /* Icons - green color in inactive state (same as dark mode) */ -[data-theme="light"] .dashboard-tabs button.tab-button .tab-icon { - color: var(--color-brand-primary-500) !important; /* Green icon - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button .tab-icon { + color: var( + --color-brand-primary-500 + ) !important; /* Green icon - same as dark mode */ } /* Hover styles - white text and white icon (same as dark mode) */ -[data-theme="light"] .dashboard-tabs button.tab-button:hover { - color: var(--color-neutral-white) !important; /* White text on hover - same as dark mode */ - background: var(--color-brand-primary-500) !important; /* Green background on hover - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button:hover { + color: var( + --color-neutral-white + ) !important; /* White text on hover - same as dark mode */ + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover - same as dark mode */ /* Removed transform: translateY(-1px) - buttons stay in position */ } -[data-theme="light"] .dashboard-tabs button.tab-button:hover .tab-icon { - color: var(--color-neutral-white) !important; /* White icon on hover - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button:hover .tab-icon { + color: var( + --color-neutral-white + ) !important; /* White icon on hover - same as dark mode */ } -[data-theme="light"] .dashboard-tabs button.tab-button:hover svg.border-fade { +[data-theme='light'] .dashboard-tabs button.tab-button:hover svg.border-fade { opacity: 0 !important; /* Hide border on hover - same as dark mode */ } /* Active styles - white text and white icon (same as dark mode) */ -[data-theme="light"] .dashboard-tabs button.tab-button.active { - color: var(--color-neutral-white) !important; /* White text when active - same as dark mode */ - background: var(--color-brand-primary-500) !important; /* Green background when active - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button.active { + color: var( + --color-neutral-white + ) !important; /* White text when active - same as dark mode */ + background: var( + --color-brand-primary-500 + ) !important; /* Green background when active - same as dark mode */ /* Removed transform: translateY(-1px) - buttons stay in position */ - font-weight: var(--font-weight-bold) !important; /* Same font weight as dark mode */ + font-weight: var( + --font-weight-bold + ) !important; /* Same font weight as dark mode */ } -[data-theme="light"] .dashboard-tabs button.tab-button.active .tab-icon { - color: var(--color-neutral-white) !important; /* White icon when active - same as dark mode */ +[data-theme='light'] .dashboard-tabs button.tab-button.active .tab-icon { + color: var( + --color-neutral-white + ) !important; /* White icon when active - same as dark mode */ } -[data-theme="light"] .dashboard-tabs button.tab-button.active svg.border-fade { +[data-theme='light'] .dashboard-tabs button.tab-button.active svg.border-fade { opacity: 0 !important; /* Hide border when active - same as dark mode */ } /* Border styles - keep exact same as dark mode (SVG border fade) */ -[data-theme="light"] .dashboard-tabs button.tab-button svg.border-fade { +[data-theme='light'] .dashboard-tabs button.tab-button svg.border-fade { opacity: 1 !important; /* Same opacity as dark mode */ } /* Border colors and styles remain the same as dark mode - no changes needed for svg.border-fade rect */ /* Buttons keep dark mode styles in light mode - explicit overrides to preserve dark mode */ -[data-theme="light"] .upload-button, -[data-theme="light"] .create-button, -[data-theme="light"] .export-button { +[data-theme='light'] .upload-button, +[data-theme='light'] .create-button, +[data-theme='light'] .export-button { color: var(--color-brand-primary-500) !important; background: transparent !important; } /* Upload Bundle button - green icon in inactive state */ -[data-theme="light"] .upload-button svg, -[data-theme="light"] .upload-button svg * { - color: var(--color-brand-primary-500) !important; /* Green icon when inactive */ -} - -[data-theme="light"] .upload-button:hover:not(:disabled):not(.uploading), -[data-theme="light"] .create-button:hover:not(:disabled), -[data-theme="light"] .export-button:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .upload-button svg, +[data-theme='light'] .upload-button svg * { + color: var( + --color-brand-primary-500 + ) !important; /* Green icon when inactive */ +} + +[data-theme='light'] .upload-button:hover:not(:disabled):not(.uploading), +[data-theme='light'] .create-button:hover:not(:disabled), +[data-theme='light'] .export-button:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ } /* Upload Bundle button - white icon on hover */ -[data-theme="light"] .upload-button:hover:not(:disabled):not(.uploading) svg, -[data-theme="light"] .upload-button:hover:not(:disabled):not(.uploading) svg * { +[data-theme='light'] .upload-button:hover:not(:disabled):not(.uploading) svg, +[data-theme='light'] .upload-button:hover:not(:disabled):not(.uploading) svg * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* App Bundles page - light mode only */ -[data-theme="light"] .app-bundles-section .refresh-button { - color: var(--color-neutral-600) !important; /* Keep current inactive state - grey text */ +[data-theme='light'] .app-bundles-section .refresh-button { + color: var( + --color-neutral-600 + ) !important; /* Keep current inactive state - grey text */ background: transparent !important; } /* Refresh button icon - grey in inactive state */ -[data-theme="light"] .app-bundles-section .refresh-button svg, -[data-theme="light"] .app-bundles-section .refresh-button svg * { +[data-theme='light'] .app-bundles-section .refresh-button svg, +[data-theme='light'] .app-bundles-section .refresh-button svg * { color: var(--color-neutral-600) !important; /* Grey icon when inactive */ } -[data-theme="light"] .app-bundles-section .refresh-button:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .app-bundles-section .refresh-button:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ } /* Refresh button icon - white on hover */ -[data-theme="light"] .app-bundles-section .refresh-button:hover:not(:disabled) svg, -[data-theme="light"] .app-bundles-section .refresh-button:hover:not(:disabled) svg * { +[data-theme='light'] + .app-bundles-section + .refresh-button:hover:not(:disabled) + svg, +[data-theme='light'] + .app-bundles-section + .refresh-button:hover:not(:disabled) + svg + * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* Other pages refresh button - keep existing style (more specific selector) */ /* Users page - light mode only */ -[data-theme="light"] .users-section .refresh-button { +[data-theme='light'] .users-section .refresh-button { color: var(--color-neutral-600) !important; /* Same color as border */ background: transparent !important; } /* Refresh button icon - keep as is (don't change icon color) */ -[data-theme="light"] .users-section .refresh-button:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .users-section .refresh-button:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ } /* Refresh button icon - white on hover */ -[data-theme="light"] .users-section .refresh-button:hover:not(:disabled) svg, -[data-theme="light"] .users-section .refresh-button:hover:not(:disabled) svg * { +[data-theme='light'] .users-section .refresh-button:hover:not(:disabled) svg, +[data-theme='light'] .users-section .refresh-button:hover:not(:disabled) svg * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* Create User button - Users page only, light mode */ -[data-theme="light"] .users-section .create-button { +[data-theme='light'] .users-section .create-button { color: var(--color-brand-primary-500) !important; /* Green text */ background: transparent !important; } /* Create User button icon - border color (green) in non-hover state */ -[data-theme="light"] .users-section .create-button svg, -[data-theme="light"] .users-section .create-button svg * { +[data-theme='light'] .users-section .create-button svg, +[data-theme='light'] .users-section .create-button svg * { color: var(--color-brand-primary-500) !important; /* Green, same as border */ } -[data-theme="light"] .users-section .create-button:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .users-section .create-button:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ } /* Create User button icon - white on hover */ -[data-theme="light"] .users-section .create-button:hover:not(:disabled) svg, -[data-theme="light"] .users-section .create-button:hover:not(:disabled) svg * { +[data-theme='light'] .users-section .create-button:hover:not(:disabled) svg, +[data-theme='light'] .users-section .create-button:hover:not(:disabled) svg * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* System page - light mode only */ -[data-theme="light"] .system-section .refresh-button { +[data-theme='light'] .system-section .refresh-button { color: var(--color-neutral-600) !important; /* Same color as border */ background: transparent !important; } /* Refresh button icon - keep as is (don't change icon color) */ -[data-theme="light"] .system-section .refresh-button:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .system-section .refresh-button:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ } /* Refresh button icon - white on hover */ -[data-theme="light"] .system-section .refresh-button:hover:not(:disabled) svg, -[data-theme="light"] .system-section .refresh-button:hover:not(:disabled) svg * { +[data-theme='light'] .system-section .refresh-button:hover:not(:disabled) svg, +[data-theme='light'] + .system-section + .refresh-button:hover:not(:disabled) + svg + * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* Delete button - Users page only, light mode */ -[data-theme="light"] .users-section .delete-btn { +[data-theme='light'] .users-section .delete-btn { color: var(--color-neutral-600) !important; /* Same color as border */ } /* User Management Delete – same hover as navbar Logout (light) */ -[data-theme="light"] .users-section .delete-btn:hover:not(:disabled) { +[data-theme='light'] .users-section .delete-btn:hover:not(:disabled) { background: var(--color-semantic-error-alpha-15) !important; border: var(--border-width-thin) solid var(--color-semantic-error-500) !important; color: var(--color-semantic-error-600) !important; transform: translateY(-1px); } -[data-theme="light"] .users-section .delete-btn:hover:not(:disabled) svg, -[data-theme="light"] .users-section .delete-btn:hover:not(:disabled) svg * { +[data-theme='light'] .users-section .delete-btn:hover:not(:disabled) svg, +[data-theme='light'] .users-section .delete-btn:hover:not(:disabled) svg * { color: var(--color-semantic-error-600) !important; } /* Other pages delete button - keep existing style */ -[data-theme="light"] .delete-btn:not(.users-section .delete-btn) { +[data-theme='light'] .delete-btn:not(.users-section .delete-btn) { color: var(--color-neutral-white) !important; } -[data-theme="light"] .delete-btn:not(.users-section .delete-btn):hover:not(:disabled) { +[data-theme='light'] + .delete-btn:not(.users-section .delete-btn):hover:not(:disabled) { background: var(--color-semantic-error-alpha-30) !important; border-color: var(--color-semantic-error-500) !important; color: var(--color-semantic-error-500) !important; } /* Reset Password button - Users page only, light mode */ -[data-theme="light"] .users-section .reset-password-btn { +[data-theme='light'] .users-section .reset-password-btn { color: var(--color-neutral-600) !important; border-color: var(--color-brand-primary-alpha-20) !important; } /* Reset Password button icon - keep as is (don't change icon color) */ -[data-theme="light"] .users-section .reset-password-btn:hover:not(:disabled) { - background: var(--color-brand-primary-500) !important; /* Green background on hover */ +[data-theme='light'] .users-section .reset-password-btn:hover:not(:disabled) { + background: var( + --color-brand-primary-500 + ) !important; /* Green background on hover */ color: var(--color-neutral-white) !important; /* White text on hover */ border-color: var(--color-brand-primary-500) !important; } /* Reset Password button icon - white on hover */ -[data-theme="light"] .users-section .reset-password-btn:hover:not(:disabled) svg, -[data-theme="light"] .users-section .reset-password-btn:hover:not(:disabled) svg * { +[data-theme='light'] + .users-section + .reset-password-btn:hover:not(:disabled) + svg, +[data-theme='light'] + .users-section + .reset-password-btn:hover:not(:disabled) + svg + * { color: var(--color-neutral-white) !important; /* White icon on hover */ } /* Other pages reset password button - keep existing style */ -[data-theme="light"] .reset-password-btn:not(.users-section .reset-password-btn) { +[data-theme='light'] + .reset-password-btn:not(.users-section .reset-password-btn) { color: var(--color-neutral-white) !important; border-color: var(--color-brand-primary-alpha-20) !important; } -[data-theme="light"] .reset-password-btn:not(.users-section .reset-password-btn):hover:not(:disabled) { +[data-theme='light'] + .reset-password-btn:not(.users-section .reset-password-btn):hover:not( + :disabled + ) { background: var(--color-brand-primary-alpha-20) !important; border-color: var(--color-brand-primary-500) !important; color: var(--color-brand-primary-300) !important; box-shadow: 0 0 8px var(--color-brand-primary-alpha-30) !important; } -[data-theme="light"] .empty-state, -[data-theme="light"] .loading-state { +[data-theme='light'] .empty-state, +[data-theme='light'] .loading-state { color: var(--color-text-secondary); } -[data-theme="light"] .empty-icon { +[data-theme='light'] .empty-icon { color: var(--color-text-tertiary); } -[data-theme="light"] .error-message { - background: var(--color-semantic-error-50, #FEF2F2); +[data-theme='light'] .error-message { + background: var(--color-semantic-error-50, #fef2f2); border-color: var(--color-semantic-error-alpha-30); - color: var(--color-semantic-error-600, #DC2626); + color: var(--color-semantic-error-600, #dc2626); } -[data-theme="light"] .success-message { - background: var(--color-semantic-success-50, #F0F9F0); +[data-theme='light'] .success-message { + background: var(--color-semantic-success-50, #f0f9f0); border-color: var(--color-semantic-success-alpha-30); - color: var(--color-semantic-success-600, #2E7D32); + color: var(--color-semantic-success-600, #2e7d32); } -[data-theme="light"] .role-dropdown-menu { +[data-theme='light'] .role-dropdown-menu { background: var(--color-background-elevated); border-color: var(--color-border-default); - box-shadow: + box-shadow: 0 8px 32px var(--color-neutral-black-alpha-10), inset 0 1px 0 var(--color-neutral-black-alpha-3); } -[data-theme="light"] .role-option { +[data-theme='light'] .role-option { color: var(--color-text-primary) !important; } -[data-theme="light"] .role-option:hover { +[data-theme='light'] .role-option:hover { background: var(--color-background-card) !important; } -[data-theme="light"] .role-option.selected { +[data-theme='light'] .role-option.selected { background: var(--color-brand-primary-alpha-8) !important; border-color: var(--color-brand-primary-alpha-20) !important; } -[data-theme="light"] .role-option .role-name { +[data-theme='light'] .role-option .role-name { color: var(--color-text-primary) !important; } -[data-theme="light"] .role-option .role-description { +[data-theme='light'] .role-option .role-description { color: var(--color-text-secondary) !important; } -[data-theme="light"] .role-select-button { +[data-theme='light'] .role-select-button { background: var(--color-background-card); border-color: var(--color-border-default); color: var(--color-text-primary); } -[data-theme="light"] .role-select-button:hover { +[data-theme='light'] .role-select-button:hover { background: var(--color-background-elevated); border-color: var(--color-border-hover); } -[data-theme="light"] .users-table-container .search-input input, -[data-theme="light"] .users-table-container .ode-input input { +[data-theme='light'] .users-table-container .search-input input, +[data-theme='light'] .users-table-container .ode-input input { background: var(--color-neutral-white) !important; border-color: var(--color-border-default) !important; color: var(--color-text-primary) !important; } -[data-theme="light"] .users-table-container .search-input input:focus, -[data-theme="light"] .users-table-container .ode-input input:focus { +[data-theme='light'] .users-table-container .search-input input:focus, +[data-theme='light'] .users-table-container .ode-input input:focus { background: var(--color-neutral-white) !important; border-color: var(--color-brand-primary-500) !important; } -[data-theme="light"] .users-table-container .search-input input::placeholder, -[data-theme="light"] .users-table-container .ode-input input::placeholder { +[data-theme='light'] .users-table-container .search-input input::placeholder, +[data-theme='light'] .users-table-container .ode-input input::placeholder { color: var(--color-text-tertiary) !important; } -[data-theme="light"] .search-clear-icon, -[data-theme="light"] .search-icon-button, -[data-theme="light"] .users-table-container .search-clear-icon, -[data-theme="light"] .users-table-container .search-icon-button { +[data-theme='light'] .search-clear-icon, +[data-theme='light'] .search-icon-button, +[data-theme='light'] .users-table-container .search-clear-icon, +[data-theme='light'] .users-table-container .search-icon-button { color: var(--color-text-secondary); } -[data-theme="light"] .search-clear-icon:hover, -[data-theme="light"] .search-icon-button:hover, -[data-theme="light"] .users-table-container .search-clear-icon:hover, -[data-theme="light"] .users-table-container .search-icon-button:hover { +[data-theme='light'] .search-clear-icon:hover, +[data-theme='light'] .search-icon-button:hover, +[data-theme='light'] .users-table-container .search-clear-icon:hover, +[data-theme='light'] .users-table-container .search-icon-button:hover { color: var(--color-text-primary); } /* Ensure all icons are dark in light mode */ /* Tab icons keep dark mode styles - no light mode overrides */ -[data-theme="light"] .info-icon, -[data-theme="light"] .empty-icon, -[data-theme="light"] .stat-icon { +[data-theme='light'] .info-icon, +[data-theme='light'] .empty-icon, +[data-theme='light'] .stat-icon { color: var(--color-text-secondary); } /* Tab buttons keep dark mode styles - no light mode overrides for colors/hover */ -[data-theme="light"] .info-icon { +[data-theme='light'] .info-icon { color: var(--color-brand-primary-500); } /* Additional light mode text and icon overrides */ -[data-theme="light"] .modal-input label, -[data-theme="light"] .form-group label, -[data-theme="light"] .ode-input label { +[data-theme='light'] .modal-input label, +[data-theme='light'] .form-group label, +[data-theme='light'] .ode-input label { color: var(--color-text-primary) !important; } -[data-theme="light"] .modal-body, -[data-theme="light"] .modal-content p, -[data-theme="light"] .modal-content span:not(.role-name):not(.role-description) { +[data-theme='light'] .modal-body, +[data-theme='light'] .modal-content p, +[data-theme='light'] + .modal-content + span:not(.role-name):not(.role-description) { color: var(--color-text-primary); } -[data-theme="light"] .role-option { +[data-theme='light'] .role-option { border-bottom-color: var(--color-border-default) !important; } -[data-theme="light"] .role-option:hover { +[data-theme='light'] .role-option:hover { background: var(--color-background-card) !important; } -[data-theme="light"] .role-option.selected { +[data-theme='light'] .role-option.selected { background: var(--color-brand-primary-alpha-8) !important; border-left-color: var(--color-brand-primary-500) !important; } /* Ensure all SVG icons inherit proper colors in light mode */ /* Exclude tab icons from general SVG color override */ -[data-theme="light"] svg:not(.theme-switcher-icon):not(.theme-option-icon):not(.tab-icon):not(.dashboard-tabs svg):not(.welcome-icon svg) { +[data-theme='light'] + svg:not(.theme-switcher-icon):not(.theme-option-icon):not(.tab-icon):not( + .dashboard-tabs svg + ):not(.welcome-icon svg) { color: var(--color-text-secondary); } /* Tab buttons keep dark mode styles - icons stay as dark mode */ -[data-theme="light"] .info-icon svg { +[data-theme='light'] .info-icon svg { color: var(--color-brand-primary-500); } -[data-theme="light"] .info-card:nth-child(even) .info-icon svg { +[data-theme='light'] .info-card:nth-child(even) .info-icon svg { color: var(--color-brand-primary-500); } - - - - From 408108b0a385caee9e70fb702d87a07620c35ee8 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 17 Apr 2026 17:40:13 +0200 Subject: [PATCH 8/8] fix: stunning --- desktop/src-tauri/src/lib.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0c1352ead..109f53892 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -179,12 +179,7 @@ fn resolve_attachment_path(workspace: &Path, basename: &str) -> Option root.join(basename), root.join(ATTACH_LEGACY_PENDING_UPLOAD).join(basename), ]; - for p in candidates { - if p.is_file() { - return Some(p); - } - } - None + candidates.into_iter().find(|p| p.is_file()) } fn attachment_path_synced(workspace: &Path, basename: &str) -> PathBuf { @@ -1359,7 +1354,7 @@ fn list_workspace_items( is_dir: ty.is_dir(), }); } - items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + items.sort_by_key(|a| a.name.to_lowercase()); Ok(items) } @@ -1895,7 +1890,7 @@ fn write_workspace_file( if rel.is_empty() { return Err("relative path is required".to_string()); } - for part in rel.split(|c| c == '/' || c == '\\') { + for part in rel.split(['/', '\\']) { if part.is_empty() || part == "." || part == ".." { return Err("invalid relative path".to_string()); }