1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-08-08 04:20:07 +02:00

♻️ Rework various common classes

This commit is contained in:
2021-12-21 16:00:27 +01:00
parent a6387e96e7
commit ceb168c6cf
21 changed files with 272 additions and 216 deletions

View File

@ -0,0 +1,202 @@
//
// Services.swift
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
import AppKit
class Actions {
// MARK: - Services
public static func restartPhpFpm()
{
brew("services restart \(PhpSwitcher.phpInstall.formula)", sudo: true)
}
public static func restartNginx()
{
brew("services restart nginx", sudo: true)
}
public static func restartDnsMasq()
{
brew("services restart dnsmasq", sudo: true)
}
public static func stopAllServices()
{
brew("services stop \(PhpSwitcher.phpInstall.formula)", sudo: true)
brew("services stop nginx", sudo: true)
brew("services stop dnsmasq", sudo: true)
}
/**
Kindly asks Valet to switch to a specific PHP version.
*/
public static func switchToPhpVersionUsingValet(
version: String,
availableVersions: [String],
completed: @escaping () -> Void
) {
print("Switching to \(version) using Valet")
print(valet("use php@\(version)"))
completed()
}
/**
Switching to a new PHP version involves:
- unlinking the current version
- stopping the active services
- linking the new desired version
Please note that depending on which version is installed,
the version that is switched to may or may not be identical to `php` (without @version).
*/
public static func switchToPhpVersion(
version: String,
availableVersions: [String],
completed: @escaping () -> Void
) {
print("Switching to \(version), unlinking all versions...")
let group = DispatchGroup()
availableVersions.forEach { (available) in
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
let formula = (available == PhpSwitcher.brewPhpVersion)
? "php" : "php@\(available)"
brew("unlink \(formula)")
brew("services stop \(formula)", sudo: true)
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) {
print("All versions have been unlinked!")
print("Linking the new version!")
let formula = (version == PhpSwitcher.brewPhpVersion) ? "php" : "php@\(version)"
brew("link \(formula) --overwrite --force")
brew("services start \(formula)", sudo: true)
print("The new version has been linked!")
completed()
}
}
// MARK: - Finding Config Files
public static func openGenericPhpConfigFolder()
{
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php")];
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
}
public static func openGlobalComposerFolder()
{
let file = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".composer/composer.json")
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
public static func openPhpConfigFolder(version: String)
{
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php/\(version)/php.ini")];
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
}
public static func openValetConfigFolder()
{
let file = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/valet")
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
// MARK: - Quick Fix
/**
Detects all currently available PHP versions,
and unlinks each and every one of them.
After this, the brew services are also stopped,
the latest PHP version is linked, and php + nginx are restarted.
If this does not solve the issue, the user may need to install additional
extensions and/or run `composer global update`.
*/
public static func fixMyPhp()
{
brew("services restart dnsmasq", sudo: true)
PhpSwitcher.shared.detectPhpVersions().forEach { (version) in
let formula = (version == PhpSwitcher.brewPhpVersion) ? "php" : "php@\(version)"
brew("unlink php@\(version)")
brew("services stop \(formula)")
brew("services stop \(formula)", sudo: true)
}
brew("services stop php")
brew("services stop nginx")
brew("link php")
brew("services restart dnsmasq", sudo: true)
brew("services stop php", sudo: true)
brew("services stop nginx", sudo: true)
}
// MARK: Common Shell Commands
/**
Runs a `valet` command.
*/
public static func valet(_ command: String) -> String
{
return Shell.pipe("sudo \(Paths.valet) \(command)", requiresPath: true)
}
/**
Runs a `brew` command. Can run as superuser.
*/
public static func brew(_ command: String, sudo: Bool = false)
{
Shell.run("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
}
/**
Runs `sed` in order to replace all occurrences of a string in a specific file with another.
*/
public static func sed(file: String, original: String, replacement: String)
{
// Escape slashes (or `sed` won't work)
let e_original = original.replacingOccurrences(of: "/", with: "\\/")
let e_replacement = replacement.replacingOccurrences(of: "/", with: "\\/")
// Check if gsed exists; it is able to follow symlinks,
// which we want to do to toggle the extension
if Shell.fileExists("\(Paths.binPath)/gsed") {
Shell.run("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
} else {
Shell.run("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
}
}
/**
Uses `grep` to determine whether a particular query string can be found in a particular file.
*/
public static func grepContains(file: String, query: String) -> Bool
{
return Shell.pipe("""
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
""")
.trimmingCharacters(in: .whitespacesAndNewlines)
.contains("YES")
}
}

View File

@ -1,6 +1,6 @@
//
// Command.swift
// phpmon-common
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//

View File

@ -0,0 +1,56 @@
//
// Constants.swift
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Cocoa
class Constants {
/**
* The latest PHP version that is considered to be stable at the time of release.
* This version number is currently not used (only as a default fallback).
*/
static let LatestStablePhpVersion = "8.1"
/**
The minimum version of Valet that is recommended.
If the installed version is older, a notification will be shown
every time the app launches (with a recommendation to upgrade).
The minimum requirement is currently synced to PHP 8.1 compatibility.
See also: https://github.com/laravel/valet/releases/tag/v2.16.2
*/
static let MinimumRecommendedValetVersion = "2.16.2"
/**
* The PHP versions supported by this application.
* Versions that do not appear in this array are omitted from the list.
*/
static let SupportedPhpVersions = [
// ====================
// STABLE RELEASES
// ====================
// Versions of PHP that are stable and are supported.
"5.6",
"7.0",
"7.1",
"7.2",
"7.3",
"7.4",
"8.0",
"8.1",
// ====================
// EXPERIMENTAL SUPPORT
// ====================
// Every release that supports the next release will always support the next
// dev release. In this case, that means that the version below is detected.
"8.2"
]
}

View File

@ -1,6 +1,6 @@
//
// Paths.swift
// phpmon-common
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//

View File

@ -1,6 +1,6 @@
//
// Shell.swift
// phpmon-common
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//

View File

@ -0,0 +1,167 @@
//
// ActivePhpInstallation.swift
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
/**
An installed version of PHP, that was detected by scanning the `/opt/php@version/bin` directory.
When initialized, that version's .ini files are also scanned (for active or inactive extensions).
Integrity checks can be performed to determine whether PHP-FPM is configured correctly.
- Note: Each installation has a separate version number.
Using `version.short` is advisable if you want to interact with Homebrew.
*/
class ActivePhpInstallation {
var version: Version!
var limits: Limits!
var extensions: [PhpExtension]!
// MARK: - Computed
var formula: String {
return (version.short == PhpSwitcher.brewPhpVersion) ? "php" : "php@\(version.short)"
}
// MARK: - Initializer
init() {
// Show information about the current version
getVersion()
// If an error occurred, exit early
if (version.error) {
limits = Limits()
extensions = []
return
}
// Load extension information
let path = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(version.short)/php.ini")
extensions = PhpExtension.load(from: path)
// Get configuration values
limits = Limits(
memory_limit: getByteCount(key: "memory_limit"),
upload_max_filesize: getByteCount(key: "upload_max_filesize"),
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();"])
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
// See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in
let exts = PhpExtension.load(from: URL(fileURLWithPath: iniFilePath))
if exts.count > 0 {
extensions.append(contentsOf: exts)
}
}
}
/**
When the app tries to retrieve the version, the installation is considered broken if the output is nothing,
_or_ if the output contains the word "Warning" or "Error". In normal situations this should not be the case.
*/
private func getVersion() -> Void {
self.version = Version()
let version = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
if (version == "" || version.contains("Warning") || version.contains("Error")) {
self.version.short = "💩 BROKEN"
self.version.long = ""
self.version.error = true
return
}
// That's the long version
self.version.long = version
// Next up, let's strip away the minor version number
let segments = self.version.long.components(separatedBy: ".")
// Get the first two elements
self.version.short = segments[0...1].joined(separator: ".")
}
/**
Retrieves the display value for a specific key in the `.ini` file.
The following values are valid:
* -1: unlimited (show the infinity icon)
* 10000: an integer = amount of bytes
* 1K, 1M, 1G = shorthand for kilobytes, megabytes and gigabytes
If none of these notations are used, the _fallback_ value is used.
We'll show an emoji to indicate something has gone wrong here.
To clarify, B gets appended to valid values.
As a result, "5M" (valid) becomes "5MB", and "5MB" (invalid) becomes .
- Parameter key: The key of the `ini` value that needs to be retrieved. For example, you can use `memory_limit`.
*/
private func getByteCount(key: String) -> String {
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"])
// Check if the value is unlimited
if (value == "-1") {
return ""
}
// Check if the syntax is valid otherwise
let regex = try! NSRegularExpression(pattern: #"^([0-9]*)(K|M|G|)$"#, options: [])
let match = regex.matches(in: value, options: [], range: NSMakeRange(0, value.count)).first
return (match == nil) ? "⚠️" : "\(value)B"
}
/**
Determine if PHP-FPM is configured correctly.
For PHP 5.6, we'll check if `valet.sock` is included in the main `php-fpm.conf` file, but for more recent
versions of PHP, we can just check for the existence of the `valet-fpm.conf` file. If the check here fails,
that means that Valet won't work properly.
*/
func checkPhpFpmStatus() -> Bool {
if self.version.short == "5.6" {
// The main PHP config file should contain `valet.sock` and then we're probably fine?
let fileName = "\(Paths.etcPath)/php/5.6/php-fpm.conf"
return Shell.pipe("cat \(fileName)").contains("valet.sock")
}
// Make sure to check if valet-fpm.conf exists. If it does, we should be fine :)
return Shell.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
}
// MARK: - Structs
/**
Struct containing information about the version number of the current PHP installation.
Also includes information about whether the install is considered "broken" or not.
If an error was found in the terminal output, `error` is set to `true` and the installation
can be considered broken. (The app will display this as well.)
*/
struct Version {
var short = "???"
var long = "???"
var error = false
}
/**
Struct containing information about the limits of the current PHP installation.
Includes: memory limit, max upload size and max post size.
*/
struct Limits {
var memory_limit = "???"
var upload_max_filesize = "???"
var post_max_size = "???"
}
}

View File

@ -0,0 +1,30 @@
//
// HomebrewPackage.swift
// PHP Monitor
//
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
struct HomebrewPackage: Decodable {
let name: String
let full_name: String
let aliases: [String]
let installed: [HomebrewInstalled]
let linked_keg: String?
public var version: String {
return aliases.first!
.replacingOccurrences(of: "php@", with: "")
}
}
struct HomebrewInstalled: Decodable {
let version: String
let built_as_bottle: Bool
let installed_as_dependency: Bool
let installed_on_request: Bool
}

View File

@ -0,0 +1,108 @@
//
// PhpExtension.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 31/01/2021.
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
/**
A PHP extension that was detected in the php.ini file.
Please note that the extension may be disabled.
- Note: You need to know more about regular expressions to be able to deal with these NSRegularExpression
instances. You can find more information here: https://nshipster.com/swift-regular-expressions/
*/
class PhpExtension {
/// The file where this extension was located.
var file: String
/// The original string that was used to determine this extension is active.
var line: String
/// The name of the extension. This is always identical to the name found in the original string. If you want to display this name, capitalize this.
var name: String
/// Whether the extension has been enabled.
var enabled: Bool
/// The file where this extension was located, but only the filename, not the full path to the .ini file.
var fileNameOnly: String {
return String(file.split(separator: "/").last ?? "php.ini")
}
/**
This regular expression will allow us to identify lines which activate an extension.
It will match the following items:
* `extension="name.so"`
* `zend_extension="name.so"`
* `; extension="name.so"`
* `; zend_extension="name.so"`
- Note: Extensions that are disabled in a different way will not be detected. This is intentional.
*/
static let extensionRegex = #"^(extension|zend_extension|;(\s?)extension|;(\s?)zend_extension)(\s?)(=)(\s?)(?<name>["]?(?:\/?.\/?)+(?:\.so)"?)$"#
/**
When registering an extension, we do that based on the line found inside the .ini file.
*/
init(_ line: String, file: String) {
let regex = try! NSRegularExpression(pattern: Self.extensionRegex, options: [])
let match = regex.matches(in: line, options: [], range: NSMakeRange(0, line.count)).first
let range = Range(match!.range(withName: "name"), in: line)!
self.line = line
let fullPath = String(line[range])
.replacingOccurrences(of: "\"", with: "") // replace excess "
.replacingOccurrences(of: ".so", with: "") // replace excess .so
self.name = String(fullPath.split(separator: "/").last!) // take last segment
self.enabled = !line.contains(";")
self.file = file
}
/**
This simply toggles the extension in the .ini file. You may need to restart the other services in order for this change to apply.
*/
func toggle() {
let newLine = enabled
// DISABLED: Commented out line
? "; \(line)"
// ENABLED: Line where the comment delimiter (;) is removed
: line.replacingOccurrences(of: "; ", with: "")
Actions.sed(file: file, original: line, replacement: newLine)
enabled.toggle()
}
// MARK: - Static Methods
/**
This method will attempt to identify all extensions in the .ini file at a certain URL.
*/
static func load(from path: URL) -> [PhpExtension] {
let file = try? String(contentsOf: path, encoding: .utf8)
if (file == nil) {
print("There was an issue reading the file. Assuming no extensions were found.")
return []
}
return file!.components(separatedBy: "\n")
.filter {
return $0.range(of: Self.extensionRegex, options: .regularExpression) != nil
}
.map {
return PhpExtension($0, file: path.path)
}
}
}

View File

@ -0,0 +1,30 @@
//
// PhpInstallation.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 28/11/2021.
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
class PhpInstallation {
var longVersion: String
/**
In order to determine details about a PHP installation, well simply run `php-config --version`
in the relevant directory.
*/
init(_ version: String) {
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
self.longVersion = version
if Shell.fileExists(phpConfigExecutablePath) {
self.longVersion = Command.execute(
path: phpConfigExecutablePath,
arguments: ["--version"]
)
}
}
}

View File

@ -0,0 +1,146 @@
//
// PhpSwitcher.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/12/2021.
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
protocol PhpSwitcherDelegate: AnyObject {
func switcherDidStartSwitching()
func switcherDidCompleteSwitch()
}
class PhpSwitcher {
// MARK: - Initializer
init() {
self.currentInstall = ActivePhpInstallation()
let brewPhpAlias = Shell.pipe("\(Paths.brew) info php --json");
self.homebrewPackage = try! JSONDecoder().decode(
[HomebrewPackage].self,
from: brewPhpAlias.data(using: .utf8)!
).first!
print("When on your system, the `php` formula means version \(homebrewPackage.version)!")
}
// MARK: - Properties
/** The delegate that is informed of updates. */
weak var delegate: PhpSwitcherDelegate?
/** The static app instance. Accessible at any time. */
static let shared = PhpSwitcher()
/** Whether the switcher is busy performing any actions. */
var isBusy: Bool = false
/** All available versions of PHP. */
var availablePhpVersions: [String] = []
/** Cached information about the PHP installations. */
var cachedPhpInstallations: [String: PhpInstallation] = [:]
/** Information about the currently linked PHP installation. */
var currentInstall: ActivePhpInstallation
/**
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.
As such, we take that information from Homebrew.
*/
static var brewPhpVersion: String {
return Self.shared.homebrewPackage.version
}
/**
The currently linked and active PHP installation.
*/
static var phpInstall: ActivePhpInstallation {
return Self.shared.currentInstall
}
/**
Information we were able to discern from the Homebrew info command.
*/
var homebrewPackage: HomebrewPackage! = nil
// MARK: - Methods
public static func detectPhpVersions() -> Void {
_ = Self.shared.detectPhpVersions()
}
/**
Detects which versions of PHP are installed.
*/
public func detectPhpVersions() -> [String]
{
let files = Shell.pipe("ls \(Paths.optPath) | grep php@")
var versionsOnly = extractPhpVersions(from: files.components(separatedBy: "\n"))
// 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
let phpAlias = homebrewPackage.version
// Avoid inserting a duplicate
if (!versionsOnly.contains(phpAlias) && Shell.fileExists("\(Paths.optPath)/php/bin/php")) {
versionsOnly.append(phpAlias)
}
print("The PHP versions that were detected are: \(versionsOnly)")
availablePhpVersions = versionsOnly
var mappedVersions: [String: PhpInstallation] = [:]
availablePhpVersions.forEach { version in
mappedVersions[version] = PhpInstallation(version)
}
cachedPhpInstallations = mappedVersions
return versionsOnly
}
/**
Extracts valid PHP versions from an array of strings.
This array of strings is usually retrieved from `grep`.
*/
public func extractPhpVersions(
from versions: [String],
checkBinaries: Bool = true
) -> [String] {
var output : [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)
&& Constants.SupportedPhpVersions.contains(version)
&& (checkBinaries ? Shell.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true)
{
output.append(version)
}
}
return output
}
}

View File

@ -1,60 +0,0 @@
//
// PhpSwitcher.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/12/2021.
// Copyright © 2021 Nico Verbruggen. All rights reserved.
//
import Foundation
protocol PhpSwitcherDelegate: AnyObject {
func switcherDidStartSwitching()
func switcherDidCompleteSwitch()
}
class PhpSwitcher {
init() {
self.currentInstall = ActivePhpInstallation()
}
/** The delegate that is informed of updates. */
weak var delegate: PhpSwitcherDelegate?
/** The static app instance. Accessible at any time. */
static let shared = PhpSwitcher()
/** Whether the switcher is busy performing any actions. */
var isBusy: Bool = false
/** All available versions of PHP. */
var availablePhpVersions: [String] = []
/** Cached information about the PHP installations. */
var cachedPhpInstallations: [String: PhpInstallation] = [:]
/** Static accessor for `PhpSwitcher.shared.currentInstall`. */
static var phpInstall: ActivePhpInstallation {
return Self.shared.currentInstall
}
/** Information about the currently linked PHP installation. */
var currentInstall: ActivePhpInstallation
/**
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.
*/
var brewPhpVersion: String {
return homebrewPackage.version
}
/**
Information we were able to discern from the Homebrew info command.
*/
var homebrewPackage: HomebrewPackage! = nil
}