From 6d097601fe89f7e028129c6f5ea22edeeedf4657 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 09:09:15 +0200 Subject: [PATCH 01/45] fix(formulus): Limiting keyboard re-popping up during text editing --- ...seKeyboardScrollClamp.integration.test.tsx | 29 +++++++- .../src/hooks/useKeyboardScrollClamp.ts | 68 +++++++++++++------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx index 709c07174..26494453e 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx @@ -7,6 +7,30 @@ import * as keyboardScroll from '../utils/keyboardScroll'; afterEach(() => cleanup()); describe('FormLayout keyboard scroll integration', () => { + it('does not re-reveal on visualViewport scroll after keyboard session ends', async () => { + const revealSpy = vi.spyOn(keyboardScroll, 'revealFieldIfNeeded'); + + render( + +
+ +
+
, + ); + + const input = screen.getByTestId('field'); + + fireEvent.focusIn(input, { bubbles: true }); + await new Promise(resolve => setTimeout(resolve, 150)); + revealSpy.mockClear(); + + window.visualViewport?.dispatchEvent(new Event('scroll')); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(revealSpy).not.toHaveBeenCalled(); + revealSpy.mockRestore(); + }); + it('does not re-reveal on input or layout resize while focused', async () => { const revealSpy = vi.spyOn(keyboardScroll, 'revealFieldIfNeeded'); @@ -22,13 +46,14 @@ describe('FormLayout keyboard scroll integration', () => { const input = screen.getByTestId('field'); fireEvent.focusIn(input, { bubbles: true }); - await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, 250)); revealSpy.mockClear(); fireEvent.input(input, { target: { value: '5' }, bubbles: true }); scrollArea.appendChild(document.createElement('div')); + window.visualViewport?.dispatchEvent(new Event('resize')); - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); expect(revealSpy).not.toHaveBeenCalled(); revealSpy.mockRestore(); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 32ab90c64..65dc9a5b3 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef } from 'react'; -import { clampScrollTop, revealFieldIfNeeded } from '../utils/keyboardScroll'; +import { + clampScrollTop, + isFieldObscuredInContainer, + revealFieldIfNeeded, +} from '../utils/keyboardScroll'; /** Input types that should trigger scroll clamp on value change. */ export function isClampableInputType(type: string | undefined): boolean { @@ -35,27 +39,44 @@ const KEYBOARD_REVEAL_DELAY_MS = 100; /** * Clamps FormLayout scroll when the IME opens and reveals focused fields only - * when obscured after keyboard animation — never scrollIntoView on value change. + * during the initial keyboard-open window — never on value change or caret moves. */ export function useKeyboardScrollClamp() { const scrollRef = useRef(null); const focusedFieldRef = useRef(null); const revealTimerRef = useRef | null>(null); + /** True from focus until the IME settle window ends; blocks re-reveal while typing. */ + const keyboardRevealSessionRef = useRef(false); const clamp = useCallback(() => { const el = scrollRef.current; if (el) clampScrollTop(el); }, []); + const endKeyboardRevealSession = useCallback(() => { + keyboardRevealSessionRef.current = false; + }, []); + const tryRevealFocused = useCallback(() => { const container = scrollRef.current; const field = focusedFieldRef.current; if (!container || !field || !container.contains(field)) return; + revealFieldIfNeeded(container, field, { marginBottom: 24, marginTop: 8 }); clamp(); - }, [clamp]); + + const containerRect = container.getBoundingClientRect(); + const fieldRect = field.getBoundingClientRect(); + if ( + !isFieldObscuredInContainer(containerRect, fieldRect, 8, 24) + ) { + endKeyboardRevealSession(); + } + }, [clamp, endKeyboardRevealSession]); const scheduleReveal = useCallback(() => { + if (!keyboardRevealSessionRef.current) return; + if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null; @@ -66,11 +87,12 @@ export function useKeyboardScrollClamp() { tryRevealFocused(); revealTimerRef.current = setTimeout(() => { tryRevealFocused(); + endKeyboardRevealSession(); revealTimerRef.current = null; }, KEYBOARD_REVEAL_DELAY_MS); }); }); - }, [tryRevealFocused]); + }, [endKeyboardRevealSession, tryRevealFocused]); const runClampChain = useCallback(() => { requestAnimationFrame(() => { @@ -85,20 +107,26 @@ export function useKeyboardScrollClamp() { const vv = window.visualViewport; - const onViewportChange = () => { - if (focusedFieldRef.current) { + const onViewportResize = () => { + if (focusedFieldRef.current && keyboardRevealSessionRef.current) { scheduleReveal(); } else { - requestAnimationFrame(clamp); + runClampChain(); } }; + // Caret moves while typing fire visualViewport scroll on Android WebView — clamp only. + const onViewportScroll = () => { + runClampChain(); + }; + const onFocusIn = (event: FocusEvent) => { const target = event.target; if (!isFormFieldForScrollClamp(target)) return; if (!(target instanceof HTMLElement)) return; focusedFieldRef.current = target; + keyboardRevealSessionRef.current = true; scheduleReveal(); }; @@ -118,6 +146,7 @@ export function useKeyboardScrollClamp() { if (focusedFieldRef.current === target) { focusedFieldRef.current = null; } + endKeyboardRevealSession(); if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null; @@ -125,11 +154,6 @@ export function useKeyboardScrollClamp() { runClampChain(); }; - const onInputOrChange = (event: Event) => { - if (!isFormFieldForScrollClamp(event.target)) return; - runClampChain(); - }; - // Clamp only on content resize (e.g. value change re-render). Re-revealing here // caused a scroll gap above the keyboard on first keystroke in number fields. const resizeObserver = @@ -141,26 +165,28 @@ export function useKeyboardScrollClamp() { resizeObserver?.observe(el); - vv?.addEventListener('resize', onViewportChange); - vv?.addEventListener('scroll', onViewportChange); + vv?.addEventListener('resize', onViewportResize); + vv?.addEventListener('scroll', onViewportScroll); el.addEventListener('focusin', onFocusIn); el.addEventListener('focusout', onFocusOut); - el.addEventListener('input', onInputOrChange, true); - el.addEventListener('change', onInputOrChange, true); return () => { resizeObserver?.disconnect(); - vv?.removeEventListener('resize', onViewportChange); - vv?.removeEventListener('scroll', onViewportChange); + vv?.removeEventListener('resize', onViewportResize); + vv?.removeEventListener('scroll', onViewportScroll); el.removeEventListener('focusin', onFocusIn); el.removeEventListener('focusout', onFocusOut); - el.removeEventListener('input', onInputOrChange, true); - el.removeEventListener('change', onInputOrChange, true); if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); } }; - }, [clamp, runClampChain, scheduleReveal, tryRevealFocused]); + }, [ + clamp, + endKeyboardRevealSession, + runClampChain, + scheduleReveal, + tryRevealFocused, + ]); return scrollRef; } From 774cd1c82308f182f387280261ab270d6da9d7f5 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 09:57:59 +0200 Subject: [PATCH 02/45] feat(desktop): improved bundle handling, stability --- desktop/AGENTS.md | 10 +- desktop/docs/UI_FEEDBACK.md | 8 +- desktop/src-tauri/Cargo.lock | 18 +- desktop/src-tauri/Cargo.toml | 3 +- desktop/src-tauri/src/lib.rs | 566 ++++++++++++++++-- desktop/src-tauri/src/observation_index.rs | 15 +- desktop/src/App.css | 15 + desktop/src/App.test.tsx | 2 +- desktop/src/App.tsx | 95 ++- desktop/src/lib/autoSynkAuth.ts | 40 +- desktop/src/lib/bundleTauriEvents.test.ts | 43 ++ desktop/src/lib/bundleTauriEvents.ts | 49 ++ desktop/src/lib/profileDraftDirty.test.ts | 71 +++ desktop/src/lib/profileDraftDirty.ts | 48 ++ desktop/src/lib/synkAuthErrors.test.ts | 20 + desktop/src/lib/synkAuthErrors.ts | 9 + desktop/src/lib/tauriClient.ts | 26 +- desktop/src/pages/ProfilesPage.tsx | 187 ++++-- desktop/src/pages/WorkbenchBundlesPage.tsx | 367 +++++++++--- desktop/src/store/useCustodianStore.ts | 41 ++ .../store/useProfileDraftGuardStore.test.ts | 49 ++ .../src/store/useProfileDraftGuardStore.ts | 39 ++ desktop/src/types/domain.ts | 23 + 23 files changed, 1492 insertions(+), 252 deletions(-) create mode 100644 desktop/src/lib/bundleTauriEvents.test.ts create mode 100644 desktop/src/lib/bundleTauriEvents.ts create mode 100644 desktop/src/lib/profileDraftDirty.test.ts create mode 100644 desktop/src/lib/profileDraftDirty.ts create mode 100644 desktop/src/lib/synkAuthErrors.test.ts create mode 100644 desktop/src/lib/synkAuthErrors.ts create mode 100644 desktop/src/store/useProfileDraftGuardStore.test.ts create mode 100644 desktop/src/store/useProfileDraftGuardStore.ts diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 039a5e07f..9498708cb 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -41,7 +41,9 @@ Persisted per profile via `upsertProfileRemote` / Rust `ServerProfile`. | Off | `bundles/active/app/` | `bundles/active/forms/` | | On | `bundles/dev-local/app/` (mirror) | `bundles/dev-local/forms/` (mirror if `/forms` exists) | -Synk downloads and **Refresh from server** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified. +Synk downloads and **Download & apply** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified. + +**Bundle download:** `download_and_apply_app_bundle` (TS: `tauriClient.downloadAndApplyAppBundle()`) — native Rust HTTP download; no zip bytes over IPC. Progress events: `bundle/apply-progress`, `bundle/index-rebuild`. Observation index rebuild runs in the background after apply. ### UI @@ -64,13 +66,15 @@ On success, Zustand bumps `devMirrorGeneration` so embeds and form lists reload. - `src/hooks/useDeveloperMode.ts` — profile read/write, refresh, generation counter from store. - `src/components/DeveloperModePanel.tsx` — full vs banner UI; auto-mirror `useEffect` only on `variant="full"`. - `src/lib/bundleLayout.ts` — `bundleSegment()`, `bundleFormsRel()`. -- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`. +- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`, `bundleActivity`. +- `src/lib/bundleTauriEvents.ts` — bundle apply progress listeners. ### Key Rust - `profile_developer_mode`, `bundle_segment`, `bundle_form_roots_for_ctx` - Dev-aware: `list_active_bundle_forms`, `read_bundle_form_spec`, `get_active_bundle_forms_file_base_url`, `scan_bundle_custom_question_types`, `bundle_app_config_path` -- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree` +- `download_and_apply_app_bundle` — streamed Synkronus zip download + apply (never pass large binaries through WebView IPC as `number[]`) +- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree`, `apply_app_bundle_zip_at_workspace_writes_state_and_active` ### Errors diff --git a/desktop/docs/UI_FEEDBACK.md b/desktop/docs/UI_FEEDBACK.md index 92ccb7b52..91dba17b1 100644 --- a/desktop/docs/UI_FEEDBACK.md +++ b/desktop/docs/UI_FEEDBACK.md @@ -4,11 +4,13 @@ Single source of truth for how the shell surfaces status, errors, and confirmati ## Progress banner (full-width) -**Use for:** long-running sync and import jobs only. +**Use for:** long-running sync, import, and **app bundle download/apply** jobs. - Renders below the mode switch / dev bar in `Shell`. -- Shows spinner + status text; dismissible but reappears on next activity. -- Do not use for quick actions (save, auth, bundle download). +- Shows spinner + status text; optional determinate progress bar when `bundleActivity.total > 0`. +- Dismissible but reappears on next activity. +- Bundle apply: Rust emits `bundle/apply-progress` and `bundle/index-rebuild`; `bundleActivity` in `useCustodianStore` drives the banner. +- Do not use for quick actions (save, auth, dev mirror refresh). ## Toasts (bottom-right stack) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 93b92c6ba..aca5128db 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2603,6 +2603,7 @@ name = "odedesktop" version = "1.1.1" dependencies = [ "chrono", + "futures-util", "keyring", "rayon", "reqwest 0.12.28", @@ -3456,12 +3457,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3496,7 +3499,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -5299,6 +5302,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 60fbc37b9..7a6e011c9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -25,7 +25,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } rusqlite = { version = "0.32", features = ["bundled", "chrono", "serde_json"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "stream"] } +futures-util = "0.3" rayon = "1" uuid = { version = "1", features = ["v4", "serde"] } thiserror = "2" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c0abc3a63..2564af7f5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -6,9 +6,11 @@ use std::{ path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, sync::{Arc, Mutex}, - time::{Instant, UNIX_EPOCH}, + time::{Duration, Instant, UNIX_EPOCH}, }; +use futures_util::StreamExt; + use chrono::{DateTime, Utc}; use keyring::Entry; use rayon::prelude::*; @@ -67,6 +69,26 @@ pub struct AppBundleState { pub archived_versions: Vec, } +/// Emitted on `bundle/apply-progress` and `bundle/index-rebuild`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BundleApplyProgressEvent { + job_id: String, + phase: String, + done: i64, + total: i64, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DownloadAndApplyAppBundleResult { + state: AppBundleState, + index_rebuild_scheduled: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ActiveBundleFormEntry { @@ -1183,10 +1205,76 @@ fn sanitize_version_for_filename(version: &str) -> String { } } +fn format_byte_progress_mb(done: i64, total: i64) -> String { + const MB: f64 = 1024.0 * 1024.0; + if total > 0 { + format!("{:.1} / {:.1} MB", done as f64 / MB, total as f64 / MB) + } else { + format!("{:.1} MB", done as f64 / MB) + } +} + +fn emit_bundle_apply_progress( + app: &tauri::AppHandle, + job_id: &str, + phase: &str, + done: i64, + total: i64, + message: &str, + detail: Option<&str>, +) { + let _ = app.emit( + "bundle/apply-progress", + BundleApplyProgressEvent { + job_id: job_id.to_string(), + phase: phase.to_string(), + done, + total, + message: message.to_string(), + detail: detail.map(|s| s.to_string()), + }, + ); +} + +fn emit_bundle_index_rebuild_progress( + app: &tauri::AppHandle, + job_id: &str, + phase: &str, + done: i64, + total: i64, + message: &str, + detail: Option<&str>, +) { + let _ = app.emit( + "bundle/index-rebuild", + BundleApplyProgressEvent { + job_id: job_id.to_string(), + phase: phase.to_string(), + done, + total, + message: message.to_string(), + detail: detail.map(|s| s.to_string()), + }, + ); +} + +#[allow(dead_code)] fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianError> { + extract_zip_to_dir_with_progress(zip_bytes, dest, None) +} + +fn extract_zip_to_dir_with_progress( + zip_bytes: &[u8], + dest: &Path, + mut on_progress: Option<&mut dyn FnMut(i64, i64, Option<&str>)>, +) -> Result<(), CustodianError> { let reader = Cursor::new(zip_bytes); let mut archive = ZipArchive::new(reader) .map_err(|e| CustodianError::Message(format!("invalid zip: {}", e)))?; + let total = archive.len() as i64; + if let Some(ref mut cb) = on_progress { + cb(0, total, None); + } for i in 0..archive.len() { let mut file = archive .by_index(i) @@ -1195,7 +1283,7 @@ fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianErro Some(p) => p.to_owned(), None => continue, }; - let outpath = dest.join(rel); + let outpath = dest.join(&rel); if file.is_dir() || file.name().ends_with('/') { fs::create_dir_all(&outpath)?; } else { @@ -1206,10 +1294,275 @@ fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianErro std::io::copy(&mut file, &mut outfile) .map_err(|e| CustodianError::Message(e.to_string()))?; } + let done = (i + 1) as i64; + if let Some(ref mut cb) = on_progress { + let emit = done == total || done % 10 == 0; + if emit { + let detail = rel.to_string_lossy(); + cb(done, total, Some(detail.as_ref())); + } + } } Ok(()) } +fn apply_app_bundle_zip_at_workspace( + ws: &Path, + version: &str, + hash: &str, + zip_bytes: &[u8], + on_extract_progress: Option<&mut dyn FnMut(i64, i64, Option<&str>)>, +) -> Result { + let ver = version.trim(); + if ver.is_empty() { + return Err(CustodianError::Message("version is required".to_string())); + } + let hash = hash.trim(); + if hash.is_empty() { + return Err(CustodianError::Message("hash is required".to_string())); + } + if zip_bytes.is_empty() { + return Err(CustodianError::Message("zip is empty".to_string())); + } + let bundles = ws.join("bundles"); + let archives_dir = bundles.join("archives"); + let active_dir = bundles.join("active"); + fs::create_dir_all(&archives_dir)?; + let prev = read_app_bundle_state_unlocked(&bundles)?; + let mut archived = prev + .as_ref() + .map(|s| s.archived_versions.clone()) + .unwrap_or_default(); + if !archived.iter().any(|v| v == ver) { + archived.push(ver.to_string()); + } + archived.sort(); + let sanit = sanitize_version_for_filename(ver); + let archive_zip = archives_dir.join(format!("{sanit}.zip")); + fs::write(&archive_zip, zip_bytes)?; + if active_dir.exists() { + fs::remove_dir_all(&active_dir)?; + } + fs::create_dir_all(&active_dir)?; + if let Some(cb) = on_extract_progress { + extract_zip_to_dir_with_progress(zip_bytes, &active_dir, Some(cb))?; + } else { + extract_zip_to_dir_with_progress(zip_bytes, &active_dir, None)?; + } + let state = AppBundleState { + schema_version: 1, + active_version: ver.to_string(), + active_hash: hash.to_string(), + downloaded_at: Utc::now().to_rfc3339(), + archived_versions: archived, + }; + let state_path = bundles.join("state.json"); + if let Some(parent) = state_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &state_path, + serde_json::to_string_pretty(&state) + .map_err(|e| CustodianError::Message(e.to_string()))?, + )?; + let legacy = bundles.join("app-bundle.zip"); + if legacy.exists() { + let _ = fs::remove_file(&legacy); + } + Ok(state) +} + +fn apply_app_bundle_zip_bytes( + app: &tauri::AppHandle, + job_id: &str, + ctx: &AppCtxHandle, + version: &str, + hash: &str, + zip_bytes: &[u8], +) -> Result { + emit_bundle_apply_progress(app, job_id, "archiving", 0, 1, "Saving archive…", None); + let app_c = app.clone(); + let job = job_id.to_string(); + let state = with_workspace_fs_exclusive(ctx, |ctx| { + let ws = get_workspace_path(ctx)?; + emit_bundle_apply_progress(&app_c, &job, "archiving", 1, 1, "Saving archive…", None); + emit_bundle_apply_progress( + &app_c, + &job, + "extracting", + 0, + 0, + "Extracting bundle…", + None, + ); + let mut extract_cb = |done: i64, total: i64, detail: Option<&str>| { + emit_bundle_apply_progress( + &app_c, + &job, + "extracting", + done, + total, + "Extracting bundle…", + detail, + ); + }; + apply_app_bundle_zip_at_workspace( + &ws, + version, + hash, + zip_bytes, + Some(&mut extract_cb), + ) + })?; + Ok(state) +} + +async fn download_synkronus_app_bundle_zip( + app: &tauri::AppHandle, + job_id: &str, + base_url: &str, + bearer_token: &str, + x_ode_version: &str, +) -> Result, CustodianError> { + let url = format!( + "{}/api/app-bundle/download-zip", + base_url.trim().trim_end_matches('/') + ); + let parsed = Url::parse(&url).map_err(|e| CustodianError::Message(format!("invalid URL: {e}")))?; + let client = reqwest::Client::new(); + let res = client + .get(parsed) + .header(AUTHORIZATION, format!("Bearer {}", bearer_token.trim())) + .header("x-ode-version", x_ode_version.trim()) + .send() + .await?; + if !res.status().is_success() { + return Err(CustodianError::Message(format!( + "bundle download failed: HTTP {}", + res.status() + ))); + } + let total_bytes = res.content_length().map(|n| n as i64).unwrap_or(0); + emit_bundle_apply_progress( + app, + job_id, + "downloading", + 0, + total_bytes, + "Downloading bundle from server…", + None, + ); + let mut buf = Vec::new(); + let mut received: i64 = 0; + let mut last_emit = Instant::now(); + let mut last_emit_bytes: i64 = 0; + let mut stream = res.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + received += chunk.len() as i64; + buf.extend_from_slice(&chunk); + let elapsed = last_emit.elapsed(); + let bytes_since = received - last_emit_bytes; + if elapsed >= Duration::from_millis(250) || bytes_since >= 512 * 1024 { + emit_bundle_apply_progress( + app, + job_id, + "downloading", + received, + total_bytes, + "Downloading bundle from server…", + Some(&format_byte_progress_mb(received, total_bytes)), + ); + last_emit = Instant::now(); + last_emit_bytes = received; + } + } + emit_bundle_apply_progress( + app, + job_id, + "downloading", + received, + total_bytes.max(received), + "Downloading bundle from server…", + Some(&format_byte_progress_mb(received, total_bytes.max(received))), + ); + if buf.is_empty() { + return Err(CustodianError::Message("zip is empty".to_string())); + } + Ok(buf) +} + +fn spawn_bundle_index_rebuild(app: tauri::AppHandle, ctx: AppCtxHandle, job_id: String) { + tauri::async_runtime::spawn_blocking(move || { + let app_config = match bundle_app_config_path(&ctx) { + Ok(p) if p.exists() => p, + _ => return, + }; + let defs = observation_index::load_index_config(&app_config); + if defs.is_empty() { + return; + } + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "indexing", + 0, + 0, + "Rebuilding observation indexes…", + None, + ); + let conn = match open_db(&ctx) { + Ok(c) => c, + Err(err) => { + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Rebuilding observation indexes…", + Some(&err.to_string()), + ); + return; + } + }; + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM observations", [], |r| r.get(0)) + .unwrap_or(0); + let mut progress_cb = |done: i64, tot: i64| { + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "indexing", + done, + tot, + "Rebuilding observation indexes…", + None, + ); + }; + match observation_index::rebuild_all_indexes(&conn, &defs, Some(&mut progress_cb)) { + Ok(_) => emit_bundle_index_rebuild_progress( + &app, + &job_id, + "completed", + total, + total.max(1), + "Observation indexes rebuilt.", + None, + ), + Err(err) => emit_bundle_index_rebuild_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Rebuilding observation indexes…", + Some(&err.to_string()), + ), + } + }); +} + fn read_app_bundle_state_unlocked( bundles_root: &Path, ) -> Result, CustodianError> { @@ -2202,7 +2555,8 @@ async fn rebuild_observation_indexes( let defs = load_active_index_defs(&ctx); let conn = open_db(&ctx).map_err(|err| err.to_string())?; let generation = - observation_index::rebuild_all_indexes(&conn, &defs).map_err(|err| err.to_string())?; + observation_index::rebuild_all_indexes(&conn, &defs, None) + .map_err(|err| err.to_string())?; let last_rebuild_at: Option = conn .query_row( "SELECT last_rebuild_at FROM observation_index_meta WHERE id = 1", @@ -3424,15 +3778,30 @@ fn get_app_bundle_state( read_app_bundle_state_unlocked(&bundles).map_err(|e| e.to_string()) } -/// Writes `bundles/archives/{version}.zip`, replaces `bundles/active/` with extracted contents, -/// and updates `bundles/state.json`. Removes legacy `bundles/app-bundle.zip` if present. +/// Downloads the active app bundle from Synkronus and applies it under `bundles/active/`. +/// Progress: `bundle/apply-progress` (download/archive/extract) and `bundle/index-rebuild` (background). #[tauri::command] -fn apply_app_bundle_download( +async fn download_and_apply_app_bundle( + app: tauri::AppHandle, + base_url: String, + bearer_token: String, + x_ode_version: String, version: String, hash: String, - zip_bytes: Vec, ctx: tauri::State<'_, AppCtxHandle>, -) -> Result { +) -> Result { + 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 ver = version.trim(); if ver.is_empty() { return Err("version is required".to_string()); @@ -3441,61 +3810,83 @@ fn apply_app_bundle_download( if hash.is_empty() { return Err("hash is required".to_string()); } - if zip_bytes.is_empty() { - return Err("zip is empty".to_string()); - } - with_workspace_fs_exclusive(&ctx, |ctx| { - let ws = get_workspace_path(ctx)?; - let bundles = ws.join("bundles"); - let archives_dir = bundles.join("archives"); - let active_dir = bundles.join("active"); - fs::create_dir_all(&archives_dir)?; - let prev = read_app_bundle_state_unlocked(&bundles)?; - let mut archived = prev - .as_ref() - .map(|s| s.archived_versions.clone()) - .unwrap_or_default(); - if !archived.iter().any(|v| v == ver) { - archived.push(ver.to_string()); - } - archived.sort(); - let sanit = sanitize_version_for_filename(ver); - let archive_zip = archives_dir.join(format!("{sanit}.zip")); - fs::write(&archive_zip, &zip_bytes)?; - if active_dir.exists() { - fs::remove_dir_all(&active_dir)?; - } - fs::create_dir_all(&active_dir)?; - extract_zip_to_dir(&zip_bytes, &active_dir)?; - let state = AppBundleState { - schema_version: 1, - active_version: ver.to_string(), - active_hash: hash.to_string(), - downloaded_at: Utc::now().to_rfc3339(), - archived_versions: archived, - }; - let state_path = bundles.join("state.json"); - if let Some(parent) = state_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write( - &state_path, - serde_json::to_string_pretty(&state) - .map_err(|e| CustodianError::Message(e.to_string()))?, - )?; - let legacy = bundles.join("app-bundle.zip"); - if legacy.exists() { - let _ = fs::remove_file(&legacy); + + let job_id = Uuid::new_v4().to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "downloading", + 0, + 0, + "Downloading bundle from server…", + None, + ); + + let zip_bytes = match download_synkronus_app_bundle_zip( + &app, + &job_id, + base, + token, + ode_ver, + ) + .await + { + Ok(b) => b, + Err(e) => { + let msg = e.to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Bundle download failed.", + Some(&msg), + ); + return Err(msg); } - let app_config = active_dir.join("app/app.config.json"); - if app_config.exists() { - let defs = observation_index::load_index_config(&app_config); - let conn = open_db(ctx)?; - let _ = observation_index::rebuild_all_indexes(&conn, &defs); + }; + + let ctx_inner = ctx.inner().clone(); + let state = match apply_app_bundle_zip_bytes(&app, &job_id, &ctx_inner, ver, hash, &zip_bytes) { + Ok(s) => s, + Err(e) => { + let msg = e.to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Applying bundle failed.", + Some(&msg), + ); + return Err(msg); } - Ok(state) + }; + + emit_bundle_apply_progress( + &app, + &job_id, + "completed", + 1, + 1, + "Bundle applied.", + None, + ); + + let needs_index = bundle_app_config_path(&ctx_inner) + .ok() + .filter(|p| p.exists()) + .is_some(); + if needs_index { + spawn_bundle_index_rebuild(app.clone(), ctx_inner.clone(), job_id); + } + + Ok(DownloadAndApplyAppBundleResult { + state, + index_rebuild_scheduled: needs_index, }) - .map_err(|e: CustodianError| e.to_string()) } fn reserved_form_dir_name(name: &str) -> bool { @@ -4116,7 +4507,7 @@ pub fn run() { write_workspace_file, get_app_bundle_state, refresh_custom_app_dev_mirror, - apply_app_bundle_download, + download_and_apply_app_bundle, list_active_bundle_forms, read_bundle_form_spec, read_workspace_text_file, @@ -4151,8 +4542,9 @@ mod tests { use std::path::Path; use super::{ - bind_query_params, mirror_custom_app_dev_folder, parse_time, resolve_attachment_path, - should_mark_conflict, validate_custom_app_dev_source_folder, + apply_app_bundle_zip_at_workspace, bind_query_params, mirror_custom_app_dev_folder, + parse_time, resolve_attachment_path, should_mark_conflict, + validate_custom_app_dev_source_folder, CompressionMethod, SimpleFileOptions, ZipWriter, }; use crate::observation_query::SqlParam; use rusqlite::Connection; @@ -4272,4 +4664,52 @@ mod tests { assert_eq!(b, 7); assert!(rows.next().unwrap().is_none()); } + + fn minimal_bundle_zip_bytes() -> Vec { + use std::io::Write; + let base = std::env::temp_dir().join(format!( + "ode_bundle_zip_fixture_{}", + std::process::id() + )); + let zip_path = base.join("fixture.zip"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + { + let file = fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = + SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + zip.start_file("app/index.html", options).unwrap(); + zip.write_all(b"").unwrap(); + zip.finish().unwrap(); + } + let bytes = fs::read(&zip_path).unwrap(); + let _ = fs::remove_dir_all(&base); + bytes + } + + #[test] + fn apply_app_bundle_zip_at_workspace_writes_state_and_active() { + let base = std::env::temp_dir().join(format!( + "ode_bundle_apply_test_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let zip_bytes = minimal_bundle_zip_bytes(); + let state = apply_app_bundle_zip_at_workspace( + Path::new(&base), + "1.0.0", + "abc123", + &zip_bytes, + None, + ) + .unwrap(); + assert_eq!(state.active_version, "1.0.0"); + assert_eq!(state.active_hash, "abc123"); + assert!(base.join("bundles/active/app/index.html").is_file()); + assert!(base.join("bundles/archives/1.0.0.zip").is_file()); + assert!(base.join("bundles/state.json").is_file()); + let _ = fs::remove_dir_all(&base); + } } diff --git a/desktop/src-tauri/src/observation_index.rs b/desktop/src-tauri/src/observation_index.rs index 02adb7a2e..b735b4607 100644 --- a/desktop/src-tauri/src/observation_index.rs +++ b/desktop/src-tauri/src/observation_index.rs @@ -148,6 +148,7 @@ fn scalar_to_columns(val: &Value, value_type: Option<&str>) -> (Option, pub fn rebuild_all_indexes( conn: &Connection, defs: &[ObservationIndexDef], + mut progress: Option<&mut dyn FnMut(i64, i64)>, ) -> rusqlite::Result { let active: i64 = conn.query_row( "SELECT active_generation FROM observation_index_meta WHERE id = 1", @@ -166,6 +167,11 @@ pub fn rebuild_all_indexes( params![new_gen], )?; + let total: i64 = conn.query_row("SELECT COUNT(*) FROM observations", [], |r| r.get(0))?; + if let Some(ref mut cb) = progress { + cb(0, total); + } + let mut stmt = conn.prepare("SELECT id, form_type, payload FROM observations")?; let rows = stmt.query_map([], |row| { Ok(( @@ -175,10 +181,17 @@ pub fn rebuild_all_indexes( )) })?; + let mut done = 0i64; for row in rows { let (id, form_type, payload) = row?; let ft = form_type.unwrap_or_default(); reindex_observation(conn, &id, &ft, &payload, defs, new_gen)?; + done += 1; + if let Some(ref mut cb) = progress { + if total == 0 || done == total || done % 50 == 0 { + cb(done, total); + } + } } recreate_sqlite_indexes(conn, defs)?; @@ -429,7 +442,7 @@ mod tests { [], ) .unwrap(); - let gen1 = rebuild_all_indexes(&conn, &defs).unwrap(); + let gen1 = rebuild_all_indexes(&conn, &defs, None).unwrap(); assert_eq!(gen1, 2); let active: i64 = active_generation(&conn).unwrap(); assert_eq!(active, 2); diff --git a/desktop/src/App.css b/desktop/src/App.css index 1799bd41c..27e26f1d3 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -1041,6 +1041,21 @@ textarea { gap: 0.5rem; flex: 1; min-width: 0; + flex-wrap: wrap; +} + +.activity-progress { + flex: 1 1 100%; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--color-text) 12%, transparent); + overflow: hidden; +} + +.activity-progress-fill { + height: 100%; + background: var(--color-accent, #2563eb); + transition: width 0.2s ease; } .app-sync-banner-dismiss { diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index da421a2a9..f45e0b7ea 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -75,7 +75,7 @@ vi.mock('./lib/tauriClient', () => ({ .fn() .mockResolvedValue('/tmp/custodian-ws/bundles/app-bundle.zip'), getAppBundleState: vi.fn().mockResolvedValue(null), - applyAppBundleDownload: vi.fn(), + downloadAndApplyAppBundle: vi.fn(), listActiveBundleForms: vi.fn().mockResolvedValue([]), readBundleFormSpec: vi.fn(), removeWorkspaceAttachment: vi.fn(), diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 9594cc4af..21eafbc7d 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { WorkbenchCustomAppPage } from './pages/WorkbenchCustomAppPage'; import { useSynkServerStatus } from './hooks/useSynkServerStatus'; import { selectActiveProfileState, + selectBundleActivity, selectSyncActivity, useCustodianStore, } from './store/useCustodianStore'; @@ -31,6 +32,7 @@ import { selectImportActivity, useImportStagingStore, } from './store/useImportStagingStore'; +import { guardedProfileNavigation } from './store/useProfileDraftGuardStore'; import { useToastStore } from './store/useToastStore'; import './App.css'; @@ -82,7 +84,11 @@ function ModeSwitch() { const upsertProfileRemote = useCustodianStore(s => s.upsertProfileRemote); async function goData() { - navigate('/data/profiles'); + await guardedProfileNavigation( + navigate, + '/data/profiles', + location.pathname, + ); if (active) { await upsertProfileRemote({ ...active, @@ -92,7 +98,11 @@ function ModeSwitch() { } async function goWorkbench() { - navigate('/workbench/bundles'); + await guardedProfileNavigation( + navigate, + '/workbench/bundles', + location.pathname, + ); if (active) { await upsertProfileRemote({ ...active, @@ -159,6 +169,46 @@ function RootRedirect() { return ; } +function SidebarNavLink({ + to, + end, + icon, + label, +}: { + to: string; + end?: boolean; + icon: string; + label: string; +}) { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + `nav-link${isActive ? ' active' : ''}`} + onClick={e => { + if ( + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey || + e.button !== 0 + ) { + return; + } + if (location.pathname === '/data/profiles' && to !== '/data/profiles') { + e.preventDefault(); + void guardedProfileNavigation(navigate, to, location.pathname); + } + }}> + {icon} + {label} + + ); +} + function Shell() { const year = useMemo(() => new Date().getFullYear(), []); const location = useLocation(); @@ -168,11 +218,24 @@ function Shell() { const clearSyncMessage = useCustodianStore(s => s.clearSyncMessage); const pushToast = useToastStore(s => s.pushToast); const syncActivity = useCustodianStore(selectSyncActivity); + const bundleActivity = useCustodianStore(selectBundleActivity); const importActivity = useImportStagingStore(selectImportActivity); const activityText: string | null = - syncActivity?.statusText ?? importActivity?.statusText ?? null; + syncActivity?.statusText ?? + bundleActivity?.statusText ?? + importActivity?.statusText ?? + null; - const activityPresent = syncActivity !== null || importActivity !== null; + const activityProgress = + bundleActivity && bundleActivity.total > 0 + ? Math.min( + 100, + Math.round((bundleActivity.done / bundleActivity.total) * 100), + ) + : null; + + const activityPresent = + syncActivity !== null || bundleActivity !== null || importActivity !== null; const [activityBannerDismissed, setActivityBannerDismissed] = useState(false); useEffect(() => { @@ -225,18 +288,15 @@ function Shell() {