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
22 changes: 13 additions & 9 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,16 @@ export function App() {
setTemplates(list);
}, []);

const handleConfigSave = useCallback((next: AppConfig) => {
// Saving from any settings dialog (welcome or regular) counts as
// having completed onboarding — the user has actively chosen a
// configuration, so future page loads can skip the auto-popup.
const handleConfigSave = useCallback(async (next: AppConfig, closeModal: boolean = true) => {
// Only sync Composio key to the daemon when it actually changed,
// so unrelated saves (theme, model, etc.) are never blocked.
const composioChanged =
next.composio?.apiKey !== config.composio?.apiKey ||
next.composio?.apiKeyConfigured !== config.composio?.apiKeyConfigured;
if (composioChanged) {
const ok = await syncComposioConfigToDaemon(next.composio);
if (!ok) return { success: false };
}
const withOnboarding: AppConfig = {
...next,
composio: normalizeSavedComposioConfig(next.composio),
Expand All @@ -257,12 +263,10 @@ export function App() {
force: true,
});
void syncConfigToDaemon(withOnboarding);
// Keep the Composio secret out of localStorage, but send the raw pending
// edit to the daemon before it is normalized away for local persistence.
void syncComposioConfigToDaemon(next.composio);
setConfig(withOnboarding);
setSettingsOpen(false);
}, []);
if (closeModal) setSettingsOpen(false);
return { success: true };
}, [config]);

const handleModeChange = useCallback(
(mode: AppConfig['mode']) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ interface Props {
appVersionInfo: AppVersionInfo | null;
welcome?: boolean;
initialSection?: SettingsSection;
onSave: (cfg: AppConfig) => void;
onSave: (cfg: AppConfig, closeModal?: boolean) => Promise<{ success: boolean }> | void;
onClose: () => void;
onRefreshAgents: (
options?: AgentRefreshOptions,
Expand Down Expand Up @@ -1686,7 +1686,7 @@ export function SettingsDialog({
type="button"
className="primary"
disabled={!canSave}
onClick={() => onSave(cfg)}
onClick={() => onSave(cfg, activeSection !== 'composio')}
>
{welcome ? t('settings.getStarted') : t('common.save')}
</button>
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/runtime/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,31 @@ export function exportAsPdf(
}
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank', sandboxedPreview ? 'noopener,noreferrer' : undefined);

// Open an empty tab synchronously (without noopener) to reliably detect popup blocking.
// Since window.open with 'noopener' returns null on success by specification,
// this approach allows us to distinguish between a successful export and a blocked popup.
const win = window.open('', '_blank');
Comment thread
PratikRai0101 marked this conversation as resolved.

if (!win) {
// Popup blocked — at least the tab navigation may have happened above.
// Nothing else we can do without a fresh user gesture.
if (typeof alert !== 'undefined') {
alert('Popup blocked! Please allow popups for this site to export as PDF.');
}
URL.revokeObjectURL(url); // Prevent memory leaks on early exit
return;
Comment thread
PratikRai0101 marked this conversation as resolved.
}
// Revoke later — the loaded document keeps a reference until the tab
// closes; revoking the URL string only removes the lookup name.
setTimeout(() => URL.revokeObjectURL(url), 60_000);

if (sandboxedPreview) {
try {
// Disassociate the opener reference to preserve sandboxing/noopener behavior
win.opener = null;
} catch (e) {
// Guard against potential context environment restrictions
}
}

// Navigate the verified window to the generated Blob URL
win.location.href = url;
}

function injectPrintScript(doc: string, title: string): string {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/state/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,19 +294,20 @@ export async function fetchComposioConfigFromDaemon(): Promise<AppConfig['compos

export async function syncComposioConfigToDaemon(
config: AppConfig['composio'] | undefined,
): Promise<void> {
): Promise<boolean> {
const apiKey = config?.apiKey ?? '';
const payload = {
...(apiKey.trim() || !config?.apiKeyConfigured ? { apiKey } : {}),
};
try {
await fetch('/api/connectors/composio/config', {
const response = await fetch('/api/connectors/composio/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
return response.ok;
} catch {
// Daemon offline; localStorage keeps the user's copy for the next save.
return false;
}
}

Expand Down
40 changes: 33 additions & 7 deletions apps/web/tests/runtime/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,24 +145,30 @@ describe('exportAsMd', () => {
describe('sandboxed preview Blob exports', () => {
let capturedBlob: Blob | undefined;
let openedFeatures: string | undefined;
let mockWin: { opener: unknown; location: { href: string } };
let openCalls: string[][];

beforeEach(() => {
capturedBlob = undefined;
openedFeatures = undefined;
openCalls = [];
mockWin = { opener: {}, location: { href: '' } };
vi.stubGlobal('URL', {
createObjectURL: (blob: Blob) => {
capturedBlob = blob;
return 'blob:test';
},
revokeObjectURL: () => {},
revokeObjectURL: vi.fn(),
});
vi.stubGlobal('window', {
open: (_url: string, _target: string, features?: string) => {
openCalls.push([_url, _target]);
openedFeatures = features;
return null;
return mockWin;
},
addEventListener: () => {},
});
vi.stubGlobal('alert', vi.fn());
});

afterEach(() => {
Expand Down Expand Up @@ -206,10 +212,12 @@ describe('sandboxed preview Blob exports', () => {
expect(wrapper).not.toContain('allow-same-origin');
});

it('uses a sandboxed noopener Blob wrapper by default for PDF exports', async () => {
it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');

expect(openedFeatures).toBe('noopener,noreferrer');
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toBeNull();
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const wrapper = await capturedBlob!.text();
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
Expand All @@ -221,11 +229,12 @@ describe('sandboxed preview Blob exports', () => {
it('preserves deck print handling inside sandboxed PDF exports', async () => {
exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });

expect(openedFeatures).toBe('noopener,noreferrer');
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toBeNull();
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const wrapper = await capturedBlob!.text();
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
expect(wrapper).not.toContain('allow-same-origin');
expect(wrapper).toContain('data-deck-print=&quot;injected&quot;');
expect(wrapper).toContain('page-break-after: always;');
});
Expand All @@ -235,10 +244,27 @@ describe('sandboxed preview Blob exports', () => {
sandboxedPreview: false,
});

expect(openedFeatures).toBeUndefined();
expect(openCalls).toEqual([['', '_blank']]);
expect(mockWin.opener).toEqual({});
expect(mockWin.location.href).toBe('blob:test');
expect(capturedBlob).toBeDefined();
const doc = await capturedBlob!.text();
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
expect(doc).toContain('<main>Trusted local document</main>');
});

it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
vi.stubGlobal('window', {
...window,
open: () => null,
});

const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
revokeSpy.mockClear();

exportAsPdf('<p>test</p>', 'Blocked');

expect(alert).toHaveBeenCalledWith('Popup blocked! Please allow popups for this site to export as PDF.');
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
});
Loading