1- import React , { useRef , useEffect , useCallback , useMemo , useState } from 'react' ;
1+ import React , { useRef , useEffect , useCallback , useMemo } from 'react' ;
22import cloneDeep from 'lodash/cloneDeep' ;
33import { useDispatch } from 'react-redux' ;
44import { sendNetworkRequest } from 'utils/network/index' ;
@@ -7,31 +7,28 @@ import {
77 getEnvironmentVariables ,
88 getGlobalEnvironmentVariables
99} from 'utils/collections' ;
10- import { responseReceived , appSetRuntimeVariable , toggleAppMode , initRunRequestEvent } from 'providers/ReduxStore/slices/collections' ;
10+ import {
11+ responseReceived ,
12+ appSetRuntimeVariable ,
13+ toggleAppMode ,
14+ initRunRequestEvent
15+ } from 'providers/ReduxStore/slices/collections' ;
1116import { uuid } from 'utils/common' ;
1217import { useTheme } from 'providers/Theme' ;
1318import StyledWrapper from './StyledWrapper' ;
14-
15- /*
16- * App content runs inside an Electron <webview>, which is an out-of-process guest
17- * with its own document, so it does NOT inherit the app's strict CSP (script-src 'self')
18- * This mirrors the HtmlPreview component used for HTML response previews.
19- *
20- * Messaging (no node integration in the guest, so postMessage/ipc aren't available):
21- * host -> guest : webview.executeJavaScript(`window.__brunoReceive(<json>)`)
22- * guest -> host : console.log(SENTINEL + json), read via the 'console-message' event
23- */
24- const SENTINEL = '__BRUNO_APP_MSG__' ;
25-
26- // Encode a value for safe inlining into an executeJavaScript() string as a JS object literal.
27- const toJsArg = ( value ) =>
28- JSON . stringify ( value === undefined ? null : value )
29- . replace ( / < / g, '\\u003c' )
30- . replace ( / \u2028 / g, '\\u2028' )
31- . replace ( / \u2029 / g, '\\u2029' ) ;
32-
33- // The ctx bridge. Injected as early as possible so window.ctx exists before user scripts run.
34- const BOOTSTRAP_SCRIPT = `<script>
19+ import EmptyAppState from './EmptyAppState' ;
20+ import {
21+ SENTINEL ,
22+ wrapHtml ,
23+ toDataUrl ,
24+ serializeTimeline ,
25+ projectResponse ,
26+ useAppWebview
27+ } from './webview-bridge' ;
28+
29+ // Request-level ctx bootstrap. Injected into the guest so window.ctx exists
30+ // before user scripts run.
31+ const REQUEST_CTX_BOOTSTRAP = `<script>
3532(function () {
3633 if (window.__brunoBootstrapped) return;
3734 window.__brunoBootstrapped = true;
@@ -81,7 +78,6 @@ const BOOTSTRAP_SCRIPT = `<script>
8178 }
8279 }
8380
84- // Host -> guest entry point.
8581 window.__brunoReceive = function (msg) {
8682 if (!msg) return;
8783 switch (msg.type) {
@@ -130,66 +126,6 @@ const BOOTSTRAP_SCRIPT = `<script>
130126})();
131127</script>` ;
132128
133- const FRAGMENT_STYLES = `<style>
134- * { box-sizing: border-box; }
135- body {
136- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
137- margin: 0;
138- background: #ffffff;
139- color: #1e1e1e;
140- transition: background-color 0.15s, color 0.15s;
141- }
142- body.dark { background: #1e1e1e; color: #e0e0e0; }
143- </style>` ;
144-
145- // User code may be a full HTML document or a fragment. For a full document we inject
146- // the bootstrap into it (avoids producing a malformed nested document); a fragment is
147- // wrapped in a minimal shell.
148- const generateAppHtml = ( userCode ) => {
149- const code = userCode || '' ;
150- const isFullDocument = / < h t m l [ \s > ] / i. test ( code ) || / < ! d o c t y p e / i. test ( code ) ;
151-
152- if ( isFullDocument ) {
153- if ( / < h e a d [ ^ > ] * > / i. test ( code ) ) {
154- return code . replace ( / < h e a d [ ^ > ] * > / i, ( m ) => `${ m } ${ BOOTSTRAP_SCRIPT } ` ) ;
155- }
156- if ( / < b o d y [ ^ > ] * > / i. test ( code ) ) {
157- return code . replace ( / < b o d y [ ^ > ] * > / i, ( m ) => `${ m } ${ BOOTSTRAP_SCRIPT } ` ) ;
158- }
159- return `${ BOOTSTRAP_SCRIPT } ${ code } ` ;
160- }
161-
162- return `<!DOCTYPE html>
163- <html>
164- <head>
165- <meta charset="utf-8" />
166- <meta name="viewport" content="width=device-width, initial-scale=1" />
167- ${ FRAGMENT_STYLES }
168- ${ BOOTSTRAP_SCRIPT }
169- </head>
170- <body>
171- ${ code }
172- </body>
173- </html>` ;
174- } ;
175-
176- const serializeTimeline = ( timeline ) => {
177- if ( ! Array . isArray ( timeline ) ) return timeline ;
178- return timeline . map ( ( entry ) => ( {
179- ...entry ,
180- timestamp : entry . timestamp instanceof Date ? entry . timestamp . getTime ( ) : entry . timestamp
181- } ) ) ;
182- } ;
183-
184- const projectResponse = ( r ) => ( {
185- status : r ?. status ?? null ,
186- statusText : r ?. statusText ?? null ,
187- data : r ?. data ?? null ,
188- headers : r ?. headers ?? null ,
189- duration : r ?. duration ?? null ,
190- size : r ?. size ?? null
191- } ) ;
192-
193129const buildVariables = ( collection ) => {
194130 const env = getEnvironmentVariables ( collection ) ;
195131 const global = getGlobalEnvironmentVariables ( {
@@ -207,13 +143,7 @@ const buildVariables = (collection) => {
207143const AppView = ( { item, collection, code } ) => {
208144 const dispatch = useDispatch ( ) ;
209145 const { displayedTheme } = useTheme ( ) ;
210- const webviewRef = useRef ( null ) ;
211- const [ domReady , setDomReady ] = useState ( false ) ;
212-
213- const src = useMemo (
214- ( ) => `data:text/html;charset=utf-8,${ encodeURIComponent ( generateAppHtml ( code || '' ) ) } ` ,
215- [ code ]
216- ) ;
146+ const src = useMemo ( ( ) => toDataUrl ( wrapHtml ( REQUEST_CTX_BOOTSTRAP , code || '' ) ) , [ code ] ) ;
217147
218148 const environment = useMemo (
219149 ( ) => findEnvironmentInCollection ( collection , collection . activeEnvironmentUid ) ,
@@ -224,28 +154,26 @@ const AppView = ({ item, collection, code }) => {
224154 const assertionResults = useMemo ( ( ) => item . assertionResults || [ ] , [ item . assertionResults ] ) ;
225155 const testResults = useMemo ( ( ) => item . testResults || [ ] , [ item . testResults ] ) ;
226156
227- // Push a message into the guest. Safe to call before dom-ready (no-op until then).
228- const pushToGuest = useCallback ( ( msg ) => {
229- const webview = webviewRef . current ;
230- if ( ! webview || ! domReady ) return ;
231- try {
232- webview . executeJavaScript ( `window.__brunoReceive && window.__brunoReceive(${ toJsArg ( msg ) } )` ) . catch ( ( ) => { } ) ;
233- } catch ( _ ) {
234- /* webview not attached yet */
235- }
236- } , [ domReady ] ) ;
157+ // pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
158+ // routing through a ref lets the callbacks call the *latest* pushToGuest without
159+ // creating a circular useCallback dependency. Without this, the request-id reply
160+ // (and error reply) close over the first-render no-op pushToGuest and the guest's
161+ // ctx.sendRequest() promise never resolves.
162+ const pushToGuestRef = useRef ( ( ) => { } ) ;
237163
238164 const handleSendRequest = useCallback (
239165 async ( requestId , overrides ) => {
166+ const push = pushToGuestRef . current ;
240167 try {
241168 // Mint a requestUid and register the run so the main process emits its
242- // test/assertion/script events against an id the store recognises — this is
243- // what makes ctx.testResults / ctx.assertionResults populate (same as Send) .
169+ // test/assertion/script events against an id the store recognises — this
170+ // is what makes ctx.testResults / ctx.assertionResults populate.
244171 const requestUid = uuid ( ) ;
245172 const requestItem = cloneDeep ( item . draft || item ) ;
246173 requestItem . requestUid = requestUid ;
247174 dispatch ( initRunRequestEvent ( { requestUid, itemUid : item . uid , collectionUid : collection . uid } ) ) ;
248175
176+ // Variable overrides: accept flat keys or { variables: {...} }.
249177 const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : { } ;
250178 const explicitVars = flatOverrides . variables ;
251179 delete flatOverrides . variables ;
@@ -257,13 +185,13 @@ const AppView = ({ item, collection, code }) => {
257185
258186 const result = await sendNetworkRequest ( requestItem , collection , environment , mergedRuntime ) ;
259187
260- // sendNetworkRequest resolves (rather than rejects) on network/request
261- // errors with an `error` payload — surface that to the guest as a rejection .
188+ // sendNetworkRequest resolves on network/request errors with `error` set —
189+ // surface as a guest-side promise rejection rather than a fake success .
262190 if ( result ?. error ) {
263191 const errorMessage = typeof result . error === 'string'
264192 ? result . error
265193 : result . error ?. message || 'Request failed' ;
266- pushToGuest ( { type : 'response' , requestId, error : errorMessage } ) ;
194+ push ( { type : 'response' , requestId, error : errorMessage } ) ;
267195 return ;
268196 }
269197
@@ -284,19 +212,18 @@ const AppView = ({ item, collection, code }) => {
284212 } )
285213 ) ;
286214
287- pushToGuest ( { type : 'response' , requestId, response : projectResponse ( result ) } ) ;
215+ push ( { type : 'response' , requestId, response : projectResponse ( result ) } ) ;
288216 } catch ( err ) {
289- pushToGuest ( { type : 'response' , requestId, error : err ?. message || 'Request failed' } ) ;
217+ push ( { type : 'response' , requestId, error : err ?. message || 'Request failed' } ) ;
290218 }
291219 } ,
292- [ item , collection , environment , dispatch , pushToGuest ]
220+ [ item , collection , environment , dispatch ]
293221 ) ;
294222
295223 const handleGuestMessage = useCallback (
296224 ( data ) => {
297225 switch ( data ?. type ) {
298226 case 'ready' :
299- // Readiness is tracked via the webview 'dom-ready' event; nothing to do here.
300227 break ;
301228 case 'sendRequest' :
302229 handleSendRequest ( data . requestId , data . overrides ) ;
@@ -316,38 +243,12 @@ const AppView = ({ item, collection, code }) => {
316243 [ handleSendRequest , dispatch , collection . uid ]
317244 ) ;
318245
319- useEffect ( ( ) => {
320- const webview = webviewRef . current ;
321- if ( ! webview ) return ;
246+ const { domReady, pushToGuest, webviewRef } = useAppWebview ( handleGuestMessage ) ;
247+ pushToGuestRef . current = pushToGuest ;
322248
323- const onConsoleMessage = ( e ) => {
324- const text = e ?. message ;
325- if ( typeof text !== 'string' || ! text . startsWith ( SENTINEL ) ) return ;
326- try {
327- handleGuestMessage ( JSON . parse ( text . slice ( SENTINEL . length ) ) ) ;
328- } catch ( _ ) {
329- /* not our message */
330- }
331- } ;
332- // executeJavaScript() is only valid after Electron's 'dom-ready'; gate on that.
333- // A reload (e.g. code change) tears the guest down, so reset readiness then.
334- const onDomReady = ( ) => setDomReady ( true ) ;
335- const onStartLoading = ( ) => setDomReady ( false ) ;
336-
337- webview . addEventListener ( 'console-message' , onConsoleMessage ) ;
338- webview . addEventListener ( 'dom-ready' , onDomReady ) ;
339- webview . addEventListener ( 'did-start-loading' , onStartLoading ) ;
340-
341- return ( ) => {
342- webview . removeEventListener ( 'console-message' , onConsoleMessage ) ;
343- webview . removeEventListener ( 'dom-ready' , onDomReady ) ;
344- webview . removeEventListener ( 'did-start-loading' , onStartLoading ) ;
345- } ;
346- } , [ handleGuestMessage ] ) ;
347-
348- // Push initial state once the guest signals ready (also after a reload).
349- // Push a full state snapshot on the readiness transition (initial load and after reloads).
350- // Subsequent changes are handled by the granular effects below.
249+ // Push a full state snapshot on each readiness transition. Subsequent changes
250+ // are handled by the granular effects below; using a ref avoids re-firing
251+ // this effect (which would be a needless full re-broadcast).
351252 const stateRef = useRef ( ) ;
352253 stateRef . current = { theme : displayedTheme , response, assertionResults, testResults, variables } ;
353254 useEffect ( ( ) => {
@@ -383,15 +284,22 @@ const AppView = ({ item, collection, code }) => {
383284 Exit to editor
384285 </ button >
385286 </ div >
386- < div className = "app-webview-container" >
387- < webview
388- ref = { webviewRef }
389- src = { src }
390- partition = "persist:bruno-app-view"
391- webpreferences = "disableDialogs=true, javascript=yes"
392- className = "app-webview"
287+ { code && code . trim ( ) . length ? (
288+ < div className = "app-webview-container" >
289+ < webview
290+ ref = { webviewRef }
291+ src = { src }
292+ partition = "persist:bruno-app-view"
293+ webpreferences = "disableDialogs=true, javascript=yes"
294+ className = "app-webview"
295+ />
296+ </ div >
297+ ) : (
298+ < EmptyAppState
299+ title = "No app yet"
300+ hint = "Switch to the App tab on this request and write some HTML/JS to get started."
393301 />
394- </ div >
302+ ) }
395303 </ StyledWrapper >
396304 ) ;
397305} ;
0 commit comments