Skip to content
Merged
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
39 changes: 39 additions & 0 deletions desktop/public/formplayer-host-stub.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,43 @@
}
},
};

/**
* ODE Desktop: deliver bridge *_response to pending Formulus promises without
* relying on postMessage from the outer shell (unreliable for srcdoc iframes in WebView2).
*/
function deliverBridgeResponseBody(body) {
if (!body || !body.type || !body.messageId) {
return;
}
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify(body),
}),
);
}

window.__odeFormplayerDeliverBridgeResponse = function (
requestType,
messageId,
payload,
) {
var responseType = requestType + '_response';
var body = { type: responseType, messageId: messageId };
if (payload && typeof payload === 'object') {
if ('result' in payload) body.result = payload.result;
if ('error' in payload) body.error = payload.error;
}
deliverBridgeResponseBody(body);
};

/** Same-origin broadcast fallback when the outer shell cannot postMessage into srcdoc iframes (WebView2). */
if (typeof BroadcastChannel !== 'undefined') {
var bridgeResponseChannel = new BroadcastChannel(
'ode-formplayer-bridge-response',
);
bridgeResponseChannel.onmessage = function (event) {
deliverBridgeResponseBody(event.data);
};
}
})();
2 changes: 0 additions & 2 deletions desktop/public/formulus-injection.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,6 @@
const messageId =
'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000);

// Add response handler for methods that return values

