From e8306289ce9c09eb1862b19ce29d8bb2322df766 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Sun, 26 Nov 2023 21:26:48 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Load=20extension=20info=20for=20all?= =?UTF-8?q?=20PHP=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to make this possible, I've added a new `sync()` method to the Shellable protocol, which now should allow us to run shell commands synchronously. Back to basics, as this was how *all* commands were run in legacy versions of PHP Monitor. The advantage here is that there is now a choice. Previously, you'd have to use the `system()` helper that I added. Usage of that helper is now discouraged, as is the synchronous shell method, but it may be useful for some methods where waiting for the outcome of the output is required. (Important: the `system()` helper is still required when determining what the preferred terminal is during the initialization of the `Paths` class.) --- PHP Monitor.xcodeproj/project.pbxproj | 16 +++-- README.md | 2 +- phpmon/Common/Filesystem/RealFileSystem.swift | 2 +- phpmon/Common/Helpers/System.swift | 1 - phpmon/Common/PHP/ActivePhpInstallation.swift | 20 ++---- .../PHP/PHP Version/PhpEnvironments.swift | 2 +- phpmon/Common/PHP/PhpInstallation.swift | 45 +++++++++++-- phpmon/Common/Shell/RealShell.swift | 64 ++++++++++++------- phpmon/Common/Shell/ShellProtocol.swift | 10 +++ phpmon/Common/Testables/TestableShell.swift | 34 ++++++++++ phpmon/Domain/App/Startup.swift | 2 +- .../InstallPhpExtensionCommand.swift | 0 .../RemovePhpExtensionCommand.swift | 0 .../ModifyPhpVersionCommand.swift | 28 ++++---- .../RemovePhpVersionCommand.swift | 0 15 files changed, 155 insertions(+), 71 deletions(-) rename phpmon/Domain/Integrations/Homebrew/Commands/{ => PHP Extensions}/InstallPhpExtensionCommand.swift (100%) rename phpmon/Domain/Integrations/Homebrew/Commands/{ => PHP Extensions}/RemovePhpExtensionCommand.swift (100%) rename phpmon/Domain/Integrations/Homebrew/Commands/{ => PHP Versions}/ModifyPhpVersionCommand.swift (89%) rename phpmon/Domain/Integrations/Homebrew/Commands/{ => PHP Versions}/RemovePhpVersionCommand.swift (100%) diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index e246095..8da0427 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -1213,6 +1213,8 @@ 033D459C2B0D506B00070080 /* PHP Versions */ = { isa = PBXGroup; children = ( + C43BCD4329FBEF40001547BC /* ModifyPhpVersionCommand.swift */, + C4B79ECA29CA475900A483EE /* RemovePhpVersionCommand.swift */, ); path = "PHP Versions"; sourceTree = ""; @@ -1667,6 +1669,15 @@ path = phpmon; sourceTree = ""; }; + C4513F8D2B13CD08001AD760 /* PHP Extensions */ = { + isa = PBXGroup; + children = ( + 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */, + 033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */, + ); + path = "PHP Extensions"; + sourceTree = ""; + }; C456A0D02AA6175D0080144F /* Config */ = { isa = PBXGroup; children = ( @@ -1961,12 +1972,9 @@ C4B79EBA29CA38D100A483EE /* Commands */ = { isa = PBXGroup; children = ( + C4513F8D2B13CD08001AD760 /* PHP Extensions */, 033D459C2B0D506B00070080 /* PHP Versions */, C4B79EBB29CA38DB00A483EE /* BrewCommand.swift */, - C43BCD4329FBEF40001547BC /* ModifyPhpVersionCommand.swift */, - C4B79ECA29CA475900A483EE /* RemovePhpVersionCommand.swift */, - 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */, - 033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */, ); path = Commands; sourceTree = ""; diff --git a/README.md b/README.md index ad1a26a..502a3c8 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ All stable and supported PHP versions are also supported by PHP Monitor. However Backports that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php). -PHP extensions that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-extensions). +PHP extensions that are installable via PHP Monitor's **PHP Extension Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-extensions). For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet. diff --git a/phpmon/Common/Filesystem/RealFileSystem.swift b/phpmon/Common/Filesystem/RealFileSystem.swift index 8cc9457..1327570 100644 --- a/phpmon/Common/Filesystem/RealFileSystem.swift +++ b/phpmon/Common/Filesystem/RealFileSystem.swift @@ -64,7 +64,7 @@ class RealFileSystem: FileSystemProtocol { // MARK: — FS Attributes func makeExecutable(_ path: String) throws { - _ = system("chmod +x \(path.replacingTildeWithHomeDirectory)") + _ = ActiveShell.shared.sync("chmod +x \(path.replacingTildeWithHomeDirectory)") } // MARK: - Checks diff --git a/phpmon/Common/Helpers/System.swift b/phpmon/Common/Helpers/System.swift index 5bbba22..10c4a43 100644 --- a/phpmon/Common/Helpers/System.swift +++ b/phpmon/Common/Helpers/System.swift @@ -10,7 +10,6 @@ import Foundation /** Run a simple blocking Shell command on the user's own system. - Avoid using this method in favor of the fakeable Shell class unless needed for express system operations. */ public func system(_ command: String) -> String { let task = Process() diff --git a/phpmon/Common/PHP/ActivePhpInstallation.swift b/phpmon/Common/PHP/ActivePhpInstallation.swift index c4ea2ea..6bd92d6 100644 --- a/phpmon/Common/PHP/ActivePhpInstallation.swift +++ b/phpmon/Common/PHP/ActivePhpInstallation.swift @@ -62,13 +62,6 @@ class ActivePhpInstallation { return } - // Load extension information - let mainConfigurationFileUrl = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(version.short)/php.ini") - - if let file = PhpConfigurationFile.from(filePath: mainConfigurationFileUrl.path) { - iniFiles.append(file) - } - // Get configuration values limits = Limits( memory_limit: getByteCount(key: "memory_limit"), @@ -76,15 +69,10 @@ class ActivePhpInstallation { post_max_size: getByteCount(key: "post_max_size") ) - // Return a list of .ini files parsed after php.ini - let paths = Command.execute( - path: Paths.php, - arguments: ["-r", "echo php_ini_scanned_files();"], - trimNewlines: false - ) - .replacingOccurrences(of: "\n", with: "") - .split(separator: ",") - .map { String($0) } + let paths = ActiveShell.shared + .sync("\(Paths.php) --ini | grep -E -o '(/[^ ]+\\.ini)'").out + .split(separator: "\n") + .map { String($0) } // See if any extensions are present in said .ini files paths.forEach { (iniFilePath) in diff --git a/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift b/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift index ff3a299..dd81ee3 100644 --- a/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift +++ b/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift @@ -172,7 +172,7 @@ class PhpEnvironments { let phpAliasInstall = PhpInstallation(phpAlias) // Before inserting, ensure that the actual output matches the alias // if that isn't the case, our formula remains out-of-date - if !phpAliasInstall.missingBinary { + if !phpAliasInstall.isMissingBinary { supportedVersions.insert(phpAlias) } } diff --git a/phpmon/Common/PHP/PhpInstallation.swift b/phpmon/Common/PHP/PhpInstallation.swift index 4c9c5de..db44863 100644 --- a/phpmon/Common/PHP/PhpInstallation.swift +++ b/phpmon/Common/PHP/PhpInstallation.swift @@ -12,21 +12,36 @@ class PhpInstallation { var versionNumber: VersionNumber - var missingBinary: Bool = false + var iniFiles: [PhpConfigurationFile] = [] + + var isMissingBinary: Bool = false var isHealthy: Bool = true + var extensions: [PhpExtension] { + return self.iniFiles.flatMap({ $0.extensions }) + } + /** In order to determine details about a PHP installation, we’ll simply run `php-config --version` in the relevant directory. */ init(_ version: String) { - let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config" + let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config", + phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php" - let phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php" + versionNumber = VersionNumber.make(from: version)! - self.versionNumber = VersionNumber.make(from: version)! + determineVersion(phpConfigExecutablePath, phpExecutablePath) + determineHealth(phpExecutablePath) + determineIniFiles(phpExecutablePath) + // Find all enabled extensions + let enabled = self.extensions.filter({ $0.enabled }).map({ $0.name }) + Log.info("PHP \(versionNumber.short) has the following extensions enabled: \(enabled)") + } + + private func determineVersion(_ phpConfigExecutablePath: String, _ phpExecutablePath: String) { if FileSystem.fileExists(phpConfigExecutablePath) { let longVersionString = Command.execute( path: phpConfigExecutablePath, @@ -36,13 +51,15 @@ class PhpInstallation { // The parser should always work, or the string has to be very unusual. // If so, the app SHOULD crash, so that the users report what's up. - self.versionNumber = try! VersionNumber.parse(longVersionString) + versionNumber = try! VersionNumber.parse(longVersionString) } else { // Keep track that the `php-config` binary is missing; this often means there's a mismatch between - // the `php` version alias and the actual installed version (e.g. you haven't upgraded `php`) - missingBinary = true + // the `php` version alias and the actual installed version (e.g. you haven't upgraded `php`) + isMissingBinary = true } + } + private func determineHealth(_ phpExecutablePath: String) { if FileSystem.fileExists(phpExecutablePath) { let testCommand = Command.execute( path: phpExecutablePath, @@ -59,4 +76,18 @@ class PhpInstallation { } } } + + private func determineIniFiles(_ phpExecutablePath: String) { + let paths = ActiveShell.shared + .sync("\(phpExecutablePath) --ini | grep -E -o '(/[^ ]+\\.ini)'").out + .split(separator: "\n") + .map { String($0) } + + // See if any extensions are present in said .ini files + paths.forEach { (iniFilePath) in + if let file = PhpConfigurationFile.from(filePath: iniFilePath) { + iniFiles.append(file) + } + } + } } diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index 88ed706..668230b 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -86,14 +86,37 @@ class RealShell: ShellProtocol { // MARK: - Shellable Protocol + func sync(_ command: String) -> ShellOutput { + let task = getShellProcess(for: command) + + let outputPipe = Pipe() + let errorPipe = Pipe() + + if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil { + sleep(3) + } + + task.standardOutput = outputPipe + task.standardError = errorPipe + task.launch() + task.waitUntilExit() + + let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! + let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! + + if Log.shared.verbosity == .cli { + log(task: task, stdOut: stdOut, stdErr: stdErr) + } + + return .out(stdOut, stdErr) + } + func pipe(_ command: String) async -> ShellOutput { let task = getShellProcess(for: command) let outputPipe = Pipe() let errorPipe = Pipe() - // Seriously slow down how long it takes for the shell to return output - // (in order to debug or identify async issues) if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil { Log.info("[SLOW SHELL] \(command)") await delay(seconds: 3.0) @@ -104,20 +127,20 @@ class RealShell: ShellProtocol { task.launch() task.waitUntilExit() - let stdOut = String( - data: outputPipe.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )! - - let stdErr = String( - data: errorPipe.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )! + let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! + let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! if Log.shared.verbosity == .cli { - var args = task.arguments ?? [] - let last = "\"" + (args.popLast() ?? "") + "\"" - var log = """ + log(task: task, stdOut: stdOut, stdErr: stdErr) + } + + return .out(stdOut, stdErr) + } + + private func log(task: Process, stdOut: String, stdErr: String) { + var args = task.arguments ?? [] + let last = "\"" + (args.popLast() ?? "") + "\"" + var log = """ <~~~~~~~~~~~~~~~~~~~~~~~ $ \(([self.launchPath] + args + [last]).joined(separator: " ")) @@ -126,22 +149,19 @@ class RealShell: ShellProtocol { \(stdOut) """ - if !stdErr.isEmpty { - log.append(""" + if !stdErr.isEmpty { + log.append(""" [ERR]: \(stdErr) """) - } + } - log.append(""" + log.append(""" ~~~~~~~~~~~~~~~~~~~~~~~~> """) - Log.info(log) - } - - return .out(stdOut, stdErr) + Log.info(log) } func quiet(_ command: String) async { diff --git a/phpmon/Common/Shell/ShellProtocol.swift b/phpmon/Common/Shell/ShellProtocol.swift index 14927a5..0ea309e 100644 --- a/phpmon/Common/Shell/ShellProtocol.swift +++ b/phpmon/Common/Shell/ShellProtocol.swift @@ -14,6 +14,16 @@ protocol ShellProtocol { */ var PATH: String { get } + /** + Run a command synchronously. Use with caution. + + Common usage: + ``` + let output = Shell.sync("php -v") + ``` + */ + func sync(_ command: String) -> ShellOutput + /** Run a command asynchronously. Returns the most relevant output (prefers error output if it exists). diff --git a/phpmon/Common/Testables/TestableShell.swift b/phpmon/Common/Testables/TestableShell.swift index 696f9f3..fa2797f 100644 --- a/phpmon/Common/Testables/TestableShell.swift +++ b/phpmon/Common/Testables/TestableShell.swift @@ -19,6 +19,17 @@ public class TestableShell: ShellProtocol { var expectations: [String: BatchFakeShellOutput] = [:] + func sync(_ command: String) -> ShellOutput { + // This assertion will only fire during test builds + assert(expectations.keys.contains(command), "No response declared for command: \(command)") + + guard let expectation = expectations[command] else { + return .err("No Expected Output") + } + + return expectation.syncOutput() + } + func quiet(_ command: String) async { _ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60) } @@ -112,6 +123,29 @@ struct BatchFakeShellOutput: Codable { return output } + /** + Outputs the fake shell output as expected, but does this synchronously. + */ + public func syncOutput( + ignoreDelay: Bool = false + ) -> ShellOutput { + let output = ShellOutput.empty() + + for item in items { + if !ignoreDelay { + sleep(UInt32(item.delay)) + } + + if item.stream == .stdErr { + output.err += item.output + } else if item.stream == .stdOut { + output.out += item.output + } + } + + return output + } + /** For testing purposes (and speed) we may omit the delay, regardless of its timespan. */ diff --git a/phpmon/Domain/App/Startup.swift b/phpmon/Domain/App/Startup.swift index a130f63..06583a5 100644 --- a/phpmon/Domain/App/Startup.swift +++ b/phpmon/Domain/App/Startup.swift @@ -142,7 +142,7 @@ class Startup { return await Shell.pipe("\(Paths.binPath)/php -v").err .contains("Library not loaded") }, - name: "`no dyld issue detected", + name: "no `dyld` issue (`Library not loaded`) detected", titleText: "startup.errors.dyld_library.title".localized, subtitleText: "startup.errors.dyld_library.subtitle".localized( Paths.optPath diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpExtensionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/PHP Extensions/InstallPhpExtensionCommand.swift similarity index 100% rename from phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpExtensionCommand.swift rename to phpmon/Domain/Integrations/Homebrew/Commands/PHP Extensions/InstallPhpExtensionCommand.swift diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpExtensionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/PHP Extensions/RemovePhpExtensionCommand.swift similarity index 100% rename from phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpExtensionCommand.swift rename to phpmon/Domain/Integrations/Homebrew/Commands/PHP Extensions/RemovePhpExtensionCommand.swift diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/ModifyPhpVersionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/PHP Versions/ModifyPhpVersionCommand.swift similarity index 89% rename from phpmon/Domain/Integrations/Homebrew/Commands/ModifyPhpVersionCommand.swift rename to phpmon/Domain/Integrations/Homebrew/Commands/PHP Versions/ModifyPhpVersionCommand.swift index baf3054..b0d6fdb 100644 --- a/phpmon/Domain/Integrations/Homebrew/Commands/ModifyPhpVersionCommand.swift +++ b/phpmon/Domain/Integrations/Homebrew/Commands/PHP Versions/ModifyPhpVersionCommand.swift @@ -21,8 +21,15 @@ class ModifyPhpVersionCommand: BrewCommand { /** You can pass in which PHP versions need to be upgraded and which ones need to be installed. The process will be executed in two steps: first upgrades, then installations. + Upgrades come first because... well, otherwise installations may very well break. - Each version that is installed will need to be checked afterwards (if it is OK). + Each version that is installed will need to be checked afterwards. Installing a + newer formula may break other PHP installations, which in turn need to be fixed. + + - Important: If any PHP formula is a major upgrade that causes a PHP "version" to be + uninstalled, this is remedied by running `upgradeMainPhpFormula()`. This process + will ensure that the upgrade is applied, but the also that old version is + re-installed and linked again. */ public init( title: String, @@ -44,6 +51,8 @@ class ModifyPhpVersionCommand: BrewCommand { description: "PHP Monitor is preparing Homebrew..." )) + // Determine if a formula will become unavailable + // This is the case when `php` will be bumped to a new version let unavailable = upgrading.first(where: { formula in formula.unavailableAfterUpgrade }) @@ -71,10 +80,7 @@ class ModifyPhpVersionCommand: BrewCommand { await self.completedOperations(onProgress) } - private func upgradeMainPhpFormula( - _ unavailable: BrewPhpFormula, - _ onProgress: @escaping (BrewCommandProgress) -> Void - ) async throws { + private func upgradeMainPhpFormula(_ unavailable: BrewPhpFormula, _ onProgress: @escaping (BrewCommandProgress) -> Void) async throws { // Determine which version was previously available (that will become unavailable) guard let short = try? VersionNumber .parse(unavailable.installedVersion!).short else { @@ -92,18 +98,6 @@ class ModifyPhpVersionCommand: BrewCommand { try await run(command, onProgress) } - private func checkPhpTap(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws { - if !BrewDiagnostics.installedTaps.contains("shivammathur/php") { - let command = "brew tap shivammathur/php" - try await run(command, onProgress) - } - - if !BrewDiagnostics.installedTaps.contains("shivammathur/extensions") { - let command = "brew tap shivammathur/extensions" - try await run(command, onProgress) - } - } - private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws { // If no upgrades are needed, early exit if self.upgrading.isEmpty { diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/PHP Versions/RemovePhpVersionCommand.swift similarity index 100% rename from phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift rename to phpmon/Domain/Integrations/Homebrew/Commands/PHP Versions/RemovePhpVersionCommand.swift