├── bin └── .gitkeep ├── tests ├── fixtures │ ├── prebuilt.tar.gz │ ├── basic │ │ ├── web │ │ │ ├── index.php │ │ │ ├── .ht.router.php │ │ │ └── sites │ │ │ │ └── default │ │ │ │ └── default.settings.php │ │ ├── dependencies.json │ │ ├── composer.json │ │ └── assert-settings.php │ ├── composer-always-error.php │ └── composer-install-error.php └── main.spec.ts ├── src ├── renderer │ ├── renderer.ts │ ├── index.html │ └── App.svelte ├── preload │ ├── Drupal.ts │ └── preload.ts └── main │ ├── ComposerCommand.ts │ ├── PhpCommand.ts │ ├── main.ts │ └── Drupal.ts ├── electron.vite.config.js ├── tsconfig.json ├── settings.local.php ├── package.json ├── LICENSE.txt ├── playwright.config.ts ├── .github └── workflows │ ├── test.yml │ ├── build-linux.yml │ ├── build-windows.yml │ └── build-macos.yml ├── icon.svg ├── electron-builder.yml ├── .gitignore ├── LICENSE.php.txt └── README.md /bin/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/prebuilt.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drupal/cms-launcher/HEAD/tests/fixtures/prebuilt.tar.gz -------------------------------------------------------------------------------- /src/renderer/renderer.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'svelte'; 2 | import App from './App.svelte'; 3 | 4 | mount(App, { target: document.body }); 5 | -------------------------------------------------------------------------------- /tests/fixtures/basic/web/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

It worked!

4 |

Running PHP via .

