Skip to content

Commit 84c53b6

Browse files
authored
feat(app): introduce standalone app functionality (usebruno#8338)
1 parent cbc812b commit 84c53b6

28 files changed

Lines changed: 1564 additions & 169 deletions

File tree

packages/bruno-app/src/components/AIAssist/index.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,34 @@ const SUGGESTIONS = {
2727
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
2828
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
2929
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
30+
],
31+
'app-request': [
32+
{ label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' },
33+
{ label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' },
34+
{ label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' },
35+
{ label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' }
36+
],
37+
'app-collection': [
38+
{ label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' },
39+
{ label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' },
40+
{ label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' },
41+
{ label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' }
3042
]
3143
};
3244

3345
const TITLES = {
3446
'tests': 'Generate Tests',
3547
'pre-request': 'Generate Pre-Request Script',
3648
'post-response': 'Generate Post-Response Script',
37-
'docs': 'Generate Documentation'
49+
'docs': 'Generate Documentation',
50+
'app-request': 'Generate App',
51+
'app-collection': 'Generate App'
3852
};
3953

4054
const PREVIEW_LABELS = {
41-
docs: 'Preview · replaces current documentation'
55+
'docs': 'Preview · replaces current documentation',
56+
'app-request': 'Preview · replaces current app',
57+
'app-collection': 'Preview · replaces current app'
4258
};
4359

4460
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { IconApps } from '@tabler/icons';
4+
5+
const Wrapper = styled.div`
6+
flex: 1 1 0;
7+
min-height: 0;
8+
display: flex;
9+
align-items: center;
10+
justify-content: center;
11+
border: 1px dashed ${(props) => props.theme.border.border1};
12+
border-radius: 4px;
13+
background: ${(props) => props.theme.background.surface0};
14+
color: ${(props) => props.theme.colors.text.muted};
15+
16+
.empty-app-inner {
17+
display: flex;
18+
flex-direction: column;
19+
align-items: center;
20+
gap: 0.5rem;
21+
padding: 2rem;
22+
text-align: center;
23+
max-width: 360px;
24+
}
25+
26+
.empty-app-title {
27+
font-size: 13px;
28+
font-weight: 500;
29+
color: ${(props) => props.theme.text};
30+
}
31+
32+
.empty-app-hint {
33+
font-size: 12px;
34+
line-height: 1.4;
35+
}
36+
`;
37+
38+
const EmptyAppState = ({ title = 'No app yet', hint }) => (
39+
<Wrapper data-testid="empty-app-state">
40+
<div className="empty-app-inner">
41+
<IconApps size={32} strokeWidth={1.25} />
42+
<div className="empty-app-title">{title}</div>
43+
{hint ? <div className="empty-app-hint">{hint}</div> : null}
44+
</div>
45+
</Wrapper>
46+
);
47+
48+
export default EmptyAppState;

packages/bruno-app/src/components/AppView/index.js

Lines changed: 57 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react';
1+
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
22
import cloneDeep from 'lodash/cloneDeep';
33
import { useDispatch } from 'react-redux';
44
import { 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';
1116
import { uuid } from 'utils/common';
1217
import { useTheme } from 'providers/Theme';
1318
import 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 = /<html[\s>]/i.test(code) || /<!doctype/i.test(code);
151-
152-
if (isFullDocument) {
153-
if (/<head[^>]*>/i.test(code)) {
154-
return code.replace(/<head[^>]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`);
155-
}
156-
if (/<body[^>]*>/i.test(code)) {
157-
return code.replace(/<body[^>]*>/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-
193129
const buildVariables = (collection) => {
194130
const env = getEnvironmentVariables(collection);
195131
const global = getGlobalEnvironmentVariables({
@@ -207,13 +143,7 @@ const buildVariables = (collection) => {
207143
const 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

Comments
 (0)