diff --git a/composer.json b/composer.json index 9fe38e01..53c6ac6f 100644 --- a/composer.json +++ b/composer.json @@ -34,10 +34,11 @@ "php": "^8.1", "illuminate/contracts": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1.1|^0.2|^0.3", - "nativephp/laravel": "^1.0-beta.2", + "nativephp/laravel": "dev-feat/bundle-builds", "nativephp/php-bin": "^0.6", "spatie/laravel-package-tools": "^1.16.4", - "symfony/filesystem": "^6.4|^7.2" + "symfony/filesystem": "^6.4|^7.2", + "ext-zip": "*" }, "require-dev": { "laravel/pint": "^1.0", diff --git a/phpstan.neon b/phpstan.neon index b02c336c..9c47a6b8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,7 +4,6 @@ parameters: - src - config - database - tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true - + noEnvCallsOutsideOfConfig: false diff --git a/resources/js/electron-builder.js b/resources/js/electron-builder.js index ef6defdb..28bc1e6e 100644 --- a/resources/js/electron-builder.js +++ b/resources/js/electron-builder.js @@ -40,13 +40,7 @@ try { } if (isBuilding) { - console.log(); - console.log('==================================================================='); - console.log(' Building for ' + targetOs); - console.log('==================================================================='); - console.log(); - console.log('Updater config', updaterConfig); - console.log(); + console.log(' • updater config', updaterConfig); } export default { diff --git a/resources/js/electron-plugin/dist/index.js b/resources/js/electron-plugin/dist/index.js index a85a2cd5..a63ba8ad 100644 --- a/resources/js/electron-plugin/dist/index.js +++ b/resources/js/electron-plugin/dist/index.js @@ -67,16 +67,6 @@ class NativePHP { } event.preventDefault(); }); - if (process.platform === 'win32') { - app.on('second-instance', (event, commandLine, workingDirectory) => { - if (this.mainWindow) { - if (this.mainWindow.isMinimized()) - this.mainWindow.restore(); - this.mainWindow.focus(); - } - this.handleDeepLink(commandLine.pop()); - }); - } } bootstrapApp(app) { return __awaiter(this, void 0, void 0, function* () { @@ -135,12 +125,28 @@ class NativePHP { else { app.setAsDefaultProtocolClient(deepLinkProtocol); } - if (process.platform === 'win32') { + if (process.platform !== "darwin") { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); return; } + else { + app.on("second-instance", (event, commandLine, workingDirectory) => { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) + this.mainWindow.restore(); + this.mainWindow.focus(); + } + notifyLaravel("events", { + event: "\\Native\\Laravel\\Events\\App\\OpenedFromURL", + payload: { + url: commandLine[commandLine.length - 1], + workingDirectory: workingDirectory, + }, + }); + }); + } } } } diff --git a/resources/js/electron-plugin/dist/server/api/childProcess.js b/resources/js/electron-plugin/dist/server/api/childProcess.js index 94b28de5..64ec1dad 100644 --- a/resources/js/electron-plugin/dist/server/api/childProcess.js +++ b/resources/js/electron-plugin/dist/server/api/childProcess.js @@ -11,80 +11,122 @@ import express from 'express'; import { utilityProcess } from 'electron'; import state from '../state.js'; import { notifyLaravel } from "../utils.js"; -import { getDefaultEnvironmentVariables, getDefaultPhpIniSettings } from "../php.js"; +import { getAppPath, getDefaultEnvironmentVariables, getDefaultPhpIniSettings, runningSecureBuild } from "../php.js"; import killSync from "kill-sync"; import { fileURLToPath } from "url"; +import { join } from "path"; const router = express.Router(); function startProcess(settings) { - const { alias, cmd, cwd, env, persistent } = settings; + const { alias, cmd, cwd, env, persistent, spawnTimeout = 30000 } = settings; if (getProcess(alias) !== undefined) { return state.processes[alias]; } - const proc = utilityProcess.fork(fileURLToPath(new URL('../../electron-plugin/dist/server/childProcess.js', import.meta.url)), cmd, { - cwd, - stdio: 'pipe', - serviceName: alias, - env: Object.assign(Object.assign({}, process.env), env) - }); - proc.stdout.on('data', (data) => { - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\MessageReceived', - payload: { - alias, - data: data.toString(), + try { + const proc = utilityProcess.fork(fileURLToPath(new URL('../../electron-plugin/dist/server/childProcess.js', import.meta.url)), cmd, { + cwd, + stdio: 'pipe', + serviceName: alias, + env: Object.assign(Object.assign({}, process.env), env) + }); + const startTimeout = setTimeout(() => { + if (!state.processes[alias] || !state.processes[alias].pid) { + console.error(`Process [${alias}] failed to start within timeout period`); + try { + proc.kill(); + } + catch (e) { + } + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\StartupError', + payload: { + alias, + error: 'Startup timeout exceeded', + } + }); } + }, spawnTimeout); + proc.stdout.on('data', (data) => { + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\MessageReceived', + payload: { + alias, + data: data.toString(), + } + }); }); - }); - proc.stderr.on('data', (data) => { - console.error('Error received from process [' + alias + ']:', data.toString()); - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ErrorReceived', - payload: { - alias, - data: data.toString(), + proc.stderr.on('data', (data) => { + console.error('Process [' + alias + '] ERROR:', data.toString().trim()); + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ErrorReceived', + payload: { + alias, + data: data.toString(), + } + }); + }); + proc.on('spawn', () => { + clearTimeout(startTimeout); + console.log('Process [' + alias + '] spawned!'); + state.processes[alias] = { + pid: proc.pid, + proc, + settings + }; + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessSpawned', + payload: [alias, proc.pid] + }); + }); + proc.on('exit', (code) => { + clearTimeout(startTimeout); + console.log(`Process [${alias}] exited with code [${code}].`); + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessExited', + payload: { + alias, + code, + } + }); + const settings = Object.assign({}, getSettings(alias)); + delete state.processes[alias]; + if (settings === null || settings === void 0 ? void 0 : settings.persistent) { + console.log('Process [' + alias + '] watchdog restarting...'); + setTimeout(() => startProcess(settings), 1000); } }); - }); - proc.on('spawn', () => { - console.log('Process [' + alias + '] spawned!'); - state.processes[alias] = { - pid: proc.pid, + return { + pid: null, proc, settings }; + } + catch (error) { + console.error(`Failed to create process [${alias}]: ${error.message}`); notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessSpawned', - payload: [alias, proc.pid] - }); - }); - proc.on('exit', (code) => { - console.log(`Process [${alias}] exited with code [${code}].`); - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessExited', + event: 'Native\\Laravel\\Events\\ChildProcess\\StartupError', payload: { alias, - code, + error: error.toString(), } }); - const settings = Object.assign({}, getSettings(alias)); - delete state.processes[alias]; - if (settings.persistent) { - console.log('Process [' + alias + '] watchdog restarting...'); - startProcess(settings); - } - }); - return { - pid: null, - proc, - settings - }; + return { + pid: null, + proc: null, + settings, + error: error.message + }; + } } function startPhpProcess(settings) { const defaultEnv = getDefaultEnvironmentVariables(state.randomSecret, state.electronApiPort); - const iniSettings = Object.assign(Object.assign({}, getDefaultPhpIniSettings()), state.phpIni); + const customIniSettings = settings.iniSettings || {}; + const iniSettings = Object.assign(Object.assign(Object.assign({}, getDefaultPhpIniSettings()), state.phpIni), customIniSettings); const iniArgs = Object.keys(iniSettings).map(key => { return ['-d', `${key}=${iniSettings[key]}`]; }).flat(); + if (settings.cmd[0] === 'artisan' && runningSecureBuild()) { + settings.cmd.unshift(join(getAppPath(), 'build', '__nativephp_app_bundle')); + } settings = Object.assign(Object.assign({}, settings), { cmd: [state.php, ...iniArgs, ...settings.cmd], env: Object.assign(Object.assign({}, settings.env), defaultEnv) }); return startProcess(settings); } diff --git a/resources/js/electron-plugin/dist/server/api/notification.js b/resources/js/electron-plugin/dist/server/api/notification.js index 8433dcce..6058a5df 100644 --- a/resources/js/electron-plugin/dist/server/api/notification.js +++ b/resources/js/electron-plugin/dist/server/api/notification.js @@ -3,8 +3,9 @@ import { Notification } from 'electron'; import { notifyLaravel } from "../utils.js"; const router = express.Router(); router.post('/', (req, res) => { - const { title, body, subtitle, silent, icon, hasReply, timeoutType, replyPlaceholder, sound, urgency, actions, closeButtonText, toastXml, event: customEvent } = req.body; + const { title, body, subtitle, silent, icon, hasReply, timeoutType, replyPlaceholder, sound, urgency, actions, closeButtonText, toastXml, event: customEvent, reference, } = req.body; const eventName = customEvent !== null && customEvent !== void 0 ? customEvent : '\\Native\\Laravel\\Events\\Notifications\\NotificationClicked'; + const notificationReference = reference !== null && reference !== void 0 ? reference : (Date.now() + '.' + Math.random().toString(36).slice(2, 9)); const notification = new Notification({ title, body, @@ -22,11 +23,45 @@ router.post('/', (req, res) => { }); notification.on("click", (event) => { notifyLaravel('events', { - event: eventName, - payload: JSON.stringify(event) + event: eventName || '\\Native\\Laravel\\Events\\Notifications\\NotificationClicked', + payload: { + reference: notificationReference, + event: JSON.stringify(event), + }, + }); + }); + notification.on("action", (event, index) => { + notifyLaravel('events', { + event: '\\Native\\Laravel\\Events\\Notifications\\NotificationActionClicked', + payload: { + reference: notificationReference, + index, + event: JSON.stringify(event), + }, + }); + }); + notification.on("reply", (event, reply) => { + notifyLaravel('events', { + event: '\\Native\\Laravel\\Events\\Notifications\\NotificationReply', + payload: { + reference: notificationReference, + reply, + event: JSON.stringify(event), + }, + }); + }); + notification.on("close", (event) => { + notifyLaravel('events', { + event: '\\Native\\Laravel\\Events\\Notifications\\NotificationClosed', + payload: { + reference: notificationReference, + event: JSON.stringify(event), + }, }); }); notification.show(); - res.sendStatus(200); + res.status(200).json({ + reference: notificationReference, + }); }); export default router; diff --git a/resources/js/electron-plugin/dist/server/php.js b/resources/js/electron-plugin/dist/server/php.js index 06742711..2334f17d 100644 --- a/resources/js/electron-plugin/dist/server/php.js +++ b/resources/js/electron-plugin/dist/server/php.js @@ -9,19 +9,32 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; import { mkdirSync, statSync, writeFileSync, existsSync } from 'fs'; import fs_extra from 'fs-extra'; -const { copySync } = fs_extra; +const { copySync, mkdirpSync } = fs_extra; import Store from 'electron-store'; import { promisify } from 'util'; import { join } from 'path'; import { app } from 'electron'; -import { execFile, spawn } from 'child_process'; +import { execFile, spawn, spawnSync } from 'child_process'; import state from "./state.js"; import getPort, { portNumbers } from 'get-port'; const storagePath = join(app.getPath('userData'), 'storage'); const databasePath = join(app.getPath('userData'), 'database'); const databaseFile = join(databasePath, 'database.sqlite'); +const bootstrapCache = join(app.getPath('userData'), 'bootstrap', 'cache'); const argumentEnv = getArgumentEnv(); const appPath = getAppPath(); +mkdirpSync(bootstrapCache); +function runningSecureBuild() { + return existsSync(join(appPath, 'build', '__nativephp_app_bundle')) + && process.env.NODE_ENV !== 'development'; +} +function shouldMigrateDatabase(store) { + return store.get('migrated_version') !== app.getVersion() + && process.env.NODE_ENV !== 'development'; +} +function shouldOptimize(store) { + return true; +} function getPhpPort() { return __awaiter(this, void 0, void 0, function* () { return yield getPort({ @@ -32,42 +45,64 @@ function getPhpPort() { } function retrievePhpIniSettings() { return __awaiter(this, void 0, void 0, function* () { - const env = { - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_STORAGE_PATH: storagePath, - NATIVEPHP_DATABASE_PATH: databaseFile, - }; + const env = getDefaultEnvironmentVariables(); const phpOptions = { cwd: appPath, env }; - return yield promisify(execFile)(state.php, ['artisan', 'native:php-ini'], phpOptions); + let command = ['artisan', 'native:php-ini']; + if (runningSecureBuild()) { + command.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + return yield promisify(execFile)(state.php, command, phpOptions); }); } function retrieveNativePHPConfig() { return __awaiter(this, void 0, void 0, function* () { - const env = { - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_STORAGE_PATH: storagePath, - NATIVEPHP_DATABASE_PATH: databaseFile, - }; + const env = getDefaultEnvironmentVariables(); const phpOptions = { cwd: appPath, env }; - return yield promisify(execFile)(state.php, ['artisan', 'native:config'], phpOptions); + let command = ['artisan', 'native:config']; + if (runningSecureBuild()) { + command.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + return yield promisify(execFile)(state.php, command, phpOptions); }); } function callPhp(args, options, phpIniSettings = {}) { + if (args[0] === 'artisan' && runningSecureBuild()) { + args.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } let iniSettings = Object.assign(getDefaultPhpIniSettings(), phpIniSettings); Object.keys(iniSettings).forEach(key => { args.unshift('-d', `${key}=${iniSettings[key]}`); }); + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Calling PHP', state.php, args); + } return spawn(state.php, args, { cwd: options.cwd, env: Object.assign(Object.assign({}, process.env), options.env), }); } +function callPhpSync(args, options, phpIniSettings = {}) { + if (args[0] === 'artisan' && runningSecureBuild()) { + args.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + let iniSettings = Object.assign(getDefaultPhpIniSettings(), phpIniSettings); + Object.keys(iniSettings).forEach(key => { + args.unshift('-d', `${key}=${iniSettings[key]}`); + }); + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Calling PHP', state.php, args); + } + return spawnSync(state.php, args, { + cwd: options.cwd, + env: Object.assign(Object.assign({}, process.env), options.env) + }); +} function getArgumentEnv() { const envArgs = process.argv.filter(arg => arg.startsWith('--env.')); const env = {}; @@ -85,7 +120,10 @@ function getAppPath() { return appPath; } function ensureAppFoldersAreAvailable() { + console.log('Copying storage folder...'); + console.log('Storage path:', storagePath); if (!existsSync(storagePath) || process.env.NODE_ENV === 'development') { + console.log("App path:", appPath); copySync(join(appPath, 'storage'), storagePath); } mkdirSync(databasePath, { recursive: true }); @@ -113,14 +151,13 @@ function getPath(name) { } } function getDefaultEnvironmentVariables(secret, apiPort) { - return { + let variables = { APP_ENV: process.env.NODE_ENV === 'development' ? 'local' : 'production', APP_DEBUG: process.env.NODE_ENV === 'development' ? 'true' : 'false', + LARAVEL_STORAGE_PATH: storagePath, + NATIVEPHP_RUNNING: 'true', NATIVEPHP_STORAGE_PATH: storagePath, NATIVEPHP_DATABASE_PATH: databaseFile, - NATIVEPHP_API_URL: `http://localhost:${apiPort}/api/`, - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_SECRET: secret, NATIVEPHP_USER_HOME_PATH: getPath('home'), NATIVEPHP_APP_DATA_PATH: getPath('appData'), NATIVEPHP_USER_DATA_PATH: getPath('userData'), @@ -132,6 +169,18 @@ function getDefaultEnvironmentVariables(secret, apiPort) { NATIVEPHP_VIDEOS_PATH: getPath('videos'), NATIVEPHP_RECENT_PATH: getPath('recent'), }; + if (secret && apiPort) { + variables.NATIVEPHP_API_URL = `http://localhost:${apiPort}/api/`; + variables.NATIVEPHP_SECRET = secret; + } + if (runningSecureBuild()) { + variables.APP_SERVICES_CACHE = join(bootstrapCache, 'services.php'); + variables.APP_PACKAGES_CACHE = join(bootstrapCache, 'packages.php'); + variables.APP_CONFIG_CACHE = join(bootstrapCache, 'config.php'); + variables.APP_ROUTES_CACHE = join(bootstrapCache, 'routes-v7.php'); + variables.APP_EVENTS_CACHE = join(bootstrapCache, 'events.php'); + } + return variables; } function getDefaultPhpIniSettings() { return { @@ -151,53 +200,81 @@ function serveApp(secret, apiPort, phpIniSettings) { cwd: appPath, env }; - const store = new Store(); - callPhp(['artisan', 'storage:link', '--force'], phpOptions, phpIniSettings); - if (store.get('migrated_version') !== app.getVersion() && process.env.NODE_ENV !== 'development') { + const store = new Store({ + name: 'nativephp', + }); + if (!runningSecureBuild()) { + console.log('Linking storage path...'); + callPhpSync(['artisan', 'storage:link', '--force'], phpOptions, phpIniSettings); + } + if (shouldOptimize(store)) { + console.log('Caching view and routes...'); + let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings); + if (result.status !== 0) { + console.error('Failed to cache view and routes:', result.stderr.toString()); + } + else { + store.set('optimized_version', app.getVersion()); + } + } + if (shouldMigrateDatabase(store)) { console.log('Migrating database...'); - callPhp(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings); - store.set('migrated_version', app.getVersion()); + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Database path:', databaseFile); + } + let result = callPhpSync(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings); + if (result.status !== 0) { + console.error('Failed to migrate database:', result.stderr.toString()); + } + else { + store.set('migrated_version', app.getVersion()); + } } if (process.env.NODE_ENV === 'development') { console.log('Skipping Database migration while in development.'); console.log('You may migrate manually by running: php artisan native:migrate'); } + console.log('Starting PHP server...'); const phpPort = yield getPhpPort(); - const serverPath = join(appPath, 'vendor', 'laravel', 'framework', 'src', 'Illuminate', 'Foundation', 'resources', 'server.php'); + let serverPath; + let cwd; + if (runningSecureBuild()) { + serverPath = join(appPath, 'build', '__nativephp_app_bundle'); + } + else { + console.log('* * * Running from source * * *'); + serverPath = join(appPath, 'vendor', 'laravel', 'framework', 'src', 'Illuminate', 'Foundation', 'resources', 'server.php'); + cwd = join(appPath, 'public'); + } const phpServer = callPhp(['-S', `127.0.0.1:${phpPort}`, serverPath], { - cwd: join(appPath, 'public'), + cwd: cwd, env }, phpIniSettings); const portRegex = /Development Server \(.*:([0-9]+)\) started/gm; phpServer.stdout.on('data', (data) => { - const match = portRegex.exec(data.toString()); - if (match) { - console.log("PHP Server started on port: ", match[1]); - const port = parseInt(match[1]); - resolve({ - port, - process: phpServer - }); + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log(data.toString().trim()); } }); phpServer.stderr.on('data', (data) => { const error = data.toString(); - const match = portRegex.exec(error); + const match = portRegex.exec(data.toString()); if (match) { const port = parseInt(match[1]); console.log("PHP Server started on port: ", port); resolve({ port, - process: phpServer + process: phpServer, }); } else { - if (error.startsWith('[NATIVE_EXCEPTION]: ', 27)) { + if (error.includes('[NATIVE_EXCEPTION]:')) { + let logFile = join(storagePath, 'logs'); console.log(); console.error('Error in PHP:'); - console.error(' ' + error.slice(47)); - console.log('Please check your log file:'); - console.log(' ' + join(appPath, 'storage', 'logs', 'laravel.log')); + console.error(' ' + error.split('[NATIVE_EXCEPTION]:')[1].trim()); + console.log('Please check your log files:'); + console.log(' ' + logFile); console.log(); } } @@ -205,6 +282,9 @@ function serveApp(secret, apiPort, phpIniSettings) { phpServer.on('error', (error) => { reject(error); }); + phpServer.on('close', (code) => { + console.log(`PHP server exited with code ${code}`); + }); })); } -export { startScheduler, serveApp, getAppPath, retrieveNativePHPConfig, retrievePhpIniSettings, getDefaultEnvironmentVariables, getDefaultPhpIniSettings }; +export { startScheduler, serveApp, getAppPath, retrieveNativePHPConfig, retrievePhpIniSettings, getDefaultEnvironmentVariables, getDefaultPhpIniSettings, runningSecureBuild }; diff --git a/resources/js/electron-plugin/src/server/api/childProcess.ts b/resources/js/electron-plugin/src/server/api/childProcess.ts index bb19c114..c7fd6b5f 100644 --- a/resources/js/electron-plugin/src/server/api/childProcess.ts +++ b/resources/js/electron-plugin/src/server/api/childProcess.ts @@ -1,99 +1,159 @@ import express from 'express'; -import { utilityProcess } from 'electron'; +import {utilityProcess} from 'electron'; import state from '../state.js'; -import { notifyLaravel } from "../utils.js"; -import { getDefaultEnvironmentVariables, getDefaultPhpIniSettings } from "../php.js"; +import {notifyLaravel} from "../utils.js"; +import {getAppPath, getDefaultEnvironmentVariables, getDefaultPhpIniSettings, runningSecureBuild} from "../php.js"; import killSync from "kill-sync"; import {fileURLToPath} from "url"; +import {join} from "path"; const router = express.Router(); function startProcess(settings) { - const {alias, cmd, cwd, env, persistent} = settings; + const {alias, cmd, cwd, env, persistent, spawnTimeout = 30000} = settings; if (getProcess(alias) !== undefined) { return state.processes[alias]; } - const proc = utilityProcess.fork( - fileURLToPath(new URL('../../electron-plugin/dist/server/childProcess.js', import.meta.url)), - cmd, - { - cwd, - stdio: 'pipe', - serviceName: alias, - env: { - ...process.env, - ...env, + try { + const proc = utilityProcess.fork( + fileURLToPath(new URL('../../electron-plugin/dist/server/childProcess.js', import.meta.url)), + cmd, + { + cwd, + stdio: 'pipe', + serviceName: alias, + env: { + ...process.env, + ...env, + } } - } - ); - - proc.stdout.on('data', (data) => { - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\MessageReceived', - payload: { - alias, - data: data.toString(), + ); + + // Set timeout to detect if process never spawns + const startTimeout = setTimeout(() => { + if (!state.processes[alias] || !state.processes[alias].pid) { + console.error(`Process [${alias}] failed to start within timeout period`); + + // Attempt to clean up + try { + proc.kill(); + } catch (e) { + // Ignore kill errors + } + + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\StartupError', + payload: { + alias, + error: 'Startup timeout exceeded', + } + }); } + }, spawnTimeout); + + proc.stdout.on('data', (data) => { + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\MessageReceived', + payload: { + alias, + data: data.toString(), + } + }); }); - }); - proc.stderr.on('data', (data) => { - console.error('Error received from process [' + alias + ']:', data.toString()); + proc.stderr.on('data', (data) => { + console.error('Process [' + alias + '] ERROR:', data.toString().trim()); - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ErrorReceived', - payload: { - alias, - data: data.toString(), - } + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ErrorReceived', + payload: { + alias, + data: data.toString(), + } + }); }); - }); - proc.on('spawn', () => { - console.log('Process [' + alias + '] spawned!'); + // Experimental feature on Electron, + // I keep this here to remember and retry when we upgrade + // https://www.electronjs.org/docs/latest/api/utility-process#event-error-experimental + // proc.on('error', (error) => { + // clearTimeout(startTimeout); + // console.error(`Process [${alias}] error: ${error.message}`); + // + // notifyLaravel('events', { + // event: 'Native\\Laravel\\Events\\ChildProcess\\StartupError', + // payload: { + // alias, + // error: error.toString(), + // } + // }); + // }); + + proc.on('spawn', () => { + clearTimeout(startTimeout); + console.log('Process [' + alias + '] spawned!'); + + state.processes[alias] = { + pid: proc.pid, + proc, + settings + }; + + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessSpawned', + payload: [alias, proc.pid] + }); + }); + + proc.on('exit', (code) => { + clearTimeout(startTimeout); + console.log(`Process [${alias}] exited with code [${code}].`); + + notifyLaravel('events', { + event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessExited', + payload: { + alias, + code, + } + }); + + const settings = {...getSettings(alias)}; + delete state.processes[alias]; + + if (settings?.persistent) { + console.log('Process [' + alias + '] watchdog restarting...'); + // Add delay to prevent rapid restart loops + setTimeout(() => startProcess(settings), 1000); + } + }); - state.processes[alias] = { - pid: proc.pid, + return { + pid: null, proc, settings }; + } catch (error) { + console.error(`Failed to create process [${alias}]: ${error.message}`); notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessSpawned', - payload: [alias, proc.pid] - }); - }); - - proc.on('exit', (code) => { - console.log(`Process [${alias}] exited with code [${code}].`); - - notifyLaravel('events', { - event: 'Native\\Laravel\\Events\\ChildProcess\\ProcessExited', + event: 'Native\\Laravel\\Events\\ChildProcess\\StartupError', payload: { alias, - code, + error: error.toString(), } }); - const settings = {...getSettings(alias)}; - - delete state.processes[alias]; - - if (settings.persistent) { - console.log('Process [' + alias + '] watchdog restarting...'); - startProcess(settings); - } - }); - - return { - pid: null, - proc, - settings - }; + return { + pid: null, + proc: null, + settings, + error: error.message + }; + } } function startPhpProcess(settings) { @@ -103,18 +163,22 @@ function startPhpProcess(settings) { ); // Construct command args from ini settings - const iniSettings = { ...getDefaultPhpIniSettings(), ...state.phpIni }; + const customIniSettings = settings.iniSettings || {}; + const iniSettings = {...getDefaultPhpIniSettings(), ...state.phpIni, ...customIniSettings}; const iniArgs = Object.keys(iniSettings).map(key => { return ['-d', `${key}=${iniSettings[key]}`]; }).flat(); + if (settings.cmd[0] === 'artisan' && runningSecureBuild()) { + settings.cmd.unshift(join(getAppPath(), 'build', '__nativephp_app_bundle')); + } settings = { ...settings, // Prepend cmd with php executable path & ini settings - cmd: [ state.php, ...iniArgs, ...settings.cmd ], + cmd: [state.php, ...iniArgs, ...settings.cmd], // Mix in the internal NativePHP env - env: { ...settings.env, ...defaultEnv } + env: {...settings.env, ...defaultEnv} }; return startProcess(settings); diff --git a/resources/js/electron-plugin/src/server/php.ts b/resources/js/electron-plugin/src/server/php.ts index 48b08a51..5bda7b24 100644 --- a/resources/js/electron-plugin/src/server/php.ts +++ b/resources/js/electron-plugin/src/server/php.ts @@ -1,22 +1,48 @@ import {mkdirSync, statSync, writeFileSync, existsSync} from 'fs' import fs_extra from 'fs-extra'; -const { copySync } = fs_extra; + +const {copySync, mkdirpSync} = fs_extra; import Store from 'electron-store' import {promisify} from 'util' import {join} from 'path' import {app} from 'electron' -import {execFile, spawn} from 'child_process' +import {execFile, spawn, spawnSync} from 'child_process' import state from "./state.js"; import getPort, {portNumbers} from 'get-port'; -import { ProcessResult } from "./ProcessResult.js"; +import {ProcessResult} from "./ProcessResult.js"; +// TODO: maybe in dev, don't go to the userData folder and stay in the Laravel app folder const storagePath = join(app.getPath('userData'), 'storage') const databasePath = join(app.getPath('userData'), 'database') const databaseFile = join(databasePath, 'database.sqlite') +const bootstrapCache = join(app.getPath('userData'), 'bootstrap', 'cache') const argumentEnv = getArgumentEnv(); const appPath = getAppPath(); +mkdirpSync(bootstrapCache); + +function runningSecureBuild() { + return existsSync(join(appPath, 'build', '__nativephp_app_bundle')) + && process.env.NODE_ENV !== 'development'; +} + +function shouldMigrateDatabase(store) { + return store.get('migrated_version') !== app.getVersion() + && process.env.NODE_ENV !== 'development'; +} + +function shouldOptimize(store) { + /* + * For some weird reason, + * the cached config is not picked up on subsequent launches, + * so we'll just rebuilt it every time for now + */ + return true; + // return runningSecureBuild(); + // return runningSecureBuild() && store.get('optimized_version') !== app.getVersion(); +} + async function getPhpPort() { return await getPort({ host: '127.0.0.1', @@ -25,43 +51,55 @@ async function getPhpPort() { } async function retrievePhpIniSettings() { - const env = { - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_STORAGE_PATH: storagePath, - NATIVEPHP_DATABASE_PATH: databaseFile, - }; - - const phpOptions = { - cwd: appPath, - env - }; - - return await promisify(execFile)(state.php, ['artisan', 'native:php-ini'], phpOptions); + const env = getDefaultEnvironmentVariables() as any; + + const phpOptions = { + cwd: appPath, + env + }; + + let command = ['artisan', 'native:php-ini']; + + if (runningSecureBuild()) { + command.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + + return await promisify(execFile)(state.php, command, phpOptions); } async function retrieveNativePHPConfig() { - const env = { - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_STORAGE_PATH: storagePath, - NATIVEPHP_DATABASE_PATH: databaseFile, - }; + const env = getDefaultEnvironmentVariables() as any; const phpOptions = { cwd: appPath, env }; - return await promisify(execFile)(state.php, ['artisan', 'native:config'], phpOptions); + let command = ['artisan', 'native:config']; + + if (runningSecureBuild()) { + command.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + + return await promisify(execFile)(state.php, command, phpOptions); } function callPhp(args, options, phpIniSettings = {}) { + if (args[0] === 'artisan' && runningSecureBuild()) { + args.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + let iniSettings = Object.assign(getDefaultPhpIniSettings(), phpIniSettings); Object.keys(iniSettings).forEach(key => { - args.unshift('-d', `${key}=${iniSettings[key]}`); + args.unshift('-d', `${key}=${iniSettings[key]}`); }); + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Calling PHP', state.php, args); + } + return spawn( state.php, args, @@ -75,15 +113,42 @@ function callPhp(args, options, phpIniSettings = {}) { ); } +function callPhpSync(args, options, phpIniSettings = {}) { + + if (args[0] === 'artisan' && runningSecureBuild()) { + args.unshift(join(appPath, 'build', '__nativephp_app_bundle')); + } + + let iniSettings = Object.assign(getDefaultPhpIniSettings(), phpIniSettings); + + Object.keys(iniSettings).forEach(key => { + args.unshift('-d', `${key}=${iniSettings[key]}`); + }); + + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Calling PHP', state.php, args); + } + + return spawnSync( + state.php, + args, + { + cwd: options.cwd, + env: { + ...process.env, + ...options.env + } + } + ); +} + function getArgumentEnv() { const envArgs = process.argv.filter(arg => arg.startsWith('--env.')); const env: { - TESTING?: number, - APP_PATH?: string - } = { - - }; + TESTING?: number, + APP_PATH?: string + } = {}; envArgs.forEach(arg => { const [key, value] = arg.slice(6).split('='); env[key] = value; @@ -102,9 +167,15 @@ function getAppPath() { } function ensureAppFoldersAreAvailable() { - if (! existsSync(storagePath) || process.env.NODE_ENV === 'development') { - copySync(join(appPath, 'storage'), storagePath) - } + + // if (!runningSecureBuild()) { + console.log('Copying storage folder...'); + console.log('Storage path:', storagePath); + if (!existsSync(storagePath) || process.env.NODE_ENV === 'development') { + console.log("App path:", appPath); + copySync(join(appPath, 'storage'), storagePath) + } + // } mkdirSync(databasePath, {recursive: true}) @@ -128,34 +199,81 @@ function startScheduler(secret, apiPort, phpIniSettings = {}) { } function getPath(name: string) { - try { - // @ts-ignore - return app.getPath(name); - } catch (error) { - return ''; - } + try { + // @ts-ignore + return app.getPath(name); + } catch (error) { + return ''; + } } -function getDefaultEnvironmentVariables(secret, apiPort) { - return { - APP_ENV: process.env.NODE_ENV === 'development' ? 'local' : 'production', - APP_DEBUG: process.env.NODE_ENV === 'development' ? 'true' : 'false', - NATIVEPHP_STORAGE_PATH: storagePath, - NATIVEPHP_DATABASE_PATH: databaseFile, - NATIVEPHP_API_URL: `http://localhost:${apiPort}/api/`, - NATIVEPHP_RUNNING: 'true', - NATIVEPHP_SECRET: secret, - NATIVEPHP_USER_HOME_PATH: getPath('home'), - NATIVEPHP_APP_DATA_PATH: getPath('appData'), - NATIVEPHP_USER_DATA_PATH: getPath('userData'), - NATIVEPHP_DESKTOP_PATH: getPath('desktop'), - NATIVEPHP_DOCUMENTS_PATH: getPath('documents'), - NATIVEPHP_DOWNLOADS_PATH: getPath('downloads'), - NATIVEPHP_MUSIC_PATH: getPath('music'), - NATIVEPHP_PICTURES_PATH: getPath('pictures'), - NATIVEPHP_VIDEOS_PATH: getPath('videos'), - NATIVEPHP_RECENT_PATH: getPath('recent'), - }; +// Define an interface for the environment variables +interface EnvironmentVariables { + APP_ENV: string; + APP_DEBUG: string; + LARAVEL_STORAGE_PATH: string; + NATIVEPHP_STORAGE_PATH: string; + NATIVEPHP_DATABASE_PATH: string; + NATIVEPHP_API_URL?: string; + NATIVEPHP_RUNNING: string; + NATIVEPHP_SECRET?: string; + NATIVEPHP_USER_HOME_PATH: string; + NATIVEPHP_APP_DATA_PATH: string; + NATIVEPHP_USER_DATA_PATH: string; + NATIVEPHP_DESKTOP_PATH: string; + NATIVEPHP_DOCUMENTS_PATH: string; + NATIVEPHP_DOWNLOADS_PATH: string; + NATIVEPHP_MUSIC_PATH: string; + NATIVEPHP_PICTURES_PATH: string; + NATIVEPHP_VIDEOS_PATH: string; + NATIVEPHP_RECENT_PATH: string; + // Cache variables + APP_SERVICES_CACHE?: string; + APP_PACKAGES_CACHE?: string; + APP_CONFIG_CACHE?: string; + APP_ROUTES_CACHE?: string; + APP_EVENTS_CACHE?: string; + VIEW_COMPILED_PATH?: string; +} + +function getDefaultEnvironmentVariables(secret?: string, apiPort?: number): EnvironmentVariables { + // Base variables with string values (no null values) + let variables: EnvironmentVariables = { + APP_ENV: process.env.NODE_ENV === 'development' ? 'local' : 'production', + APP_DEBUG: process.env.NODE_ENV === 'development' ? 'true' : 'false', + LARAVEL_STORAGE_PATH: storagePath, + NATIVEPHP_RUNNING: 'true', + NATIVEPHP_STORAGE_PATH: storagePath, + NATIVEPHP_DATABASE_PATH: databaseFile, + NATIVEPHP_USER_HOME_PATH: getPath('home'), + NATIVEPHP_APP_DATA_PATH: getPath('appData'), + NATIVEPHP_USER_DATA_PATH: getPath('userData'), + NATIVEPHP_DESKTOP_PATH: getPath('desktop'), + NATIVEPHP_DOCUMENTS_PATH: getPath('documents'), + NATIVEPHP_DOWNLOADS_PATH: getPath('downloads'), + NATIVEPHP_MUSIC_PATH: getPath('music'), + NATIVEPHP_PICTURES_PATH: getPath('pictures'), + NATIVEPHP_VIDEOS_PATH: getPath('videos'), + NATIVEPHP_RECENT_PATH: getPath('recent'), + }; + + // Only if the server has already started + if (secret && apiPort) { + variables.NATIVEPHP_API_URL = `http://localhost:${apiPort}/api/`; + variables.NATIVEPHP_SECRET = secret; + } + + // Only add cache paths if in production mode + if (runningSecureBuild()) { + variables.APP_SERVICES_CACHE = join(bootstrapCache, 'services.php'); // Should be present and writable + variables.APP_PACKAGES_CACHE = join(bootstrapCache, 'packages.php'); // Should be present and writable + variables.APP_CONFIG_CACHE = join(bootstrapCache, 'config.php'); + variables.APP_ROUTES_CACHE = join(bootstrapCache, 'routes-v7.php'); + variables.APP_EVENTS_CACHE = join(bootstrapCache, 'events.php'); + // variables.VIEW_COMPILED_PATH; // TODO: keep those in the phar file if we can. + } + + return variables; } function getDefaultPhpIniSettings() { @@ -183,17 +301,50 @@ function serveApp(secret, apiPort, phpIniSettings): Promise<ProcessResult> { env }; - const store = new Store(); + const store = new Store({ + name: 'nativephp', // So it doesn't conflict with settings of the app + }); // Make sure the storage path is linked - as people can move the app around, we // need to run this every time the app starts - callPhp(['artisan', 'storage:link', '--force'], phpOptions, phpIniSettings) + if (!runningSecureBuild()) { + /* + * Simon: Note for later that we should strip out using storage:link + * all of the necessary files for the app to function should be a part of the bundle + * (whether it's a secured bundle or not), so symlinking feels redundant + */ + console.log('Linking storage path...'); + callPhpSync(['artisan', 'storage:link', '--force'], phpOptions, phpIniSettings) + } + + // Cache the project + if (shouldOptimize(store)) { + console.log('Caching view and routes...'); + + let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings); + + if (result.status !== 0) { + console.error('Failed to cache view and routes:', result.stderr.toString()); + } else { + store.set('optimized_version', app.getVersion()) + } + } // Migrate the database - if (store.get('migrated_version') !== app.getVersion() && process.env.NODE_ENV !== 'development') { - console.log('Migrating database...') - callPhp(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings) - store.set('migrated_version', app.getVersion()) + if (shouldMigrateDatabase(store)) { + console.log('Migrating database...'); + + if(parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log('Database path:', databaseFile); + } + + let result = callPhpSync(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings); + + if (result.status !== 0) { + console.error('Failed to migrate database:', result.stderr.toString()); + } else { + store.set('migrated_version', app.getVersion()) + } } if (process.env.NODE_ENV === 'development') { @@ -201,56 +352,82 @@ function serveApp(secret, apiPort, phpIniSettings): Promise<ProcessResult> { console.log('You may migrate manually by running: php artisan native:migrate') } + console.log('Starting PHP server...'); const phpPort = await getPhpPort(); - const serverPath = join(appPath, 'vendor', 'laravel', 'framework', 'src', 'Illuminate', 'Foundation', 'resources', 'server.php') + + let serverPath: string; + let cwd: string; + + if (runningSecureBuild()) { + serverPath = join(appPath, 'build', '__nativephp_app_bundle'); + } else { + console.log('* * * Running from source * * *'); + serverPath = join(appPath, 'vendor', 'laravel', 'framework', 'src', 'Illuminate', 'Foundation', 'resources', 'server.php'); + cwd = join(appPath, 'public'); + } + const phpServer = callPhp(['-S', `127.0.0.1:${phpPort}`, serverPath], { - cwd: join(appPath, 'public'), + cwd: cwd, env }, phpIniSettings) const portRegex = /Development Server \(.*:([0-9]+)\) started/gm + // Show urls called phpServer.stdout.on('data', (data) => { - const match = portRegex.exec(data.toString()) - if (match) { - console.log("PHP Server started on port: ", match[1]) - const port = parseInt(match[1]) - resolve({ - port, - process: phpServer - }) + // [Tue Jan 14 19:51:00 2025] 127.0.0.1:52779 [POST] URI: /_native/api/events + + if (parseInt(process.env.SHELL_VERBOSITY) > 0) { + console.log(data.toString().trim()); } }) + // Show PHP errors and indicate which port the server is running on phpServer.stderr.on('data', (data) => { const error = data.toString(); - const match = portRegex.exec(error); + const match = portRegex.exec(data.toString()); if (match) { const port = parseInt(match[1]); console.log("PHP Server started on port: ", port); resolve({ port, - process: phpServer + process: phpServer, }); } else { - // 27 is the length of the php -S output preamble - if (error.startsWith('[NATIVE_EXCEPTION]: ', 27)) { + if (error.includes('[NATIVE_EXCEPTION]:')) { + let logFile = join(storagePath, 'logs'); + console.log(); console.error('Error in PHP:'); - console.error(' ' + error.slice(47)); - console.log('Please check your log file:'); - console.log(' ' + join(appPath, 'storage', 'logs', 'laravel.log')); + console.error(' ' + error.split('[NATIVE_EXCEPTION]:')[1].trim()); + console.log('Please check your log files:'); + console.log(' ' + logFile); console.log(); } } }); + // Log when any error occurs (not started, not killed, couldn't send message, etc) phpServer.on('error', (error) => { reject(error) - }) + }); + + // Log when the PHP server exits + phpServer.on('close', (code) => { + console.log(`PHP server exited with code ${code}`); + }); }) } -export {startScheduler, serveApp, getAppPath, retrieveNativePHPConfig, retrievePhpIniSettings, getDefaultEnvironmentVariables, getDefaultPhpIniSettings} +export { + startScheduler, + serveApp, + getAppPath, + retrieveNativePHPConfig, + retrievePhpIniSettings, + getDefaultEnvironmentVariables, + getDefaultPhpIniSettings, + runningSecureBuild +} diff --git a/resources/js/package.json b/resources/js/package.json index fdf42c94..d5a4d51e 100644 --- a/resources/js/package.json +++ b/resources/js/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@electron-toolkit/preload": "^3.0.1", - "@electron-toolkit/utils": "^3.0.0", + "@electron-toolkit/utils": "^4.0.0", "@electron/remote": "^2.1.2", "axios": "^1.7.9", "body-parser": "^1.20.3", @@ -79,26 +79,25 @@ "electron": "^32.2.7", "electron-builder": "^25.1.8", "electron-chromedriver": "^32.2.6", - "electron-vite": "^2.3.0", + "electron-vite": "^3.0.0", "eslint": "^9.17.0", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-unicorn": "^56.0.1", - "eslint-plugin-vue": "^9.32.0", - "globals": "^15.14.0", + "eslint-plugin-unicorn": "^57.0.0", + "globals": "^16.0.0", "jest": "^29.7.0", "less": "^4.2.1", "prettier": "^3.4.2", "rimraf": "^6.0.1", "stylelint": "^16.12.0", - "stylelint-config-recommended": "^14.0.1", + "stylelint-config-recommended": "^15.0.0", "stylelint-config-sass-guidelines": "^12.1.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tslib": "^2.8.1", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", - "vite": "^5.0.0" + "vite": "^6.2.1" }, "exports": "./electron-plugin/dist/index.js", "imports": { diff --git a/src/Commands/BuildCommand.php b/src/Commands/BuildCommand.php index 20d6fd62..82d24112 100644 --- a/src/Commands/BuildCommand.php +++ b/src/Commands/BuildCommand.php @@ -7,8 +7,8 @@ use Illuminate\Support\Str; use Native\Electron\Facades\Updater; use Native\Electron\Traits\CleansEnvFile; +use Native\Electron\Traits\CopiesBundleToBuildDirectory; use Native\Electron\Traits\CopiesCertificateAuthority; -use Native\Electron\Traits\CopiesToBuildDirectory; use Native\Electron\Traits\HasPreAndPostProcessing; use Native\Electron\Traits\InstallsAppIcon; use Native\Electron\Traits\LocatesPhpBinary; @@ -22,8 +22,8 @@ class BuildCommand extends Command { use CleansEnvFile; + use CopiesBundleToBuildDirectory; use CopiesCertificateAuthority; - use CopiesToBuildDirectory; use HasPreAndPostProcessing; use InstallsAppIcon; use LocatesPhpBinary; @@ -36,7 +36,11 @@ class BuildCommand extends Command {arch? : The Processor Architecture to build for (x64, x86, arm64)} {--publish : to publish the app}'; - protected $availableOs = ['win', 'linux', 'mac', 'all']; + protected array $availableOs = ['win', 'linux', 'mac', 'all']; + + private string $buildCommand; + + private string $buildOS; protected function buildPath(string $path = ''): string { @@ -50,32 +54,56 @@ protected function sourcePath(string $path = ''): string public function handle(): void { - $os = $this->selectOs($this->argument('os')); + $this->buildOS = $this->selectOs($this->argument('os')); - $buildCommand = 'build'; - if ($os != 'all') { - $arch = $this->selectArchitectureForOs($os, $this->argument('arch')); + $this->buildCommand = 'build'; + if ($this->buildOS != 'all') { + $arch = $this->selectArchitectureForOs($this->buildOS, $this->argument('arch')); - $os .= $arch != 'all' ? "-{$arch}" : ''; + $this->buildOS .= $arch != 'all' ? "-{$arch}" : ''; // Should we publish? - if ($publish = $this->option('publish')) { - $buildCommand = 'publish'; + if ($this->option('publish')) { + $this->buildCommand = 'publish'; } } - $this->preProcess(); + if ($this->hasBundled()) { + $this->buildBundle(); + } else { + $this->warnUnsecureBuild(); + $this->buildUnsecure(); + } + } + + private function buildBundle(): void + { + $this->setAppName(); - $this->setAppName(slugify: true); + $this->updateElectronDependencies(); $this->newLine(); - intro('Updating Electron dependencies...'); - Process::path(__DIR__.'/../../resources/js/') - ->env($this->getEnvironmentVariables()) - ->forever() - ->run('npm ci', function (string $type, string $output) { - echo $output; - }); + intro('Copying Bundle to build directory...'); + $this->copyBundleToBuildDirectory(); + $this->keepRequiredDirectories(); + + $this->newLine(); + $this->copyCertificateAuthorityCertificate(); + + $this->newLine(); + intro('Copying app icons...'); + $this->installIcon(); + + $this->buildOrPublish(); + } + + private function buildUnsecure(): void + { + $this->preProcess(); + + $this->setAppName(); + + $this->updateElectronDependencies(); $this->newLine(); intro('Copying App to build directory...'); @@ -96,15 +124,7 @@ public function handle(): void intro('Pruning vendor directory'); $this->pruneVendorDirectory(); - $this->newLine(); - intro((($publish ?? false) ? 'Publishing' : 'Building')." for {$os}"); - Process::path(__DIR__.'/../../resources/js/') - ->env($this->getEnvironmentVariables()) - ->forever() - ->tty(SymfonyProcess::isTtySupported() && ! $this->option('no-interaction')) - ->run("npm run {$buildCommand}:{$os}", function (string $type, string $output) { - echo $output; - }); + $this->buildOrPublish(); $this->postProcess(); } @@ -129,4 +149,29 @@ protected function getEnvironmentVariables(): array Updater::environmentVariables(), ); } + + private function updateElectronDependencies(): void + { + $this->newLine(); + intro('Updating Electron dependencies...'); + Process::path(__DIR__.'/../../resources/js/') + ->env($this->getEnvironmentVariables()) + ->forever() + ->run('npm ci', function (string $type, string $output) { + echo $output; + }); + } + + private function buildOrPublish(): void + { + $this->newLine(); + intro((($this->buildCommand == 'publish') ? 'Publishing' : 'Building')." for {$this->buildOS}"); + Process::path(__DIR__.'/../../resources/js/') + ->env($this->getEnvironmentVariables()) + ->forever() + ->tty(SymfonyProcess::isTtySupported() && ! $this->option('no-interaction')) + ->run("npm run {$this->buildCommand}:{$this->buildOS}", function (string $type, string $output) { + echo $output; + }); + } } diff --git a/src/Commands/BundleCommand.php b/src/Commands/BundleCommand.php new file mode 100644 index 00000000..a2762056 --- /dev/null +++ b/src/Commands/BundleCommand.php @@ -0,0 +1,401 @@ +<?php + +namespace Native\Electron\Commands; + +use Carbon\CarbonInterface; +use Illuminate\Console\Command; +use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Process; +use Illuminate\Support\Number; +use Illuminate\Support\Str; +use Native\Electron\Traits\CleansEnvFile; +use Native\Electron\Traits\CopiesToBuildDirectory; +use Native\Electron\Traits\HandlesZephpyr; +use Native\Electron\Traits\HasPreAndPostProcessing; +use Native\Electron\Traits\InstallsAppIcon; +use Native\Electron\Traits\PrunesVendorDirectory; +use Native\Electron\Traits\SetsAppName; +use Symfony\Component\Finder\Finder; +use ZipArchive; + +use function Laravel\Prompts\intro; + +class BundleCommand extends Command +{ + use CleansEnvFile; + use CopiesToBuildDirectory; + use HandlesZephpyr; + use HasPreAndPostProcessing; + use InstallsAppIcon; + use PrunesVendorDirectory; + use SetsAppName; + + protected $signature = 'native:bundle {--fetch} {--clear} {--without-cleanup}'; + + protected $description = 'Bundle your application for distribution.'; + + private ?string $key; + + private string $zipPath; + + private string $zipName; + + public function handle(): int + { + // Remove the bundle + if ($this->option('clear')) { + if (file_exists(base_path('build/__nativephp_app_bundle'))) { + unlink(base_path('build/__nativephp_app_bundle')); + } + + $this->info('Bundle removed. Building in this state would be unsecure.'); + + return static::SUCCESS; + } + + // Check for ZEPHPYR_KEY + if (! $this->checkForZephpyrKey()) { + return static::FAILURE; + } + + // Check for ZEPHPYR_TOKEN + if (! $this->checkForZephpyrToken()) { + return static::FAILURE; + } + + // Check if the token is valid + if (! $this->checkAuthenticated()) { + $this->error('Invalid API token: check your ZEPHPYR_TOKEN on '.$this->baseUrl().'user/api-tokens'); + + return static::FAILURE; + } + + // Download the latest bundle if requested + if ($this->option('fetch')) { + if (! $this->fetchLatestBundle()) { + + return static::FAILURE; + } + + $this->info('Latest bundle downloaded.'); + + return static::SUCCESS; + } + + $this->preProcess(); + + $this->setAppName(); + intro('Copying App to build directory...'); + + // We update composer.json later, + $this->copyToBuildDirectory(); + + $this->newLine(); + intro('Cleaning .env file...'); + $this->cleanEnvFile(); + + $this->newLine(); + intro('Copying app icons...'); + $this->installIcon(); + + $this->newLine(); + intro('Pruning vendor directory'); + $this->pruneVendorDirectory(); + + $this->cleanEnvFile(); + + // Check composer.json for symlinked or private packages + if (! $this->checkComposerJson()) { + return static::FAILURE; + } + + // Package the app up into a zip + if (! $this->zipApplication()) { + $this->error("Failed to create zip archive at {$this->zipPath}."); + + return static::FAILURE; + } + + // Send the zip file + $result = $this->sendToZephpyr(); + $this->handleApiErrors($result); + + // Success + $this->info('Successfully uploaded to Zephpyr.'); + $this->line('Use native:bundle --fetch to retrieve the latest bundle.'); + + // Clean up temp files + $this->cleanUp(); + + return static::SUCCESS; + } + + private function zipApplication(): bool + { + $this->zipName = 'app_'.str()->random(8).'.zip'; + $this->zipPath = $this->zipPath($this->zipName); + + // Create zip path + if (! @mkdir(dirname($this->zipPath), recursive: true) && ! is_dir(dirname($this->zipPath))) { + return false; + } + + $zip = new ZipArchive; + + if ($zip->open($this->zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + $this->addFilesToZip($zip); + + $zip->close(); + + return true; + } + + private function checkComposerJson(): bool + { + $composerJson = json_decode(file_get_contents($this->buildPath('composer.json')), true); + + // // Fail if there is symlinked packages + // foreach ($composerJson['repositories'] ?? [] as $repository) { + // + // $symlinked = $repository['options']['symlink'] ?? true; + // if ($repository['type'] === 'path' && $symlinked) { + // $this->error('Symlinked packages are not supported. Please remove them from your composer.json.'); + // + // return false; + // } + // // Work with private packages but will not in the future + // // elseif ($repository['type'] === 'composer') { + // // if (! $this->checkComposerPackageAuth($repository['url'])) { + // // $this->error('Cannot authenticate with '.$repository['url'].'.'); + // // $this->error('Go to '.$this->baseUrl().' and add your composer package credentials.'); + // // + // // return false; + // // } + // // } + // } + + // Remove repositories with type path, we include symlinked packages + if (! empty($composerJson['repositories'])) { + + $this->newLine(); + intro('Patching composer.json in development mode…'); + + $filteredRepo = array_filter($composerJson['repositories'], + fn ($repository) => $repository['type'] !== 'path'); + + if (count($filteredRepo) !== count($composerJson['repositories'])) { + $composerJson['repositories'] = $filteredRepo; + file_put_contents($this->buildPath('composer.json'), + json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Process::path($this->buildPath()) + // ->run('composer install --no-dev', function (string $type, string $output) { + // echo $output; + // }); + } + + } + + return true; + } + + // private function checkComposerPackageAuth(string $repositoryUrl): bool + // { + // // Check if the user has authenticated the package on Zephpyr + // $host = parse_url($repositoryUrl, PHP_URL_HOST); + // $this->line('Checking '.$host.' authentication…'); + // + // return Http::acceptJson() + // ->withToken(config('nativephp-internal.zephpyr.token')) + // ->get($this->baseUrl().'api/v1/project/'.$this->key.'/composer/auth/'.$host) + // ->successful(); + // } + + private function addFilesToZip(ZipArchive $zip): void + { + $this->newLine(); + intro('Creating zip archive…'); + + $finder = (new Finder)->files() + ->followLinks() + // ->ignoreVCSIgnored(true) // TODO: Make our own list of ignored files + ->in($this->buildPath()) + ->exclude([ + // We add those a few lines below and they are ignored by most .gitignore anyway + 'vendor', + 'node_modules', + + // Exclude the following directories + 'dist', // Compiled nativephp assets + 'build', // Compiled box assets + 'temp', // Temp files + 'tests', // Tests + ]) + ->exclude(config('nativephp.cleanup_exclude_files', [])); + + $this->finderToZip($finder, $zip); + + // Why do I have to force this? please someone explain. + $this->finderToZip( + (new Finder)->files() + ->followLinks() + ->in($this->buildPath('public/build')), $zip, 'public/build'); + + // Add .env file manually because Finder ignores VCS and dot files + $zip->addFile($this->buildPath('.env'), '.env'); + + // Add auth.json file to support private packages + // WARNING: Only for testing purposes, don't uncomment this + // $zip->addFile($this->buildPath('auth.json'), 'auth.json'); + + // Custom binaries + $binaryPath = Str::replaceStart($this->buildPath('vendor'), '', config('nativephp.binary_path')); + + // Add composer dependencies without unnecessary files + $vendor = (new Finder)->files() + ->exclude(array_filter([ + 'nativephp/php-bin', + 'nativephp/electron/resources/js', + '*/*/vendor', // Exclude sub-vendor directories + $binaryPath, + ])) + ->in($this->buildPath('vendor')); + + $this->finderToZip($vendor, $zip, 'vendor'); + + // Add javascript dependencies + if (file_exists($this->buildPath('node_modules'))) { + $nodeModules = (new Finder)->files() + ->in($this->buildPath('node_modules')); + + $this->finderToZip($nodeModules, $zip, 'node_modules'); + } + } + + private function finderToZip(Finder $finder, ZipArchive $zip, ?string $path = null): void + { + foreach ($finder as $file) { + if ($file->getRealPath() === false) { + continue; + } + + $zip->addFile($file->getRealPath(), str($path)->finish(DIRECTORY_SEPARATOR).$file->getRelativePathname()); + } + } + + private function sendToZephpyr() + { + intro('Uploading zip to Zephpyr…'); + + return Http::acceptJson() + ->timeout(300) // 5 minutes + ->withoutRedirecting() // Upload won't work if we follow redirects (it transform POST to GET) + ->withToken(config('nativephp-internal.zephpyr.token')) + ->attach('archive', fopen($this->zipPath, 'r'), $this->zipName) + ->post($this->baseUrl().'api/v1/project/'.$this->key.'/build/'); + } + + private function fetchLatestBundle(): bool + { + intro('Fetching latest bundle…'); + + $response = Http::acceptJson() + ->withToken(config('nativephp-internal.zephpyr.token')) + ->get($this->baseUrl().'api/v1/project/'.$this->key.'/build/download'); + + if ($response->failed()) { + + if ($response->status() === 404) { + $this->error('Project or bundle not found.'); + } elseif ($response->status() === 500) { + $url = $response->json('url'); + + if ($url) { + $this->error('Build failed. Inspect the build here: '.$url); + } else { + $this->error('Build failed. Please try again later.'); + } + } elseif ($response->status() === 503) { + $retryAfter = intval($response->header('Retry-After')); + $diff = now()->addSeconds($retryAfter); + $diffMessage = $retryAfter <= 60 ? 'a minute' : $diff->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE); + $this->warn('Bundle not ready. Please try again in '.$diffMessage.'.'); + } else { + $this->handleApiErrors($response); + } + + return false; + } + + // Save the bundle + @mkdir(base_path('build'), recursive: true); + file_put_contents(base_path('build/__nativephp_app_bundle'), $response->body()); + + return true; + } + + protected function exitWithMessage(string $message): void + { + $this->error($message); + $this->cleanUp(); + + exit(static::FAILURE); + } + + private function handleApiErrors(Response $result): void + { + if ($result->status() === 413) { + $fileSize = Number::fileSize(filesize($this->zipPath)); + $this->exitWithMessage('File is too large to upload ('.$fileSize.'). Please contact support.'); + } elseif ($result->status() === 422) { + $this->error('Request refused:'.$result->json('message')); + } elseif ($result->status() === 429) { + $this->exitWithMessage('Too many requests. Please try again in '.now()->addSeconds(intval($result->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.'); + } elseif ($result->failed()) { + $this->exitWithMessage("Request failed. Error code: {$result->status()}"); + } + } + + protected function cleanUp(): void + { + $this->postProcess(); + + if ($this->option('without-cleanup')) { + return; + } + + $previousBuilds = glob($this->zipPath().'/app_*.zip'); + $failedZips = glob($this->zipPath().'/app_*.part'); + + $deleteFiles = array_merge($previousBuilds, $failedZips); + + if (empty($deleteFiles)) { + return; + } + + $this->line('Cleaning up…'); + + foreach ($deleteFiles as $file) { + @unlink($file); + } + } + + protected function buildPath(string $path = ''): string + { + return base_path('build/app/'.$path); + } + + protected function zipPath(string $path = ''): string + { + return base_path('build/zip/'.$path); + } + + protected function sourcePath(string $path = ''): string + { + return base_path($path); + } +} diff --git a/src/Commands/DevelopCommand.php b/src/Commands/DevelopCommand.php index f855cfa6..e2ffadfe 100644 --- a/src/Commands/DevelopCommand.php +++ b/src/Commands/DevelopCommand.php @@ -7,6 +7,7 @@ use Native\Electron\Traits\Developer; use Native\Electron\Traits\Installer; use Native\Electron\Traits\InstallsAppIcon; +use Native\Electron\Traits\SetsAppName; use function Laravel\Prompts\intro; use function Laravel\Prompts\note; @@ -17,6 +18,7 @@ class DevelopCommand extends Command use Developer; use Installer; use InstallsAppIcon; + use SetsAppName; protected $signature = 'native:serve {--no-queue} {--D|no-dependencies} {--installer=npm}'; @@ -40,7 +42,7 @@ public function handle(): void $this->patchPlist(); } - $this->patchPackageJson(); + $this->setAppName(developmentMode: true); $this->installIcon(); @@ -70,14 +72,4 @@ protected function patchPlist(): void file_put_contents(__DIR__.'/../../resources/js/node_modules/electron/dist/Electron.app/Contents/Info.plist', $pList); } - - protected function patchPackageJson(): void - { - $packageJsonPath = __DIR__.'/../../resources/js/package.json'; - $packageJson = json_decode(file_get_contents($packageJsonPath), true); - - $packageJson['name'] = config('app.name'); - - file_put_contents($packageJsonPath, json_encode($packageJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - } } diff --git a/src/Commands/ResetCommand.php b/src/Commands/ResetCommand.php new file mode 100644 index 00000000..bf5261a6 --- /dev/null +++ b/src/Commands/ResetCommand.php @@ -0,0 +1,83 @@ +<?php + +namespace Native\Electron\Commands; + +use Illuminate\Console\Command; +use Native\Electron\Traits\SetsAppName; +use Symfony\Component\Filesystem\Filesystem; + +use function Laravel\Prompts\intro; + +class ResetCommand extends Command +{ + use SetsAppName; + + protected $signature = 'native:reset {--with-app-data : Clear the app data as well}'; + + protected $description = 'Clear all build and dist files'; + + public function handle(): int + { + intro('Clearing build and dist directories...'); + + // Removing and recreating the native serve resource path + $nativeServeResourcePath = realpath(__DIR__.'/../../resources/js/resources/app/'); + $this->line('Clearing: '.$nativeServeResourcePath); + + $filesystem = new Filesystem; + $filesystem->remove($nativeServeResourcePath); + $filesystem->mkdir($nativeServeResourcePath); + + // Removing the bundling directories + $bundlingPath = base_path('build/'); + $this->line('Clearing: '.$bundlingPath); + + if ($filesystem->exists($bundlingPath)) { + $filesystem->remove($bundlingPath); + } + + // Removing the built path + $builtPath = base_path('dist/'); + $this->line('Clearing: '.$builtPath); + + if ($filesystem->exists($builtPath)) { + $filesystem->remove($builtPath); + } + + if ($this->option('with-app-data')) { + + foreach ([true, false] as $developmentMode) { + $appName = $this->setAppName($developmentMode); + + // Eh, just in case, I don't want to delete all user data by accident. + if (! empty($appName)) { + $appDataPath = $this->appDataDirectory($appName); + $this->line('Clearing: '.$appDataPath); + + if ($filesystem->exists($appDataPath)) { + $filesystem->remove($appDataPath); + } + } + } + } + + return 0; + } + + protected function appDataDirectory(string $name): string + { + /* + * Platform Location + * macOS ~/Library/Application Support + * Linux $XDG_CONFIG_HOME or ~/.config + * Windows %APPDATA% + */ + + return match (PHP_OS_FAMILY) { + 'Darwin' => $_SERVER['HOME'].'/Library/Application Support/'.$name, + 'Linux' => $_SERVER['XDG_CONFIG_HOME'] ?? $_SERVER['HOME'].'/.config/'.$name, + 'Windows' => $_SERVER['APPDATA'].'/'.$name, + default => $_SERVER['HOME'].'/.config/'.$name, + }; + } +} diff --git a/src/ElectronServiceProvider.php b/src/ElectronServiceProvider.php index 9e609362..568bdf2a 100644 --- a/src/ElectronServiceProvider.php +++ b/src/ElectronServiceProvider.php @@ -4,9 +4,11 @@ use Illuminate\Foundation\Application; use Native\Electron\Commands\BuildCommand; +use Native\Electron\Commands\BundleCommand; use Native\Electron\Commands\DevelopCommand; use Native\Electron\Commands\InstallCommand; use Native\Electron\Commands\PublishCommand; +use Native\Electron\Commands\ResetCommand; use Native\Electron\Updater\UpdaterManager; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -23,6 +25,8 @@ public function configurePackage(Package $package): void DevelopCommand::class, BuildCommand::class, PublishCommand::class, + BundleCommand::class, + ResetCommand::class, ]); } diff --git a/src/Traits/CopiesBundleToBuildDirectory.php b/src/Traits/CopiesBundleToBuildDirectory.php new file mode 100644 index 00000000..5d0f0989 --- /dev/null +++ b/src/Traits/CopiesBundleToBuildDirectory.php @@ -0,0 +1,53 @@ +<?php + +namespace Native\Electron\Traits; + +use Symfony\Component\Filesystem\Filesystem; + +use function Laravel\Prompts\warning; + +trait CopiesBundleToBuildDirectory +{ + use CopiesToBuildDirectory; + + protected static string $bundlePath = 'build/__nativephp_app_bundle'; + + protected function hasBundled(): bool + { + return (new Filesystem)->exists($this->sourcePath(self::$bundlePath)); + } + + public function copyBundleToBuildDirectory(): bool + { + $filesystem = new Filesystem; + + $this->line('Copying secure app bundle to build directory...'); + $this->line('From: '.realpath(dirname($this->sourcePath(self::$bundlePath)))); + $this->line('To: '.realpath(dirname($this->buildPath(self::$bundlePath)))); + + // Clean and create build directory + $filesystem->remove($this->buildPath()); + $filesystem->mkdir($this->buildPath()); + + $filesToCopy = [ + self::$bundlePath, + // '.env', + ]; + foreach ($filesToCopy as $file) { + $filesystem->copy($this->sourcePath($file), $this->buildPath($file), true); + } + // $this->keepRequiredDirectories(); + + return true; + } + + public function warnUnsecureBuild(): void + { + warning('==================================================================='); + warning(' * * * INSECURE BUILD * * *'); + warning('==================================================================='); + warning('Secure app bundle not found! Building with exposed source files.'); + warning('See https://nativephp.com/docs/publishing/building#security'); + warning('==================================================================='); + } +} diff --git a/src/Traits/CopiesCertificateAuthority.php b/src/Traits/CopiesCertificateAuthority.php index b8ed6486..ce627cce 100644 --- a/src/Traits/CopiesCertificateAuthority.php +++ b/src/Traits/CopiesCertificateAuthority.php @@ -18,6 +18,7 @@ protected function copyCertificateAuthorityCertificate(): void $phpBinaryDirectory = base_path('vendor/nativephp/php-bin/'); // Check if the class this trait is used in also uses LocatesPhpBinary + /* @phpstan-ignore-next-line */ if (method_exists($this, 'phpBinaryPath')) { // Get binary directory but up one level $phpBinaryDirectory = dirname(base_path($this->phpBinaryPath())); diff --git a/src/Traits/CopiesToBuildDirectory.php b/src/Traits/CopiesToBuildDirectory.php index f9627934..4c9cf9d5 100644 --- a/src/Traits/CopiesToBuildDirectory.php +++ b/src/Traits/CopiesToBuildDirectory.php @@ -27,6 +27,8 @@ abstract protected function sourcePath(string $path = ''): string; // .git and dev directories '.git', 'dist', + 'build', + 'temp', 'docker', 'packages', '**/.github', @@ -53,7 +55,7 @@ abstract protected function sourcePath(string $path = ''): string; 'vendor/bin', ]; - public function copyToBuildDirectory() + public function copyToBuildDirectory(): bool { $sourcePath = $this->sourcePath(); $buildPath = $this->buildPath(); @@ -69,7 +71,10 @@ public function copyToBuildDirectory() $filesystem->mkdir($buildPath); // A filtered iterator that will exclude files matching our skip patterns - $directory = new RecursiveDirectoryIterator($sourcePath, RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS); + $directory = new RecursiveDirectoryIterator( + $sourcePath, + RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS + ); $filter = new RecursiveCallbackFilterIterator($directory, function ($current) use ($patterns) { $relativePath = substr($current->getPathname(), strlen($this->sourcePath()) + 1); @@ -109,6 +114,8 @@ public function copyToBuildDirectory() } $this->keepRequiredDirectories(); + + return true; } private function keepRequiredDirectories() diff --git a/src/Traits/HandlesZephpyr.php b/src/Traits/HandlesZephpyr.php new file mode 100644 index 00000000..26749968 --- /dev/null +++ b/src/Traits/HandlesZephpyr.php @@ -0,0 +1,64 @@ +<?php + +namespace Native\Electron\Traits; + +use Illuminate\Support\Facades\Http; + +use function Laravel\Prompts\intro; + +trait HandlesZephpyr +{ + private function baseUrl(): string + { + return str(config('nativephp-internal.zephpyr.host'))->finish('/'); + } + + private function checkAuthenticated() + { + intro('Checking authentication…'); + + return Http::acceptJson() + ->withToken(config('nativephp-internal.zephpyr.token')) + ->get($this->baseUrl().'api/v1/user')->successful(); + } + + private function checkForZephpyrKey() + { + $this->key = config('nativephp-internal.zephpyr.key'); + + if (! $this->key) { + $this->line(''); + $this->warn('No ZEPHPYR_KEY found. Cannot bundle!'); + $this->line(''); + $this->line('Add this app\'s ZEPHPYR_KEY to its .env file:'); + $this->line(base_path('.env')); + $this->line(''); + $this->info('Not set up with Zephpyr yet? Secure your NativePHP app builds and more!'); + $this->info('Check out '.$this->baseUrl().''); + $this->line(''); + + return false; + } + + return true; + } + + private function checkForZephpyrToken() + { + if (! config('nativephp-internal.zephpyr.token')) { + $this->line(''); + $this->warn('No ZEPHPYR_TOKEN found. Cannot bundle!'); + $this->line(''); + $this->line('Add your Zephpyr API token to your .env file (ZEPHPYR_TOKEN):'); + $this->line(base_path('.env')); + $this->line(''); + $this->info('Not set up with Zephpyr yet? Secure your NativePHP app builds and more!'); + $this->info('Check out '.$this->baseUrl().''); + $this->line(''); + + return false; + } + + return true; + } +} diff --git a/src/Traits/SetsAppName.php b/src/Traits/SetsAppName.php index 5281db64..c97f148c 100644 --- a/src/Traits/SetsAppName.php +++ b/src/Traits/SetsAppName.php @@ -4,19 +4,26 @@ trait SetsAppName { - protected function setAppName(bool $slugify = false): void + protected function setAppName($developmentMode = false): string { $packageJsonPath = __DIR__.'/../../resources/js/package.json'; $packageJson = json_decode(file_get_contents($packageJsonPath), true); - $name = config('app.name'); + $name = str(config('app.name'))->slug(); - if ($slugify) { - $name = str($name)->lower()->kebab(); + /* + * Suffix the app name with '-dev' if it's a development build + * this way, when the developer test his freshly built app, + * configs, migrations won't be mixed up with the production app + */ + if ($developmentMode) { + $name .= '-dev'; } $packageJson['name'] = $name; file_put_contents($packageJsonPath, json_encode($packageJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $name; } } diff --git a/tests/Feature/BootingTest.php b/tests/Feature/BootingTest.php index c90f942c..8f5c9f79 100644 --- a/tests/Feature/BootingTest.php +++ b/tests/Feature/BootingTest.php @@ -19,15 +19,16 @@ // $process->wait(); // Uncomment this line to debug try { - retry(30, function () use ($output) { + retry(10, function () use ($output) { // Wait until port 8100 is open dump('Waiting for port 8100 to open...'); - $fp = @fsockopen('localhost', 8100, $errno, $errstr, 1); + $fp = @fsockopen('127.0.0.1', 8100, $errno, $errstr, 1); if ($fp === false) { throw new Exception(sprintf( - 'Port 8100 is not open yet. Output: "%s"', + 'Port 8100 is not open yet. Output: "%s", Errstr: "%s"', $output, + $errstr )); } }, 5000);