5 | 6 | -------------------------------------------------------------------------------- /src/preload/Drupal.ts: -------------------------------------------------------------------------------- 1 | export interface Drupal 2 | { 3 | start: () => void; 4 | 5 | open: () => void; 6 | 7 | visit: () => void; 8 | 9 | destroy: () => void; 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/basic/web/.ht.router.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Drupal CMS 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/fixtures/composer-always-error.php: -------------------------------------------------------------------------------- 1 | !str_starts_with($a, '--') 10 | ); 11 | $arguments = array_values($arguments); 12 | $arguments = array_slice($arguments, 1); 13 | 14 | if ($arguments[0] === 'install') { 15 | throw new \Exception('An imaginary error occurred!'); 16 | } 17 | else { 18 | fwrite(STDERR, 'Doing step: '. $arguments[0]); 19 | 20 | if ($arguments[0] === 'create-project') { 21 | mkdir($arguments[2]); 22 | } 23 | usleep(300000); 24 | } 25 | -------------------------------------------------------------------------------- /tests/fixtures/basic/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drupal/cms", 3 | "version": "1.0.0", 4 | "description": "A mock version of the Drupal CMS project template for testing purposes.", 5 | "type": "project", 6 | "repositories": { 7 | "dependencies": { 8 | "type": "composer", 9 | "url": "dependencies.json" 10 | }, 11 | "packagist.org": false 12 | }, 13 | "scripts": { 14 | "drupal:recipe-unpack": "echo" 15 | }, 16 | "scripts-descriptions": { 17 | "drupal:recipe-unpack": "A mocked drupal:recipe-unpack command that does nothing." 18 | }, 19 | "replace": { 20 | "drupal/drupal_association_extras": "*" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/fixtures/basic/assert-settings.php: -------------------------------------------------------------------------------- 1 | { 5 | window.onload = resolve; 6 | }); 7 | 8 | ipcRenderer.on('port', async (event): Promise => { 9 | // Wait for the window to be fully loaded before we send the port to it. 10 | // @see https://www.electronjs.org/docs/latest/tutorial/message-ports#communicating-directly-between-the-main-process-and-the-main-world-of-a-context-isolated-page 11 | await windowLoaded; 12 | window.postMessage('port', '*', event.ports); 13 | }); 14 | 15 | contextBridge.exposeInMainWorld('drupal', { 16 | 17 | start (): void 18 | { 19 | ipcRenderer.send('drupal:start'); 20 | }, 21 | 22 | open (): void 23 | { 24 | ipcRenderer.send('drupal:open'); 25 | }, 26 | 27 | visit (): void 28 | { 29 | ipcRenderer.send('drupal:visit'); 30 | }, 31 | 32 | destroy (): void 33 | { 34 | ipcRenderer.send('drupal:destroy'); 35 | } 36 | 37 | } as Drupal); 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2025 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the “Software”), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './tests', 8 | 9 | /* Run tests in files in parallel */ 10 | fullyParallel: true, 11 | 12 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 13 | forbidOnly: !!process.env.CI, 14 | 15 | /* Retry on CI only */ 16 | retries: process.env.CI ? 2 : 0, 17 | 18 | /* Opt out of parallel tests on CI. */ 19 | workers: process.env.CI ? 1 : undefined, 20 | 21 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 22 | reporter: 'html', 23 | 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 27 | trace: 'on-first-retry', 28 | }, 29 | 30 | /* Configure projects for major browsers */ 31 | projects: [ 32 | { 33 | name: 'firefox', 34 | use: { ...devices['Desktop Firefox'] }, 35 | }, 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | schedule: 11 | # Run this workflow at 9 AM UTC every day. 12 | - cron: '0 9 * * *' 13 | workflow_dispatch: 14 | 15 | jobs: 16 | test: 17 | name: Test on ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: 21 | - macos-latest 22 | # Testing on Linux is temporarily disabled until we can figure out the SUID sandboxing issue. 23 | # - ubuntu-latest 24 | - windows-latest 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Set up PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: latest 33 | tools: composer 34 | ini-values: memory_limit=-1 35 | 36 | - name: Set up Node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: latest 40 | 41 | - name: Cache dependencies 42 | id: cache 43 | uses: actions/cache@v4 44 | with: 45 | path: node_modules 46 | key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }} 47 | 48 | - name: Install dependencies 49 | if: steps.cache.outputs.cache-hit != 'true' 50 | run: npm install 51 | 52 | - name: Run tests 53 | run: | 54 | composer run assets --working-dir=build 55 | npm run test 56 | 57 | - uses: actions/upload-artifact@v4 58 | if: ${{ !cancelled() }} 59 | with: 60 | name: playwright-report-${{ runner.os }} 61 | path: playwright-report/ 62 | retention-days: 7 63 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 23 | 24 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | # This is a global constant, don't change it. 2 | appId: 'org.drupal.cms-launcher' 3 | 4 | # These fuses are largely adapted from Electron Forge's boilerplate. 5 | electronFuses: 6 | runAsNode: false 7 | enableCookieEncryption: true 8 | enableEmbeddedAsarIntegrityValidation: true 9 | enableNodeOptionsEnvironmentVariable: false 10 | # This needs to be `true` for the app to be testable by Playwright. 11 | # @see https://playwright.dev/docs/api/class-electron 12 | enableNodeCliInspectArguments: true 13 | onlyLoadAppFromAsar: false 14 | 15 | extraResources: 16 | # In conjunction with `files` below, ensure that the PHP and Composer executables 17 | # are in the app's resources directory, not the ASAR archive. 18 | - 'bin/**' 19 | - prebuilt.tar.gz 20 | - settings.local.php 21 | 22 | files: 23 | - '!**/bin/**' 24 | # Since we're using a bundler, exclude source code from the packaged app. 25 | - '!**/src/**' 26 | # Don't include tests in the packaged app. 27 | - '!**/tests/**' 28 | 29 | nsis: 30 | # This is only applicable to the one-click installer, which is what we build on 31 | # Windows. When we uninstall, it should clean up entirely. 32 | deleteAppDataOnUninstall: true 33 | 34 | linux: 35 | # The artifact name cannot contain spaces, or it will be renamed before being attached 36 | # to a GitHub release. 37 | artifactName: 'Drupal_CMS-Linux-${arch}.${ext}' 38 | # By default, an AppImage and a snap are built. For the moment, we'll stick with AppImage. 39 | target: AppImage 40 | 41 | mac: 42 | # The artifact name cannot contain spaces, or it will be renamed before being attached 43 | # to a GitHub release. 44 | artifactName: 'Drupal_CMS-macOS.${ext}' 45 | # Only generate ZIP files, since DMGs cannot be auto-updated without them anyway. 46 | target: zip 47 | 48 | productName: 'Launch Drupal CMS' 49 | 50 | win: 51 | # The artifact name cannot contain spaces, or it will be renamed before being attached 52 | # to a GitHub release. 53 | artifactName: 'Drupal_CMS-Windows.${ext}' 54 | azureSignOptions: 55 | certificateProfileName: drupal-association-cert-profile 56 | codeSigningAccountName: da-trusted-signing-001 57 | endpoint: 'https://wus2.codesigning.azure.net/' 58 | # Best guess. 59 | publisherName: 'Drupal Association' 60 | -------------------------------------------------------------------------------- /src/main/ComposerCommand.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import logger from 'electron-log'; 3 | import { type ExecFileOptions } from 'node:child_process'; 4 | import { join } from 'node:path'; 5 | import { OutputHandler, PhpCommand } from './PhpCommand'; 6 | 7 | /** 8 | * An abstraction layer for running Composer commands in a consistent way. 9 | */ 10 | export class ComposerCommand extends PhpCommand 11 | { 12 | constructor (...options: string[]) 13 | { 14 | super( 15 | ComposerCommand.binary, 16 | // Don't let Composer ask any questions, since users have no way 17 | // to answer them. 18 | '--no-interaction', 19 | // Strip out any ANSI color codes, since they are useless in the 20 | // GUI and make logs unreadable. 21 | '--no-ansi', 22 | ...options, 23 | ); 24 | } 25 | 26 | inDirectory (dir: string): ComposerCommand 27 | { 28 | this.arguments.push( 29 | this.arguments.includes('create-project') ? dir : `--working-dir=${dir}`, 30 | ); 31 | return this; 32 | } 33 | 34 | async run (options: ExecFileOptions = {}, callback?: OutputHandler): Promise 35 | { 36 | options.env = Object.assign({}, process.env, { 37 | // Set COMPOSER_ROOT_VERSION so that Composer won't try to guess the 38 | // root package version, which would cause it to invoke Git and other 39 | // command-line utilities that might not be installed and could 40 | // therefore raise unexpected warnings on macOS. 41 | // @see https://getcomposer.org/doc/03-cli.md#composer-root-version 42 | COMPOSER_ROOT_VERSION: '1.0.0', 43 | // For performance reasons, skip security audits for now. 44 | // @see https://getcomposer.org/doc/03-cli.md#composer-no-audit 45 | COMPOSER_NO_AUDIT: '1', 46 | // Composer doesn't work without COMPOSER_HOME. 47 | COMPOSER_HOME: process.env.COMPOSER_HOME ?? join(app.getPath('home'), '.composer'), 48 | }); 49 | // An exceptionally generous timeout. No Composer command should take 50 | // 10 minutes. 51 | options.timeout ??= 600000; 52 | 53 | return super.run(options, (line: string, ...rest): void => { 54 | logger.debug(line); 55 | 56 | if (callback) { 57 | callback(line, ...rest); 58 | } 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/PhpCommand.ts: -------------------------------------------------------------------------------- 1 | import logger from 'electron-log'; 2 | import { type ChildProcess, execFile, type ExecFileOptions, type PromiseWithChild } from 'node:child_process'; 3 | import { access, realpath } from 'node:fs/promises'; 4 | import { dirname, join } from 'node:path'; 5 | import { createInterface as readFrom } from 'node:readline'; 6 | import { promisify as toPromise } from 'node:util'; 7 | 8 | export enum OutputType { 9 | Output = 'out', 10 | Error = 'err', 11 | } 12 | 13 | export type OutputHandler = (line: string, type: OutputType, process: ChildProcess) => void; 14 | 15 | const execFileAsPromise = toPromise(execFile); 16 | 17 | /** 18 | * An abstraction layer for invoking the PHP interpreter in a consistent way. 19 | */ 20 | export class PhpCommand 21 | { 22 | protected arguments: string[] = []; 23 | 24 | public static binary: string; 25 | 26 | constructor (...options: string[]) 27 | { 28 | this.arguments = options; 29 | } 30 | 31 | private async getCommandLine (): Promise<[string, string[]]> 32 | { 33 | const phpBin = await realpath(PhpCommand.binary); 34 | 35 | // Always provide the cURL CA bundle so that HTTPS requests from Composer 36 | // and Drupal have a better chance of succeeding (especially on Windows). 37 | const caFile = join(dirname(phpBin), 'cacert.pem'); 38 | try { 39 | await access(caFile); 40 | this.arguments.unshift('-d', `curl.cainfo="${caFile}"`); 41 | } 42 | catch { 43 | logger.warn(`CA bundle not found: ${caFile}`); 44 | } 45 | 46 | // For forensic purposes, log the full command line. 47 | logger.debug(`${phpBin} ${this.arguments.join(' ')}`); 48 | 49 | return [phpBin, this.arguments]; 50 | } 51 | 52 | private setOutputHandler (process: ChildProcess, callback: OutputHandler): void 53 | { 54 | if (process.stdout) { 55 | readFrom(process.stdout).on('line', (line: string): void => { 56 | callback(line, OutputType.Output, process); 57 | }); 58 | } 59 | if (process.stderr) { 60 | readFrom(process.stderr).on('line', (line: string): void => { 61 | callback(line, OutputType.Error, process); 62 | }); 63 | } 64 | } 65 | 66 | async start (options: ExecFileOptions = {}, callback?: OutputHandler): Promise 67 | { 68 | const process = execFile(...await this.getCommandLine(), options); 69 | 70 | if (callback) { 71 | this.setOutputHandler(process, callback); 72 | } 73 | return process; 74 | } 75 | 76 | async run (options: ExecFileOptions = {}, callback?: OutputHandler): Promise 77 | { 78 | const p = execFileAsPromise(...await this.getCommandLine(), options) as PromiseWithChild; 79 | 80 | if (callback) { 81 | this.setOutputHandler(p.child, callback); 82 | } 83 | return p; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # macOS junk 11 | .DS_Store 12 | 13 | # IDE configuration 14 | .idea 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | .cache 112 | 113 | # vitepress build output 114 | **/.vitepress/dist 115 | 116 | # vitepress cache directory 117 | **/.vitepress/cache 118 | 119 | # Docusaurus cache and generated files 120 | .docusaurus 121 | 122 | # Serverless directories 123 | .serverless/ 124 | 125 | # FuseBox cache 126 | .fusebox/ 127 | 128 | # DynamoDB Local files 129 | .dynamodb/ 130 | 131 | # TernJS port file 132 | .tern-port 133 | 134 | # Stores VSCode versions used for testing VSCode extensions 135 | .vscode-test 136 | 137 | # yarn v2 138 | .yarn/cache 139 | .yarn/unplugged 140 | .yarn/build-state.yml 141 | .yarn/install-state.gz 142 | .pnp.* 143 | 144 | /bin 145 | /out 146 | 147 | # Playwright 148 | /test-results/ 149 | /playwright-report/ 150 | /blob-report/ 151 | /playwright/.cache/ 152 | -------------------------------------------------------------------------------- /LICENSE.php.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------- 2 | The PHP License, version 3.01 3 | Copyright (c) 1999 - 2019 The PHP Group. All rights reserved. 4 | -------------------------------------------------------------------- 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, is permitted provided that the following conditions 8 | are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in 15 | the documentation and/or other materials provided with the 16 | distribution. 17 | 18 | 3. The name "PHP" must not be used to endorse or promote products 19 | derived from this software without prior written permission. For 20 | written permission, please contact group@php.net. 21 | 22 | 4. Products derived from this software may not be called "PHP", nor 23 | may "PHP" appear in their name, without prior written permission 24 | from group@php.net. You may indicate that your software works in 25 | conjunction with PHP by saying "Foo for PHP" instead of calling 26 | it "PHP Foo" or "phpfoo" 27 | 28 | 5. The PHP Group may publish revised and/or new versions of the 29 | license from time to time. Each version will be given a 30 | distinguishing version number. 31 | Once covered code has been published under a particular version 32 | of the license, you may always continue to use it under the terms 33 | of that version. You may also choose to use such covered code 34 | under the terms of any subsequent version of the license 35 | published by the PHP Group. No one other than the PHP Group has 36 | the right to modify the terms applicable to covered code created 37 | under this License. 38 | 39 | 6. Redistributions of any form whatsoever must retain the following 40 | acknowledgment: 41 | "This product includes PHP software, freely available from 42 | ". 43 | 44 | THIS SOFTWARE IS PROVIDED BY THE PHP DEVELOPMENT TEAM ``AS IS'' AND 45 | ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 46 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 47 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE PHP 48 | DEVELOPMENT TEAM OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 49 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 50 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 51 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 52 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 53 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 54 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 55 | OF THE POSSIBILITY OF SUCH DAMAGE. 56 | 57 | -------------------------------------------------------------------- 58 | 59 | This software consists of voluntary contributions made by many 60 | individuals on behalf of the PHP Group. 61 | 62 | The PHP Group can be contacted via Email at group@php.net. 63 | 64 | For more information on the PHP Group and the PHP project, 65 | please see . 66 | 67 | PHP includes the Zend Engine, freely available at 68 | . 69 | -------------------------------------------------------------------------------- /.github/workflows/build-linux.yml: -------------------------------------------------------------------------------- 1 | name: Build for Linux 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | # PR branches that alter the build process should be prefixed with `build/`, so 8 | # that this workflow runs. 9 | - 'build/**' 10 | schedule: 11 | # Run this workflow every 8 hours to repackage the pre-built Drupal CMS site 12 | # with the latest dependencies. 13 | - cron: 0 */8 * * * 14 | workflow_dispatch: 15 | 16 | env: 17 | # The extensions and libraries needed to build PHP. These need to be variables so we can 18 | # use them to generate a cache key. 19 | # @see https://static-php.dev/en/guide/cli-generator.html 20 | PHP_EXTENSIONS: bz2,ctype,curl,dom,filter,gd,iconv,mbstring,opcache,openssl,pcntl,pdo,pdo_sqlite,phar,posix,session,simplexml,sodium,sqlite3,tokenizer,xml,xmlwriter,yaml,zip,zlib 21 | PHP_VERSION: 8.3 22 | # Don't publish by default. This is overridden for the release branch, below. 23 | PUBLISH: never 24 | 25 | # On Linux, we need to build separate versions for x64 and arm64. Their build process is 26 | # identical; the only difference is what kind of machine they are targeting. So it's all 27 | # done as a single job, but run as a matrix. 28 | jobs: 29 | app: 30 | name: App on ${{ matrix.runner }} 31 | strategy: 32 | matrix: 33 | runner: 34 | - ubuntu-latest # x64 35 | - ubuntu-22.04-arm # arm64 36 | runs-on: ${{ matrix.runner }} 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | 42 | - name: Check out latest tag 43 | if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' }} 44 | run: | 45 | LATEST_TAG=$(git describe --tags --abbrev=0) 46 | git checkout $LATEST_TAG 47 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV 48 | 49 | - name: Set up PHP 50 | uses: shivammathur/setup-php@v2 51 | with: 52 | # Install PHP with the tools needed to build the interpreter, if necessary. 53 | php-version: latest 54 | tools: pecl, composer 55 | extensions: curl, openssl, mbstring, tokenizer 56 | ini-values: memory_limit=-1 57 | 58 | # Cache the built binary so we can skip the build steps if there is a cache hit. 59 | - name: Generate cache key 60 | shell: bash 61 | run: | 62 | CACHE_KEY=${{ runner.os }}-${{ runner.arch }}-$PHP_VERSION--$(echo $PHP_EXTENSIONS | tr ',' '-') 63 | echo "CACHE_KEY=${CACHE_KEY}" >> "$GITHUB_ENV" 64 | 65 | - id: cache-php 66 | name: Cache PHP interpreter 67 | uses: actions/cache@v4 68 | with: 69 | path: build/buildroot/bin 70 | key: php-${{ env.CACHE_KEY }} 71 | 72 | - if: steps.cache-php.outputs.cache-hit != 'true' 73 | name: Install dependencies and build PHP 74 | run: | 75 | composer install 76 | composer exec spc -- doctor 77 | composer exec spc -- download --with-php=${{ env.PHP_VERSION }} --for-extensions=${{ env.PHP_EXTENSIONS }} --prefer-pre-built 78 | composer exec spc -- build ${{ env.PHP_EXTENSIONS }} --build-cli --with-libs=freetype,libavif,libjpeg,libwebp --debug 79 | env: 80 | # Allows static-php-cli to download its many dependencies more smoothly. 81 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 82 | working-directory: build 83 | 84 | - name: Set up Node 85 | uses: actions/setup-node@v4 86 | with: 87 | node-version: latest 88 | 89 | - name: Cache dependencies 90 | id: cache 91 | uses: actions/cache@v4 92 | with: 93 | path: node_modules 94 | key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }} 95 | 96 | - name: Install dependencies 97 | if: steps.cache.outputs.cache-hit != 'true' 98 | run: npm install 99 | 100 | # If we're on a PR branch, we don't want Electron Builder to publish the app. 101 | - name: Enable publishing for release branch 102 | if: github.ref_name == 'main' 103 | run: | 104 | # Configure Electron Builder to publish. 105 | echo "PUBLISH=onTagOrDraft" >> $GITHUB_ENV 106 | 107 | # Electron Builder needs a token to publish releases. 108 | echo "GH_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 109 | 110 | - name: Make application 111 | run: | 112 | composer run assets --working-dir=build 113 | cp -f ./build/buildroot/bin/php ./bin 114 | chmod +x ./bin/php 115 | npx electron-vite build 116 | npx electron-builder --publish=${{ env.PUBLISH }} 117 | 118 | # For manual testing, upload the final artifact if we're not in a release branch. 119 | - name: Upload distributable 120 | if: ${{ env.PUBLISH == 'never' }} 121 | uses: actions/upload-artifact@v4 122 | with: 123 | name: app-${{ runner.arch }} 124 | path: dist/*.AppImage 125 | retention-days: 7 126 | 127 | - name: Update prebuilt Drupal code base 128 | if: ${{ env.LATEST_TAG }} 129 | uses: softprops/action-gh-release@v2 130 | with: 131 | files: | 132 | dist/Drupal_CMS-Linux-arm64.AppImage 133 | dist/Drupal_CMS-Linux-x86_64.AppImage 134 | dist/latest-linux.yml 135 | dist/latest-linux-arm64.yml 136 | tag_name: ${{ env.LATEST_TAG }} 137 | -------------------------------------------------------------------------------- /.github/workflows/build-windows.yml: -------------------------------------------------------------------------------- 1 | name: Build for Windows 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | # PR branches that alter the build process should be prefixed with `build/`, so 8 | # that this workflow runs. 9 | - 'build/**' 10 | schedule: 11 | # Run this workflow every 8 hours to repackage the pre-built Drupal CMS site 12 | # with the latest dependencies. 13 | - cron: 0 */8 * * * 14 | workflow_dispatch: 15 | 16 | # Since only the x64 architecture is supported on Windows, there's only one job to 17 | # compile PHP and build the launcher. 18 | jobs: 19 | app: 20 | name: App 21 | runs-on: windows-latest 22 | env: 23 | # The extensions and libraries needed to build PHP. These need to be variables so we can 24 | # use them to generate a cache key. 25 | # @see https://static-php.dev/en/guide/cli-generator.html 26 | PHP_EXTENSIONS: bz2,ctype,curl,dom,filter,gd,iconv,mbstring,opcache,openssl,pdo,pdo_sqlite,phar,session,simplexml,sqlite3,tokenizer,xml,xmlwriter,yaml,zip,zlib 27 | PHP_VERSION: 8.3 28 | # Don't publish by default. This is overridden for the release branch, below. 29 | PUBLISH: never 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Check out latest tag 36 | if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' }} 37 | run: | 38 | $LATEST_TAG = git describe --tags --abbrev=0 39 | git checkout $LATEST_TAG 40 | "LATEST_TAG=$LATEST_TAG" >> $env:GITHUB_ENV 41 | 42 | - name: Set up PHP 43 | uses: shivammathur/setup-php@v2 44 | with: 45 | # Install PHP with the tools needed to build the interpreter, if necessary. 46 | php-version: latest 47 | # Composer 2.9.x is currently broken on Windows, so stick to 2.8.x for now. 48 | # @see https://github.com/drupal/cms-launcher/actions/runs/19337936548/job/55318143930#step:12:44 49 | tools: pecl, composer:2.8 50 | extensions: curl, openssl, mbstring, tokenizer 51 | ini-values: memory_limit=-1 52 | 53 | # Cache the built binary so we can skip the build steps if there is a cache hit. 54 | - name: Generate cache key 55 | shell: bash 56 | run: | 57 | CACHE_KEY=${{ runner.os }}-$PHP_VERSION-$(echo $PHP_EXTENSIONS | tr ',' '-') 58 | echo "CACHE_KEY=${CACHE_KEY}" >> "$GITHUB_ENV" 59 | 60 | - id: cache-php 61 | name: Cache PHP interpreter 62 | uses: actions/cache@v4 63 | with: 64 | path: build/buildroot/bin 65 | key: php-${{ env.CACHE_KEY }} 66 | 67 | - if: steps.cache-php.outputs.cache-hit != 'true' 68 | name: Install dependencies and build PHP 69 | run: | 70 | composer install 71 | composer exec spc -- doctor 72 | composer exec spc -- download --with-php=${{ env.PHP_VERSION }} --for-extensions=${{ env.PHP_EXTENSIONS }} --prefer-pre-built 73 | composer exec spc -- build ${{ env.PHP_EXTENSIONS }} --build-cli --with-libs=freetype,libavif,libjpeg,libwebp --debug 74 | env: 75 | # Allows static-php-cli to download its many dependencies more smoothly. 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | working-directory: build 78 | 79 | - name: Set up Node 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: latest 83 | 84 | - name: Cache dependencies 85 | id: cache 86 | uses: actions/cache@v4 87 | with: 88 | path: node_modules 89 | key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }} 90 | 91 | - name: Install dependencies 92 | if: steps.cache.outputs.cache-hit != 'true' 93 | run: npm install 94 | 95 | - name: Build app 96 | run: | 97 | composer run assets --working-dir=build 98 | Remove-Item -Recurse -Force ./bin/php.exe 99 | Copy-Item -Force ./build/buildroot/bin/php.exe ./bin 100 | npx electron-vite build 101 | 102 | # If we're on a PR branch, we don't want Electron Builder to publish the app. 103 | # On Windows, adding environment variables in PowerShell has a special syntax: see https://github.com/orgs/community/discussions/25713 104 | - name: Prepare to publish 105 | if: github.ref_name == 'main' 106 | run: | 107 | # Configure Electron Builder to publish. 108 | "PUBLISH=onTagOrDraft" >> $env:GITHUB_ENV 109 | 110 | # Electron Builder needs a token to publish releases. 111 | "GH_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $env:GITHUB_ENV 112 | 113 | # Pre-build the Drupal site. 114 | npx electron . --root=drupal --no-server 115 | Get-ChildItem -Path drupal -Recurse -Directory -Name tests | ForEach-Object { Remove-Item "drupal\$_" -Recurse -Force } 116 | tar -c -z -f prebuilt.tar.gz --directory=drupal . 117 | Remove-Item -Path drupal -Recurse -Force 118 | 119 | - name: Make application 120 | run: npx electron-builder --publish=${{ env.PUBLISH }} 121 | env: 122 | # Set environment variables for Azure Trusted Signing. Electron Builder will 123 | # handle that for us automatically. 124 | AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} 125 | AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} 126 | AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} 127 | 128 | # For manual testing, upload the final artifact if we're not in a release branch. 129 | - name: Upload distributable 130 | if: ${{ env.PUBLISH == 'never' }} 131 | uses: actions/upload-artifact@v4 132 | with: 133 | name: app 134 | path: dist/*.exe 135 | retention-days: 7 136 | 137 | - name: Update prebuilt Drupal code base 138 | if: ${{ env.LATEST_TAG }} 139 | uses: softprops/action-gh-release@v2 140 | with: 141 | files: | 142 | dist/Drupal_CMS-Windows.exe 143 | dist/Drupal_CMS-Windows.exe.blockmap 144 | dist/latest.yml 145 | tag_name: ${{ env.LATEST_TAG }} 146 | -------------------------------------------------------------------------------- /tests/main.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, _electron as electron, type ElectronApplication, type Page, type TestInfo } from '@playwright/test'; 2 | import { accessSync } from 'node:fs'; 3 | import { join } from 'node:path'; 4 | import { PhpCommand } from '@/main/PhpCommand'; 5 | import { ComposerCommand } from '@/main/ComposerCommand'; 6 | 7 | async function launchApp(testInfo: TestInfo, ...options: string[]): Promise<[ElectronApplication, string]> { 8 | const root = testInfo.outputPath('drupal'); 9 | 10 | const app = await electron.launch({ 11 | args: [ 12 | '.', 13 | `--root=${root}`, 14 | `--log=${testInfo.outputPath('app.log')}`, 15 | `--timeout=2`, 16 | ...options, 17 | ], 18 | env: { 19 | // The fixture is located in a path repository, so we want to ensure Composer 20 | // makes a full copy of it. 21 | COMPOSER_MIRROR_PATH_REPOS: '1', 22 | // Disable the network so we don't inadvertently test the internet. 23 | COMPOSER_DISABLE_NETWORK: '1', 24 | }, 25 | }); 26 | return [app, root]; 27 | } 28 | 29 | async function visitSite (app: ElectronApplication): Promise 30 | { 31 | const window = await app.firstWindow(); 32 | const url = await window.getByText(/^http:\/\/localhost:/).textContent() as string; 33 | expect(typeof url).toBe('string') 34 | await window.goto(url); 35 | 36 | return window; 37 | } 38 | 39 | test.beforeAll(() => { 40 | const binDir = join(__dirname, '..', 'bin'); 41 | PhpCommand.binary = join(binDir, process.platform == 'win32' ? 'php.exe' : 'php'); 42 | ComposerCommand.binary = join(binDir, 'composer', 'bin', 'composer'); 43 | }); 44 | 45 | test.beforeEach(async ({}, testInfo) => { 46 | process.env.COMPOSER_HOME = testInfo.outputPath('composer-home'); 47 | }); 48 | 49 | test.afterEach(async ({}, testInfo) => { 50 | // Always attach the log, regardless of success or failure, for forensic purposes. 51 | await testInfo.attach('log', { 52 | path: testInfo.outputPath('app.log'), 53 | }); 54 | }); 55 | 56 | test('happy path', async ({}, testInfo) => { 57 | const [app, root] = await launchApp(testInfo, '--fixture=basic'); 58 | 59 | const page = await visitSite(app); 60 | await expect(page.locator('body')).toContainText('It worked! Running PHP via cli-server.'); 61 | await app.close(); 62 | 63 | // Confirm that launcher-specific Drupal settings are valid PHP. 64 | await new PhpCommand('-f', join(root, 'assert-settings.php'), '-d', 'zend.assertions=1') 65 | .run({ cwd: root }, (line: string): void => { 66 | console.debug(line); 67 | }); 68 | 69 | // Confirm that a version number was recorded. 70 | const { stdout } = await new ComposerCommand('config', 'extra.drupal-launcher.version') 71 | .inDirectory(root) 72 | .run(); 73 | expect(stdout.toString().trim()).toBe('1'); 74 | 75 | // Confirm that the lock file is valid. 76 | const { stderr } = await new ComposerCommand('validate', '--check-lock') 77 | .inDirectory(root) 78 | .run(); 79 | expect(stderr.toString().trim()).not.toContain('errors'); 80 | }); 81 | 82 | test('clean up on failed install', async ({}, testInfo) => { 83 | const [app, root] = await launchApp( 84 | testInfo, 85 | '--fixture=basic', 86 | `--composer=${join(__dirname, 'fixtures', 'composer-install-error.php')}`, 87 | ); 88 | 89 | const window = await app.firstWindow(); 90 | // Confirm that STDERR output (i.e., progress messages) is streamed to the window. 91 | await expect(window.getByText('Doing step: ')).toBeVisible(); 92 | 93 | const errorElement = window.locator('.error'); 94 | await expect(errorElement).toBeVisible(); 95 | await expect(errorElement).toContainText('An imaginary error occurred!'); 96 | 97 | // The "Start" button should not be visible when there is an error. 98 | await expect(window.getByTitle('Start site')).not.toBeVisible(); 99 | 100 | // We expect access() to throw a "no such file or directory" error, because the 101 | // directory has been deleted. 102 | expect(() => accessSync(root)).toThrow(); 103 | await app.close(); 104 | }); 105 | 106 | test("no clean up if server doesn't start", async ({}, testInfo) => { 107 | const [app, root] = await launchApp(testInfo, '--fixture=basic', '--url=not-a-valid-host'); 108 | 109 | const window = await app.firstWindow(); 110 | await expect(window.getByText('The web server did not start after 2 seconds.')).toBeVisible({ 111 | // Give an extra-long grace period in case this test is being run on a painfully 112 | // slow machine. 113 | timeout: 10_000, 114 | }); 115 | 116 | // The Drupal root should still exist, because the install succeeded but 117 | // the server failed to start. 118 | try { 119 | accessSync(root); 120 | } 121 | finally { 122 | await app.close(); 123 | } 124 | }); 125 | 126 | test('server can be disabled', async ({}, testInfo) => { 127 | const [app, root] = await launchApp(testInfo, '--fixture=basic', '--no-server'); 128 | 129 | const window = await app.firstWindow(); 130 | await expect(window.getByText('Installation complete!')).toBeVisible(); 131 | 132 | try { 133 | accessSync(root); 134 | } 135 | finally { 136 | await app.close(); 137 | } 138 | }); 139 | 140 | test('install from a pre-built archive', async ({}, testInfo) => { 141 | const fixturesDir = join(__dirname, 'fixtures'); 142 | 143 | const [app] = await launchApp( 144 | testInfo, 145 | `--archive=${join(fixturesDir, 'prebuilt.tar.gz')}`, 146 | `--composer=${join(fixturesDir, 'composer-always-error.php')}`, 147 | ); 148 | 149 | const page = await visitSite(app); 150 | await expect(page.locator('body')).toContainText('A prebuilt archive worked! Running PHP via cli-server.'); 151 | await app.close(); 152 | }); 153 | 154 | test('reset site', async ({}, testInfo) => { 155 | const [app, root] = await launchApp(testInfo, '--fixture=basic'); 156 | 157 | // If the site started up successfully, we should have a button to delete it, and 158 | // a button to open it in the file explorer. 159 | const window = await app.firstWindow(); 160 | await expect(window.getByText('Visit Site')).toBeVisible(); 161 | await expect(window.getByTitle('Open Drupal directory')).toBeVisible(); 162 | const deleteButton = window.getByTitle('Delete site'); 163 | await expect(deleteButton).toBeVisible(); 164 | 165 | // Clicking the Delete button should put up a confirmation dialog. 166 | window.on('dialog', async (dialog) => { 167 | expect(dialog.type()).toBe('confirm'); 168 | expect(dialog.message()).toBe("Your site and content will be permanently deleted. You can't undo this. Are you sure?"); 169 | await dialog.accept(); 170 | }); 171 | await deleteButton.click(); 172 | 173 | // Once the delete is done, the Drupal directory should be gone and we should have 174 | // a button to start (i.e., reinstall) the site again. 175 | await expect(window.getByText('Reinstall Drupal CMS')).toBeVisible(); 176 | const startButton = window.getByTitle('Start site'); 177 | await expect(startButton).toBeVisible(); 178 | await expect(window.getByText('Reinstall Drupal CMS')).toBeVisible(); 179 | expect(() => accessSync(root)).toThrow(); 180 | 181 | // Clicking that button should get us back up and running. 182 | await startButton.click(); 183 | await expect(window.getByText('Installing...')).toBeVisible(); 184 | await expect(window.getByText('Visit Site')).toBeVisible(); 185 | 186 | await app.close(); 187 | }); 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drupal CMS Launcher 2 | 3 | A simple, standalone app to run Drupal CMS locally with zero setup. 4 | 5 | [![License](https://img.shields.io/github/license/drupal/cms-launcher)](LICENSE) 6 | [![Latest Release](https://img.shields.io/github/v/tag/drupal/cms-launcher 7 | )](https://github.com/drupal/cms-launcher/releases) 8 | 9 | This installs [Drupal CMS](https://new.drupal.org/drupal-cms) on your machine and opens it in your default browser. The idea is a basic Drupal environment that just works: no fiddly setup process, no Docker, no external dependencies. Simply double-click the app to get started. 10 | 11 | This product includes PHP software, freely available from . 12 | 13 | ## Table of contents 14 | 15 | - [How to try it](#how-to-try-it) 16 | - [What this app is for](#what-this-app-is-for) 17 | - [How it works](#how-it-works) 18 | - [Where are the Drupal files and database?](#where-are-the-drupal-files-and-database) 19 | - [How to uninstall](#how-to-uninstall) 20 | - [Troubleshooting](#troubleshooting) 21 | - [Sponsorship](#sponsorship) 22 | - [Contributing](#contributing) 23 | - [Limitations and alternatives](#limitations-and-alternatives) 24 | 25 | ## How to try it 26 | 27 | * **Windows**: [Download the latest release](https://github.com/drupal/cms-launcher/releases/latest/download/Drupal_CMS-Windows.exe) and double-click to install and run. 28 | * **macOS**: [Download the latest release](https://github.com/drupal/cms-launcher/releases/latest/download/Drupal_CMS-macOS.zip), unzip it, and move the app to your _Applications_ folder. Then double-click it to run. 29 | * **Linux**: Install [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher), then [download the latest release](https://github.com/drupal/cms-launcher/releases/latest) of the Drupal CMS Launcher appropriate for your system, right-click file and select "Executable as Program", and double-click it. 30 | **Tip**: Use the `arch` or `dpkg --print-architecture` command to check what kind of system you have. If you get `x86_64` or `amd64`, use the `x64` version. If you get `aarch64`, use the `arm64` version. 31 | 32 | This app is actively developed, so if you encounter buggy behavior, [please report it](https://github.com/drupal/cms-launcher/issues)! 33 | 34 | ## What this app is for 35 | 36 | This launcher is designed to help you **test out and build a site with Drupal CMS**. It's ideal for site builders, themers, and evaluators who want to explore Drupal CMS quickly. 37 | 38 | It is **not intended for contributing to or developing Drupal CMS itself**. If your goal is to contribute code to Drupal CMS upstream, visit the [Drupal CMS project on Drupal.org](https://www.drupal.org/project/drupal_cms). 39 | 40 | ## How it works 41 | 42 | We use [static-php-cli](https://static-php.dev/) to compile a copy of PHP 8.3 that includes everything needed to run Drupal CMS. We bundle that with the [Composer](https://getcomposer.org/) dependency manager, and use those two tools to install and serve Drupal CMS. The app itself is a very simple wrapper around PHP and Composer, written in TypeScript using the [Electron](https://www.electronjs.org/) framework. 43 | 44 | ## Where are the Drupal files and database? 45 | 46 | The Drupal files and database are stored outside of the application, in the _drupal_ directory under the system's application data directory. The site uses a lightweight SQLite database, located at `web/sites/default/files/.ht.sqlite`. 47 | 48 | To reset your Drupal CMS instance, press the trash icon to delete the _drupal_ directory from your system's application data directory. You will then be able to reinstall Drupal CMS from scratch. 49 | 50 | ## How to uninstall 51 | 52 | To completely uninstall the Drupal CMS Launcher and remove all associated files: 53 | 54 | 1. **Remove the Drupal files:** 55 | - Run the app and press the trash icon to delete the Drupal directory. 56 | - Quit the app. 57 | 58 | 2. **Delete the application:** 59 | - On macOS: Move the app from your _Applications_ directory to the Trash. 60 | - On Windows: Uninstall the app via the control panel, the same way you would uninstall any other app. 61 | - On Linux: Delete the AppImage file you downloaded. 62 | 63 | ## Troubleshooting 64 | 65 | This section provides solutions to common problems. 66 | 67 | ### Windows Defender Firewall is blocking the application 68 | 69 | On Windows, the built-in firewall may block the launcher because it needs to start a local web server. If the application fails to start or gives a connection error, you may need to manually allow it through the firewall. 70 | 71 | **Solution:** 72 | 73 | 1. Press the Windows Key, type `Allow an app through Windows Firewall`, and open it. 74 | 2. Click the **"Change settings"** button. You may need administrator permission. 75 | 3. Click the **"Allow another app..."** button at the bottom. 76 | 4. In the new window, click **"Browse..."** and navigate to where you installed the launcher (usually at `C:\Users\YOUR_USERNAME\AppData\Local\Programs\cms-launcher`). Select `Launch Drupal CMS.exe` and click **"Open"**. 77 | 5. Click the **"Add"** button to add the application to the firewall list. 78 | 6. You will now see the launcher in the list. Ensure that the checkboxes for **"Private"** and **"Public"** are checked. 79 | 7. Click **"OK"** to save your changes. The launcher should now be able to connect properly. 80 | 81 | ## Sponsorship 82 | 83 | This launcher is maintained by the [Drupal Association](https://www.drupal.org/association) and community contributors, but it depends on the amazing `static-php-cli` project, which is used to build the PHP interpreter bundled with the launcher. We heartily encourage you to [sponsor that project's maintainer](https://github.com/sponsors/crazywhalecc) and/or become a Drupal Association member. 84 | 85 | ## Contributing 86 | 87 | Found a bug or want to suggest a feature? [Open an issue](https://github.com/drupal/cms-launcher/issues) or submit a pull request. All contributions welcome! 88 | 89 | ### Prerequisites 90 | 91 | * Node.js (the JavaScript runtime—not the Drupal module 😉) 92 | * PHP 8.3 or later, globally installed 93 | * Composer, globally installed 94 | 95 | Clone this repository, then `cd` into it and run: 96 | 97 | ```shell 98 | composer run assets --working-dir=build 99 | npm install 100 | npm start 101 | ``` 102 | 103 | To run tests, run `npm test`. To test the full app bundle, run `npx electron-builder` and look for the final binary in the `dist` directory. 104 | 105 | ## Limitations and alternatives 106 | 107 | This launcher is meant to get Drupal CMS up and running with no fuss, but it can't replace a full-featured development environment. Specifically, this launcher: 108 | 109 | * Does not support deploying to hosting services (yet). 110 | * Only supports one Drupal CMS site at a time. 111 | * Only supports SQLite databases, since SQLite is compiled into the PHP interpreter and has no additional dependencies. 112 | * Uses [the web server built into the PHP interpreter](https://www.php.net/manual/en/features.commandline.webserver.php), which is meant for testing, does _not_ support HTTPS, and generally isn't as powerful as Apache or nginx. 113 | * Might not be able to send email, since it relies on whatever mail transfer program is (or isn't) on your system. 114 | * Uses PHP 8.3—the minimum required by Drupal 11—for maximum compatibility and performance. 115 | 116 | If these limitations don’t meet your needs, consider the following alternatives: 117 | 118 | * [DDEV](https://ddev.com) is Drupal's Docker-based local development platform of choice and gives you everything you need. It even has [a quick-start for Drupal CMS](https://ddev.readthedocs.io/en/stable/users/quickstart/#drupal-drupal-cms). 119 | * [Lando](https://lando.dev/) and [Docksal](https://docksal.io/) are also widely used, Docker-based options. 120 | * If you'd rather avoid Docker, [Laravel Herd](https://herd.laravel.com/) is a fine choice. 121 | * [MAMP](http://mamp.info/) is a long-standing favorite among web developers and is worth exploring. 122 | * For Mac users, [Laravel Valet](https://laravel.com/docs/11.x/valet) is great if you're comfortable at the command line. 123 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | ipcMain, 5 | Menu, 6 | MessageChannelMain, 7 | type MessagePortMain, 8 | type WebContents, 9 | } from 'electron'; 10 | import logger from 'electron-log'; 11 | import { autoUpdater } from 'electron-updater'; 12 | import { basename, join } from 'node:path'; 13 | import * as Sentry from "@sentry/electron/main"; 14 | import { Drupal } from './Drupal'; 15 | import yargs from 'yargs'; 16 | import { hideBin } from 'yargs/helpers'; 17 | import { PhpCommand } from './PhpCommand'; 18 | import { ComposerCommand } from './ComposerCommand'; 19 | 20 | // If the app is packaged, send any uncaught exceptions to Sentry. 21 | if (app.isPackaged) { 22 | Sentry.init({ 23 | beforeSend: (event, hint) => { 24 | logger.transports.file.readAllLogs().forEach((log) => { 25 | hint.attachments ??= []; 26 | 27 | hint.attachments = [{ 28 | filename: basename(log.path), 29 | data: log.lines.join('\n'), 30 | contentType: 'text/plain', 31 | }]; 32 | }); 33 | return event; 34 | }, 35 | dsn: "https://12eb563e258a6344878c10f16bbde85e@o4509476487233536.ingest.de.sentry.io/4509476503683152", 36 | // We don't need to send any PII at all, so explicitly disable it. It's disabled 37 | // by default, but we don't want it changing unexpectedly. 38 | sendDefaultPii: false, 39 | }); 40 | } 41 | 42 | logger.initialize(); 43 | 44 | const resourceDir = app.isPackaged ? process.resourcesPath : app.getAppPath(); 45 | 46 | // Define the command-line options we support. 47 | const commandLine = yargs().options({ 48 | root: { 49 | type: 'string', 50 | description: 'The absolute path to the Drupal project root.', 51 | default: join(app.getPath('appData'), 'drupal'), 52 | }, 53 | log: { 54 | type: 'string', 55 | description: "Path of the log file.", 56 | default: logger.transports.file.getFile().path, 57 | }, 58 | composer: { 59 | type: 'string', 60 | description: "The path of the Composer PHP script. Don't set this unless you know what you're doing.", 61 | default: join(resourceDir, 'bin', 'composer', 'bin', 'composer'), 62 | }, 63 | url: { 64 | type: 'string', 65 | description: "The URL of the Drupal site. Don't set this unless you know what you're doing.", 66 | }, 67 | timeout: { 68 | type: 'number', 69 | description: 'How long to wait for the web server to start before timing out, in seconds.', 70 | default: 30, 71 | }, 72 | server: { 73 | type: 'boolean', 74 | description: 'Whether to automatically start the web server once Drupal is installed.', 75 | default: true, 76 | }, 77 | archive: { 78 | type: 'string', 79 | description: "The path of a .tar.gz archive that contains the pre-built Drupal code base.", 80 | default: join(resourceDir, 'prebuilt.tar.gz'), 81 | }, 82 | }); 83 | 84 | // If in development, allow the Drupal code base to be spun up from a test fixture. 85 | if (! app.isPackaged) { 86 | commandLine.option('fixture', { 87 | type: 'string', 88 | description: 'The name of a test fixture from which to create the Drupal project.', 89 | }); 90 | } 91 | 92 | // Define the shape of our command-line options, to help the type checker deal with yargs. 93 | interface Options 94 | { 95 | root: string; 96 | log: string; 97 | composer: string; 98 | fixture?: string; 99 | url?: string; 100 | timeout: number; 101 | server: boolean; 102 | archive: string; 103 | } 104 | 105 | // Parse the command line and use it to set the path to Composer and the log file. 106 | const argv: Options = commandLine.parseSync( 107 | hideBin(process.argv), 108 | ); 109 | 110 | // The path to PHP. This cannot be overridden because PHP is an absolute hard requirement 111 | // of this app. 112 | PhpCommand.binary = join(resourceDir, 'bin', process.platform === 'win32' ? 'php.exe' : 'php'); 113 | 114 | // Set the path to the Composer executable. We need to use an unpacked version of Composer 115 | // because the phar file has a shebang line that breaks us due to environment variables not 116 | // being inherited when this app is launched from the UI. 117 | ComposerCommand.binary = argv.composer; 118 | 119 | // Set the path to the log file. It's a little awkward that this needs to be done by setting 120 | // a function, but that's just how electron-log works. 121 | logger.transports.file.resolvePathFn = (): string => argv.log; 122 | 123 | const drupal = new Drupal(argv.root, argv.fixture); 124 | 125 | function openPort (win: WebContents): MessagePortMain 126 | { 127 | const { 128 | port1: toRenderer, 129 | port2: fromHere, 130 | } = new MessageChannelMain(); 131 | 132 | toRenderer.start(); 133 | win.postMessage('port', null, [fromHere]); 134 | 135 | return toRenderer; 136 | } 137 | 138 | ipcMain.on('drupal:start', async ({ sender: win }): Promise => { 139 | // Set up logging to help with debugging auto-update problems, and ensure any 140 | // errors are sent to Sentry. 141 | autoUpdater.logger = logger; 142 | autoUpdater.on('error', e => Sentry.captureException(e)); 143 | 144 | const toRenderer = openPort(win); 145 | try { 146 | await drupal.start(argv.archive, argv.server ? argv.url : false, argv.timeout, toRenderer); 147 | } 148 | catch (e: any) { 149 | toRenderer.postMessage({ 150 | state: 'error', 151 | // If the error was caused by a failed Composer command, it will have an additional 152 | // `stdout` property with Composer's output. 153 | detail: e.stdout || e.toString(), 154 | }); 155 | // Send the exception to Sentry so we can analyze it later, without requiring 156 | // users to file a GitHub issue. 157 | Sentry.captureException(e); 158 | } 159 | finally { 160 | // If we're in CI, we're not checking for updates; there's nothing else to do. 161 | if ('CI' in process.env) { 162 | app.quit(); 163 | } 164 | // On newer versions of macOS, the app cannot be auto-updated if it's not in 165 | // the Applications folder, and will cause an error. In that situation, don't 166 | // even bother to check for updates. 167 | else if (process.platform === 'darwin' && ! app.isInApplicationsFolder()) { 168 | logger.debug('macOS: Skipping update check because app is not in the Applications folder.'); 169 | } 170 | else { 171 | await autoUpdater.checkForUpdatesAndNotify(); 172 | } 173 | } 174 | }); 175 | 176 | ipcMain.on('drupal:open', async (): Promise => { 177 | await drupal.open(); 178 | }); 179 | 180 | ipcMain.on('drupal:visit', async (): Promise => { 181 | await drupal.visit(); 182 | }); 183 | 184 | ipcMain.on('drupal:destroy', async ({ sender: win }): Promise => { 185 | const toRenderer = openPort(win); 186 | await drupal.destroy(toRenderer); 187 | }); 188 | 189 | // Quit the app when all windows are closed. Normally you'd keep keep the app 190 | // running on macOS, even with no windows open, since that's the common pattern. 191 | // But for a pure launcher like this one, it makes more sense to just quit. 192 | app.on('window-all-closed', app.quit); 193 | 194 | function createWindow (): void 195 | { 196 | const win = new BrowserWindow({ 197 | width: 800, 198 | height: 500, 199 | webPreferences: { 200 | preload: join(__dirname, '..', 'preload', 'preload.js'), 201 | }, 202 | }); 203 | 204 | // On macOS, totally redefine the menu. 205 | if (process.platform === 'darwin') { 206 | const menu: Menu = Menu.buildFromTemplate([ 207 | { 208 | label: app.getName(), 209 | submenu: [ 210 | { 211 | label: 'About', 212 | role: 'about', 213 | }, 214 | { 215 | label: 'Quit', 216 | accelerator: 'Command+Q', 217 | click () { 218 | app.quit(); 219 | }, 220 | }, 221 | ], 222 | } 223 | ]); 224 | Menu.setApplicationMenu(menu); 225 | } 226 | else { 227 | // Disable the default menu on Windows and Linux, since it doesn't make sense 228 | // for this app. 229 | Menu.setApplicationMenu(null); 230 | } 231 | 232 | win.loadFile(join(__dirname, '..', 'renderer', 'index.html')); 233 | } 234 | 235 | app.whenReady().then((): void => { 236 | createWindow(); 237 | 238 | app.on('activate', () => { 239 | if (BrowserWindow.getAllWindows().length === 0) { 240 | createWindow(); 241 | } 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /.github/workflows/build-macos.yml: -------------------------------------------------------------------------------- 1 | name: Build for macOS 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | # PR branches that alter the build process should be prefixed with `build/`, so 8 | # that this workflow runs. 9 | - 'build/**' 10 | schedule: 11 | # Run this workflow every 8 hours to repackage the pre-built Drupal CMS site 12 | # with the latest dependencies. 13 | - cron: 0 */8 * * * 14 | workflow_dispatch: 15 | 16 | jobs: 17 | # The PHP interpreter needs to have its own job so it can be built in parallel for all 18 | # supported architectures, before being merged into a universal binary while building 19 | # the app itself. 20 | php: 21 | name: PHP on ${{ matrix.os }} 22 | strategy: 23 | matrix: 24 | os: 25 | # x64, supported until 2027, at which point GitHub Actions will drop support for 26 | # macOS on Intel. 27 | # @see https://github.com/actions/runner-images/issues/13046 28 | - macos-15-intel 29 | - macos-latest # arm64 30 | runs-on: ${{ matrix.os }} 31 | env: 32 | # The extensions and libraries needed to build PHP. These need to be variables so we can 33 | # use them to generate a cache key. 34 | # @see https://static-php.dev/en/guide/cli-generator.html 35 | PHP_EXTENSIONS: bz2,ctype,curl,dom,filter,gd,iconv,mbstring,opcache,openssl,pcntl,pdo,pdo_sqlite,phar,posix,session,simplexml,sodium,sqlite3,tokenizer,xml,xmlwriter,yaml,zip,zlib 36 | PHP_VERSION: 8.3 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | # Cache the built binary so we can skip the build steps if there is a cache hit. 41 | - name: Generate cache key 42 | run: | 43 | CACHE_KEY=${{ runner.os }}-${{ runner.arch }}-$PHP_VERSION--$(echo $PHP_EXTENSIONS | tr ',' '-') 44 | echo "CACHE_KEY=${CACHE_KEY}" >> $GITHUB_ENV 45 | 46 | - id: cache 47 | name: Cache PHP interpreter 48 | uses: actions/cache@v4 49 | with: 50 | path: build/php-${{ runner.arch }} 51 | key: php-${{ env.CACHE_KEY }} 52 | 53 | - if: steps.cache.outputs.cache-hit != 'true' 54 | name: "Set up PHP" 55 | uses: shivammathur/setup-php@v2 56 | with: 57 | # Install PHP with the tools needed to build the interpreter, if necessary. 58 | php-version: latest 59 | tools: pecl, composer 60 | extensions: curl, openssl, mbstring, tokenizer 61 | ini-values: memory_limit=-1 62 | 63 | - if: steps.cache.outputs.cache-hit != 'true' 64 | name: Install dependencies and build PHP 65 | run: | 66 | brew install automake gzip 67 | composer install 68 | composer exec spc -- doctor 69 | composer exec spc -- download --with-php=${{ env.PHP_VERSION }} --for-extensions=${{ env.PHP_EXTENSIONS }} --prefer-pre-built 70 | composer exec spc -- build ${{ env.PHP_EXTENSIONS }} --build-cli --with-libs=freetype,libavif,libjpeg,libwebp --debug 71 | mv ./buildroot/bin/php ./php-${{ runner.arch }} 72 | env: 73 | # Allows static-php-cli to download its many dependencies more smoothly. 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | working-directory: build 76 | 77 | - name: Upload PHP binary 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: php-${{ runner.arch }} 81 | path: build/php-${{ runner.arch }} 82 | retention-days: 1 83 | 84 | app: 85 | name: App 86 | runs-on: macos-latest 87 | needs: 88 | - php 89 | env: 90 | # Don't publish by default. This is overridden for the release branch, below. 91 | PUBLISH: never 92 | steps: 93 | - uses: actions/checkout@v4 94 | with: 95 | fetch-depth: 0 96 | 97 | - name: Check out latest tag 98 | if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' }} 99 | run: | 100 | LATEST_TAG=$(git describe --tags --abbrev=0) 101 | git checkout $LATEST_TAG 102 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV 103 | 104 | - name: Download PHP binaries 105 | uses: actions/download-artifact@v4 106 | with: 107 | path: build 108 | pattern: php-* 109 | merge-multiple: true 110 | 111 | - name: Set up PHP 112 | uses: shivammathur/setup-php@v2 113 | with: 114 | tools: composer 115 | 116 | - name: Set up Node 117 | uses: actions/setup-node@v4 118 | with: 119 | node-version: latest 120 | 121 | - name: Cache dependencies 122 | id: cache 123 | uses: actions/cache@v4 124 | with: 125 | path: node_modules 126 | key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }} 127 | 128 | - name: Install dependencies 129 | if: steps.cache.outputs.cache-hit != 'true' 130 | run: npm install 131 | 132 | # This was copied from the example in 133 | # https://docs.github.com/en/actions/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development. 134 | - name: Set up code signing 135 | env: 136 | BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} 137 | P12_PASSWORD: ${{ secrets.P12_PASSWORD }} 138 | BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} 139 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 140 | run: | 141 | # Set up some useful variables. 142 | CERTIFICATE_PATH=$RUNNER_TEMP/build.p12 143 | PROVISION_PROFILE_PATH=$RUNNER_TEMP/build.provisionprofile 144 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db 145 | 146 | # Import the signing certificate and provisioning profile from our secrets. 147 | echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH 148 | echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PROVISION_PROFILE_PATH 149 | 150 | # Create a temporary keychain which will hold the signing certificate. 151 | security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 152 | security set-keychain-settings -lut 21600 $KEYCHAIN_PATH 153 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 154 | 155 | # Add the certificate to the keychain. 156 | security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH 157 | security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 158 | security list-keychain -d user -s $KEYCHAIN_PATH 159 | 160 | # Apply the provisioning profile. 161 | # This path is based on what I found at https://stackoverflow.com/questions/45625347/xcode-provisioning-profiles-location#45642752 162 | mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles 163 | cp $PROVISION_PROFILE_PATH ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles 164 | 165 | - name: Build app 166 | run: | 167 | composer run assets --working-dir=build 168 | # Generate a universal binary of the PHP interpreter. 169 | lipo -create ./build/php-X64 ./build/php-ARM64 -output ./bin/php 170 | chmod +x ./bin/php 171 | npx electron-vite build 172 | 173 | - name: Prepare to publish 174 | # If we're on a PR branch, we don't want Electron Builder to publish the app. 175 | if: github.ref_name == 'main' 176 | run: | 177 | # Configure Electron Builder to notarize and publish. 178 | echo "APPLE_ID=${{ secrets.APPLE_ID }}" >> $GITHUB_ENV 179 | echo "APPLE_APP_SPECIFIC_PASSWORD=${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" >> $GITHUB_ENV 180 | echo "APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }}" >> $GITHUB_ENV 181 | echo "PUBLISH=onTagOrDraft" >> $GITHUB_ENV 182 | 183 | # Electron Builder needs a token to publish releases. 184 | echo "GH_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 185 | 186 | # Pre-build the Drupal site. 187 | npx electron . --root=drupal --no-server 188 | find drupal -depth -type d -name tests -exec rm -r -f {} ';' 189 | tar -c -z -f prebuilt.tar.gz --directory=drupal . 190 | rm -r -f drupal 191 | 192 | - name: Make application 193 | run: npx electron-builder --universal --publish=${{ env.PUBLISH }} 194 | 195 | # For manual testing, upload the final artifact if we're not in a release branch. 196 | - name: Upload distributable 197 | if: ${{ env.PUBLISH == 'never' }} 198 | uses: actions/upload-artifact@v4 199 | with: 200 | name: app 201 | path: dist/*.zip 202 | retention-days: 7 203 | 204 | - name: Update prebuilt Drupal code base 205 | if: ${{ env.LATEST_TAG }} 206 | uses: softprops/action-gh-release@v2 207 | with: 208 | files: | 209 | dist/Drupal_CMS-macOS.zip 210 | dist/Drupal_CMS-macOS.zip.blockmap 211 | dist/latest-mac.yml 212 | tag_name: ${{ env.LATEST_TAG }} 213 | -------------------------------------------------------------------------------- /src/main/Drupal.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'node:child_process'; 2 | import { default as getPort, portNumbers } from 'get-port'; 3 | import { OutputType, PhpCommand } from './PhpCommand'; 4 | import { app, type MessagePortMain, shell } from 'electron'; 5 | import { ComposerCommand } from './ComposerCommand'; 6 | import { join } from 'node:path'; 7 | import { access, copyFile, glob, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; 8 | import * as tar from 'tar'; 9 | import logger from 'electron-log'; 10 | import { Drupal as DrupalInterface } from '../preload/Drupal'; 11 | import * as YAML from 'yaml'; 12 | 13 | /** 14 | * Provides methods for installing and serving a Drupal code base. 15 | */ 16 | export class Drupal implements DrupalInterface 17 | { 18 | private readonly root: string; 19 | 20 | private url: string | null = null; 21 | 22 | private server: ChildProcess | null = null; 23 | 24 | private readonly commands = { 25 | 26 | install: [ 27 | // Create the project, but don't install dependencies yet. 28 | ['create-project', '--no-install', 'drupal/cms'], 29 | 30 | // Prevent core's scaffold plugin from trying to dynamically determine if 31 | // the project is a Git repository, since that will make it try to run Git, 32 | // which might not be installed. 33 | ['config', 'extra.drupal-scaffold.gitignore', 'false', '--json'], 34 | 35 | // Require the Drupal Association Extras module, which will be injected into 36 | // the install profile by prepareSettings(). 37 | ['require', 'drupal/drupal_association_extras:@alpha', '--no-update'], 38 | 39 | // Finally, install dependencies. We suppress the progress bar because it 40 | // looks lame when streamed to the renderer. 41 | ['install', '--no-progress'], 42 | 43 | // Unpack all recipes. This would normally be done during the `create-project` command 44 | // if dependencies were being installed at that time. 45 | ['drupal:recipe-unpack'], 46 | 47 | ], 48 | 49 | update: [], 50 | 51 | } 52 | 53 | constructor (root: string, fixture?: string) 54 | { 55 | this.root = root; 56 | 57 | // Record a version number in composer.json so we can update the built project 58 | // later if needed. This must be done before the lock file is created (i.e., 59 | // before dependencies are installed) so that `composer validate --check-lock` 60 | // will be happy. 61 | this.commands.install.splice( 62 | this.commands.install.findIndex(command => command[0] === 'install'), 63 | 0, 64 | ['config', '--merge', '--json', 'extra.drupal-launcher', `{"version": ${this.commands.update.length + 1}}`] 65 | ); 66 | 67 | if (fixture) { 68 | const repository = JSON.stringify({ 69 | type: 'path', 70 | url: join(__dirname, '..', '..', 'tests', 'fixtures', fixture), 71 | }); 72 | // The option does not need to be escaped or quoted, because Composer is not being 73 | // executed through a shell. 74 | this.commands.install[0].push(`--repository=${repository}`); 75 | } 76 | } 77 | 78 | public async start (archive?: string, url?: string | false, timeout: number = 2, port?: MessagePortMain): Promise 79 | { 80 | try { 81 | await access(this.root); 82 | } 83 | catch { 84 | // The root directory doesn't exist, so we need to install Drupal. 85 | try { 86 | await this.install(archive, port); 87 | } 88 | catch (e) { 89 | // Courteously try to clean up the broken site before re-throwing. 90 | await this.destroy(); 91 | throw e; 92 | } 93 | } 94 | 95 | // If no URL was provided, find an open port on localhost. 96 | if (typeof url === 'undefined') { 97 | const port: number = await getPort({ 98 | port: portNumbers(8888, 9999), 99 | }); 100 | url = `http://localhost:${port}`; 101 | } 102 | 103 | if (url) { 104 | port?.postMessage({ state: 'start' }); 105 | [this.url, this.server] = await this.serve(url, timeout); 106 | port?.postMessage({ state: 'on', detail: this.url }); 107 | } 108 | else { 109 | port?.postMessage({ state: 'off' }); 110 | } 111 | } 112 | 113 | public async visit (): Promise 114 | { 115 | if (this.url) { 116 | await shell.openExternal(this.url); 117 | } 118 | else { 119 | throw Error('The Drupal site is not running.'); 120 | } 121 | } 122 | 123 | public async open (): Promise 124 | { 125 | await access(this.root); 126 | await shell.openPath(this.root); 127 | } 128 | 129 | public async destroy (port?: MessagePortMain): Promise 130 | { 131 | port?.postMessage({ state: 'destroy' }); 132 | 133 | this.server?.kill(); 134 | this.server = null; 135 | await rm(this.root, { force: true, recursive: true, maxRetries: 3 }); 136 | 137 | port?.postMessage({ state: 'clean' }); 138 | } 139 | 140 | private webRoot (): string 141 | { 142 | // @todo Determine this dynamically. 143 | return join(this.root, 'web'); 144 | } 145 | 146 | private async install (archive?: string, port?: MessagePortMain): Promise 147 | { 148 | port?.postMessage({ 149 | state: 'install', 150 | detail: 'Initializing...', 151 | }); 152 | 153 | if (archive) { 154 | logger.debug(`Using pre-built archive: ${archive}`); 155 | try { 156 | await access(archive); 157 | return this.extractArchive(archive, port); 158 | } 159 | catch { 160 | logger.info('Falling back to Composer because pre-built archive does not exist.'); 161 | } 162 | } 163 | 164 | // We'll try to parse Composer's output to provide progress information. 165 | let progress: [number, number] | null = null; 166 | 167 | for (const command of this.commands.install) { 168 | await new ComposerCommand(...command) 169 | .inDirectory(this.root) 170 | .run({}, (line: string, type: OutputType): void => { 171 | // Progress messages are sent to STDERR, not STDOUT. 172 | if (type === OutputType.Output) { 173 | return; 174 | } 175 | // When Composer reports the number of operations it intends to do, 176 | // initialize the progress information. 177 | const matches = line.match(/^Package operations: ([0-9]+) installs?,\s*/); 178 | if (matches) { 179 | const total = parseInt(matches[1]); 180 | progress = [0, total]; 181 | } 182 | else if (progress && line.includes('- Installing ')) { 183 | progress[0]++; 184 | } 185 | // Send the output line and progress information to the renderer. 186 | port?.postMessage({ state: 'install', detail: line, progress }); 187 | }); 188 | } 189 | await this.prepareSettings(); 190 | } 191 | 192 | private async extractArchive (file: string, port?: MessagePortMain): Promise 193 | { 194 | let total: number = 0; 195 | let done: number = 0; 196 | 197 | // Find our how many files are in the archive, so we can provide accurate 198 | // progress information. 199 | await tar.list({ 200 | file, 201 | onReadEntry: (): void => { 202 | total++; 203 | }, 204 | }); 205 | 206 | // We need to create the directory where we'll extract the files. 207 | await mkdir(this.root, { recursive: true }); 208 | 209 | // Send progress information every 500 milliseconds while extracting the 210 | // archive. 211 | const interval = setInterval((): void => { 212 | port?.postMessage({ 213 | state: 'install', 214 | detail: `Extracting archive (% done)`, 215 | progress: [done, total], 216 | }); 217 | }, 500); 218 | 219 | // Extract the archive and, regardless of success or failure, stop sending progress 220 | // information when done. 221 | return tar.extract({ 222 | cwd: this.root, 223 | file, 224 | onReadEntry: (): void => { 225 | done++; 226 | }, 227 | }).finally((): void => { 228 | clearInterval(interval); 229 | }); 230 | } 231 | 232 | private async prepareSettings (): Promise 233 | { 234 | const siteDir: string = join(this.webRoot(), 'sites', 'default'); 235 | 236 | // Copy our settings.local.php, which contains helpful overrides. 237 | await copyFile( 238 | join(app.isPackaged ? process.resourcesPath : app.getAppPath(), 'settings.local.php'), 239 | join(siteDir, 'settings.local.php'), 240 | ); 241 | 242 | // Uncomment the last few lines of default.settings.php so that the local 243 | // settings get loaded. It's a little clunky to do this as an array operation, 244 | // but as this is a one-time change to a not-too-large file, it's an acceptable 245 | // trade-off. 246 | const settingsPath: string = join(siteDir, 'default.settings.php'); 247 | const lines: string[] = (await readFile(settingsPath)).toString().split('\n'); 248 | const replacements: string[] = lines.slice(-4).map((line: string): string => { 249 | return line.startsWith('# ') ? line.substring(2) : line; 250 | }); 251 | lines.splice(-4, 3, ...replacements); 252 | await writeFile(settingsPath, lines.join('\n')); 253 | 254 | // Add the drupal_association_extras module to every install profile. We don't want to 255 | // hard-code the name or path of the info file, in case Drupal CMS changes it. 256 | const finder = glob( 257 | join(this.webRoot(), 'profiles', '*', '*.info.yml'), 258 | ); 259 | for await (const infoPath of finder) { 260 | const info = YAML.parse((await readFile(infoPath)).toString()); 261 | info.install ??= []; 262 | info.install.push('drupal_association_extras'); 263 | await writeFile(infoPath, YAML.stringify(info)); 264 | } 265 | } 266 | 267 | private async serve (url: string, timeout: number): Promise<[string, ChildProcess]> 268 | { 269 | // This needs to be returned as a promise so that, if we reach the timeout, 270 | // the exception will be caught by the calling code. 271 | return new Promise(async (resolve, reject): Promise => { 272 | const timeoutId = setTimeout((): void => { 273 | reject(`The web server did not start after ${timeout} seconds.`); 274 | }, timeout * 1000); 275 | 276 | const checkForServerStart = (line: string, _: any, server: ChildProcess): void => { 277 | if (line.includes(`(${url}) started`)) { 278 | clearTimeout(timeoutId); 279 | // Automatically kill the server on quit. 280 | app.on('will-quit', () => server.kill()); 281 | resolve([url, server]); 282 | } 283 | }; 284 | 285 | await new PhpCommand('-d max_execution_time=300', '-S', url.substring(7), '.ht.router.php') 286 | .start({ cwd: this.webRoot() }, checkForServerStart); 287 | }); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/renderer/App.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 |
58 |
59 |

60 | 61 | Drupal CMS 62 | 63 | 64 | 65 | 66 | 67 | 68 |

69 |
70 | 71 |
72 |
73 |

{titleText[state] || ''}

74 | {#if isWorking.includes(state)} 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 |
105 | {/if} 106 |

{@html statusText[state]}

107 | {#if state === 'on'} 108 |
109 | 115 |
116 | {/if} 117 |
{detail}
118 | {#if progress} 119 |
120 | 121 |
122 | {/if} 123 |
124 | {#if state === 'on'} 125 | 128 | 131 | {:else if state === 'clean'} 132 | 135 | {/if} 136 |
137 |
138 |
139 |
140 | 141 | 288 | --------------------------------------------------------------------------------