Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ type SnapshotKeyValueEventData = {

export interface IpcEvents {
'add-site': [ void ];
'add-site-blueprint': [ { blueprintPath: string } ];
'add-site-blueprint-from-url': [ { blueprintPath: string } ];
'add-site-blueprint-from-base64': [ { blueprintJson: string } ];
'auth-updated': [ { token: StoredToken } | { error: unknown } ];
'on-export': [ ImportExportEventData, string ];
'on-import': [ ImportExportEventData, string ];
Expand Down
48 changes: 35 additions & 13 deletions src/lib/deeplink/handlers/add-site-blueprint-with-url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app, dialog } from 'electron';
import nodePath from 'path';
import * as Sentry from '@sentry/electron/main';
import { __ } from '@wordpress/i18n';
import fs from 'fs-extra';
import { sendIpcEventToRenderer } from 'src/ipc-utils';
Expand All @@ -9,17 +10,46 @@ import { getMainWindow } from 'src/main-window';
/**
* Handles the add-site deeplink callback.
* This function is called when a user clicks a deeplink like:
* wpcom-local-dev://add-site?blueprint_url=<encoded-url>
* - wpcom-local-dev://add-site?blueprint_url=<encoded-url>
* - wpcom-local-dev://add-site?blueprint=<base64-encoded-json>
*
* It downloads the blueprint from the URL, saves it locally, and opens the Add Site modal
* with the blueprint pre-filled.
* It either downloads the blueprint from the URL or decodes the base64 blueprint,
* and opens the Add Site modal with the blueprint pre-filled.
*/
export async function handleAddSiteWithBlueprint( urlObject: URL ): Promise< void > {
const { searchParams } = urlObject;
const blueprintUrl = searchParams.get( 'blueprint_url' );
const blueprintBase64 = searchParams.get( 'blueprint' );

const mainWindow = await getMainWindow();
if ( mainWindow.isMinimized() ) {
mainWindow.restore();
}
mainWindow.focus();

// Handle base64-encoded blueprint in the deeplink URL
if ( blueprintBase64 ) {
try {
const blueprintJson = Buffer.from( blueprintBase64, 'base64' ).toString( 'utf-8' );
JSON.parse( blueprintJson );
await sendIpcEventToRenderer( 'add-site-blueprint-from-base64', { blueprintJson } );
} catch ( error ) {
Sentry.captureException( error );
console.error( 'Failed to parse blueprint from deeplink:', error );

await dialog.showMessageBox( mainWindow, {
type: 'error',
message: __( 'Failed to load blueprint' ),
detail: __( 'The blueprint data is invalid. Please check the link and try again.' ),
buttons: [ __( 'OK' ) ],
} );
}
return;
}

// Handle blueprint linked in the deeplink URL
if ( ! blueprintUrl ) {
console.error( 'add-site deeplink missing blueprint_url parameter' );
console.error( 'add-site deeplink missing blueprint_url or blueprint parameter' );
return;
}

Expand All @@ -41,21 +71,13 @@ export async function handleAddSiteWithBlueprint( urlObject: URL ): Promise< voi

try {
await download( decodedUrl, blueprintPath, false, 'blueprint' );

const mainWindow = await getMainWindow();
if ( mainWindow.isMinimized() ) {
mainWindow.restore();
}
mainWindow.focus();

await sendIpcEventToRenderer( 'add-site-blueprint', { blueprintPath } );
await sendIpcEventToRenderer( 'add-site-blueprint-from-url', { blueprintPath } );
} catch ( error ) {
console.error( 'Failed to download blueprint from deeplink:', error );
await fs.remove( blueprintPath ).catch( () => {
// Ignore cleanup errors
} );

const mainWindow = await getMainWindow();
await dialog.showMessageBox( mainWindow, {
type: 'error',
message: __( 'Failed to download blueprint' ),
Expand Down
38 changes: 34 additions & 4 deletions src/lib/deeplink/tests/add-site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
false,
'blueprint'
);
expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'add-site-blueprint', {
expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'add-site-blueprint-from-url', {
blueprintPath: expect.stringContaining( 'blueprint-' ),
} );
expect( mockMainWindow.focus ).toHaveBeenCalled();
Expand Down Expand Up @@ -97,9 +97,7 @@ describe( 'handleAddSiteWithBlueprint', () => {

expect( download ).toHaveBeenCalled();
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( fs.remove ).toHaveBeenCalledWith(
expect.stringContaining( 'blueprint-' )
);
expect( fs.remove ).toHaveBeenCalledWith( expect.stringContaining( 'blueprint-' ) );
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
Expand Down Expand Up @@ -135,4 +133,36 @@ describe( 'handleAddSiteWithBlueprint', () => {

expect( dialog.showMessageBox ).toHaveBeenCalled();
} );

describe( 'base64 blueprint handling', () => {
it( 'should handle add-site with valid base64-encoded blueprint', async () => {
const blueprintData = {
steps: [ { step: 'login', username: 'admin' } ],
meta: { title: 'Test Blueprint', description: 'A test blueprint' },
};
const blueprintJson = JSON.stringify( blueprintData );
const blueprintBase64 = Buffer.from( blueprintJson ).toString( 'base64' );
const url = new URL( `wpcom-local-dev://add-site?blueprint=${ blueprintBase64 }` );

await handleAddSiteWithBlueprint( url );

expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'add-site-blueprint-from-base64', {
blueprintJson,
} );
expect( download ).not.toHaveBeenCalled();
} );

it( 'should handle invalid base64-encoded blueprint and display error message', async () => {
const url = new URL( 'wpcom-local-dev://add-site?blueprint=invalid-base64!!!' );
await handleAddSiteWithBlueprint( url );

expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
detail: expect.any( String ),
buttons: expect.any( Array ),
} );
} );
} );
} );
121 changes: 88 additions & 33 deletions src/modules/add-site/hooks/use-blueprint-deeplink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { Blueprint } from 'src/stores/wpcom-api';

type BlueprintMetadata = {
title?: string;
description?: string;
};

interface UseBlueprintDeeplinkOptions {
showModal: boolean;
isAnySiteProcessing: boolean;
Expand Down Expand Up @@ -45,7 +50,46 @@ export function useBlueprintDeeplink(
setBlueprintError( null );
}, [] );

const handleAddSiteBlueprint = useCallback(
const createBlueprintFromData = useCallback(
(
blueprintData: { meta?: BlueprintMetadata; [ key: string ]: unknown },
slug: string,
defaultTitle: string,
defaultExcerpt: string
): Blueprint => {
const blueprintMeta = blueprintData.meta as BlueprintMetadata | undefined;
return {
slug,
title: blueprintMeta?.title || defaultTitle,
excerpt: blueprintMeta?.description || defaultExcerpt,
image: '',
playground_url: '',
blueprint: blueprintData,
};
},
[]
);

const applyPreferredVersions = useCallback(
( blueprintData: { preferredVersions?: { php?: string; wp?: string } } ) => {
if ( blueprintData.preferredVersions ) {
const preferredVersions = blueprintData.preferredVersions as {
php?: string;
wp?: string;
};
setBlueprintPreferredVersions( preferredVersions );
if ( preferredVersions.php && preferredVersions.php !== 'latest' ) {
setPhpVersion( preferredVersions.php );
}
if ( preferredVersions.wp && preferredVersions.wp !== 'latest' ) {
setWpVersion( preferredVersions.wp );
}
}
},
[ setBlueprintPreferredVersions, setPhpVersion, setWpVersion ]
);

const handleBlueprintFromUrl = useCallback(
async ( _event: unknown, { blueprintPath }: { blueprintPath: string } ) => {
if ( isAnySiteProcessing ) {
return;
Expand All @@ -56,7 +100,40 @@ export function useBlueprintDeeplink(
},
[ isAnySiteProcessing, openModal ]
);
useIpcListener( 'add-site-blueprint', handleAddSiteBlueprint );
useIpcListener( 'add-site-blueprint-from-url', handleBlueprintFromUrl );

const handleBlueprintFromBase64 = useCallback(
( _event: unknown, { blueprintJson }: { blueprintJson: string } ) => {
if ( isAnySiteProcessing ) {
return;
}
try {
const blueprintData = JSON.parse( blueprintJson );
const blueprint = createBlueprintFromData(
blueprintData,
`deeplink-${ Date.now() }`,
__( 'Custom Blueprint' ),
__( 'Blueprint from base64' )
);

setSelectedBlueprint( blueprint );
applyPreferredVersions( blueprintData );
setInitialNavigatorPath( '/blueprint/create' );
openModal();
} catch ( error ) {
console.error( 'Failed to parse blueprint from IPC event:', error );
}
},
[
isAnySiteProcessing,
__,
createBlueprintFromData,
setSelectedBlueprint,
applyPreferredVersions,
openModal,
]
);
useIpcListener( 'add-site-blueprint-from-base64', handleBlueprintFromBase64 );

// Load and set blueprint when modal opens with a pending blueprint
useEffect( () => {
Expand All @@ -72,37 +149,16 @@ export function useBlueprintDeeplink(
throw new Error( errorMessage );
}

// Create a file blueprint object similar to handleFileSelect
const fileName = pendingBlueprintPath.split( /[/\\]/ ).pop() || 'blueprint.json';
const blueprintMeta = blueprintJson.meta as
| { title?: string; description?: string }
| undefined;
const fileBlueprint: Blueprint = {
slug: `file:${ fileName }`,
title: blueprintMeta?.title || fileName.replace( '.json', '' ),
excerpt: blueprintMeta?.description || __( 'Blueprint loaded from URL' ),
image: '',
playground_url: '',
blueprint: blueprintJson,
};
const fileBlueprint = createBlueprintFromData(
blueprintJson,
`file:${ fileName }`,
fileName.replace( '.json', '' ),
__( 'Blueprint loaded from URL' )
);

setSelectedBlueprint( fileBlueprint );

// Apply preferred versions if any
if ( blueprintJson.preferredVersions ) {
const preferredVersions = blueprintJson.preferredVersions as {
php?: string;
wp?: string;
};
setBlueprintPreferredVersions( preferredVersions );
if ( preferredVersions.php && preferredVersions.php !== 'latest' ) {
setPhpVersion( preferredVersions.php );
}
if ( preferredVersions.wp && preferredVersions.wp !== 'latest' ) {
setWpVersion( preferredVersions.wp );
}
}

applyPreferredVersions( blueprintJson );
setPendingBlueprintPath( null );
} catch ( error ) {
const errorMessage =
Expand All @@ -119,10 +175,9 @@ export function useBlueprintDeeplink(
}, [
showModal,
pendingBlueprintPath,
createBlueprintFromData,
setSelectedBlueprint,
setPhpVersion,
setWpVersion,
setBlueprintPreferredVersions,
applyPreferredVersions,
setBlueprintError,
__,
] );
Expand Down