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