diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index 649746173..04ff3acd9 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -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 ]; diff --git a/src/lib/deeplink/handlers/add-site-blueprint-with-url.ts b/src/lib/deeplink/handlers/add-site-blueprint-with-url.ts index f13af130a..6615a09ce 100644 --- a/src/lib/deeplink/handlers/add-site-blueprint-with-url.ts +++ b/src/lib/deeplink/handlers/add-site-blueprint-with-url.ts @@ -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'; @@ -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= + * - wpcom-local-dev://add-site?blueprint_url= + * - wpcom-local-dev://add-site?blueprint= * - * 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; } @@ -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' ), diff --git a/src/lib/deeplink/tests/add-site.test.ts b/src/lib/deeplink/tests/add-site.test.ts index 43eb5a1dc..fac1f9f93 100644 --- a/src/lib/deeplink/tests/add-site.test.ts +++ b/src/lib/deeplink/tests/add-site.test.ts @@ -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(); @@ -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 ), @@ -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 ), + } ); + } ); + } ); } ); diff --git a/src/modules/add-site/hooks/use-blueprint-deeplink.ts b/src/modules/add-site/hooks/use-blueprint-deeplink.ts index 08d854ead..fbaa7c806 100644 --- a/src/modules/add-site/hooks/use-blueprint-deeplink.ts +++ b/src/modules/add-site/hooks/use-blueprint-deeplink.ts @@ -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; @@ -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; @@ -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( () => { @@ -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 = @@ -119,10 +175,9 @@ export function useBlueprintDeeplink( }, [ showModal, pendingBlueprintPath, + createBlueprintFromData, setSelectedBlueprint, - setPhpVersion, - setWpVersion, - setBlueprintPreferredVersions, + applyPreferredVersions, setBlueprintError, __, ] );