diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index d559877f16..3630d3da29 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -67,7 +67,11 @@ export interface StartPlaygroundOptions { */ onBeforeBlueprint?: () => Promise; mounts?: Array; - shouldInstallWordPress?: boolean; + /** + * Whether to install WordPress. Value may be boolean or 'auto'. + * If 'auto', WordPress will be installed if it is not already installed. + */ + shouldInstallWordPress?: boolean | 'auto'; /** * The string prefix used in the site URL served by the currently * running remote.html. E.g. for a prefix like `/scope:playground/`, diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index d5100dd27a..d104124fe5 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -15,7 +15,10 @@ import { sqliteDatabaseIntegrationModuleDetails, MinifiedWordPressVersionsList, } from '@wp-playground/wordpress-builds'; -import { directoryHandleFromMountDevice } from '@wp-playground/storage'; +import { + directoryHandleFromMountDevice, + fileExistsUnderDirectoryHandle, +} from '@wp-playground/storage'; import { randomString } from '@php-wasm/util'; import { spawnHandlerFactory, @@ -45,6 +48,7 @@ import { bootWordPress, getFileNotFoundActionForWordPress, getLoadedWordPressVersion, + looksLikePlaygroundDirectory, } from '@wp-playground/wordpress'; import { wpVersionToStaticAssetsDirectory } from '@wp-playground/wordpress-builds'; import { logger } from '@php-wasm/logger'; @@ -72,7 +76,7 @@ export type WorkerBootOptions = { scope: string; withNetworking: boolean; mounts?: Array; - shouldInstallWordPress?: boolean; + shouldInstallWordPress?: boolean | 'auto'; }; /** @inheritDoc PHPClient */ @@ -188,6 +192,29 @@ export class PlaygroundWorkerEndpoint extends PHPWorker { } try { + if (shouldInstallWordPress === 'auto') { + // Default to installing WordPress unless we detect + // it in one of the mounts. + shouldInstallWordPress = true; + + // NOTE: This check is insufficient if a complete WordPress + // installation is composed of multiple mounts. + for (const mount of mounts) { + const dirHandle = await directoryHandleFromMountDevice( + mount.device + ); + const fileExistsUnderMount = (relativePath: string) => + fileExistsUnderDirectoryHandle(dirHandle, relativePath); + + if ( + await looksLikePlaygroundDirectory(fileExistsUnderMount) + ) { + shouldInstallWordPress = false; + break; + } + } + } + // Start downloading WordPress if needed let wordPressRequest = null; if (shouldInstallWordPress) { diff --git a/packages/playground/storage/src/lib/browser-fs.ts b/packages/playground/storage/src/lib/browser-fs.ts index 889745b7ba..0b602da645 100644 --- a/packages/playground/storage/src/lib/browser-fs.ts +++ b/packages/playground/storage/src/lib/browser-fs.ts @@ -23,6 +23,31 @@ export async function opfsPathToDirectoryHandle( return handle; } +export async function fileExistsUnderDirectoryHandle( + directoryHandle: FileSystemDirectoryHandle, + relativePath: string +): Promise { + const parts = relativePath.split('/').filter((p) => p.length > 0); + const fileName = parts.pop(); + if (fileName === undefined) { + throw new Error('Invalid relative path.'); + } + + for (const part of parts) { + try { + directoryHandle = await directoryHandle.getDirectoryHandle(part); + } catch (e: any) { + return false; + } + } + try { + await directoryHandle.getFileHandle(fileName); + return true; + } catch (e) { + return false; + } +} + export async function directoryHandleToOpfsPath( directoryHandle: FileSystemDirectoryHandle ): Promise { diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 7df006b8bf..d34566c7a2 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -16,6 +16,8 @@ import { logger } from '@php-wasm/logger'; import { setupPostMessageRelay } from '@php-wasm/web'; import { startPlaygroundWeb } from '@wp-playground/client'; import { PlaygroundClient } from '@wp-playground/remote'; +import { fileExistsUnderDirectoryHandle } from '@wp-playground/storage'; +import { looksLikePlaygroundDirectory } from '@wp-playground/wordpress'; import { getRemoteUrl } from '../../config'; import { setActiveModal, setActiveSiteError } from './slice-ui'; import { PlaygroundDispatch, PlaygroundReduxState } from './store'; @@ -72,8 +74,15 @@ export function bootSiteClient( let isWordPressInstalled = false; if (mountDescriptor) { try { - isWordPressInstalled = await playgroundAvailableInOpfs( - await directoryHandleFromMountDevice(mountDescriptor.device) + const mountDirHandle = await directoryHandleFromMountDevice( + mountDescriptor.device + ); + isWordPressInstalled = await looksLikePlaygroundDirectory( + (relativePath: string) => + fileExistsUnderDirectoryHandle( + mountDirHandle, + relativePath + ) ); } catch (e) { logger.error(e); @@ -207,47 +216,3 @@ export function bootSiteClient( signal.onabort = null; }; } - -/** - * Check if the given directory handle directory is a Playground directory. - * - * @TODO: Create a shared package like @wp-playground/wordpress for such utilities - * and bring in the context detection logic from wp-now – only express it in terms of - * either abstract FS operations or isomorphic PHP FS operations. - * (we can't just use Node.js require('fs') in the browser, for example) - * - * @TODO: Reuse the "isWordPressInstalled" logic implemented in the boot protocol. - * Perhaps mount OPFS first, and only then check for the presence of the - * WordPress installation? Or, if not, perhaps implement a shared file access - * abstraction that can be used both with the PHP module and OPFS directory handles? - * - * @param dirHandle - */ -export async function playgroundAvailableInOpfs( - dirHandle: FileSystemDirectoryHandle -) { - // Run this loop just to trigger an exception if the directory handle is no good. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of dirHandle.keys()) { - break; - } - - try { - /** - * Assume it's a Playground directory if these files exist: - * - wp-config.php - * - wp-content/database/.ht.sqlite - */ - await dirHandle.getFileHandle('wp-config.php', { create: false }); - const wpContent = await dirHandle.getDirectoryHandle('wp-content', { - create: false, - }); - const database = await wpContent.getDirectoryHandle('database', { - create: false, - }); - await database.getFileHandle('.ht.sqlite', { create: false }); - } catch (e) { - return false; - } - return true; -} diff --git a/packages/playground/wordpress/src/index.ts b/packages/playground/wordpress/src/index.ts index 711a9dcc8b..65f8787c13 100644 --- a/packages/playground/wordpress/src/index.ts +++ b/packages/playground/wordpress/src/index.ts @@ -336,7 +336,7 @@ export async function unzipWordPress(php: PHP, wpZip: File) { : '/tmp/unzipped-wordpress'; // Dive one directory deeper if the zip root does not contain the sample - // config file. This is relevant when unzipping a zipped branch from the + // config file. This is relevant when unzipping a zipped branch from the // https://github.com/WordPress/WordPress repository. if (!php.fileExists(joinPaths(wpPath, 'wp-config-sample.php'))) { // Still don't know the directory structure of the zip file. @@ -384,6 +384,21 @@ export async function unzipWordPress(php: PHP, wpZip: File) { } } +/** + * Check if the given directory handle directory is a Playground directory. + * + * @param fileExists Function A function that checks if a file exists relative to an assumed directory. + * @returns Promise Whether the directory looks like a Playground directory. + */ +export async function looksLikePlaygroundDirectory( + fileExists: (relativePath: string) => Promise +) { + const results = await Promise.all( + ['wp-config.php', 'wp-content/database/.ht.sqlite'].map(fileExists) + ); + return results.every(Boolean); +} + function isCleanDirContainingSiteMetadata(path: string, php: PHP) { const files = php.listFiles(path); if (files.length === 0) {