1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-04-02 01:30:07 +02:00
Files
app/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift
Nico Verbruggen 9856840533 ♻️ Use discardableResult
- Removed ShellProtocol.quiet(), now that pipe() is discardable
- Detecting PHP versions is also discardable
- The system command is also discardable

This is a nice quality of life change overall, and gets rid of a couple
of silly `_ =` assignments.
2026-02-17 15:17:34 +01:00

366 lines
13 KiB
Swift

//
// PhpEnvironments.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/12/2021.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
class PhpEnvironments {
var container: Container
// MARK: - Initializer
/**
Loads the currently active PHP installation upon startup. May be empty.
*/
init(container: Container) {
self.container = container
self.currentInstall = ActivePhpInstallation.load(container)
}
/**
Loads the valid HomebrewPackage information.
If invalid, this will prevent PHP Monitor from starting correctly.
*/
func getHomebrewInformation() async {
// Let's see which formula we need to check
var formulaToLoad = "php"
// Depending on whether the `shivammathur/php` tap is installed, this command will vary
if BrewDiagnostics.shared.installedTaps.contains("shivammathur/php") {
formulaToLoad = "shivammathur/php/php"
}
// Let's check the alias by using `brew info`
let brewPhpAlias = await container.shell.pipe("\(container.paths.brew) info \(formulaToLoad) --json").out
// Remove any non-JSON output (progress indicators, etc.) before the actual JSON array
// This is a workaround for https://github.com/homebrew/brew/issues/20978
// Since users may not upgrade Homebrew frequently, this fix will remain
let jsonString = brewPhpAlias
.components(separatedBy: .newlines)
.drop(while: { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("[") })
.joined(separator: "\n")
// Get all packages
let packages = try? JSONDecoder().decode(
[HomebrewPackage].self,
from: jsonString.data(using: .utf8)!
)
// But we only need the first one!
guard let package = packages?.first else {
Log.err("Could not determine PHP version due to malformed output.")
return
}
self.homebrewPackage = package
}
/**
Determine which PHP version the `php` formula is aliased to.
*/
func determinePhpAlias() async {
if let alias = self.homebrewPackage.version {
PhpEnvironments.brewPhpAlias = self.homebrewPackage.version
Log.info("[BREW] On your system, the `php` formula means version \(alias).")
} else {
Log.info("[BREW] Could not determine what version the `php` formula is. The alias may have been removed.")
return
}
// Check if that version actually corresponds to an older version
let phpConfigExecutablePath = "\(container.paths.optPath)/php/bin/php-config"
if container.filesystem.fileExists(phpConfigExecutablePath) {
let longVersionString = container.command.execute(
path: phpConfigExecutablePath,
arguments: ["--version"],
trimNewlines: false
).trimmingCharacters(in: .whitespacesAndNewlines)
if let version = try? VersionNumber.parse(longVersionString) {
PhpEnvironments.brewPhpAlias = version.short
if version.short != homebrewPackage.version {
Log.info("[BREW] An older or newer version of `php` is actually installed (\(version.short)).")
}
} else {
Log.warn("Could not determine the actual version of the php binary; assuming Homebrew is correct.")
PhpEnvironments.brewPhpAlias = homebrewPackage.version
}
}
}
// MARK: - Properties
/** The delegate that is informed of updates. */
weak var delegate: PhpSwitcherDelegate?
/** Whether the switcher is busy performing any actions. */
@MainActor var isBusy: Bool = false {
didSet {
MainMenu.shared.refreshIcon()
MainMenu.shared.rebuild()
}
}
// MARK: - Thread-Safe PHP Version Storage
/** All versions of PHP that are currently supported. */
private let _availablePhpVersions = Locked<[String]>([])
var availablePhpVersions: [String] {
get { _availablePhpVersions.value }
set { _availablePhpVersions.value = newValue }
}
/** All versions of PHP that are currently installed but not compatible. */
private let _incompatiblePhpVersions = Locked<[String]>([])
var incompatiblePhpVersions: [String] {
get { _incompatiblePhpVersions.value }
set { _incompatiblePhpVersions.value = newValue }
}
/** Cached information about the PHP installations. */
private let _cachedPhpInstallations = Locked<[String: PhpInstallation]>([:])
var cachedPhpInstallations: [String: PhpInstallation] {
get { _cachedPhpInstallations.value }
set { _cachedPhpInstallations.value = newValue }
}
/** Information about the currently linked PHP installation. */
private let _currentInstall = Locked<ActivePhpInstallation?>(nil)
var currentInstall: ActivePhpInstallation? {
get { _currentInstall.value }
set {
// Update the synchronized value
_currentInstall.value = newValue
// Let the PHP extension manager, if it exists, know the version changed
App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion()
}
}
/**
The version that the `php` formula via Brew is aliased to on the current system.
If you're up to date, `php` will be aliased to the latest version,
but that might not be the case since not everyone keeps their
software up-to-date.
In order for our check to be correct, we query Homebrew locally.
*/
private static let _brewPhpAlias = Locked<String?>(nil)
static var brewPhpAlias: String? {
get { _brewPhpAlias.value }
set { _brewPhpAlias.value = newValue }
}
/**
Information we were able to discern from the Homebrew info command.
*/
private let _homebrewPackage = Locked<HomebrewPackage?>(nil)
var homebrewPackage: HomebrewPackage! {
get { _homebrewPackage.value }
set { _homebrewPackage.value = newValue }
}
/**
It's possible for the alias to be newer than the actual installed version of PHP.
*/
var homebrewBrewPhpAlias: String? {
if homebrewPackage == nil {
// For UI testing and as a fallback, determine this version by using (fake) php-config
let version = App.shared.container.command.execute(path: "/opt/homebrew/bin/php-config",
arguments: ["--version"],
trimNewlines: true)
return try! VersionNumber.parse(version).short
}
return homebrewPackage.version
}
/**
The currently linked and active PHP installation.
*/
var phpInstall: ActivePhpInstallation? {
return currentInstall
}
/**
The most recent and stable PHP version available.
Used when the Homebrew PHP alias could not be determined.
*/
var fallbackPhpVersion: String {
let stableVersion = container.phpEnvs.cachedPhpInstallations.first { (_: String, value: PhpInstallation) in
return value.isPreRelease == false
}
if let stableVersion {
return stableVersion.value.versionNumber.short
} else {
guard let unstableVersion = container.phpEnvs.cachedPhpInstallations.first else {
fatalError("Could not find a valid PHP version to fallback to. None are installed?")
}
return unstableVersion.value.versionNumber.short
}
}
// MARK: - Methods
/**
The switcher that is currently in use.
This was originally added so the Internal and Valet switcher could be swapped out,
but currently this is no longer needed.
*/
public static var switcher: PhpSwitcher {
return InternalSwitcher(App.shared.container)
}
public func reloadPhpVersions() async {
await self.detectPhpVersions()
}
/**
Detects which versions of PHP are installed.
This step also detects which versions of PHP are incompatible with the current version of Valet.
If a PHP installation is currently broken, that will also be reflected.
Returns a `Set<String>` of installations that are considered valid.
*/
@discardableResult
public func detectPhpVersions() async -> Set<String> {
let files = await container.shell.pipe("ls \(container.paths.optPath) | grep php@").out
let versions = await extractPhpVersions(from: files.components(separatedBy: "\n"))
let supportedByValet: Set<String> = {
guard let version = Valet.shared.version else {
return Constants.DetectedPhpVersions
}
return Constants.ValetSupportedPhpVersionMatrix[version.major] ?? []
}()
var supportedVersions = Valet.installed ? versions.intersection(supportedByValet) : versions
// Make sure the aliased version is detected
// The user may have `php` installed, but not e.g. `php@8.0`
// We should also detect that as a version that is installed
if let phpAlias = homebrewPackage.version {
// Avoid inserting a duplicate
if !supportedVersions.contains(phpAlias) && container.filesystem.fileExists("\(container.paths.optPath)/php/bin/php") {
let phpAliasInstall = PhpInstallation(container, 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.isMissingBinary {
supportedVersions.insert(phpAlias)
}
}
}
availablePhpVersions = Array(supportedVersions)
.sorted(by: { $0.versionCompare($1) == .orderedDescending })
incompatiblePhpVersions = Array(versions.subtracting(supportedByValet))
.sorted(by: { $0.versionCompare($1) == .orderedDescending })
Log.info("The PHP versions that were detected are: \(availablePhpVersions)")
Log.info("The PHP versions that were unsupported are: \(incompatiblePhpVersions)")
var mappedVersions: [String: PhpInstallation] = [:]
availablePhpVersions.forEach { version in
mappedVersions[version] = PhpInstallation(container, version)
}
cachedPhpInstallations = mappedVersions
return supportedVersions
}
/**
Extracts valid PHP versions from an array of strings.
This array of strings is usually retrieved from `grep`.
If `generateHelpers` is set to true, after detecting
all versions, helper scripts are generated as well.
*/
public func extractPhpVersions(
from versions: [String],
checkBinaries: Bool = true,
generateHelpers: Bool = true
) async -> Set<String> {
let supported = Constants.DetectedPhpVersions
var output: Set<String> = []
versions.filter { (version) -> Bool in
// Omit everything that doesn't start with php@
// (e.g. something-php@8.0 won't be detected)
return version.starts(with: "php@")
}.forEach { (string) in
let version = string.components(separatedBy: "php@")[1]
// Only append the version if it doesn't already exist (avoid dupes),
// is supported and where the binary exists (avoids broken installs)
if !output.contains(version)
&& supported.contains(version)
&& (checkBinaries ? container.filesystem.fileExists("\(container.paths.optPath)/php@\(version)/bin/php") : true) {
output.insert(version)
}
}
if generateHelpers {
for item in output {
await PhpHelper.generate(container, for: item)
}
}
return output
}
/**
Returns a list of `VersionNumber` instances based on the available PHP versions
that are valid to switch to for a given constraint.
*/
public func validVersions(for constraint: String) -> [VersionNumber] {
constraint.split(separator: "|").flatMap {
return PhpVersionNumberCollection
.make(from: self.availablePhpVersions)
.matching(constraint: $0.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
/**
Validates whether the currently running version matches the provided version.
*/
public func validate(_ version: String) -> Bool {
guard let install = self.phpInstall else {
Log.info("It appears as if no PHP installation is currently active.")
return false
}
if install.version.short == version {
Log.info("Switching to version \(version) seems to have succeeded. Validation passed.")
Log.info("Keeping track that this is the new version!")
Stats.persistCurrentGlobalPhpVersion(version: version)
return true
}
return false
}
/**
Returns the configuration file instance that is used for a specific config value.
You can then use the configuration file instance to change values.
*/
public func getConfigFile(forKey key: String) -> PhpConfigurationFile? {
guard let install = self.phpInstall else {
return nil
}
return install.iniFiles
.reversed()
.first(where: { $0.has(key: key) })
}
}