diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 7207daf0d..c6ad1ddc7 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,8 +1,57 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-06-19T12:32:54.430Z +// Last generated: 2026-09-18T16:52:28.301Z (function () { + const profileId = globalThis.__odeProfileId; + if (typeof profileId !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(profileId)) { + throw new Error( + 'Formulus requires a host profile ID before bridge initialization', + ); + } + const deletedIds = globalThis.__odeDeletedProfileIds || []; + if ( + !Array.isArray(deletedIds) || + deletedIds.some( + id => typeof id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(id), + ) + ) { + throw new Error('Invalid deleted profile IDs'); + } + // Tombstones are permanent: each origin is cleaned when it is next loaded. + // Never clear the whole origin; unrelated third-party storage is not ours. + function removePrefix(storage, prefix) { + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key !== null && key.startsWith(prefix)) storage.removeItem(key); + } + } + if (deletedIds.length) { + const storage = globalThis.localStorage; + deletedIds.forEach(id => removePrefix(storage, 'ode:' + id + ':')); + if (deletedIds.includes(globalThis.__odeLegacyWebStorageProfileId)) { + storage.removeItem('formulus_drafts'); + storage.removeItem('formulus_sticky_fields'); + } + } + if (deletedIds.includes(profileId)) + throw new Error('Active profile has been deleted'); + const appPrefix = 'ode:' + profileId + ':app:'; + const profileLocalStorage = Object.freeze({ + getItem: function (key) { + return globalThis.localStorage.getItem(appPrefix + String(key)); + }, + setItem: function (key, value) { + globalThis.localStorage.setItem(appPrefix + String(key), String(value)); + }, + removeItem: function (key) { + globalThis.localStorage.removeItem(appPrefix + String(key)); + }, + clear: function () { + removePrefix(globalThis.localStorage, appPrefix); + }, + }); + // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist @@ -14,8 +63,21 @@ function isFormulusAvailable() { const api = getFormulus(); + if ( + api && + typeof api.getProfileId === 'function' && + api.getProfileId() !== profileId + ) { + throw new Error( + 'A Formulus browser context cannot change profiles; remount it', + ); + } return ( - api && typeof api === 'object' && typeof api.getVersion === 'function' + api && + typeof api === 'object' && + typeof api.getVersion === 'function' && + typeof api.getProfileId === 'function' && + typeof api.getLocalStorageRef === 'function' ); } @@ -72,7 +134,7 @@ data = event.data; // Already an object } else { // 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. + return; // Or handle as an error, but for now, just return to avoid breaking others. } // Handle callbacks @@ -109,6 +171,13 @@ // Initialize the formulus interface globalThis.formulus = { + getProfileId: function () { + return profileId; + }, + getLocalStorageRef: function () { + return profileLocalStorage; + }, + // getVersion: => Promise getVersion: function () { return new Promise((resolve, reject) => { @@ -229,7 +298,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -356,7 +425,7 @@ }); }, - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: any; whereClause?: string; } => Promise getObservationsByQuery: function (options) { return new Promise((resolve, reject) => { const messageId = diff --git a/desktop/scripts/copy-formplayer-to-desktop.mjs b/desktop/scripts/copy-formplayer-to-desktop.mjs index 935f7a589..fc4cff9ff 100644 --- a/desktop/scripts/copy-formplayer-to-desktop.mjs +++ b/desktop/scripts/copy-formplayer-to-desktop.mjs @@ -49,5 +49,16 @@ if (!fs.existsSync(buildDir)) { fs.mkdirSync(targetDir, { recursive: true }); cleanDirectory(targetDir); copyRecursive(buildDir, targetDir); +fs.copyFileSync( + path.join( + formplayerRoot, + '..', + 'formulus', + 'assets', + 'webview', + 'FormulusInjectionScript.js', + ), + path.join(__dirname, '..', 'public', 'formulus-injection.js'), +); console.log(`✓ Copied formplayer build → ${targetDir}`); console.log(' Served by Vite as /formplayer_dist/ (base URL in dev).'); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index da6baac78..78e03f11c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -145,6 +145,9 @@ struct ServerProfile { struct AppConfigFile { #[serde(default = "schema_version_default")] schema_version: u32, + /// Permanent tombstones for cleanup when a browser origin is next loaded. + #[serde(default)] + deleted_profile_ids: Vec, active_profile_id: String, profiles: Vec, } @@ -487,6 +490,7 @@ struct AuthSession { #[serde(rename_all = "camelCase")] struct SettingsResponse { active_profile_id: String, + deleted_profile_ids: Vec, profiles: Vec, /// App data dir for constructing default per-profile DB paths in the UI. data_directory: String, @@ -837,6 +841,7 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { let db_path = sqlite_path_for_workspace(&workspace_dir); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -862,6 +867,7 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig let db = sqlite_path_for_workspace(&ws); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -2300,6 +2306,7 @@ fn get_settings(ctx: tauri::State<'_, AppCtxHandle>) -> Result) -> Re if cfg.profiles.len() <= 1 { return Err("cannot delete the last profile".to_string()); } + if !cfg.profiles.iter().any(|p| p.id == profile_id) { + return Err("profile not found".to_string()); + } + if !cfg.deleted_profile_ids.contains(&profile_id) { + cfg.deleted_profile_ids.push(profile_id.clone()); + } cfg.profiles.retain(|p| p.id != profile_id); if cfg.active_profile_id == profile_id { cfg.active_profile_id = cfg.profiles[0].id.clone(); @@ -5866,6 +5882,19 @@ mod tests { use std::io::Read; use std::time::Instant; + #[test] + fn profile_browser_storage_tombstones_survive_config_round_trip() { + let mut cfg = super::default_app_config(Path::new("/tmp/ode-profile-test")); + cfg.deleted_profile_ids.push("deleted-id".to_string()); + let json = serde_json::to_string(&cfg).unwrap(); + let restored: super::AppConfigFile = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.deleted_profile_ids, vec!["deleted-id"]); + let mut legacy = serde_json::to_value(&cfg).unwrap(); + legacy.as_object_mut().unwrap().remove("deletedProfileIds"); + let restored: super::AppConfigFile = serde_json::from_value(legacy).unwrap(); + assert!(restored.deleted_profile_ids.is_empty()); + } + #[test] fn attachment_copy_progress_step_scales_with_batch_size() { assert_eq!(attachment_copy_progress_step(5), 1); diff --git a/desktop/src/components/CustomAppEmbed.tsx b/desktop/src/components/CustomAppEmbed.tsx index 9672958fd..48ec79a00 100644 --- a/desktop/src/components/CustomAppEmbed.tsx +++ b/desktop/src/components/CustomAppEmbed.tsx @@ -16,6 +16,8 @@ import { } from '../lib/rewriteEmbeddedBundleHtml'; import { tauriClient } from '../lib/tauriClient'; import { WORKSPACE_BUNDLE_DEV_APP_INDEX } from '../lib/workspacePaths'; +import { buildProfileStorageInjection } from '../lib/profileStorageInjection'; +import { useCustodianStore } from '../store/useCustodianStore'; /** Matches Formulus: custom app entry under the extracted bundle (see `HomeScreen.tsx`). */ export const CUSTOM_APP_BUNDLE_INDEX_REL = 'bundles/active/app/index.html'; @@ -114,6 +116,8 @@ export const CustomAppEmbed = forwardRef< ref, ) { const innerRef = useRef(null); + const profileId = useCustodianStore(s => s.activeProfileId); + const mountGeneration = useRef(0); const onContentWindowReadyRef = useRef(onContentWindowReady); onContentWindowReadyRef.current = onContentWindowReady; const setRefs = useCallback( @@ -138,9 +142,16 @@ export const CustomAppEmbed = forwardRef< if (!el) { return; } + const generation = ++mountGeneration.current; + const isCurrent = () => generation === mountGeneration.current; setLoading(true); setError(null); try { + const settings = await tauriClient.getSettings(); + if (!isCurrent()) return; + if (settings.activeProfileId !== profileId) + throw new Error('Active profile changed while loading custom app'); + const profileStub = buildProfileStorageInjection(settings); const workspace = await tauriClient.getWorkspace(); if (!workspace) { throw new Error('No workspace configured for the active profile.'); @@ -160,10 +171,12 @@ export const CustomAppEmbed = forwardRef< const baseHref = appDirAssetUrl.endsWith('/') ? appDirAssetUrl : `${appDirAssetUrl}/`; - const stub = buildHostStub(devicePixelRatio); + if (!isCurrent()) return; + const stub = profileStub + buildHostStub(devicePixelRatio); const doc = injectIntoHead(html, stub, baseHref); const enc = new TextEncoder(); await tauriClient.writeWorkspaceFile(indexRel, enc.encode(doc)); + if (!isCurrent()) return; // Query busts document cache. Do not use a `#fragment` here: many SPAs use the // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; @@ -173,13 +186,17 @@ export const CustomAppEmbed = forwardRef< }; el.src = url; } catch (e) { + if (!isCurrent()) return; setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, [indexRel, mode, devicePixelRatio]); + }, [indexRel, mode, devicePixelRatio, profileId]); useEffect(() => { void mountBlob(); + return () => { + mountGeneration.current++; + }; }, [mountKey, mountBlob]); const defaultLoadingLabel = @@ -195,6 +212,7 @@ export const CustomAppEmbed = forwardRef<

{loadingLabel ?? defaultLoadingLabel}

) : null}