Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions desktop/public/formulus-injection.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +171,13 @@

// Initialize the formulus interface
globalThis.formulus = {
getProfileId: function () {
return profileId;
},
getLocalStorageRef: function () {
return profileLocalStorage;
},

// getVersion: => Promise<string>
getVersion: function () {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -229,7 +298,7 @@
});
},

// openFormplayer: formType: string, params: Record<string, unknown>, savedData: Record<string, unknown>, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise<FormCompletionResult>
// openFormplayer: formType: string, params: Record<string, unknown>, savedData: Record<string, unknown>, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise<FormCompletionResult>
openFormplayer: function (formType, params, savedData, options) {
return new Promise((resolve, reject) => {
const messageId =
Expand Down Expand Up @@ -356,7 +425,7 @@
});
},

// getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise<FormObservation[]>
// getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: any; whereClause?: string; } => Promise<FormObservation[]>
getObservationsByQuery: function (options) {
return new Promise((resolve, reject) => {
const messageId =
Expand Down
11 changes: 11 additions & 0 deletions desktop/scripts/copy-formplayer-to-desktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).');
29 changes: 29 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
active_profile_id: String,
profiles: Vec<ServerProfile>,
}
Expand Down Expand Up @@ -487,6 +490,7 @@ struct AuthSession {
#[serde(rename_all = "camelCase")]
struct SettingsResponse {
active_profile_id: String,
deleted_profile_ids: Vec<String>,
profiles: Vec<ServerProfile>,
/// App data dir for constructing default per-profile DB paths in the UI.
data_directory: String,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -2300,6 +2306,7 @@ fn get_settings(ctx: tauri::State<'_, AppCtxHandle>) -> Result<SettingsResponse,
.map_err(|_| "failed to lock config".to_string())?;
Ok(SettingsResponse {
active_profile_id: cfg.active_profile_id.clone(),
deleted_profile_ids: cfg.deleted_profile_ids.clone(),
profiles: cfg.profiles.clone(),
data_directory: ctx.data_dir.to_string_lossy().to_string(),
})
Expand Down Expand Up @@ -2346,6 +2353,9 @@ fn upsert_profile(
.config
.lock()
.map_err(|_| "failed to lock config".to_string())?;
if cfg.deleted_profile_ids.contains(&profile.id) {
return Err("deleted profile IDs cannot be reused".to_string());
}
if let Some(i) = cfg.profiles.iter().position(|p| p.id == profile.id) {
cfg.profiles[i] = profile;
} else {
Expand All @@ -2366,6 +2376,12 @@ fn delete_profile(profile_id: String, ctx: tauri::State<'_, AppCtxHandle>) -> 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();
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 20 additions & 2 deletions desktop/src/components/CustomAppEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
} 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';
Expand Down Expand Up @@ -114,6 +116,8 @@
ref,
) {
const innerRef = useRef<HTMLIFrameElement | null>(null);
const profileId = useCustodianStore(s => s.activeProfileId);
const mountGeneration = useRef(0);
const onContentWindowReadyRef = useRef(onContentWindowReady);
onContentWindowReadyRef.current = onContentWindowReady;
const setRefs = useCallback(
Expand All @@ -138,9 +142,16 @@
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.');
Expand All @@ -160,10 +171,12 @@
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()}`;
Expand All @@ -173,13 +186,17 @@
};
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++;

Check warning on line 198 in desktop/src/components/CustomAppEmbed.tsx

View workflow job for this annotation

GitHub Actions / Lint, test, and Rust checks

The ref value 'mountGeneration.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'mountGeneration.current' to a variable inside the effect, and use that variable in the cleanup function
};
}, [mountKey, mountBlob]);

const defaultLoadingLabel =
Expand All @@ -195,6 +212,7 @@
<p className="muted">{loadingLabel ?? defaultLoadingLabel}</p>
) : null}
<iframe
key={profileId}
ref={setRefs}
title="Custom app"
className="formplayer-embed-frame custom-app-embed-frame"
Expand Down
20 changes: 17 additions & 3 deletions desktop/src/components/FormplayerEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import { postFormplayerBridgeReply } from '../lib/formPreviewBridge';
import { buildDevicePixelRatioInjectionScript } from '../lib/devicePixelRatioStub';
import type { FormInitData } from '../lib/formplayerHost';
import { buildProfileStorageInjection } from '../lib/profileStorageInjection';
import { tauriClient } from '../lib/tauriClient';
import { useCustodianStore } from '../store/useCustodianStore';

const FORMSPLAYER_INDEX = `${import.meta.env.BASE_URL}formplayer_dist/index.html`;
const INJECTION_SCRIPT = `${import.meta.env.BASE_URL}formulus-injection.js`;
Expand Down Expand Up @@ -79,6 +82,7 @@
ref,
) {
const innerRef = useRef<HTMLIFrameElement | null>(null);
const profileId = useCustodianStore(s => s.activeProfileId);
const timeoutRef = useRef<number | null>(null);
const mountGenerationRef = useRef(0);
const onContentWindowReadyRef = useRef(onContentWindowReady);
Expand Down Expand Up @@ -127,6 +131,11 @@
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
const settings = await tauriClient.getSettings();
if (generation !== mountGenerationRef.current) return;
if (settings.activeProfileId !== profileId)
throw new Error('Active profile changed while loading Formplayer');
const profileStub = buildProfileStorageInjection(settings);
const res = await fetch(FORMSPLAYER_INDEX);
if (generation !== mountGenerationRef.current) {
return;
Expand All @@ -146,10 +155,13 @@
window.location.href,
);
const baseHref = new URL('./', formplayerIndexUrl).toString();
const initJson = JSON.stringify(formInitData).replace(/</g, '\\u003c');
const initJson = JSON.stringify({
...formInitData,
params: { ...formInitData.params, profileId: settings.activeProfileId },
}).replace(/</g, '\\u003c');
const dprStub = buildDevicePixelRatioInjectionScript(devicePixelRatio);
const stub = `<!--ode-formplayer-host-stub-->
${dprStub}<script id="ode-formplayer-init-data" type="application/json">${initJson}</script>
${profileStub}${dprStub}<script id="ode-formplayer-init-data" type="application/json">${initJson}</script>
<script src="${HOST_STUB_SCRIPT}"></script>
<script src="${INJECTION_SCRIPT}"></script>`;
html = html.replace(
Expand Down Expand Up @@ -184,11 +196,12 @@
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
}
}, [formInitData, devicePixelRatio]);
}, [formInitData, devicePixelRatio, profileId]);

useEffect(() => {
void mountBlob();
return () => {
mountGenerationRef.current++;

Check warning on line 204 in desktop/src/components/FormplayerEmbed.tsx

View workflow job for this annotation

GitHub Actions / Lint, test, and Rust checks

The ref value 'mountGenerationRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'mountGenerationRef.current' to a variable inside the effect, and use that variable in the cleanup function
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
Expand All @@ -211,6 +224,7 @@
{error ? <p className="notice warn">{error}</p> : null}
{loading && !error ? <p className="muted">Loading formplayer…</p> : null}
<iframe
key={profileId}
ref={el => {
innerRef.current = el;
}}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/lib/formPreviewBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function previewAllocateSequence(
}

/** Matches `FORMULUS_INTERFACE_VERSION` in formplayer (`FormulusInterfaceDefinition.ts`). */
export const FORM_PREVIEW_FORMULUS_INTERFACE_VERSION = '1.5.0';
export const FORM_PREVIEW_FORMULUS_INTERFACE_VERSION = '1.6.0';

/** Must match `formplayer-host-stub.js` — delivers `*_response` to pending Formulus promises in iframes. */
export const FORMPLAYER_BRIDGE_RESPONSE_CHANNEL =
Expand Down
Loading
Loading