Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ CLAUDE.md
apps/studio/bin/node
apps/studio/bin/node.exe

# Bundled PHP binary (downloaded during packaging)
apps/studio/php-bin/

# CLI launcher executable (built during packaging via @yao-pkg/pkg)
bin/studio-cli.exe

Expand Down
4 changes: 2 additions & 2 deletions apps/cli/lib/dependency-management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ function isNativePhpSupportedVersion( version: string ): version is NativePhpSup
return ( NativePhpSupportedVersions as readonly string[] ).includes( version );
}

// PHP binaries live in ~/.studio/php-bin/<patch>/ — downloaded on demand when a site
// using that minor version is first started. Not bundled in production builds.
// PHP binaries live in ~/.studio/php-bin/<patch>/. The default version also ships with
// Studio and is copied into this writable location by a CLI migration.
export function getPhpBinaryPath( version: NativePhpSupportedVersion | string ): string {
if ( ! isNativePhpSupportedVersion( version ) ) {
return getExactPhpBinaryPath( version );
Expand Down
63 changes: 63 additions & 0 deletions apps/cli/migrations/06-install-bundled-default-php.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
import { getConfiguredPhpBinaryVersion } from '@studio/common/lib/php-binary-metadata';
import { getPhpBinaryPath } from 'cli/lib/dependency-management/paths';
import type { Migration } from '@studio/common/lib/migration';

const PHP_BINARY_FILENAME = process.platform === 'win32' ? 'php.exe' : 'php';

function getBundledPhpBinaryRoot(): string {
return (
process.env.STUDIO_BUNDLED_PHP_BIN_ROOT ?? path.resolve( import.meta.dirname, '..', 'php-bin' )
);
}

function getDefaultPhpPatchVersion(): string {
return getConfiguredPhpBinaryVersion( DEFAULT_PHP_VERSION ) ?? DEFAULT_PHP_VERSION;
}

function getBundledDefaultPhpDir(): string {
return path.join( getBundledPhpBinaryRoot(), getDefaultPhpPatchVersion() );
}

function getBundledDefaultPhpPath(): string {
return path.join( getBundledDefaultPhpDir(), PHP_BINARY_FILENAME );
}

function getDefaultPhpDestinationDir(): string {
return path.dirname( getPhpBinaryPath( DEFAULT_PHP_VERSION ) );
}

function bundledDefaultPhpExists(): boolean {
return fs.existsSync( getBundledDefaultPhpPath() );
}

// Packaged Studio builds include the default PHP package in app resources.
// Copy it into the writable PHP cache so first native-PHP startup works offline.
export const installBundledDefaultPhp: Migration = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a comment that explains what this migration does.

needsToRun: async () => {
return bundledDefaultPhpExists() && ! fs.existsSync( getDefaultPhpDestinationDir() );
},
run: async () => {
const destinationDir = getDefaultPhpDestinationDir();
fs.mkdirSync( path.dirname( destinationDir ), { recursive: true } );
try {
fs.cpSync( getBundledDefaultPhpDir(), destinationDir, {
recursive: true,
force: true,
} );

if ( process.platform !== 'win32' ) {
fs.chmodSync( getPhpBinaryPath( DEFAULT_PHP_VERSION ), 0o755 );
}
} catch ( error ) {
fs.rmSync( destinationDir, { recursive: true, force: true } );
console.warn(
`Warning: failed to install bundled PHP ${ DEFAULT_PHP_VERSION } package: ${
( error as Error ).message
}`
);
}
},
};
2 changes: 2 additions & 0 deletions apps/cli/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { hideStudioDirWindows } from './01-hide-studio-dir-windows';
import { renameProcessManagerHome } from './03-rename-pm-home';
import { cleanupObsoleteServerFiles } from './04-cleanup-obsolete-server-files';
import { migrateConnectedSitesToShared } from './05-migrate-connected-sites-to-shared';
import { installBundledDefaultPhp } from './06-install-bundled-default-php';
import type { Migration } from '@studio/common/lib/migration';

export const migrations: Migration[] = [
Expand All @@ -11,4 +12,5 @@ export const migrations: Migration[] = [
renameProcessManagerHome,
cleanupObsoleteServerFiles,
migrateConnectedSitesToShared,
installBundledDefaultPhp,
];
78 changes: 78 additions & 0 deletions apps/cli/migrations/tests/06-install-bundled-default-php.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
import { getConfiguredPhpBinaryVersion } from '@studio/common/lib/php-binary-metadata';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installBundledDefaultPhp } from 'cli/migrations/06-install-bundled-default-php';

let configDir: string;
let bundledPhpDir: string;

function getBinaryName(): string {
return process.platform === 'win32' ? 'php.exe' : 'php';
}

function getDefaultPhpPatchVersion(): string {
return getConfiguredPhpBinaryVersion( DEFAULT_PHP_VERSION )!;
}

function getBundledDefaultPhpDir(): string {
return path.join( bundledPhpDir, getDefaultPhpPatchVersion() );
}

function getDefaultPhpDestinationDir(): string {
return path.join( configDir, 'php-bin', getDefaultPhpPatchVersion() );
}

function writeBundledDefaultPhp(): void {
const bundledDir = getBundledDefaultPhpDir();
fs.mkdirSync( bundledDir, { recursive: true } );
fs.writeFileSync( path.join( bundledDir, getBinaryName() ), 'bundled php' );
fs.writeFileSync( path.join( bundledDir, 'runtime.json' ), '{"binary":"php"}' );
}

describe( 'installBundledDefaultPhp', () => {
beforeEach( () => {
configDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-php-bin-' ) );
bundledPhpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-bundled-php-bin-' ) );
vi.stubEnv( 'DEV_CONFIG_DIR', configDir );
vi.stubEnv( 'STUDIO_BUNDLED_PHP_BIN_ROOT', bundledPhpDir );
} );

afterEach( () => {
vi.unstubAllEnvs();
fs.rmSync( configDir, { recursive: true, force: true } );
fs.rmSync( bundledPhpDir, { recursive: true, force: true } );
} );

it( 'does not run when bundled PHP is unavailable', async () => {
await expect( installBundledDefaultPhp.needsToRun() ).resolves.toBe( false );
} );

it( 'copies the bundled default PHP folder when the destination patch folder is missing', async () => {
writeBundledDefaultPhp();

await expect( installBundledDefaultPhp.needsToRun() ).resolves.toBe( true );
await installBundledDefaultPhp.run();

expect(
fs.readFileSync( path.join( getDefaultPhpDestinationDir(), getBinaryName() ), 'utf8' )
).toBe( 'bundled php' );
expect(
fs.readFileSync( path.join( getDefaultPhpDestinationDir(), 'runtime.json' ), 'utf8' )
).toBe( '{"binary":"php"}' );
} );

it( 'does not replace an existing destination patch folder', async () => {
writeBundledDefaultPhp();
fs.mkdirSync( getDefaultPhpDestinationDir(), { recursive: true } );
fs.writeFileSync( path.join( getDefaultPhpDestinationDir(), getBinaryName() ), 'existing php' );

await expect( installBundledDefaultPhp.needsToRun() ).resolves.toBe( false );

expect(
fs.readFileSync( path.join( getDefaultPhpDestinationDir(), getBinaryName() ), 'utf8' )
).toBe( 'existing php' );
} );
} );
29 changes: 27 additions & 2 deletions apps/studio/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
import { exec as pkgExec } from '@yao-pkg/pkg';
import { RecommendedPHPVersion } from '../../tools/common/types/php-versions';
import { windowsSign } from './windowsSign';
import type { ForgeConfig } from '@electron-forge/shared-types';

const repoRoot = path.resolve( __dirname, '../..' );
const bundledPhpBinaryRoot = path.join( __dirname, 'php-bin' );

const config: ForgeConfig = {
packagerConfig: {
asar: true,
extraResource: [
path.join( __dirname, 'assets' ),
path.join( __dirname, 'bin' ),
bundledPhpBinaryRoot,
path.join( repoRoot, 'apps', 'cli', 'dist', 'cli' ),
],
executableName: process.platform === 'linux' ? 'studio' : undefined,
Expand Down Expand Up @@ -174,11 +177,16 @@ const config: ForgeConfig = {
plugins: [ new AutoUnpackNativesPlugin( {} ) ],
hooks: {
prePackage: async ( _forgeConfig, platform, arch ) => {
const execAsync = ( command: string ) =>
const execAsync = ( command: string, env: NodeJS.ProcessEnv = {} ) =>
new Promise< void >( ( resolve, reject ) => {
exec(
command,
{ cwd: repoRoot, maxBuffer: 50 * 1024 * 1024, windowsHide: true },
{
cwd: repoRoot,
env: { ...process.env, ...env },
maxBuffer: 50 * 1024 * 1024,
windowsHide: true,
},
( error, stdout, stderr ) => {
if ( error ) {
if ( stdout ) console.log( stdout );
Expand Down Expand Up @@ -282,6 +290,23 @@ const config: ForgeConfig = {
) } ${ platform } ${ arch }`
);

console.log(
`Downloading PHP ${ RecommendedPHPVersion } package for ${ platform }-${ arch }...`
);
fs.rmSync( bundledPhpBinaryRoot, { recursive: true, force: true } );
await execAsync(
`npx tsx ${ path.join(
repoRoot,
'scripts',
'download-php-binary.ts'
) } ${ RecommendedPHPVersion } ${ platform } ${ arch } --install-root ${ JSON.stringify(
bundledPhpBinaryRoot
) }`,
{
STUDIO_PHP_BINARY_DOWNLOAD_REQUIRED: '1',
}
);

// Build CLI launcher executable for Windows AppX (Microsoft Store).
// AppX packages require AppExecutionAlias with an .exe target — batch files won't work.
if ( platform === 'win32' ) {
Expand Down
11 changes: 7 additions & 4 deletions docs/design-docs/native-php-binaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,13 @@ URLs and SHA-256 hashes. The metadata keeps one patch version per PHP minor
version; uploading a newer patch replaces the tracked patch for that minor.

At runtime, Studio uses `tools/common/lib/php-binary-cdn-metadata.json` as the
source of truth for the requested PHP minor version. It downloads the tracked
patch for the current platform and architecture, then verifies the checked-in
SHA-256 before extracting the archive. If metadata is missing for the requested
device, native PHP install fails for that version.
source of truth for the requested PHP minor version. Packaged Studio builds
ship the recommended PHP version under the app resources `php-bin/<patch>/`;
a CLI migration copies that directory into the writable install location if the
destination patch folder does not exist. Studio downloads other PHP versions
on demand from manifest URLs, then verifies the checked-in SHA-256 before
extracting the archive. If metadata is missing for the requested device, native
PHP install fails for that version.

Downloaded binaries are installed under `~/.studio/php-bin/<patch>/`, for
example `~/.studio/php-bin/8.4.20/php`. This lets Studio download a new patch
Expand Down
64 changes: 46 additions & 18 deletions scripts/download-php-binary.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this script was there before this PR, but it strikes me now that we should maybe say that it downloads a PHP package rather than a binary, because it's not just that single binary on Windows.

Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
#!/usr/bin/env tsx
/**
* Download a Studio PHP CLI binary for local development.
* NOT used in production builds — binaries are not bundled with Studio or the CLI.
* Download a Studio PHP CLI package for local development and packaging.
*
* Source metadata: tools/common/lib/php-binary-cdn-metadata.json
*
* Usage:
* npx tsx scripts/download-php-binary.ts [version] [platform] [arch]
* npx tsx scripts/download-php-binary.ts [version] [platform] [arch] [--install-root <path>]
*
* Examples:
* npx tsx scripts/download-php-binary.ts # defaults to RecommendedPHPVersion
Expand Down Expand Up @@ -39,7 +38,7 @@ const archSchema = z.enum( [ 'x64', 'arm64' ] );
type Platform = z.infer< typeof platformSchema >;
type Arch = z.infer< typeof archSchema >;

const positionalArgs = process.argv.slice( 2 );
const { positionalArgs, installRoot } = parseArgs( process.argv.slice( 2 ) );

const { version, ...args } = z
.tuple( [
Expand All @@ -53,7 +52,7 @@ const { version, ...args } = z
const effectiveArch = getEffectivePhpBinaryArch( args.platform, args.arch );
if ( args.arch === 'arm64' && args.platform === 'win32' ) {
console.warn(
'Warning: no Windows ARM64 PHP binary available. Downloading x64 binary instead (runs under Windows 11 emulation).'
'Warning: no Windows ARM64 PHP package available. Downloading x64 package instead (runs under Windows 11 emulation).'
);
}

Expand All @@ -63,20 +62,21 @@ async function main(): Promise< void > {
const isWindows = args.platform === 'win32';
const binaryName = isWindows ? 'php.exe' : 'php';
const platformKey = `${ args.platform }-${ effectiveArch }`;
const phpPackageRoot = installRoot ?? path.join( getConfigDirectory(), 'php-bin' );

try {
const downloadInfo = resolvePhpBinaryDownloadInfo();
const binDir = path.join( getConfigDirectory(), 'php-bin', downloadInfo.patchVersion );
const binDir = path.join( phpPackageRoot, downloadInfo.patchVersion );
const destPath = path.join( binDir, binaryName );

if ( fs.existsSync( destPath ) ) {
console.log(
`PHP ${ version } (${ downloadInfo.patchVersion }) binary already exists at ${ destPath }. Delete it to re-download.`
`PHP ${ version } (${ downloadInfo.patchVersion }) package already exists at ${ binDir }. Delete it to re-download.`
);
return;
}

// Ensure ~/.studio/php-bin/ exists, then atomically claim this version's slot.
// Ensure the php-bin root exists, then atomically claim this version's slot.
fs.mkdirSync( path.dirname( binDir ), { recursive: true } );
try {
fs.mkdirSync( binDir );
Expand Down Expand Up @@ -112,7 +112,7 @@ async function main(): Promise< void > {
}
console.log( ' Hash OK.' );

console.log( 'Extracting PHP binary…' );
console.log( 'Extracting PHP package…' );
const tmpDir = os.tmpdir();
const extractDir = fs.mkdtempSync(
path.join( tmpDir, `php-${ downloadInfo.patchVersion }-` )
Expand All @@ -132,28 +132,56 @@ async function main(): Promise< void > {
fs.rmSync( extractDir, { recursive: true, force: true } );
}

const stats = fs.statSync( destPath );
console.log(
`\nPHP ${ version } binary installed: ${ destPath } (${ (
stats.size /
1024 /
1024
).toFixed( 1 ) } MB)`
);
console.log( `\nPHP ${ version } package installed: ${ binDir }` );
} catch ( err ) {
fs.rmSync( binDir, { recursive: true, force: true } );
throw err;
} finally {
fs.rmSync( downloadPath, { force: true } );
}
} catch ( error ) {
console.warn( `Warning: PHP binary download failed — ${ ( error as Error ).message }` );
console.warn( `Warning: PHP package download failed — ${ ( error as Error ).message }` );
console.warn(
`The native-php runtime will not be available. Run \`npm run download:php-binary\` to retry.`
);
if ( process.env.STUDIO_PHP_BINARY_DOWNLOAD_REQUIRED === '1' ) {
process.exitCode = 1;
}
}
}

function parseArgs( argv: string[] ): { installRoot?: string; positionalArgs: string[] } {
const positionalArgs: string[] = [];
let installRoot: string | undefined;

for ( let index = 0; index < argv.length; index++ ) {
const arg = argv[ index ];
if ( arg === '--install-root' ) {
const value = argv[ index + 1 ];
if ( ! value || value.startsWith( '--' ) ) {
throw new Error( 'Missing value for --install-root.' );
}
installRoot = value;
index++;
continue;
}
if ( arg.startsWith( '--install-root=' ) ) {
const value = arg.slice( '--install-root='.length );
if ( ! value ) {
throw new Error( 'Missing value for --install-root.' );
}
installRoot = value;
continue;
}
if ( arg.startsWith( '-' ) ) {
throw new Error( `Unknown option: ${ arg }` );
}
positionalArgs.push( arg );
}

return { installRoot, positionalArgs };
}

function getRuntimeBinaryName( extractDir: string ): string | undefined {
const runtimeJsonPath = path.join( extractDir, 'runtime.json' );
if ( ! fs.existsSync( runtimeJsonPath ) ) {
Expand Down
Loading