const callback = event => {
try {
let data;
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/components/CustomAppEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export type CustomAppEmbedProps = {
/** Workspace-relative path to `index.html`; defaults from {@link mode}. */
indexRelativePath?: string;
loadingLabel?: string;
/** Fired when the iframe document loads (bridge routing in WebView2). */
onContentWindowReady?: (contentWindow: Window | null) => void;
};

function defaultIndexRelativePath(mode: CustomAppEmbedMode): string {
Expand All @@ -93,10 +95,12 @@ export const CustomAppEmbed = forwardRef<
HTMLIFrameElement,
CustomAppEmbedProps
>(function CustomAppEmbed(
{ mountKey, mode, indexRelativePath, loadingLabel },
{ mountKey, mode, indexRelativePath, loadingLabel, onContentWindowReady },
ref,
) {
const innerRef = useRef<HTMLIFrameElement | null>(null);
const onContentWindowReadyRef = useRef(onContentWindowReady);
onContentWindowReadyRef.current = onContentWindowReady;
const setRefs = useCallback(
(el: HTMLIFrameElement | null) => {
(innerRef as MutableRefObject<HTMLIFrameElement | null>).current = el;
Expand Down Expand Up @@ -149,6 +153,7 @@ export const CustomAppEmbed = forwardRef<
// hash for routing (HashRouter or path), so `#ode-…` would break the initial route.
const url = `${indexAssetUrl}?ode=${Date.now()}`;
el.onload = () => {
onContentWindowReadyRef.current?.(el.contentWindow);
setLoading(false);
};
el.src = url;
Expand Down
55 changes: 42 additions & 13 deletions desktop/src/components/FormplayerEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type MutableRefObject,
} from 'react';
import { postFormplayerBridgeReply } from '../lib/formPreviewBridge';
import type { FormInitData } from '../lib/formplayerHost';

const FORMSPLAYER_INDEX = `${import.meta.env.BASE_URL}formplayer_dist/index.html`;
Expand Down Expand Up @@ -40,6 +41,18 @@ export type FormplayerEmbedProps = {
/** Full `FormInitData` for the embedded formplayer; `null` shows `emptyMessage` only. */
formInitData: FormInitData | null;
emptyMessage?: string;
/** Fired when the iframe document loads (used to register `contentWindow` for bridge routing). */
onContentWindowReady?: (contentWindow: Window | null) => void;
};

/** Imperative handle for bridge delivery into the iframe document (WebView2-safe). */
export type FormplayerEmbedHandle = {
getIframe: () => HTMLIFrameElement | null;
deliverBridgeResponse: (
requestType: string,
messageId: string,
payload: { result?: unknown; error?: string },
) => void;
};

/**
Expand All @@ -48,27 +61,40 @@ export type FormplayerEmbedProps = {
* base href) so Finalize / `submitObservation` and extension APIs work.
*/
export const FormplayerEmbed = forwardRef<
HTMLIFrameElement,
FormplayerEmbedHandle,
FormplayerEmbedProps
>(function FormplayerEmbed(
{
formInitData,
emptyMessage = 'Select a form type and apply params/saved JSON to load the preview.',
onContentWindowReady,
},
ref,
) {
const innerRef = useRef<HTMLIFrameElement | null>(null);
const timeoutRef = useRef<number | null>(null);
const setRefs = useCallback(
(el: HTMLIFrameElement | null) => {
(innerRef as MutableRefObject<HTMLIFrameElement | null>).current = el;
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
(ref as MutableRefObject<HTMLIFrameElement | null>).current = el;
}
},
[ref],
const onContentWindowReadyRef = useRef(onContentWindowReady);
onContentWindowReadyRef.current = onContentWindowReady;

useImperativeHandle(
ref,
() => ({
getIframe: () => innerRef.current,
deliverBridgeResponse: (requestType, messageId, payload) => {
const win = innerRef.current?.contentWindow ?? null;
if (!win) {
return;
}
postFormplayerBridgeReply(
innerRef.current,
requestType,
messageId,
payload,
win,
);
},
}),
[],
);

const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -122,6 +148,7 @@ export const FormplayerEmbed = forwardRef<
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
onContentWindowReadyRef.current?.(el.contentWindow);
setLoading(false);
};
// WebView2 can behave inconsistently with blob: + module scripts in packaged apps.
Expand Down Expand Up @@ -160,7 +187,9 @@ export const FormplayerEmbed = forwardRef<
{error ? <p className="notice warn">{error}</p> : null}
{loading && !error ? <p className="muted">Loading formplayer…</p> : null}
<iframe
ref={setRefs}
ref={el => {
innerRef.current = el;
}}
title="Formplayer preview"
className="formplayer-embed-frame"
/>
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/lib/__tests__/formPreviewBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ describe('handleFormPreviewBridgeMessage', () => {
expect(postPrimary).not.toHaveBeenCalled();
expect(defer).toHaveBeenCalledWith({
parentIframe: primaryIframe,
parentContentWindow: primaryCw,
messageId: 'op-def',
formType: 'child_form',
params: { a: 1 },
Expand Down Expand Up @@ -478,4 +479,30 @@ describe('postFormplayerBridgeReply', () => {
const payload = JSON.parse(postMessage.mock.calls[0][0] as string);
expect(payload.type).toBe('getThemeMode_response');
});

it('delivers via __odeFormplayerDeliverBridgeResponse and postMessage', () => {
const postMessage = vi.fn();
const deliver = vi.fn();
const cw = {
postMessage,
__odeFormplayerDeliverBridgeResponse: deliver,
} as unknown as Window;
const iframe = { contentWindow: cw } as HTMLIFrameElement;

postFormplayerBridgeReply(
iframe,
'openFormplayer',
'msg-parent',
{ result: { status: 'form_submitted', formData: { a: 1 } } },
cw,
);

expect(deliver).toHaveBeenCalledWith('openFormplayer', 'msg-parent', {
result: { status: 'form_submitted', formData: { a: 1 } },
});
expect(postMessage).toHaveBeenCalledTimes(1);
const payload = JSON.parse(postMessage.mock.calls[0][0] as string);
expect(payload.type).toBe('openFormplayer_response');
expect(payload.messageId).toBe('msg-parent');
});
});
43 changes: 43 additions & 0 deletions desktop/src/lib/__tests__/formPreviewSubObservationBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import type { FormplayerEmbedHandle } from '../../components/FormplayerEmbed';
import {
clearPendingSubObservationOpensForTests,
deliverSubObservationCompletion,
registerPendingSubObservationOpen,
} from '../formPreviewSubObservationBridge';

describe('formPreviewSubObservationBridge', () => {
it('delivers completion via parent embed handle and content window', () => {
clearPendingSubObservationOpensForTests();
const postMessage = vi.fn();
const deliver = vi.fn();
const parentWindow = { postMessage } as unknown as Window;
const handle = {
getIframe: () => ({ contentWindow: parentWindow }) as HTMLIFrameElement,
deliverBridgeResponse: deliver,
} satisfies FormplayerEmbedHandle;

registerPendingSubObservationOpen({
parentMessageId: 'msg-1',
parentEmbed: handle,
parentContentWindow: parentWindow,
formType: 'child_form',
});

const ok = deliverSubObservationCompletion('msg-1', {
status: 'form_submitted',
formType: 'child_form',
formData: { k: 'v' },
});

expect(ok).toBe(true);
expect(deliver).toHaveBeenCalledWith('openFormplayer', 'msg-1', {
result: {
status: 'form_submitted',
formType: 'child_form',
formData: { k: 'v' },
},
});
expect(postMessage).toHaveBeenCalled();
});
});
33 changes: 28 additions & 5 deletions desktop/src/lib/bundleExtensionLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { bundleFormsRel } from './bundleLayout';
import type { FormInitData } from './formplayerHost';
import { convertFileSrc } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { bundleFormsRel, bundleSegment } from './bundleLayout';
import type { ExtensionMetadata, FormInitData } from './formplayerHost';
import { tauriClient } from './tauriClient';

export type ExtensionFunctionShape = {
Expand Down Expand Up @@ -96,8 +98,8 @@ function mergeLayer(
export function formplayerExtensionsFromMerged(
merged: ReturnType<typeof normalizeJson>,
basePath: string,
): NonNullable<FormInitData['extensions']> {
const functions: Record<string, unknown> = {};
): ExtensionMetadata {
const functions: NonNullable<ExtensionMetadata['functions']> = {};
for (const [key, func] of Object.entries(merged.functions)) {
const modulePath = (func.module || '').replace(/^\/+/, '');
functions[key] = {
Expand All @@ -106,7 +108,7 @@ export function formplayerExtensionsFromMerged(
export: func.export,
};
}
const renderers: Record<string, unknown> = {};
const renderers: NonNullable<ExtensionMetadata['renderers']> = {};
for (const [key, r] of Object.entries(merged.renderers)) {
const modulePath = (r.module || '').replace(/^\/+/, '');
renderers[key] = {
Expand Down Expand Up @@ -173,6 +175,27 @@ export async function loadBundleFormplayerExtensions(
if (hasExtensionContent) {
const basePath = await tauriClient.getActiveBundleFormsFileBaseUrl();
extensions = formplayerExtensionsFromMerged(merged, basePath);

const ws = await tauriClient.getWorkspace();
if (ws && extensions?.functions) {
const segment = bundleSegment(developerMode);
const gbmisAbs = await join(
ws,
'bundles',
segment,
'app',
'extensions',
'gbmis.js',
);
const gbmisUrl = convertFileSrc(gbmisAbs);
for (const fn of Object.values(extensions.functions)) {
const meta = fn as Record<string, unknown>;
const modulePath = String(meta.module ?? '');
if (modulePath.includes('gbmis.js')) {
meta.module = gbmisUrl;
}
}
}
} else {
extensions = undefined;
}
Expand Down
Loading
Loading