@selfhelp/sh2-shp-survey-js-mobile 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Section-field extraction for the mobile SurveyJS runtime.
7
+ *
8
+ * Mirrors the frontend `buildRuntimeConfigFromSection` (and its field
9
+ * readers) so the mobile renderer interprets the SAME CMS style fields
10
+ * the web runtime does — `redirect_at_end`, `auto_save_interval`,
11
+ * `restart_on_refresh`, `timeout`, the once-per-* gates, the schedule
12
+ * window, and the status labels. Keeping the readers identical is what
13
+ * makes "exactly the same functionality" hold across platforms.
14
+ *
15
+ * `extractSurveyId` reads the `survey-js` field (the selected published
16
+ * survey key) and is the single source of truth for both the host shell and
17
+ * the WebView load handshake.
18
+ */
19
+
20
+ export interface ISectionLike {
21
+ id?: number;
22
+ fields?: Record<string, unknown>;
23
+ style_name?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /** Read the selected published-survey key from the section's `survey-js` field. */
28
+ export function extractSurveyId(section: ISectionLike | undefined): string | null {
29
+ return extractFieldString(section, 'survey-js');
30
+ }
31
+
32
+ export interface IRuntimeSectionConfig {
33
+ restartOnRefresh: boolean;
34
+ autoSaveIntervalSeconds: number;
35
+ timeoutMinutes: number;
36
+ savePdf: boolean;
37
+ closeModalAtEnd: boolean;
38
+ redirectAtEnd: string | null;
39
+ urlParams: boolean;
40
+ startTime: string | null;
41
+ endTime: string | null;
42
+ oncePerUser: boolean;
43
+ oncePerSchedule: boolean;
44
+ ownEntriesOnly: boolean;
45
+ allowAnonymous: boolean;
46
+ labelSurveyDone: string | null;
47
+ labelSurveyNotActive: string | null;
48
+ }
49
+
50
+ export function extractFieldString(section: ISectionLike | undefined, key: string): string | null {
51
+ const value = section?.fields?.[key] ?? section?.[key];
52
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
53
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
54
+ if (
55
+ value &&
56
+ typeof value === 'object' &&
57
+ 'content' in (value as Record<string, unknown>) &&
58
+ (typeof (value as { content?: unknown }).content === 'string' ||
59
+ typeof (value as { content?: unknown }).content === 'number')
60
+ ) {
61
+ const content = (value as { content: string | number }).content;
62
+ return String(content).trim() || null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function extractFieldBoolean(
68
+ section: ISectionLike | undefined,
69
+ key: string,
70
+ fallback: boolean,
71
+ ): boolean {
72
+ const raw = extractFieldString(section, key);
73
+ if (raw === null) return fallback;
74
+ return raw === '1' || raw.toLowerCase() === 'true';
75
+ }
76
+
77
+ export function extractFieldNumber(
78
+ section: ISectionLike | undefined,
79
+ key: string,
80
+ fallback: number,
81
+ ): number {
82
+ const raw = extractFieldString(section, key);
83
+ if (raw === null) return fallback;
84
+ const parsed = Number.parseInt(raw, 10);
85
+ return Number.isFinite(parsed) ? parsed : fallback;
86
+ }
87
+
88
+ export function buildRuntimeConfigFromSection(section?: ISectionLike): IRuntimeSectionConfig {
89
+ return {
90
+ restartOnRefresh: extractFieldBoolean(section, 'restart_on_refresh', false),
91
+ autoSaveIntervalSeconds: extractFieldNumber(section, 'auto_save_interval', 0),
92
+ timeoutMinutes: extractFieldNumber(section, 'timeout', 0),
93
+ savePdf: extractFieldBoolean(section, 'save_pdf', false),
94
+ closeModalAtEnd: extractFieldBoolean(section, 'close_modal_at_end', false),
95
+ redirectAtEnd: extractFieldString(section, 'redirect_at_end'),
96
+ urlParams: extractFieldBoolean(section, 'url_params', false),
97
+ startTime: extractFieldString(section, 'start_time'),
98
+ endTime: extractFieldString(section, 'end_time'),
99
+ oncePerUser: extractFieldBoolean(section, 'once_per_user', false),
100
+ oncePerSchedule: extractFieldBoolean(section, 'once_per_schedule', false),
101
+ ownEntriesOnly: extractFieldBoolean(section, 'own_entries_only', false),
102
+ allowAnonymous: extractFieldBoolean(section, 'allow_anonymous', true),
103
+ labelSurveyDone: extractFieldString(section, 'label_survey_done'),
104
+ labelSurveyNotActive: extractFieldString(section, 'label_survey_not_active'),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Server-side runtime-config echo (sent as `X-SurveyJs-Runtime-Config`)
110
+ * so the backend re-validates the submission against the SAME config the
111
+ * section declares. Mirrors the frontend `configToServerConfig`.
112
+ */
113
+ export function configToServerConfig(config: IRuntimeSectionConfig): Record<string, unknown> {
114
+ return {
115
+ restartOnRefresh: config.restartOnRefresh,
116
+ autoSaveIntervalSeconds: config.autoSaveIntervalSeconds,
117
+ timeoutMinutes: config.timeoutMinutes,
118
+ savePdf: config.savePdf,
119
+ closeModalAtEnd: config.closeModalAtEnd,
120
+ redirectAtEnd: config.redirectAtEnd,
121
+ urlParams: config.urlParams,
122
+ startTime: config.startTime,
123
+ endTime: config.endTime,
124
+ oncePerUser: config.oncePerUser,
125
+ oncePerSchedule: config.oncePerSchedule,
126
+ ownEntriesOnly: config.ownEntriesOnly,
127
+ allowAnonymous: config.allowAnonymous,
128
+ labelSurveyDone: config.labelSurveyDone,
129
+ labelSurveyNotActive: config.labelSurveyNotActive,
130
+ };
131
+ }
@@ -0,0 +1,53 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Native WebView transport (iOS / Android) backed by `react-native-webview`.
7
+ *
8
+ * Lazy-`require`d by `SurveyJsStyle` ONLY on native, so the native module is
9
+ * never evaluated on the web export. Security posture:
10
+ * - loads the self-contained HTML via `source={{ html }}` (no remote URL),
11
+ * - `originWhitelist` is scoped to `about:blank` (not `*`),
12
+ * - `onShouldStartLoadWithRequest` blocks every navigation the shell did not
13
+ * explicitly allow (real redirects go through the native host, not the
14
+ * WebView), and
15
+ * - DOM storage is left off; the runtime needs no persistent storage.
16
+ */
17
+
18
+ import { useEffect, useRef } from 'react';
19
+ import { WebView } from 'react-native-webview';
20
+ import type { WebViewMessageEvent } from 'react-native-webview';
21
+
22
+ import type { IWebViewTransportProps } from './contract';
23
+
24
+ export function SurveyWebViewNative({
25
+ html,
26
+ height,
27
+ onMessage,
28
+ setPost,
29
+ isAllowedUrl,
30
+ }: IWebViewTransportProps): React.ReactElement {
31
+ const ref = useRef<WebView>(null);
32
+
33
+ useEffect(() => {
34
+ setPost((json: string) => {
35
+ ref.current?.postMessage(json);
36
+ });
37
+ }, [setPost]);
38
+
39
+ return (
40
+ <WebView
41
+ ref={ref}
42
+ originWhitelist={['about:blank']}
43
+ source={{ html }}
44
+ onMessage={(event: WebViewMessageEvent) => onMessage(event.nativeEvent.data)}
45
+ onShouldStartLoadWithRequest={(request) => isAllowedUrl(request.url)}
46
+ javaScriptEnabled
47
+ domStorageEnabled={false}
48
+ setSupportMultipleWindows={false}
49
+ allowsInlineMediaPlayback
50
+ style={{ height, backgroundColor: 'transparent' }}
51
+ />
52
+ );
53
+ }
@@ -0,0 +1,63 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Web-export WebView transport backed by an `<iframe srcDoc>`.
7
+ *
8
+ * `react-native-webview` has no web implementation, so the Expo web export
9
+ * (react-native-web → react-dom) renders the SAME self-contained runtime HTML
10
+ * in a sandboxed iframe and uses `window.postMessage` for the bridge.
11
+ *
12
+ * This file uses the DOM, so it is EXCLUDED from the package `tsc` (the mobile
13
+ * tsconfig has no DOM lib) and compiled only by tsup/esbuild + Metro web. It
14
+ * is lazy-`require`d by `SurveyJsStyle` ONLY on web. The iframe is sandboxed
15
+ * (`allow-scripts allow-same-origin`) and the runtime HTML carries a strict
16
+ * CSP (`connect-src 'none'`), so the runtime cannot reach the network — every
17
+ * authenticated call goes through the native host.
18
+ */
19
+
20
+ import { useEffect, useRef } from 'react';
21
+
22
+ import type { IWebViewTransportProps } from './contract';
23
+
24
+ export function SurveyWebViewWeb({
25
+ html,
26
+ height,
27
+ onMessage,
28
+ setPost,
29
+ isAllowedUrl,
30
+ }: IWebViewTransportProps): React.ReactElement {
31
+ const ref = useRef<HTMLIFrameElement | null>(null);
32
+
33
+ useEffect(() => {
34
+ setPost((json: string) => {
35
+ const win = ref.current?.contentWindow;
36
+ if (win) win.postMessage(JSON.parse(json), '*');
37
+ });
38
+ }, [setPost]);
39
+
40
+ useEffect(() => {
41
+ const handler = (event: MessageEvent): void => {
42
+ if (ref.current && event.source === ref.current.contentWindow) {
43
+ onMessage(event.data);
44
+ }
45
+ };
46
+ window.addEventListener('message', handler);
47
+ return () => window.removeEventListener('message', handler);
48
+ }, [onMessage]);
49
+
50
+ // `isAllowedUrl` is enforced by the iframe CSP + sandbox on web; referenced
51
+ // here to keep the transport contract identical across platforms.
52
+ void isAllowedUrl;
53
+
54
+ return (
55
+ <iframe
56
+ ref={ref}
57
+ srcDoc={html}
58
+ title="SurveyJS"
59
+ sandbox="allow-scripts allow-same-origin allow-forms"
60
+ style={{ width: '100%', height, border: '0', background: 'transparent' }}
61
+ />
62
+ );
63
+ }
@@ -0,0 +1,33 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Platform-agnostic contract between the SurveyJS RN shell and its WebView
7
+ * transport. Two implementations exist — `SurveyWebViewNative` (uses
8
+ * `react-native-webview`) and `SurveyWebViewWeb` (uses an `<iframe>`); the
9
+ * shell picks one at runtime by `Platform.OS` and lazy-`require`s it so the
10
+ * native module is never evaluated on web (and vice-versa).
11
+ *
12
+ * Both transports do the SAME two jobs: load the self-contained runtime HTML
13
+ * and shuttle bridge messages. They expose `setPost` so the shell can send
14
+ * host->webview messages, and call `onMessage` for every webview->host
15
+ * message (string on native, object on web — the shell normalises both).
16
+ */
17
+
18
+ export interface IWebViewTransportProps {
19
+ /** Self-contained runtime HTML (survey-core + survey-react-ui inlined). */
20
+ html: string;
21
+ /** Measured content height (px) reported by the runtime via `RESIZE`. */
22
+ height: number;
23
+ /** Called with each inbound webview->host payload (string or object). */
24
+ onMessage: (raw: unknown) => void;
25
+ /** Hands the shell the function that posts a JSON string INTO the runtime. */
26
+ setPost: (post: (json: string) => void) => void;
27
+ /**
28
+ * Security gate: returns true only for URLs the WebView may load itself
29
+ * (the srcdoc/about:blank bootstrap). Everything else is blocked — real
30
+ * redirects go through the native host (`REQUEST_REDIRECT`).
31
+ */
32
+ isAllowedUrl: (url: string) => boolean;
33
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /* eslint-disable */
6
+ /**
7
+ * GENERATED FILE — do not edit by hand.
8
+ *
9
+ * `npm run build:webview` compiles `src/webview` with Vite into one
10
+ * self-contained HTML (SurveyJS JS + CSS inlined, no CDN), then
11
+ * `scripts/wrap-webview-html.mjs` rewrites this file with that HTML as a string
12
+ * constant. This committed PLACEHOLDER is intentionally tiny so the repo stays
13
+ * light and so `tsc`, vitest, and the tsup bundle resolve before the WebView is
14
+ * built; `npm run build` always runs `build:webview` before `tsup`, so the
15
+ * published `dist` carries the real runtime. Do NOT commit the multi-MB built
16
+ * version of this file. If this placeholder ever reaches a device it posts a
17
+ * RUNTIME_ERROR instead of rendering.
18
+ */
19
+
20
+ export const SURVEYJS_WEBVIEW_HTML =
21
+ '<!doctype html><html><head><meta charset="utf-8" /><title>SurveyJS</title></head>' +
22
+ '<body><div id="root"></div><script>' +
23
+ "var m={source:'sh2-surveyjs',type:'RUNTIME_ERROR',message:'SurveyJS WebView runtime asset was not built (run build:webview).'};" +
24
+ 'try{if(window.ReactNativeWebView){window.ReactNativeWebView.postMessage(JSON.stringify(m));}' +
25
+ "else if(window.parent&&window.parent!==window){window.parent.postMessage(m,'*');}}catch(e){}" +
26
+ '</script></body></html>';
@@ -0,0 +1,14 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Public accessor for the self-contained SurveyJS WebView runtime HTML.
7
+ *
8
+ * The native RN shell loads this string into `react-native-webview`
9
+ * (`source={{ html }}`) on native and into a `srcdoc` iframe on web export.
10
+ * The string is the Vite single-file bundle (SurveyJS JS + CSS inlined, no
11
+ * CDN/network for the runtime itself). See `generated/runtimeHtml.ts`.
12
+ */
13
+
14
+ export { SURVEYJS_WEBVIEW_HTML } from './generated/runtimeHtml';
@@ -0,0 +1,20 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ -->
5
+ <!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta
10
+ name="viewport"
11
+ content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
12
+ />
13
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; font-src data:;" />
14
+ <title>SurveyJS</title>
15
+ </head>
16
+ <body>
17
+ <div id="root"></div>
18
+ <script type="module" src="./runtime/main.tsx"></script>
19
+ </body>
20
+ </html>
@@ -0,0 +1,183 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Isolated SurveyJS WebView runtime (browser side).
7
+ *
8
+ * Renders the OFFICIAL SurveyJS React UI (`survey-react-ui` + `survey-core`)
9
+ * — the same library the web frontend uses — so mobile gets full parity
10
+ * (same JSON, question types, validation, conditional logic, completion).
11
+ * It owns the UI lifecycle but NOT the network: it emits typed intents over
12
+ * the bridge and the native host performs every authenticated call and
13
+ * returns the result.
14
+ *
15
+ * Built ONLY by the WebView Vite bundle (`vite.webview.config.ts`) into a
16
+ * single self-contained HTML (survey-core + survey-react-ui JS/CSS inlined,
17
+ * no CDN). Excluded from the package `tsc`.
18
+ */
19
+
20
+ import { useEffect, useRef, useState } from 'react';
21
+ import { Model } from 'survey-core';
22
+ import { Survey } from 'survey-react-ui';
23
+
24
+ import { BRIDGE_SOURCE, BRIDGE_PROTOCOL_VERSION } from '../../bridge/messages';
25
+ import type { THostToWebviewMessage } from '../../bridge/messages';
26
+ import {
27
+ createSurveyRuntimeController,
28
+ type ISurveyRuntimeController,
29
+ type TRuntimeLifecycle,
30
+ } from '../../runtime/controller';
31
+ import { isOutsideSchedule, stripHtml } from '../../runtime/lifecycle';
32
+ import type { IRuntimeSectionConfig } from '../../styles/section';
33
+ import { createRuntimeBridge, type IRuntimeBridge } from './bridge';
34
+
35
+ interface IRuntimeLabels {
36
+ surveyDone: string | null;
37
+ surveyNotActive: string | null;
38
+ }
39
+
40
+ export function SurveyWebviewApp(): React.ReactElement {
41
+ const [lifecycle, setLifecycle] = useState<TRuntimeLifecycle>('booting');
42
+ const [model, setModel] = useState<Model | null>(null);
43
+ const [statusText, setStatusText] = useState<string | null>(null);
44
+
45
+ const bridgeRef = useRef<IRuntimeBridge | null>(null);
46
+ const controllerRef = useRef<ISurveyRuntimeController | null>(null);
47
+ const configRef = useRef<IRuntimeSectionConfig | null>(null);
48
+ const localeRef = useRef<string | null>(null);
49
+ const labelsRef = useRef<IRuntimeLabels>({ surveyDone: null, surveyNotActive: null });
50
+
51
+ useEffect(() => {
52
+ const bridge = createRuntimeBridge((message) => handleHostMessage(message));
53
+ bridgeRef.current = bridge;
54
+ setLifecycle('loading');
55
+ bridge.post({ source: BRIDGE_SOURCE, type: 'READY', protocolVersion: BRIDGE_PROTOCOL_VERSION });
56
+ return () => bridge.dispose();
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, []);
59
+
60
+ // Report content height to the native host so it can size the WebView.
61
+ useEffect(() => {
62
+ const report = (): void => {
63
+ const height = document.documentElement.scrollHeight;
64
+ bridgeRef.current?.post({ source: BRIDGE_SOURCE, type: 'RESIZE', height });
65
+ };
66
+ report();
67
+ const observer = new ResizeObserver(report);
68
+ observer.observe(document.documentElement);
69
+ return () => observer.disconnect();
70
+ }, [lifecycle, model]);
71
+
72
+ function handleHostMessage(message: THostToWebviewMessage): void {
73
+ switch (message.type) {
74
+ case 'INIT': {
75
+ configRef.current = message.config;
76
+ localeRef.current = message.locale;
77
+ if (isOutsideSchedule(message.config)) {
78
+ setStatusText(stripHtml(message.config.labelSurveyNotActive));
79
+ setLifecycle('locked');
80
+ return;
81
+ }
82
+ bridgeRef.current?.post({
83
+ source: BRIDGE_SOURCE,
84
+ type: 'LOAD_SURVEY',
85
+ surveyKey: message.surveyKey,
86
+ });
87
+ return;
88
+ }
89
+ case 'SURVEY_LOADED': {
90
+ labelsRef.current = {
91
+ surveyDone: message.runtimeConfig.labelSurveyDone,
92
+ surveyNotActive: message.runtimeConfig.labelSurveyNotActive,
93
+ };
94
+ if (message.state.lockoutReason) {
95
+ setStatusText(stripHtml(message.runtimeConfig.labelSurveyDone));
96
+ setLifecycle('locked');
97
+ return;
98
+ }
99
+ buildModel(message);
100
+ return;
101
+ }
102
+ default:
103
+ controllerRef.current?.handleHostMessage(message);
104
+ return;
105
+ }
106
+ }
107
+
108
+ function buildModel(message: Extract<THostToWebviewMessage, { type: 'SURVEY_LOADED' }>): void {
109
+ const config = configRef.current;
110
+ if (!config || !bridgeRef.current) return;
111
+
112
+ const nextModel = new Model(message.definition);
113
+ const variables = { ...message.tokens, ...message.extraParams };
114
+ for (const [key, value] of Object.entries(variables)) {
115
+ nextModel.setVariable(key, value);
116
+ }
117
+ if (localeRef.current) nextModel.locale = localeRef.current;
118
+
119
+ let initialResponseId: string | null = null;
120
+ if (message.draft) {
121
+ nextModel.data = { ...message.draft.data };
122
+ if (Number.isInteger(message.draft.pageNo) && message.draft.pageNo >= 0) {
123
+ nextModel.currentPageNo = message.draft.pageNo;
124
+ }
125
+ initialResponseId = message.draft.responseId;
126
+ }
127
+
128
+ const controller = createSurveyRuntimeController({
129
+ config,
130
+ post: bridgeRef.current.post,
131
+ initialResponseId,
132
+ onLifecycle: (next, detail) => {
133
+ if (next === 'locked') setStatusText(stripHtml(labelsRef.current.surveyDone));
134
+ setLifecycle(next);
135
+ if (detail?.message) setStatusText(detail.message);
136
+ },
137
+ });
138
+ controllerRef.current = controller;
139
+ controller.attachModel(nextModel);
140
+ setModel(nextModel);
141
+ }
142
+
143
+ if (lifecycle === 'ready' && model) {
144
+ return <Survey model={model} />;
145
+ }
146
+
147
+ return <StatusView lifecycle={lifecycle} statusText={statusText} />;
148
+ }
149
+
150
+ function StatusView({
151
+ lifecycle,
152
+ statusText,
153
+ }: {
154
+ lifecycle: TRuntimeLifecycle;
155
+ statusText: string | null;
156
+ }): React.ReactElement {
157
+ const message = resolveStatusMessage(lifecycle, statusText);
158
+ return (
159
+ <div className="sh2-survey-status" role="status" aria-live="polite">
160
+ {message}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function resolveStatusMessage(lifecycle: TRuntimeLifecycle, statusText: string | null): string {
166
+ switch (lifecycle) {
167
+ case 'booting':
168
+ case 'loading':
169
+ return 'Loading survey…';
170
+ case 'submitting':
171
+ return 'Submitting your response…';
172
+ case 'submitted':
173
+ return 'Thank you — your response was recorded.';
174
+ case 'locked':
175
+ return statusText ?? 'You have already completed this survey.';
176
+ case 'session-expired':
177
+ return 'Your session has expired. Please reopen this page to continue.';
178
+ case 'error':
179
+ return statusText ?? 'The survey could not be displayed.';
180
+ default:
181
+ return statusText ?? 'Loading survey…';
182
+ }
183
+ }
@@ -0,0 +1,76 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Bridge transport for the isolated WebView runtime (browser side).
7
+ *
8
+ * Outbound (runtime -> host):
9
+ * - native: `window.ReactNativeWebView.postMessage(JSON.stringify(msg))`
10
+ * - web/iframe: `window.parent.postMessage(msg, '*')`
11
+ *
12
+ * Inbound (host -> runtime): both `react-native-webview` (which dispatches a
13
+ * `document`/`window` `message` event with a string `data`) and the iframe
14
+ * `postMessage` (object `data`) are accepted, parsed, and validated against
15
+ * the typed host->webview contract. Anything that fails the guard is dropped.
16
+ *
17
+ * This file is built ONLY by the WebView Vite bundle (it touches `window` /
18
+ * `document`), so it is excluded from the package's `tsc` (no DOM lib) and
19
+ * relies on esbuild for transpilation.
20
+ */
21
+
22
+ import {
23
+ type THostToWebviewMessage,
24
+ type TWebviewToHostMessage,
25
+ isHostToWebviewMessage,
26
+ } from '../../bridge/messages';
27
+
28
+ interface IReactNativeWebView {
29
+ postMessage(payload: string): void;
30
+ }
31
+
32
+ export interface IRuntimeBridge {
33
+ post(message: TWebviewToHostMessage): void;
34
+ dispose(): void;
35
+ }
36
+
37
+ export function createRuntimeBridge(onMessage: (message: THostToWebviewMessage) => void): IRuntimeBridge {
38
+ const rnWebView = (window as unknown as { ReactNativeWebView?: IReactNativeWebView }).ReactNativeWebView;
39
+
40
+ const post = (message: TWebviewToHostMessage): void => {
41
+ if (rnWebView && typeof rnWebView.postMessage === 'function') {
42
+ rnWebView.postMessage(JSON.stringify(message));
43
+ return;
44
+ }
45
+ if (window.parent && window.parent !== window) {
46
+ window.parent.postMessage(message, '*');
47
+ }
48
+ };
49
+
50
+ const handleEvent = (event: Event): void => {
51
+ const raw = (event as MessageEvent).data;
52
+ let parsed: unknown = raw;
53
+ if (typeof raw === 'string') {
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch {
57
+ return; // not our protocol — ignore
58
+ }
59
+ }
60
+ if (isHostToWebviewMessage(parsed)) {
61
+ onMessage(parsed);
62
+ }
63
+ };
64
+
65
+ // react-native-webview injects to `document`; iframe posts to `window`.
66
+ window.addEventListener('message', handleEvent);
67
+ document.addEventListener('message', handleEvent as EventListener);
68
+
69
+ return {
70
+ post,
71
+ dispose() {
72
+ window.removeEventListener('message', handleEvent);
73
+ document.removeEventListener('message', handleEvent as EventListener);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * WebView runtime entry point. Bundled by `vite.webview.config.ts` together
7
+ * with SurveyJS JS + CSS into a single self-contained HTML asset (no CDN).
8
+ */
9
+
10
+ import { StrictMode } from 'react';
11
+ import { createRoot } from 'react-dom/client';
12
+
13
+ import 'survey-core/survey-core.css';
14
+ import './runtime.css';
15
+ import { SurveyWebviewApp } from './SurveyWebviewApp';
16
+
17
+ const container = document.getElementById('root');
18
+ if (container) {
19
+ createRoot(container).render(
20
+ <StrictMode>
21
+ <SurveyWebviewApp />
22
+ </StrictMode>,
23
+ );
24
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /* Minimal runtime chrome. SurveyJS owns the survey UI; this only styles the
6
+ transient status messages and resets margins so RESIZE height is accurate. */
7
+
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ padding: 0;
12
+ background: transparent;
13
+ -webkit-text-size-adjust: 100%;
14
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
15
+ }
16
+
17
+ #root {
18
+ padding: 0;
19
+ }
20
+
21
+ .sh2-survey-status {
22
+ padding: 24px 16px;
23
+ text-align: center;
24
+ color: #4a4a4a;
25
+ font-size: 15px;
26
+ line-height: 1.5;
27
+ }