🚀 Version 5.7
@@ -7,7 +7,7 @@
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.988",
|
||||
"green" : "0.580",
|
||||
"red" : "0.277"
|
||||
"red" : "0.278"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
68
phpmon/Assets.xcassets/AppIconEA.appiconset/Contents.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 575 B |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.501",
|
||||
"green" : "0.697",
|
||||
"red" : "0.247"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.501",
|
||||
"green" : "0.765",
|
||||
"red" : "0.247"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
38
phpmon/Assets.xcassets/StatusColorRed.colorset/Contents.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.180",
|
||||
"green" : "0.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.426",
|
||||
"green" : "0.363",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.180",
|
||||
"green" : "0.841",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.426",
|
||||
"green" : "0.809",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
25
phpmon/Common/Command/ActiveCommand.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// ActiveCommand.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var Command: CommandProtocol {
|
||||
return ActiveCommand.shared
|
||||
}
|
||||
|
||||
class ActiveCommand {
|
||||
static var shared: CommandProtocol = RealCommand()
|
||||
|
||||
public static func useTestable(_ output: [String: String]) {
|
||||
Self.shared = TestableCommand(commands: output)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealCommand()
|
||||
}
|
||||
}
|
||||
22
phpmon/Common/Command/CommandProtocol.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// CommandProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol CommandProtocol {
|
||||
|
||||
/**
|
||||
Immediately executes a command.
|
||||
|
||||
- Parameter path: The path of the command or program to invoke.
|
||||
- Parameter arguments: A list of arguments that are passed on.
|
||||
- Parameter trimNewlines: Removes empty new line output.
|
||||
*/
|
||||
func execute(path: String, arguments: [String], trimNewlines: Bool) -> String
|
||||
|
||||
}
|
||||
@@ -2,21 +2,14 @@
|
||||
// Command.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
public class Command {
|
||||
public class RealCommand: CommandProtocol {
|
||||
|
||||
/**
|
||||
Immediately executes a command.
|
||||
|
||||
- Parameter path: The path of the command or program to invoke.
|
||||
- Parameter arguments: A list of arguments that are passed on.
|
||||
- Parameter trimNewlines: Removes empty new line output.
|
||||
*/
|
||||
public static func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
|
||||
let task = Process()
|
||||
task.launchPath = path
|
||||
task.arguments = arguments
|
||||
@@ -2,7 +2,7 @@
|
||||
// Services.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -12,22 +12,22 @@ class Actions {
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
public static func restartPhpFpm() {
|
||||
brew("services restart \(Homebrew.Formulae.php)", sudo: true)
|
||||
public static func restartPhpFpm() async {
|
||||
await brew("services restart \(Homebrew.Formulae.php.name)", sudo: Homebrew.Formulae.php.elevated)
|
||||
}
|
||||
|
||||
public static func restartNginx() {
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
public static func restartNginx() async {
|
||||
await brew("services restart \(Homebrew.Formulae.nginx.name)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
}
|
||||
|
||||
public static func restartDnsMasq() {
|
||||
brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
public static func restartDnsMasq() async {
|
||||
await brew("services restart \(Homebrew.Formulae.dnsmasq.name)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
}
|
||||
|
||||
public static func stopValetServices() {
|
||||
brew("services stop \(Homebrew.Formulae.php)", sudo: true)
|
||||
brew("services stop \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
public static func stopValetServices() async {
|
||||
await brew("services stop \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||
await brew("services stop \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
await brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
}
|
||||
|
||||
public static func fixHomebrewPermissions() throws {
|
||||
@@ -35,13 +35,16 @@ class Actions {
|
||||
"\(Paths.brew) services stop \(Homebrew.Formulae.nginx)",
|
||||
"\(Paths.brew) services stop \(Homebrew.Formulae.dnsmasq)"
|
||||
]
|
||||
|
||||
var cellarCommands = [
|
||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.nginx)",
|
||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.dnsmasq)"
|
||||
]
|
||||
|
||||
PhpEnv.shared.availablePhpVersions.forEach { version in
|
||||
let formula = version == PhpEnv.brewPhpVersion ? "php" : "php@\(version)"
|
||||
let formula = version == PhpEnv.brewPhpAlias
|
||||
? "php"
|
||||
: "php@\(version)"
|
||||
servicesCommands.append("\(Paths.brew) services stop \(formula)")
|
||||
cellarCommands.append("chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(formula)")
|
||||
}
|
||||
@@ -62,29 +65,6 @@ class Actions {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Third Party Services
|
||||
public static func stopService(name: String, completion: @escaping () -> Void) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
brew("services stop \(name)", sudo: ServicesManager.shared.rootServices.contains { $0.value.name == name })
|
||||
ServicesManager.loadHomebrewServices(completed: {
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public static func startService(name: String, completion: @escaping () -> Void) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
brew("services start \(name)", sudo: ServicesManager.shared.rootServices.contains { $0.value.name == name })
|
||||
ServicesManager.loadHomebrewServices(completed: {
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finding Config Files
|
||||
|
||||
public static func openGenericPhpConfigFolder() {
|
||||
@@ -92,37 +72,33 @@ class Actions {
|
||||
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 openGlobalComposerFolder() {
|
||||
let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
public static func openValetConfigFolder() {
|
||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/valet")
|
||||
let file = URL(string: "file://~/.config/valet".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
public static func openPhpMonitorConfigFile() {
|
||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/phpmon")
|
||||
let file = URL(string: "file://~/.config/phpmon".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
// MARK: - Other Actions
|
||||
|
||||
public static func createTempPhpInfoFile() -> URL {
|
||||
// Write a file called `phpmon_phpinfo.php` to /tmp
|
||||
try! "<?php phpinfo();".write(toFile: "/tmp/phpmon_phpinfo.php", atomically: true, encoding: .utf8)
|
||||
public static func createTempPhpInfoFile() async -> URL {
|
||||
try! FileSystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
|
||||
|
||||
// Tell php-cgi to run the PHP and output as an .html file
|
||||
Shell.run("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
await Shell.quiet("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
|
||||
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
|
||||
}
|
||||
@@ -141,12 +117,10 @@ class Actions {
|
||||
If this does not solve the issue, the user may need to install additional
|
||||
extensions and/or run `composer global update`.
|
||||
*/
|
||||
public static func fixMyValet(completed: @escaping () -> Void) {
|
||||
InternalSwitcher().performSwitch(to: PhpEnv.brewPhpVersion, completion: {
|
||||
brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
brew("services restart \(Homebrew.Formulae.php)", sudo: true)
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
completed()
|
||||
})
|
||||
public static func fixMyValet() async {
|
||||
await InternalSwitcher().performSwitch(to: PhpEnv.brewPhpAlias)
|
||||
await brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
await brew("services restart \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||
await brew("services restart \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Constants.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -13,37 +13,43 @@ struct Constants {
|
||||
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.
|
||||
* Any other PHP versions are considered invalid.
|
||||
*/
|
||||
static let SupportedPhpVersions = [
|
||||
// ====================
|
||||
// STABLE RELEASES
|
||||
// ====================
|
||||
// Versions of PHP that are stable and are supported.
|
||||
"5.6", // only supported when Valet 2.x is active
|
||||
"7.0",
|
||||
"7.1",
|
||||
"7.2",
|
||||
"7.3",
|
||||
"7.4",
|
||||
"8.0",
|
||||
"8.1",
|
||||
"8.2",
|
||||
static let DetectedPhpVersions: Set = [
|
||||
"5.6",
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2", "8.3"
|
||||
]
|
||||
|
||||
// ====================
|
||||
// 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.3"
|
||||
/**
|
||||
The PHP versions supported by each version of Valet.
|
||||
*/
|
||||
static let ValetSupportedPhpVersionMatrix: [Int: Set] = [
|
||||
2: // Valet v2 has the broadest legacy support
|
||||
[
|
||||
"5.6",
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2"
|
||||
],
|
||||
3: // Valet v3 dropped support for v5.6
|
||||
[
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2",
|
||||
"8.3" // dev
|
||||
],
|
||||
4: // Valet v4 dropped support for v7.0
|
||||
[
|
||||
"7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2",
|
||||
"8.3" // dev
|
||||
]
|
||||
]
|
||||
|
||||
struct Urls {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 24/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
// MARK: Common Shell Commands
|
||||
@@ -11,41 +11,49 @@
|
||||
/**
|
||||
Runs a `valet` command. Defaults to running as superuser.
|
||||
*/
|
||||
func valet(_ command: String, sudo: Bool = true) -> String {
|
||||
return Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)", requiresPath: true)
|
||||
func valet(_ command: String, sudo: Bool = true) async -> String {
|
||||
return await Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)").out
|
||||
}
|
||||
|
||||
/**
|
||||
Runs a `brew` command. Can run as superuser.
|
||||
*/
|
||||
func brew(_ command: String, sudo: Bool = false) {
|
||||
Shell.run("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
|
||||
func brew(_ command: String, sudo: Bool = false) async {
|
||||
await Shell.quiet("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
|
||||
}
|
||||
|
||||
/**
|
||||
Runs `sed` in order to replace all occurrences of a string in a specific file with another.
|
||||
*/
|
||||
func sed(file: String, original: String, replacement: String) {
|
||||
func sed(file: String, original: String, replacement: String) async {
|
||||
// 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 Filesystem.fileExists("\(Paths.binPath)/gsed") {
|
||||
Shell.run("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
if FileSystem.fileExists("\(Paths.binPath)/gsed") {
|
||||
await Shell.quiet("\(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)")
|
||||
await Shell.quiet("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.
|
||||
*/
|
||||
func grepContains(file: String, query: String) -> Bool {
|
||||
return Shell.pipe("""
|
||||
func grepContains(file: String, query: String) async -> Bool {
|
||||
return await Shell.pipe("""
|
||||
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
|
||||
""")
|
||||
""").out
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.contains("YES")
|
||||
}
|
||||
|
||||
/**
|
||||
Attempts to introduce sleep for a particular duration. Use with caution.
|
||||
Only intended for testing purposes.
|
||||
*/
|
||||
func delay(seconds: Double) async {
|
||||
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
}
|
||||
|
||||
@@ -3,23 +3,54 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/11/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class Homebrew {
|
||||
static var fake: Bool = false
|
||||
|
||||
struct Formulae {
|
||||
static var php: String {
|
||||
return PhpEnv.phpInstall.formula
|
||||
static var php: HomebrewFormula {
|
||||
if Homebrew.fake {
|
||||
return HomebrewFormula("php", elevated: true)
|
||||
}
|
||||
|
||||
if PhpEnv.shared.homebrewPackage == nil {
|
||||
fatalError("You must either load the HomebrewPackage object or call `fake` on the Homebrew class.")
|
||||
}
|
||||
|
||||
return HomebrewFormula(PhpEnv.phpInstall.formula, elevated: true)
|
||||
}
|
||||
|
||||
static var nginx: String {
|
||||
return HomebrewDiagnostics.usesNginxFullFormula ? "nginx-full" : "nginx"
|
||||
static var nginx: HomebrewFormula {
|
||||
return HomebrewDiagnostics.usesNginxFullFormula
|
||||
? HomebrewFormula("nginx-full", elevated: true)
|
||||
: HomebrewFormula("nginx", elevated: true)
|
||||
}
|
||||
|
||||
static var dnsmasq: String {
|
||||
return "dnsmasq"
|
||||
static var dnsmasq: HomebrewFormula {
|
||||
return HomebrewFormula("dnsmasq", elevated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HomebrewFormula: Equatable, Hashable {
|
||||
let name: String
|
||||
let elevated: Bool
|
||||
|
||||
init(_ name: String, elevated: Bool = true) {
|
||||
self.name = name
|
||||
self.elevated = elevated
|
||||
}
|
||||
|
||||
static func == (lhs: HomebrewFormula, rhs: HomebrewFormula) -> Bool {
|
||||
return lhs.elevated == rhs.elevated && lhs.name == rhs.name
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(name)
|
||||
hasher.combine(elevated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Paths.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -17,11 +17,15 @@ public class Paths {
|
||||
|
||||
internal var baseDir: Paths.HomebrewDir
|
||||
|
||||
private var userName: String
|
||||
private var userName: String! = nil
|
||||
|
||||
init() {
|
||||
baseDir = App.architecture != "x86_64" ? .opt : .usr
|
||||
userName = String(Shell.pipe("id -un").split(separator: "\n")[0])
|
||||
}
|
||||
|
||||
public func loadUser() async {
|
||||
let output = await Shell.pipe("id -un").out
|
||||
userName = String(output.split(separator: "\n")[0])
|
||||
}
|
||||
|
||||
public func detectBinaryPaths() {
|
||||
@@ -58,7 +62,16 @@ public class Paths {
|
||||
}
|
||||
|
||||
public static var homePath: String {
|
||||
return NSHomeDirectory()
|
||||
if FileSystem is RealFileSystem {
|
||||
return NSHomeDirectory()
|
||||
}
|
||||
|
||||
if FileSystem is TestableFileSystem {
|
||||
let fs = FileSystem as! TestableFileSystem
|
||||
return fs.homeDirectory
|
||||
}
|
||||
|
||||
fatalError("A valid FileSystem must be allowed to return the home path")
|
||||
}
|
||||
|
||||
public static var cellarPath: String {
|
||||
@@ -82,9 +95,9 @@ public class Paths {
|
||||
// (PHP Monitor will not use the user's own PATH)
|
||||
|
||||
private func detectComposerBinary() {
|
||||
if Filesystem.fileExists("/usr/local/bin/composer") {
|
||||
if FileSystem.fileExists("/usr/local/bin/composer") {
|
||||
Paths.composer = "/usr/local/bin/composer"
|
||||
} else if Filesystem.fileExists("/opt/homebrew/bin/composer") {
|
||||
} else if FileSystem.fileExists("/opt/homebrew/bin/composer") {
|
||||
Paths.composer = "/opt/homebrew/bin/composer"
|
||||
} else {
|
||||
Paths.composer = nil
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/02/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// Shell+PATH.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 15/08/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Shell {
|
||||
|
||||
var PATH: String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/zsh"
|
||||
|
||||
let command = Filesystem.fileExists("~/.zshrc")
|
||||
// source the user's .zshrc file if it exists to complete $PATH
|
||||
? ". ~/.zshrc && echo $PATH"
|
||||
// otherwise, non-interactive mode is sufficient
|
||||
: "echo $PATH"
|
||||
|
||||
task.arguments = ["--login", "-lc", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
return String(data: data, encoding: String.Encoding.utf8) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//
|
||||
// Shell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
public class Shell {
|
||||
|
||||
// MARK: - Invoke static functions
|
||||
|
||||
public static func run(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) {
|
||||
Shell.user.run(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
public static func pipe(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> String {
|
||||
return Shell.user.pipe(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
/**
|
||||
We now require macOS 11, so no need to detect which terminal to use.
|
||||
*/
|
||||
public var shell: String = "/bin/sh"
|
||||
|
||||
/** Additional exports that are sent if `requiresPath` is set to true. */
|
||||
public var exports: String = ""
|
||||
|
||||
/**
|
||||
Singleton to access a user shell (with --login)
|
||||
*/
|
||||
public static let user = Shell()
|
||||
|
||||
/**
|
||||
Runs a shell command without using the output.
|
||||
Uses the default shell.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
*/
|
||||
private func run(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) {
|
||||
// Equivalent of piping to /dev/null; don't do anything with the string
|
||||
_ = Shell.pipe(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
/**
|
||||
Runs a shell command and returns the output.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
*/
|
||||
private func pipe(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> String {
|
||||
let shellOutput = self.executeSynchronously(command, requiresPath: requiresPath)
|
||||
let hasError = (
|
||||
shellOutput.standardOutput == ""
|
||||
&& shellOutput.errorOutput.lengthOfBytes(using: .utf8) > 0
|
||||
)
|
||||
return !hasError ? shellOutput.standardOutput : shellOutput.errorOutput
|
||||
}
|
||||
|
||||
/**
|
||||
Runs the command and returns a `ShellOutput` object, which contains info about the process.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
- Parameter waitUntilExit: Waits for the command to complete before returning the `ShellOutput`
|
||||
*/
|
||||
public func executeSynchronously(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> Shell.Output {
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
|
||||
let task = self.createTask(for: command, requiresPath: requiresPath)
|
||||
task.standardOutput = outputPipe
|
||||
task.standardError = errorPipe
|
||||
task.launch()
|
||||
task.waitUntilExit()
|
||||
|
||||
let output = Shell.Output(
|
||||
standardOutput: String(
|
||||
data: outputPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
)!,
|
||||
errorOutput: String(
|
||||
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
)!,
|
||||
task: task
|
||||
)
|
||||
|
||||
if CommandLine.arguments.contains("--v") {
|
||||
log(task: task, output: output)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a new process with the correct PATH and shell.
|
||||
*/
|
||||
public func createTask(for command: String, requiresPath: Bool) -> Process {
|
||||
var completeCommand = ""
|
||||
|
||||
if requiresPath {
|
||||
// Basic export (PATH)
|
||||
completeCommand += "export PATH=\(Paths.binPath):$PATH && "
|
||||
|
||||
// Put additional exports in between
|
||||
if !self.exports.isEmpty {
|
||||
completeCommand += "\(self.exports) && "
|
||||
}
|
||||
}
|
||||
|
||||
completeCommand += command
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = self.shell
|
||||
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
Verbose logging for PHP Monitor's synchronous shell output.
|
||||
*/
|
||||
private func log(task: Process, output: Output) {
|
||||
Log.info("")
|
||||
Log.info("==== COMMAND ====")
|
||||
Log.info("")
|
||||
Log.info("\(self.shell) \(task.arguments?.joined(separator: " ") ?? "")")
|
||||
Log.info("")
|
||||
Log.info("==== OUTPUT ====")
|
||||
Log.info("")
|
||||
dump(output)
|
||||
Log.info("")
|
||||
Log.info("==== END OUTPUT ====")
|
||||
Log.info("")
|
||||
}
|
||||
|
||||
public class Output {
|
||||
public let standardOutput: String
|
||||
public let errorOutput: String
|
||||
public let task: Process
|
||||
|
||||
init(standardOutput: String,
|
||||
errorOutput: String,
|
||||
task: Process) {
|
||||
self.standardOutput = standardOutput
|
||||
self.errorOutput = errorOutput
|
||||
self.task = task
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 06/02/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/02/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/06/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
21
phpmon/Common/Extensions/DataExtension.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// DataExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
var prettyPrintedJSONString: NSString? {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: self, options: []),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||
let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return prettyPrintedString
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Date.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
17
phpmon/Common/Extensions/DictionaryExtension.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// DictionaryExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Dictionary {
|
||||
mutating func renameKey(fromKey: Key, toKey: Key) {
|
||||
if let entry = removeValue(forKey: fromKey) {
|
||||
self[toKey] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 14/04/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 18/08/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 17/02/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -2,20 +2,43 @@
|
||||
// StringExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Localization {
|
||||
static var bundle: Bundle = {
|
||||
if !isRunningTests {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
let foundBundle = Bundle(identifier: "com.nicoverbruggen.phpmon.dev")
|
||||
?? Bundle(identifier: "com.nicoverbruggen.phpmon")
|
||||
?? Bundle(identifier: "com.nicoverbruggen.phpmon.ui-tests")
|
||||
|
||||
if foundBundle == nil {
|
||||
let bundles = Bundle.allBundles
|
||||
.map { $0.bundleIdentifier }
|
||||
.filter { $0 != nil }
|
||||
.map { $0! }
|
||||
|
||||
fatalError("The following bundles were found: \(bundles)")
|
||||
}
|
||||
|
||||
return foundBundle!
|
||||
}()
|
||||
}
|
||||
|
||||
extension String {
|
||||
var localized: String {
|
||||
if #available(macOS 13, *) {
|
||||
return NSLocalizedString(
|
||||
self, tableName: nil, bundle: Bundle.main, value: "", comment: ""
|
||||
self, tableName: nil, bundle: Localization.bundle, value: "", comment: ""
|
||||
).replacingOccurrences(of: "Preferences", with: "Settings")
|
||||
}
|
||||
|
||||
return NSLocalizedString(self, tableName: nil, bundle: Bundle.main, value: "", comment: "")
|
||||
return NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "")
|
||||
}
|
||||
|
||||
var localizedForSwiftUI: LocalizedStringKey {
|
||||
@@ -42,6 +65,16 @@ extension String {
|
||||
return count
|
||||
}
|
||||
|
||||
func matches(pattern: String) -> Bool {
|
||||
let pred = NSPredicate(format: "self LIKE %@", pattern)
|
||||
return !NSArray(object: self).filtered(using: pred).isEmpty
|
||||
}
|
||||
|
||||
static func random(_ length: Int) -> String {
|
||||
let characters = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
return String((0..<length).map { _ in characters.randomElement()! })
|
||||
}
|
||||
|
||||
subscript(r: Range<String.Index>) -> String {
|
||||
let start = r.lowerBound
|
||||
let end = r.upperBound
|
||||
@@ -98,5 +131,4 @@ extension String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
15
phpmon/Common/Extensions/TimeIntervalExtension.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// TimeExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 29/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
public static func minutes(_ amount: Int) -> TimeInterval {
|
||||
return Double(amount * 60)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 04/02/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
26
phpmon/Common/Filesystem/ActiveFileSystem.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// FS.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var FileSystem: FileSystemProtocol {
|
||||
return ActiveFileSystem.shared
|
||||
}
|
||||
|
||||
class ActiveFileSystem {
|
||||
static var shared: FileSystemProtocol = RealFileSystem()
|
||||
|
||||
/** Note: Intermediate directories are not automatically inferred and have to be manually declared. */
|
||||
public static func useTestable(_ files: [String: FakeFile]) {
|
||||
Self.shared = TestableFileSystem(files: files)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealFileSystem()
|
||||
}
|
||||
}
|
||||
50
phpmon/Common/Filesystem/FileSystemProtocol.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// FileSystemProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FileSystemProtocol {
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) throws
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String]
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws
|
||||
|
||||
func remove(_ path: String) throws
|
||||
|
||||
// MARK: — Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool
|
||||
|
||||
func anyExists(_ path: String) -> Bool
|
||||
|
||||
func fileExists(_ path: String) -> Bool
|
||||
|
||||
func directoryExists(_ path: String) -> Bool
|
||||
|
||||
func isSymlink(_ path: String) -> Bool
|
||||
|
||||
func isDirectory(_ path: String) -> Bool
|
||||
}
|
||||
126
phpmon/Common/Filesystem/RealFileSystem.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// RealFileSystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingTildeWithHomeDirectory: String {
|
||||
return self.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
}
|
||||
}
|
||||
|
||||
class RealFileSystem: FileSystemProtocol {
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) {
|
||||
try! FileManager.default.createDirectory(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
withIntermediateDirectories: withIntermediateDirectories
|
||||
)
|
||||
}
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws {
|
||||
try content.write(
|
||||
to: URL(fileURLWithPath: path.replacingTildeWithHomeDirectory),
|
||||
atomically: true,
|
||||
encoding: String.Encoding.utf8
|
||||
)
|
||||
}
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String {
|
||||
return try String(
|
||||
contentsOf: URL(fileURLWithPath: path.replacingTildeWithHomeDirectory),
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String] {
|
||||
return try FileManager.default.contentsOfDirectory(atPath: path)
|
||||
}
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String {
|
||||
return try FileManager.default.destinationOfSymbolicLink(atPath: path)
|
||||
}
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws {
|
||||
try FileManager.default.moveItem(atPath: path, toPath: newPath)
|
||||
}
|
||||
|
||||
func remove(_ path: String) throws {
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
|
||||
// MARK: — FS Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws {
|
||||
_ = system("chmod +x \(path.replacingTildeWithHomeDirectory)")
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool {
|
||||
return FileManager.default.isExecutableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
) && FileManager.default.isReadableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool {
|
||||
return FileManager.default.isWritableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func anyExists(_ path: String) -> Bool {
|
||||
return FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && !isDirectory.boolValue
|
||||
}
|
||||
|
||||
func directoryExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
|
||||
func isSymlink(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeSymbolicLink
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDirectory(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeDirectory
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Alert.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -13,8 +13,8 @@ class Alert {
|
||||
onWindow window: NSWindow,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitle: String = "OK",
|
||||
secondButtonTitle: String = "Cancel",
|
||||
buttonTitle: String = "generic.ok".localized,
|
||||
secondButtonTitle: String = "generic.cancel".localized,
|
||||
style: NSAlert.Style = .warning,
|
||||
onFirstButtonPressed: @escaping (() -> Void)
|
||||
) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 07/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -34,30 +34,45 @@ class Application {
|
||||
(This will open the app if it isn't open yet.)
|
||||
*/
|
||||
@objc public func openDirectory(file: String) {
|
||||
return Shell.run("/usr/bin/open -a \"\(name)\" \"\(file)\"")
|
||||
Task { await Shell.quiet("/usr/bin/open -a \"\(name)\" \"\(file)\"") }
|
||||
}
|
||||
|
||||
/** Checks if the app is installed. */
|
||||
func isInstalled() -> Bool {
|
||||
// If this script does not complain, the app exists!
|
||||
return Shell.user.executeSynchronously(
|
||||
func isInstalled() async -> Bool {
|
||||
|
||||
let (process, output) = try! await Shell.attach(
|
||||
"/usr/bin/open -Ra \"\(name)\"",
|
||||
requiresPath: false
|
||||
).task.terminationStatus == 0
|
||||
didReceiveOutput: { _, _ in },
|
||||
withTimeout: 2.0
|
||||
)
|
||||
|
||||
if Shell is TestableShell {
|
||||
// When testing, check the error output (must not be empty)
|
||||
return !output.hasError
|
||||
} else {
|
||||
// If this script does not complain, the app exists!
|
||||
return process.terminationStatus == 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Detect which apps are available to open a specific directory.
|
||||
*/
|
||||
static public func detectPresetApplications() -> [Application] {
|
||||
return [
|
||||
static public func detectPresetApplications() async -> [Application] {
|
||||
var detected: [Application] = []
|
||||
|
||||
let detectable = [
|
||||
Application("PhpStorm", .editor),
|
||||
Application("Visual Studio Code", .editor),
|
||||
Application("Sublime Text", .editor),
|
||||
Application("Sublime Merge", .git_gui),
|
||||
Application("iTerm", .terminal)
|
||||
].filter {
|
||||
return $0.isInstalled()
|
||||
]
|
||||
|
||||
for app in detectable where await app.isInstalled() {
|
||||
detected.append(app)
|
||||
}
|
||||
|
||||
return detected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// Filesystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 07/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class Filesystem {
|
||||
|
||||
/**
|
||||
Checks if a file or directory exists at the provided path.
|
||||
*/
|
||||
public static func exists(_ path: String) -> Bool {
|
||||
return FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a file exists at the provided path.
|
||||
*/
|
||||
public static func fileExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath),
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && !isDirectory.boolValue
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a directory exists at the provided path.
|
||||
*/
|
||||
public static func directoryExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath),
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a given file is a symbolic link.
|
||||
*/
|
||||
public static func fileIsSymlink(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeSymbolicLink
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// LocalNotification.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ImageGenerator.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 05/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
28
phpmon/Common/Helpers/System.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// System.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
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()
|
||||
task.launchPath = "/bin/sh"
|
||||
task.arguments = ["-c", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output: String = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
17
phpmon/Common/Helpers/WIP.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// WIP.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
func todo(_ context: String = "") {
|
||||
if !context.isEmpty {
|
||||
fatalError("To be implemented: \(context)")
|
||||
}
|
||||
|
||||
fatalError("To be implemented")
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// ActivePhpInstallation.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -17,11 +17,12 @@ import Foundation
|
||||
Using `version.short` is advisable if you want to interact with Homebrew.
|
||||
*/
|
||||
class ActivePhpInstallation {
|
||||
|
||||
var version: Version!
|
||||
var version: VersionNumber!
|
||||
var limits: Limits!
|
||||
var iniFiles: [PhpConfigurationFile] = []
|
||||
|
||||
var hasErrorState: Bool = false
|
||||
|
||||
var extensions: [PhpExtension] {
|
||||
return iniFiles.flatMap { initFile in
|
||||
return initFile.extensions
|
||||
@@ -31,20 +32,25 @@ class ActivePhpInstallation {
|
||||
// MARK: - Computed
|
||||
|
||||
var formula: String {
|
||||
return (version.short == PhpEnv.brewPhpVersion) ? "php" : "php@\(version.short)"
|
||||
return (version.short == PhpEnv.brewPhpAlias) ? "php" : "php@\(version.short)"
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init() {
|
||||
// Show information about the current version
|
||||
getVersion()
|
||||
do {
|
||||
try determineVersion()
|
||||
} catch {
|
||||
// TODO: In future versions of PHP Monitor, this should not crash
|
||||
fatalError("Could not determine or parse PHP version; aborting")
|
||||
}
|
||||
|
||||
// Initialize the list of ini files that are loaded
|
||||
iniFiles = []
|
||||
|
||||
// If an error occurred, exit early
|
||||
if version.error {
|
||||
if self.hasErrorState {
|
||||
limits = Limits()
|
||||
return
|
||||
}
|
||||
@@ -64,10 +70,14 @@ class ActivePhpInstallation {
|
||||
)
|
||||
|
||||
// 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) }
|
||||
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) }
|
||||
|
||||
// See if any extensions are present in said .ini files
|
||||
paths.forEach { (iniFilePath) in
|
||||
@@ -81,26 +91,12 @@ class ActivePhpInstallation {
|
||||
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() {
|
||||
self.version = Version()
|
||||
private func determineVersion() throws {
|
||||
let output = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
|
||||
|
||||
let version = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
|
||||
self.hasErrorState = (output == "" || output.contains("Warning") || output.contains("Error"))
|
||||
|
||||
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: ".")
|
||||
self.version = try? VersionNumber.parse(output)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +115,7 @@ class ActivePhpInstallation {
|
||||
- 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)');"])
|
||||
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"], trimNewlines: false)
|
||||
|
||||
// Check if the value is unlimited
|
||||
if value == "-1" {
|
||||
@@ -139,31 +135,20 @@ class ActivePhpInstallation {
|
||||
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 {
|
||||
func checkPhpFpmStatus() async -> 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")
|
||||
return await Shell.pipe("cat \(fileName)").out
|
||||
.contains("valet.sock")
|
||||
}
|
||||
|
||||
// Make sure to check if valet-fpm.conf exists. If it does, we should be fine :)
|
||||
return Filesystem.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
|
||||
return FileSystem.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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/05/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// HomebrewPackage.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct HomebrewService: Decodable, Equatable {
|
||||
final class HomebrewService: Sendable, Decodable {
|
||||
let name: String
|
||||
let service_name: String
|
||||
let running: Bool
|
||||
@@ -19,10 +19,32 @@ struct HomebrewService: Decodable, Equatable {
|
||||
let log_path: String?
|
||||
let error_log_path: String?
|
||||
|
||||
init(
|
||||
name: String,
|
||||
service_name: String,
|
||||
running: Bool,
|
||||
loaded: Bool,
|
||||
pid: Int? = nil,
|
||||
user: String? = nil,
|
||||
status: String? = nil,
|
||||
log_path: String? = nil,
|
||||
error_log_path: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.service_name = service_name
|
||||
self.running = running
|
||||
self.loaded = loaded
|
||||
self.pid = pid
|
||||
self.user = user
|
||||
self.status = status
|
||||
self.log_path = log_path
|
||||
self.error_log_path = error_log_path
|
||||
}
|
||||
|
||||
/**
|
||||
Dummy data for preview purposes.
|
||||
*/
|
||||
public static func dummy(named service: String, enabled: Bool) -> Self {
|
||||
public static func dummy(named service: String, enabled: Bool, status: String? = nil) -> HomebrewService {
|
||||
return HomebrewService(
|
||||
name: service,
|
||||
service_name: service,
|
||||
@@ -30,7 +52,7 @@ struct HomebrewService: Decodable, Equatable {
|
||||
loaded: enabled,
|
||||
pid: nil,
|
||||
user: nil,
|
||||
status: nil,
|
||||
status: status,
|
||||
log_path: nil,
|
||||
error_log_path: nil
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -14,15 +14,17 @@ class PhpEnv {
|
||||
|
||||
init() {
|
||||
self.currentInstall = ActivePhpInstallation()
|
||||
}
|
||||
|
||||
let brewPhpAlias = Shell.pipe("\(Paths.brew) info php --json")
|
||||
func determinePhpAlias() async {
|
||||
let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
|
||||
|
||||
self.homebrewPackage = try! JSONDecoder().decode(
|
||||
[HomebrewPackage].self,
|
||||
from: brewPhpAlias.data(using: .utf8)!
|
||||
).first!
|
||||
|
||||
Log.info("When on your system, the `php` formula means version \(homebrewPackage.version)!")
|
||||
Log.info("[BREW] On your system, the `php` formula means version \(homebrewPackage.version)!")
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -36,14 +38,17 @@ class PhpEnv {
|
||||
/** Whether the switcher is busy performing any actions. */
|
||||
var isBusy: Bool = false
|
||||
|
||||
/** All available versions of PHP. */
|
||||
/** All versions of PHP that are currently supported. */
|
||||
var availablePhpVersions: [String] = []
|
||||
|
||||
/** All versions of PHP that are currently installed but not compatible. */
|
||||
var incompatiblePhpVersions: [String] = []
|
||||
|
||||
/** Cached information about the PHP installations. */
|
||||
var cachedPhpInstallations: [String: PhpInstallation] = [:]
|
||||
|
||||
/** Information about the currently linked PHP installation. */
|
||||
var currentInstall: ActivePhpInstallation
|
||||
var currentInstall: ActivePhpInstallation!
|
||||
|
||||
/**
|
||||
The version that the `php` formula via Brew is aliased to on the current system.
|
||||
@@ -54,7 +59,9 @@ class PhpEnv {
|
||||
|
||||
As such, we take that information from Homebrew.
|
||||
*/
|
||||
static var brewPhpVersion: String {
|
||||
static var brewPhpAlias: String {
|
||||
if Homebrew.fake { return "8.2" }
|
||||
|
||||
return Self.shared.homebrewPackage.version
|
||||
}
|
||||
|
||||
@@ -76,17 +83,21 @@ class PhpEnv {
|
||||
return InternalSwitcher()
|
||||
}
|
||||
|
||||
public static func detectPhpVersions() {
|
||||
_ = Self.shared.detectPhpVersions()
|
||||
public static func detectPhpVersions() async {
|
||||
_ = await Self.shared.detectPhpVersions()
|
||||
}
|
||||
|
||||
/**
|
||||
Detects which versions of PHP are installed.
|
||||
*/
|
||||
public func detectPhpVersions() -> [String] {
|
||||
let files = Shell.pipe("ls \(Paths.optPath) | grep php@")
|
||||
public func detectPhpVersions() async -> Set<String> {
|
||||
let files = await Shell.pipe("ls \(Paths.optPath) | grep php@").out
|
||||
|
||||
var versionsOnly = extractPhpVersions(from: files.components(separatedBy: "\n"))
|
||||
let versions = await extractPhpVersions(from: files.components(separatedBy: "\n"))
|
||||
|
||||
let supportedByValet = Constants.ValetSupportedPhpVersionMatrix[Valet.shared.version.major] ?? []
|
||||
|
||||
var supportedVersions = versions.intersection(supportedByValet)
|
||||
|
||||
// Make sure the aliased version is detected
|
||||
// The user may have `php` installed, but not e.g. `php@8.0`
|
||||
@@ -94,13 +105,18 @@ class PhpEnv {
|
||||
let phpAlias = homebrewPackage.version
|
||||
|
||||
// Avoid inserting a duplicate
|
||||
if !versionsOnly.contains(phpAlias) && Filesystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
||||
versionsOnly.append(phpAlias)
|
||||
if !supportedVersions.contains(phpAlias) && FileSystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
||||
supportedVersions.insert(phpAlias)
|
||||
}
|
||||
|
||||
Log.info("The PHP versions that were detected are: \(versionsOnly)")
|
||||
availablePhpVersions = Array(supportedVersions)
|
||||
.sorted(by: { $0.versionCompare($1) == .orderedDescending })
|
||||
|
||||
availablePhpVersions = versionsOnly
|
||||
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] = [:]
|
||||
|
||||
@@ -110,7 +126,7 @@ class PhpEnv {
|
||||
|
||||
cachedPhpInstallations = mappedVersions
|
||||
|
||||
return versionsOnly
|
||||
return supportedVersions
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,15 +140,9 @@ class PhpEnv {
|
||||
from versions: [String],
|
||||
checkBinaries: Bool = true,
|
||||
generateHelpers: Bool = true
|
||||
) -> [String] {
|
||||
var output: [String] = []
|
||||
|
||||
var supported = Constants.SupportedPhpVersions
|
||||
|
||||
if !Valet.enabled(feature: .supportForPhp56) {
|
||||
supported.removeAll { $0 == "5.6" }
|
||||
}
|
||||
|
||||
) 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)
|
||||
@@ -143,19 +153,21 @@ class PhpEnv {
|
||||
// is supported and where the binary exists (avoids broken installs)
|
||||
if !output.contains(version)
|
||||
&& supported.contains(version)
|
||||
&& (checkBinaries ? Filesystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
||||
output.append(version)
|
||||
&& (checkBinaries ? FileSystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
||||
output.insert(version)
|
||||
}
|
||||
}
|
||||
|
||||
if generateHelpers {
|
||||
output.forEach { PhpHelper.generate(for: $0) }
|
||||
for item in output {
|
||||
await PhpHelper.generate(for: item)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
public func validVersions(for constraint: String) -> [PhpVersionNumber] {
|
||||
public func validVersions(for constraint: String) -> [VersionNumber] {
|
||||
constraint.split(separator: "|").flatMap {
|
||||
return PhpVersionNumberCollection
|
||||
.make(from: self.availablePhpVersions)
|
||||
@@ -169,6 +181,9 @@ class PhpEnv {
|
||||
public func validate(_ version: String) -> Bool {
|
||||
if self.currentInstall.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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 17/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -12,7 +12,7 @@ class PhpHelper {
|
||||
|
||||
static let keyPhrase = "This file was automatically generated by PHP Monitor."
|
||||
|
||||
public static func generate(for version: String) {
|
||||
public static func generate(for version: String) async {
|
||||
// Take the PHP version (e.g. "7.2") and generate a dotless version
|
||||
let dotless = version.replacingOccurrences(of: ".", with: "")
|
||||
|
||||
@@ -20,79 +20,82 @@ class PhpHelper {
|
||||
let destination = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||
|
||||
// Check if the ~/.config/phpmon/bin directory is in the PATH
|
||||
let inPath = Shell.user.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
|
||||
let inPath = Shell.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
|
||||
|
||||
// Check if we can create symlinks (`/usr/local/bin` must be writable)
|
||||
let canWriteSymlinks = FileManager.default.isWritableFile(atPath: "/usr/local/bin/")
|
||||
let canWriteSymlinks = FileSystem.isWriteableFile("/usr/local/bin/")
|
||||
|
||||
do {
|
||||
Shell.run("mkdir -p ~/.config/phpmon/bin")
|
||||
|
||||
if FileManager.default.fileExists(atPath: destination) {
|
||||
let contents = try String(contentsOfFile: destination)
|
||||
if !contents.contains(keyPhrase) {
|
||||
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
|
||||
+ "(or is unreadable). Not updating this file.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Let's follow the symlink to the PHP binary folder
|
||||
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
|
||||
.resolvingSymlinksInPath().path
|
||||
|
||||
// The contents of the script!
|
||||
let script = """
|
||||
#!/bin/zsh
|
||||
# \(keyPhrase)
|
||||
# It reflects the location of PHP \(version)'s binaries on your system.
|
||||
# Usage: . pm\(dotless)
|
||||
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
|
||||
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|
||||
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
|
||||
export PATH=\(path):$PATH
|
||||
"""
|
||||
|
||||
// Write to the destination
|
||||
try script.write(
|
||||
to: URL(fileURLWithPath: destination),
|
||||
atomically: true,
|
||||
encoding: String.Encoding.utf8
|
||||
)
|
||||
|
||||
// Make sure the file is executable
|
||||
Shell.run("chmod +x \(destination)")
|
||||
|
||||
// Create a symlink if the folder is not in the PATH
|
||||
if !inPath {
|
||||
// First, check if we can create symlinks at all
|
||||
if !canWriteSymlinks {
|
||||
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
|
||||
return
|
||||
Task { // Create the appropriate folders and check if the files exist
|
||||
do {
|
||||
if !FileSystem.directoryExists("~/.config/phpmon/bin") {
|
||||
try FileSystem.createDirectory(
|
||||
"~/.config/phpmon/bin",
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
// Write the symlink
|
||||
self.createSymlink(dotless)
|
||||
if FileSystem.fileExists(destination) {
|
||||
let contents = try String(contentsOfFile: destination)
|
||||
if !contents.contains(keyPhrase) {
|
||||
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
|
||||
+ "(or is unreadable). Not updating this file.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Let's follow the symlink to the PHP binary folder
|
||||
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
|
||||
.resolvingSymlinksInPath().path
|
||||
|
||||
// The contents of the script!
|
||||
let script = """
|
||||
#!/bin/zsh
|
||||
# \(keyPhrase)
|
||||
# It reflects the location of PHP \(version)'s binaries on your system.
|
||||
# Usage: . pm\(dotless)
|
||||
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
|
||||
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|
||||
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
|
||||
export PATH=\(path):$PATH
|
||||
"""
|
||||
|
||||
try FileSystem.writeAtomicallyToFile(destination, content: script)
|
||||
|
||||
if !FileSystem.isExecutableFile(destination) {
|
||||
try FileSystem.makeExecutable(destination)
|
||||
}
|
||||
|
||||
// Create a symlink if the folder is not in the PATH
|
||||
if !inPath {
|
||||
// First, check if we can create symlinks at all
|
||||
if !canWriteSymlinks {
|
||||
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
|
||||
return
|
||||
}
|
||||
|
||||
// Write the symlink
|
||||
await self.createSymlink(dotless)
|
||||
}
|
||||
} catch {
|
||||
Log.err(error)
|
||||
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func createSymlink(_ dotless: String) {
|
||||
private static func createSymlink(_ dotless: String) async {
|
||||
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||
let destination = "/usr/local/bin/pm\(dotless)"
|
||||
|
||||
if !Filesystem.fileExists(destination) {
|
||||
if !FileSystem.fileExists(destination) {
|
||||
Log.info("Creating new symlink: \(destination)")
|
||||
Shell.run("ln -s \(source) \(destination)")
|
||||
await Shell.quiet("ln -s \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
if !Filesystem.fileIsSymlink(destination) {
|
||||
if !FileSystem.isSymlink(destination) {
|
||||
Log.info("Overwriting existing file with new symlink: \(destination)")
|
||||
Shell.run("ln -fs \(source) \(destination)")
|
||||
await Shell.quiet("ln -fs \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
//
|
||||
// PhpVersionNumber.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PhpVersionNumberCollection: Equatable {
|
||||
let versions: [PhpVersionNumber]
|
||||
|
||||
public static func make(from versions: [String]) -> Self {
|
||||
return PhpVersionNumberCollection(
|
||||
versions: versions.map { try! PhpVersionNumber.parse($0) }
|
||||
)
|
||||
}
|
||||
|
||||
public var first: PhpVersionNumber? {
|
||||
return self.versions.first
|
||||
}
|
||||
|
||||
public var all: [PhpVersionNumber] {
|
||||
return self.versions
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if any versions of PHP are valid for the constraint provided.
|
||||
Due to the complexity of evaluating these, a important test is maintained.
|
||||
More information on these constraints can be found here:
|
||||
https://getcomposer.org/doc/articles/versions.md#writing-version-constraints
|
||||
|
||||
- Parameter constraint: The full constraint as a string (e.g. "^7.0")
|
||||
- Parameter strict: Whether the patch version check is strict. See more below.
|
||||
|
||||
The strict mode does not matter if a patch version is provided for all versions in the collection.
|
||||
|
||||
Strict mode assumes that any PHP version lacking precise patch information, e.g. inferred
|
||||
from Homebrew corresponds to the .0 patch version of that version. The default, which is imprecise,
|
||||
assumes that the patch version is .999, which means that in all cases the patch version check is
|
||||
always going to pass.
|
||||
|
||||
**STRICT MODE (= patch precision on)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in strict mode only 8.1.? will
|
||||
be considered valid (8.0 translates to 8.0.0 and as such is older than 8.0.1, 8.1.0 is OK).
|
||||
When checking against actual PHP versions installed by the user (with patch precision), use
|
||||
strict mode.
|
||||
|
||||
**NON-STRICT MODE (= patch precision off)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in non-strict mode version 8.0
|
||||
is assumed to be equal to version 8.0.999, which is actually fine if 8.0.1 is the required version.
|
||||
In non-strict mode, the patch version is ignored for regular version checks (no caret / tilde).
|
||||
If checking compatibility with general Homebrew versions of PHP, do NOT use strict mode, since
|
||||
the patch version there is not used. (The formula php@8.0 suffices for ^8.0.1.)
|
||||
*/
|
||||
public func matching(constraint: String, strict: Bool = false) -> [PhpVersionNumber] {
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .versionOnly) {
|
||||
// Strict constraint (e.g. "7.0") -> returns specific version
|
||||
return self.versions.filter { $0.isSameAs(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .caretVersionRange) {
|
||||
// Caret range means that the major version is never higher but minor version can be higher
|
||||
// ^7.2 will be compatible with all versions between 7.2 and 8.0
|
||||
return self.versions.filter { $0.hasNewerMinorVersionOrPatch(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .tildeVersionRange) {
|
||||
// Tilde range means that most specific digit is used as the basis.
|
||||
return self.versions.filter {
|
||||
version.patch != nil
|
||||
// If a patch is provided then the minor version cannot be bumped.
|
||||
? $0.hasSameMajorAndMinorButNewerOrSamePatch(version, strict)
|
||||
// If a patch is not provided then the major version cannot be bumped.
|
||||
: $0.hasSameMajorButNewerOrSameMinor(version, strict)
|
||||
}
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .greaterThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .greaterThan) {
|
||||
return self.versions.filter { $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .smallerThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .smallerThan) {
|
||||
return self.versions.filter { $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public struct PhpVersionNumber: Equatable, Hashable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int?
|
||||
|
||||
public func toString() -> String {
|
||||
return self.patch == nil
|
||||
? "\(major).\(minor)"
|
||||
: "\(major).\(minor).\(patch!)"
|
||||
}
|
||||
|
||||
public func patch(_ strictFallback: Bool = true, _ constraint: PhpVersionNumber? = nil) -> Int {
|
||||
return patch ?? (strictFallback ? 0 : constraint?.patch ?? 999)
|
||||
}
|
||||
|
||||
public var homebrewVersion: String {
|
||||
return "\(major).\(minor)"
|
||||
}
|
||||
|
||||
public enum MatchType: String {
|
||||
case versionOnly = #"^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case caretVersionRange = #"^\^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case tildeVersionRange = #"^~(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThanOrEqual = #"^>=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThan = #"^>(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThanOrEqual = #"^<=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThan = #"^<(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
}
|
||||
|
||||
public static func parse(_ text: String) throws -> Self {
|
||||
guard let versionText = VersionExtractor.from(text) else {
|
||||
throw VersionParseError()
|
||||
}
|
||||
|
||||
return Self.make(from: versionText)!
|
||||
}
|
||||
|
||||
public static func make(from versionString: String, type: MatchType = .versionOnly) -> Self? {
|
||||
let regex = try! NSRegularExpression(pattern: type.rawValue, options: [])
|
||||
|
||||
let match = regex.matches(
|
||||
in: versionString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: versionString.count)
|
||||
).first
|
||||
|
||||
if match != nil {
|
||||
let major = Int(
|
||||
versionString[Range(match!.range(withName: "major"), in: versionString)!]
|
||||
)!
|
||||
let minor = Int(
|
||||
versionString[Range(match!.range(withName: "minor"), in: versionString)!]
|
||||
)!
|
||||
var patch: Int?
|
||||
if let minorRange = Range(match!.range(withName: "patch"), in: versionString) {
|
||||
patch = Int(versionString[minorRange])
|
||||
}
|
||||
return Self(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Comparison Logic
|
||||
|
||||
internal func isSameAs(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor == version.minor
|
||||
&& (strict ? self.patch(strict, version) == version.patch(strict) : true)
|
||||
}
|
||||
|
||||
internal func isNewerThan(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major > version.major ||
|
||||
self.major == version.major && self.minor > version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) > version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func isOlderThan(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major < version.major ||
|
||||
self.major == version.major && self.minor < version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) < version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasNewerMinorVersionOrPatch(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major &&
|
||||
(
|
||||
(self.minor == version.minor && self.patch(strict) >= version.patch(strict, self))
|
||||
|| self.minor > version.minor
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasSameMajorAndMinorButNewerOrSamePatch(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict, version) >= version.patch(strict)
|
||||
}
|
||||
|
||||
internal func hasSameMajorButNewerOrSameMinor(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor >= version.minor
|
||||
}
|
||||
}
|
||||
100
phpmon/Common/PHP/PHP Version/PhpVersionNumberCollection.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// PhpVersionNumberCollection.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 06/01/2023.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PhpVersionNumberCollection: Equatable {
|
||||
let versions: [VersionNumber]
|
||||
|
||||
public static func make(from versions: [String]) -> Self {
|
||||
return PhpVersionNumberCollection(
|
||||
versions: versions.map { try! VersionNumber.parse($0) }
|
||||
)
|
||||
}
|
||||
|
||||
public var first: VersionNumber? {
|
||||
return self.versions.first
|
||||
}
|
||||
|
||||
public var all: [VersionNumber] {
|
||||
return self.versions
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if any versions of PHP are valid for the constraint provided.
|
||||
Due to the complexity of evaluating these, a important test is maintained.
|
||||
More information on these constraints can be found here:
|
||||
https://getcomposer.org/doc/articles/versions.md#writing-version-constraints
|
||||
|
||||
- Parameter constraint: The full constraint as a string (e.g. "^7.0")
|
||||
- Parameter strict: Whether the patch version check is strict. See more below.
|
||||
|
||||
The strict mode does not matter if a patch version is provided for all versions in the collection.
|
||||
|
||||
Strict mode assumes that any PHP version lacking precise patch information, e.g. inferred
|
||||
from Homebrew corresponds to the .0 patch version of that version. The default, which is imprecise,
|
||||
assumes that the patch version is .999, which means that in all cases the patch version check is
|
||||
always going to pass.
|
||||
|
||||
**STRICT MODE (= patch precision on)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in strict mode only 8.1.? will
|
||||
be considered valid (8.0 translates to 8.0.0 and as such is older than 8.0.1, 8.1.0 is OK).
|
||||
When checking against actual PHP versions installed by the user (with patch precision), use
|
||||
strict mode.
|
||||
|
||||
**NON-STRICT MODE (= patch precision off)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in non-strict mode version 8.0
|
||||
is assumed to be equal to version 8.0.999, which is actually fine if 8.0.1 is the required version.
|
||||
In non-strict mode, the patch version is ignored for regular version checks (no caret / tilde).
|
||||
If checking compatibility with general Homebrew versions of PHP, do NOT use strict mode, since
|
||||
the patch version there is not used. (The formula php@8.0 suffices for ^8.0.1.)
|
||||
*/
|
||||
public func matching(constraint: String, strict: Bool = false) -> [VersionNumber] {
|
||||
if let version = VersionNumber.make(from: constraint, type: .versionOnly) {
|
||||
// Strict constraint (e.g. "7.0") -> returns specific version
|
||||
return self.versions.filter { $0.isSameAs(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .caretVersionRange) {
|
||||
// Caret range means that the major version is never higher but minor version can be higher
|
||||
// ^7.2 will be compatible with all versions between 7.2 and 8.0
|
||||
return self.versions.filter { $0.hasNewerMinorVersionOrPatch(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .tildeVersionRange) {
|
||||
// Tilde range means that most specific digit is used as the basis.
|
||||
return self.versions.filter {
|
||||
version.patch != nil
|
||||
// If a patch is provided then the minor version cannot be bumped.
|
||||
? $0.hasSameMajorAndMinorButNewerOrSamePatch(version, strict)
|
||||
// If a patch is not provided then the major version cannot be bumped.
|
||||
: $0.hasSameMajorButNewerOrSameMinor(version, strict)
|
||||
}
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .greaterThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .greaterThan) {
|
||||
return self.versions.filter { $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .smallerThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .smallerThan) {
|
||||
return self.versions.filter { $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
131
phpmon/Common/PHP/PHP Version/VersionNumber.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// PhpVersionNumber.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/01/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A version number that is (mostly) compatible with the semantic versioning standard.
|
||||
For more information about semantic versioning, see: https://semver.org/
|
||||
|
||||
- Note: If you want to check version constraints for PHP versions, please see `PhpVersionNumberCollection`.
|
||||
*/
|
||||
public struct VersionNumber: Equatable, Hashable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int?
|
||||
|
||||
var text: String {
|
||||
return self.patch == nil
|
||||
? "\(major).\(minor)"
|
||||
: "\(major).\(minor).\(patch!)"
|
||||
}
|
||||
|
||||
public func patch(_ strictFallback: Bool = true, _ constraint: VersionNumber? = nil) -> Int {
|
||||
return patch ?? (strictFallback ? 0 : constraint?.patch ?? 999)
|
||||
}
|
||||
|
||||
public var long: String {
|
||||
return "\(major).\(minor).\(patch ?? 0)"
|
||||
}
|
||||
|
||||
public var short: String {
|
||||
return "\(major).\(minor)"
|
||||
}
|
||||
|
||||
public enum MatchType: String {
|
||||
case versionOnly = #"^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case caretVersionRange = #"^\^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case tildeVersionRange = #"^~(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThanOrEqual = #"^>=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThan = #"^>(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThanOrEqual = #"^<=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThan = #"^<(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
}
|
||||
|
||||
public static func parse(_ text: String) throws -> Self {
|
||||
guard let versionText = VersionExtractor.from(text) else {
|
||||
throw VersionParseError()
|
||||
}
|
||||
|
||||
return Self.make(from: versionText)!
|
||||
}
|
||||
|
||||
public static func make(from versionString: String, type: MatchType = .versionOnly) -> Self? {
|
||||
let regex = try! NSRegularExpression(pattern: type.rawValue, options: [])
|
||||
|
||||
let match = regex.matches(
|
||||
in: versionString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: versionString.count)
|
||||
).first
|
||||
|
||||
if match != nil {
|
||||
let major = Int(
|
||||
versionString[Range(match!.range(withName: "major"), in: versionString)!]
|
||||
)!
|
||||
let minor = Int(
|
||||
versionString[Range(match!.range(withName: "minor"), in: versionString)!]
|
||||
)!
|
||||
var patch: Int?
|
||||
if let minorRange = Range(match!.range(withName: "patch"), in: versionString) {
|
||||
patch = Int(versionString[minorRange])
|
||||
}
|
||||
return Self(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Comparison Logic
|
||||
|
||||
internal func isSameMajorVersionAs(_ version: VersionNumber) -> Bool {
|
||||
return self.major == version.major
|
||||
}
|
||||
|
||||
internal func isSameAs(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor == version.minor
|
||||
&& (strict ? self.patch(strict, version) == version.patch(strict) : true)
|
||||
}
|
||||
|
||||
internal func isNewerThan(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major > version.major ||
|
||||
self.major == version.major && self.minor > version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) > version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func isOlderThan(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major < version.major ||
|
||||
self.major == version.major && self.minor < version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) < version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasNewerMinorVersionOrPatch(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major &&
|
||||
(
|
||||
(self.minor == version.minor && self.patch(strict) >= version.patch(strict, self))
|
||||
|| self.minor > version.minor
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasSameMajorAndMinorButNewerOrSamePatch(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict, version) >= version.patch(strict)
|
||||
}
|
||||
|
||||
internal func hasSameMajorButNewerOrSameMinor(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor >= version.minor
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 04/05/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -35,7 +35,7 @@ class PhpConfigurationFile: CreatedFromFile {
|
||||
let path = filePath.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
|
||||
do {
|
||||
let fileContents = try String(contentsOfFile: path)
|
||||
let fileContents = try FileSystem.getStringFromFile(path)
|
||||
return Self.init(path: path, contents: fileContents)
|
||||
} catch {
|
||||
Log.warn("Could not read the PHP configuration file at: `\(filePath)`")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 31/01/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -75,16 +75,23 @@ class PhpExtension {
|
||||
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() {
|
||||
func toggle() async {
|
||||
let newLine = enabled
|
||||
// DISABLED: Commented out line
|
||||
? "; \(line)"
|
||||
// ENABLED: Line where the comment delimiter (;) is removed
|
||||
: line.replacingOccurrences(of: "; ", with: "")
|
||||
|
||||
sed(file: file, original: line, replacement: newLine)
|
||||
await sed(file: file, original: line, replacement: newLine)
|
||||
|
||||
enabled.toggle()
|
||||
|
||||
if !isRunningTests {
|
||||
Task { @MainActor in
|
||||
MainMenu.shared.rebuild()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Static Methods
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 28/11/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PhpInstallation {
|
||||
|
||||
var versionNumber: PhpVersionNumber
|
||||
var versionNumber: VersionNumber
|
||||
|
||||
/**
|
||||
In order to determine details about a PHP installation, we’ll simply run `php-config --version`
|
||||
@@ -19,17 +19,18 @@ class PhpInstallation {
|
||||
init(_ version: String) {
|
||||
|
||||
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
|
||||
self.versionNumber = PhpVersionNumber.make(from: version)!
|
||||
self.versionNumber = VersionNumber.make(from: version)!
|
||||
|
||||
if Filesystem.fileExists(phpConfigExecutablePath) {
|
||||
if FileSystem.fileExists(phpConfigExecutablePath) {
|
||||
let longVersionString = Command.execute(
|
||||
path: phpConfigExecutablePath,
|
||||
arguments: ["--version"]
|
||||
arguments: ["--version"],
|
||||
trimNewlines: false
|
||||
).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// 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! PhpVersionNumber.parse(longVersionString)
|
||||
self.versionNumber = try! VersionNumber.parse(longVersionString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 24/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -15,49 +15,50 @@ class InternalSwitcher: PhpSwitcher {
|
||||
- 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).
|
||||
*/
|
||||
func performSwitch(to version: String, completion: @escaping () -> Void) {
|
||||
func performSwitch(to version: String) async {
|
||||
Log.info("Switching to \(version), unlinking all versions...")
|
||||
|
||||
let versions = getVersionsToBeHandled(version)
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
PhpEnv.shared.availablePhpVersions.forEach { (available) in
|
||||
group.enter()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.disableDefaultPhpFpmPool(available)
|
||||
self.stopPhpVersion(available)
|
||||
group.leave()
|
||||
await withTaskGroup(of: String.self, body: { group in
|
||||
for available in PhpEnv.shared.availablePhpVersions {
|
||||
group.addTask {
|
||||
await self.disableDefaultPhpFpmPool(available)
|
||||
await self.stopPhpVersion(available)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .global(qos: .userInitiated)) {
|
||||
Log.info("All versions have been unlinked!")
|
||||
Log.info("Linking the new version!")
|
||||
var unlinked: [String] = []
|
||||
for await version in group {
|
||||
unlinked.append(version)
|
||||
}
|
||||
|
||||
Log.info("These versions have been unlinked: \(unlinked)")
|
||||
Log.info("Linking the new version \(version)!")
|
||||
|
||||
for formula in versions {
|
||||
self.startPhpVersion(formula, primary: (version == formula))
|
||||
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
|
||||
await self.startPhpVersion(formula, primary: (version == formula))
|
||||
}
|
||||
|
||||
Log.info("Restarting nginx, just to be sure!")
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
await brew("services restart nginx", sudo: true)
|
||||
|
||||
Log.info("The new version(s) have been linked!")
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getVersionsToBeHandled(_ primary: String) -> Set<String> {
|
||||
let isolated = Valet.shared.sites.filter { site in
|
||||
site.isolatedPhpVersion != nil
|
||||
}.map { site in
|
||||
return site.isolatedPhpVersion!.versionNumber.homebrewVersion
|
||||
return site.isolatedPhpVersion!.versionNumber.short
|
||||
}
|
||||
|
||||
var versions: Set<String> = [primary]
|
||||
@@ -71,22 +72,22 @@ class InternalSwitcher: PhpSwitcher {
|
||||
|
||||
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
|
||||
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
return FileManager.default.fileExists(atPath: pool)
|
||||
return FileSystem.fileExists(pool)
|
||||
}
|
||||
|
||||
func disableDefaultPhpFpmPool(_ version: String) {
|
||||
func disableDefaultPhpFpmPool(_ version: String) async {
|
||||
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
if FileManager.default.fileExists(atPath: pool) {
|
||||
if FileSystem.fileExists(pool) {
|
||||
Log.info("A default `www.conf` file was found in the php-fpm.d directory for PHP \(version).")
|
||||
let existing = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf")!
|
||||
let new = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon")!
|
||||
let existing = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
let new = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon"
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: new.path) {
|
||||
if FileSystem.fileExists(new) {
|
||||
Log.info("A moved `www.conf.disabled-by-phpmon` file was found for PHP \(version), "
|
||||
+ "cleaning up so the newer `www.conf` can be moved again.")
|
||||
try FileManager.default.removeItem(at: new)
|
||||
try FileSystem.remove(new)
|
||||
}
|
||||
try FileManager.default.moveItem(at: existing, to: new)
|
||||
try FileSystem.move(from: existing, to: new)
|
||||
Log.info("Success: A default `www.conf` file was disabled for PHP \(version).")
|
||||
} catch {
|
||||
Log.err(error)
|
||||
@@ -94,28 +95,28 @@ class InternalSwitcher: PhpSwitcher {
|
||||
}
|
||||
}
|
||||
|
||||
func stopPhpVersion(_ version: String) {
|
||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
||||
brew("unlink \(formula)")
|
||||
brew("services stop \(formula)", sudo: true)
|
||||
func stopPhpVersion(_ version: String) async {
|
||||
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||
await brew("unlink \(formula)")
|
||||
await brew("services stop \(formula)", sudo: true)
|
||||
Log.info("Unlinked and stopped services for \(formula)")
|
||||
}
|
||||
|
||||
func startPhpVersion(_ version: String, primary: Bool) {
|
||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
||||
func startPhpVersion(_ version: String, primary: Bool) async {
|
||||
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||
|
||||
if primary {
|
||||
Log.info("\(formula) is the primary formula, linking and starting services...")
|
||||
brew("link \(formula) --overwrite --force")
|
||||
await brew("link \(formula) --overwrite --force")
|
||||
} else {
|
||||
Log.info("\(formula) is an isolated PHP version, starting services only...")
|
||||
}
|
||||
|
||||
brew("services start \(formula)", sudo: true)
|
||||
await brew("services start \(formula)", sudo: true)
|
||||
|
||||
if Valet.enabled(feature: .isolatedSites) && primary {
|
||||
let socketVersion = version.replacingOccurrences(of: ".", with: "")
|
||||
Shell.run("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
await Shell.quiet("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 24/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -18,6 +18,6 @@ protocol PhpSwitcherDelegate: AnyObject {
|
||||
|
||||
protocol PhpSwitcher {
|
||||
|
||||
func performSwitch(to version: String, completion: @escaping () -> Void)
|
||||
func performSwitch(to version: String) async
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 15/05/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
25
phpmon/Common/Shell/ActiveShell.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Shell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 20/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var Shell: ShellProtocol {
|
||||
return ActiveShell.shared
|
||||
}
|
||||
|
||||
class ActiveShell {
|
||||
static var shared: ShellProtocol = RealShell()
|
||||
|
||||
public static func useTestable(_ expectations: [String: BatchFakeShellOutput]) {
|
||||
Self.shared = TestableShell(expectations: expectations)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealShell()
|
||||
}
|
||||
}
|
||||
164
phpmon/Common/Shell/RealShell.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// RealShell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Process: @unchecked Sendable {}
|
||||
extension Timer: @unchecked Sendable {}
|
||||
|
||||
class RealShell: ShellProtocol {
|
||||
/**
|
||||
The launch path of the terminal in question that is used.
|
||||
On macOS, we use /bin/sh since it's pretty fast.
|
||||
*/
|
||||
private(set) var launchPath: String = "/bin/sh"
|
||||
|
||||
/**
|
||||
For some commands, we need to know what's in the user's PATH.
|
||||
The entire PATH is retrieved here, so we can set the PATH in our own terminal as necessary.
|
||||
*/
|
||||
private(set) var PATH: String = { return RealShell.getPath() }()
|
||||
|
||||
/**
|
||||
Exports are additional environment variables set by the user via the custom configuration.
|
||||
These are populated when the configuration file is being loaded.
|
||||
*/
|
||||
var exports: String = ""
|
||||
|
||||
/** Retrieves the user's PATH by opening an interactive shell and echoing $PATH. */
|
||||
private static func getPath() -> String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/zsh"
|
||||
|
||||
// We need an interactive shell so the user's PATH is loaded in correctly
|
||||
task.arguments = ["--login", "-ilc", "echo $PATH"]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
return String(
|
||||
data: pipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: String.Encoding.utf8
|
||||
) ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
Create a process that will run the required shell with the appropriate arguments.
|
||||
This process still needs to be started, or one can attach output handlers.
|
||||
*/
|
||||
private func getShellProcess(for command: String) -> Process {
|
||||
var completeCommand = ""
|
||||
|
||||
// Basic export (PATH)
|
||||
completeCommand += "export PATH=\(Paths.binPath):$PATH && "
|
||||
|
||||
// Put additional exports (as defined by the user) in between
|
||||
if !self.exports.isEmpty {
|
||||
completeCommand += "\(self.exports) && "
|
||||
}
|
||||
|
||||
completeCommand += command
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = self.launchPath
|
||||
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
|
||||
return task
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/**
|
||||
Set custom environment variables.
|
||||
These will be exported when a command is executed.
|
||||
*/
|
||||
public func setCustomEnvironmentVariables(_ variables: [String: String]) {
|
||||
self.exports = variables.map { (key, value) in
|
||||
return "export \(key)=\(value)"
|
||||
}.joined(separator: "&&")
|
||||
}
|
||||
|
||||
// MARK: - Shellable Protocol
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
)!
|
||||
|
||||
return .out(stdOut, stdErr)
|
||||
}
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = await self.pipe(command)
|
||||
}
|
||||
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval = 5.0
|
||||
) async throws -> (Process, ShellOutput) {
|
||||
let process = getShellProcess(for: command)
|
||||
|
||||
let output = ShellOutput.empty()
|
||||
|
||||
process.listen { incoming in
|
||||
output.out += incoming; didReceiveOutput(incoming, .stdOut)
|
||||
} didReceiveStandardErrorData: { incoming in
|
||||
output.err += incoming; didReceiveOutput(incoming, .stdErr)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in
|
||||
// Only terminate if the process is still running
|
||||
if process.isRunning {
|
||||
process.terminationHandler = nil
|
||||
process.terminate()
|
||||
return continuation.resume(throwing: ShellError.timedOut)
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { [timer, output] process in
|
||||
timer.invalidate()
|
||||
|
||||
process.haltListening()
|
||||
|
||||
if !output.err.isEmpty {
|
||||
return continuation.resume(returning: (process, .err(output.err)))
|
||||
}
|
||||
|
||||
return continuation.resume(returning: (process, .out(output.out)))
|
||||
}
|
||||
|
||||
process.launch()
|
||||
process.waitUntilExit()
|
||||
})
|
||||
}
|
||||
}
|
||||
83
phpmon/Common/Shell/ShellProtocol.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// ShellProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ShellProtocol {
|
||||
/**
|
||||
The PATH for the current shell.
|
||||
*/
|
||||
var PATH: String { get }
|
||||
|
||||
/**
|
||||
Run a command asynchronously.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
|
||||
Common usage:
|
||||
```
|
||||
let output = await Shell.pipe("php -v")
|
||||
```
|
||||
*/
|
||||
func pipe(_ command: String) async -> ShellOutput
|
||||
|
||||
/**
|
||||
Run a command asynchronously, without returning the output of the command.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
*/
|
||||
func quiet(_ command: String) async
|
||||
|
||||
/**
|
||||
Runs a command asynchronously, and fires closure with `stdout` or `stderr` data as it comes in.
|
||||
|
||||
You can specify how long this task should run.
|
||||
The process will always be terminated after the specified time interval.
|
||||
(Whether it is complete or not.)
|
||||
|
||||
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism.
|
||||
The end result is still the most relevant output (where error output is preferred if it exists).
|
||||
*/
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> (Process, ShellOutput)
|
||||
}
|
||||
|
||||
enum ShellStream: Codable {
|
||||
case stdOut, stdErr, stdIn
|
||||
}
|
||||
|
||||
class ShellOutput {
|
||||
var out: String
|
||||
var err: String
|
||||
|
||||
var hasError: Bool {
|
||||
return err.lengthOfBytes(using: .utf8) > 0
|
||||
}
|
||||
|
||||
init(out: String, err: String) {
|
||||
self.out = out
|
||||
self.err = err
|
||||
}
|
||||
|
||||
static func empty() -> ShellOutput {
|
||||
return ShellOutput(out: "", err: "")
|
||||
}
|
||||
|
||||
static func out(_ out: String?, _ err: String? = nil) -> ShellOutput {
|
||||
return ShellOutput(out: out ?? "", err: err ?? "")
|
||||
}
|
||||
|
||||
static func err(_ err: String?) -> ShellOutput {
|
||||
return ShellOutput(out: "", err: err ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
enum ShellError: Error {
|
||||
case timedOut
|
||||
}
|
||||
27
phpmon/Common/Testables/TestableCommand.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// TestableCommand.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TestableCommand: CommandProtocol {
|
||||
init(commands: [String: String]) {
|
||||
self.commands = commands
|
||||
}
|
||||
|
||||
var commands: [String: String]
|
||||
|
||||
func execute(path: String, arguments: [String]) -> String {
|
||||
self.execute(path: path, arguments: arguments, trimNewlines: false)
|
||||
}
|
||||
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool) -> String {
|
||||
let concatenatedCommand = "\(path) \(arguments.joined(separator: " "))"
|
||||
assert(commands.keys.contains(concatenatedCommand), "Command `\(concatenatedCommand)` not found")
|
||||
return self.commands[concatenatedCommand]!
|
||||
}
|
||||
}
|
||||
67
phpmon/Common/Testables/TestableConfiguration.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// TestableConfiguration.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TestableConfiguration: Codable {
|
||||
var architecture: String
|
||||
var filesystem: [String: FakeFile]
|
||||
var shellOutput: [String: BatchFakeShellOutput]
|
||||
var commandOutput: [String: String]
|
||||
|
||||
func apply() {
|
||||
Log.separator()
|
||||
Log.info("USING TESTABLE CONFIGURATION...")
|
||||
Homebrew.fake = true
|
||||
Log.separator()
|
||||
Log.info("Applying fake shell...")
|
||||
ActiveShell.useTestable(shellOutput)
|
||||
Log.info("Applying fake filesystem...")
|
||||
ActiveFileSystem.useTestable(filesystem)
|
||||
Log.info("Applying fake commands...")
|
||||
ActiveCommand.useTestable(commandOutput)
|
||||
Log.info("Applying fake scanner...")
|
||||
ValetScanner.useFake()
|
||||
Log.info("Applying fake services manager...")
|
||||
ServicesManager.useFake()
|
||||
Log.info("Applying fake Valet domain interactor...")
|
||||
ValetInteractor.useFake()
|
||||
}
|
||||
|
||||
func toJson(pretty: Bool = false) -> String {
|
||||
let data = try! JSONEncoder().encode(self)
|
||||
|
||||
if pretty {
|
||||
return data.prettyPrintedJSONString! as String
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)!
|
||||
}
|
||||
|
||||
static func loadFrom(path: String) -> TestableConfiguration {
|
||||
let url = URL(fileURLWithPath: path.replacingTildeWithHomeDirectory)
|
||||
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
/*
|
||||
You will need to run the `TestableConfigurationTest` test,
|
||||
which will generate two configuration files you can use.
|
||||
*/
|
||||
fatalError("Error: the expected configuration file at \(url.path) is missing!")
|
||||
}
|
||||
|
||||
/*
|
||||
If the decoder below fails to decode the configuration file,
|
||||
the configuration may have been updated.
|
||||
In that case, you will need to run the test (see above) again.
|
||||
*/
|
||||
return try! JSONDecoder().decode(
|
||||
TestableConfiguration.self,
|
||||
from: try! String(contentsOf: url, encoding: .utf8).data(using: .utf8)!
|
||||
)
|
||||
}
|
||||
}
|
||||
288
phpmon/Common/Testables/TestableFileSystem.swift
Normal file
@@ -0,0 +1,288 @@
|
||||
//
|
||||
// TestableFileSystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 04/10/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TestableFileSystem: FileSystemProtocol {
|
||||
|
||||
/**
|
||||
Initialize a fake filesystem with a bunch of files.
|
||||
You do not need to specify directories (unless symlinks), those will be created automatically.
|
||||
*/
|
||||
init(files: [String: FakeFile]) {
|
||||
self.files = files
|
||||
|
||||
// Ensure that each of the ~ characters are replaced with the home directory path
|
||||
for key in self.files.keys where key.contains("~") {
|
||||
self.files.renameKey(
|
||||
fromKey: key,
|
||||
toKey: key.replacingOccurrences(of: "~", with: self.homeDirectory)
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure that intermediate directories are created
|
||||
for file in self.files {
|
||||
self.createIntermediateDirectories(file.key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Internal file handling of the fake filesystem.
|
||||
You can easily dump what's in here by using:
|
||||
```
|
||||
let fs = FileSystem as! TestableFileSystem
|
||||
fs.printContents()
|
||||
```
|
||||
*/
|
||||
private(set) var files: [String: FakeFile]
|
||||
|
||||
/**
|
||||
The home directory for the fake filesystem.
|
||||
*/
|
||||
private(set) var homeDirectory = "/Users/fake"
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
if files[path] != nil {
|
||||
throw TestableFileSystemError.alreadyExists
|
||||
}
|
||||
|
||||
self.createIntermediateDirectories(path)
|
||||
|
||||
self.files[path] = .fake(.directory)
|
||||
}
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
if files[path] != nil {
|
||||
throw TestableFileSystemError.alreadyExists
|
||||
}
|
||||
|
||||
self.files[path] = .fake(.text, content)
|
||||
}
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
return file.content ?? ""
|
||||
}
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String] {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
var seek = path
|
||||
if !seek.hasSuffix("/") {
|
||||
seek = "\(seek)/"
|
||||
}
|
||||
|
||||
return self.files.keys
|
||||
.filter { $0.hasPrefix(seek) }
|
||||
.map { $0.replacingOccurrences(of: seek, with: "") }
|
||||
.filter { !$0.contains("/") }
|
||||
}
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
if file.type != .symlink {
|
||||
throw TestableFileSystemError.notSymlink
|
||||
}
|
||||
|
||||
guard let pathToSymlink = file.content else {
|
||||
throw TestableFileSystemError.invalidSymlink
|
||||
}
|
||||
|
||||
if !files.keys.contains(pathToSymlink) {
|
||||
throw TestableFileSystemError.invalidSymlink
|
||||
}
|
||||
|
||||
return pathToSymlink
|
||||
}
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
let newPath = newPath.replacingTildeWithHomeDirectory
|
||||
|
||||
self.files.keys.forEach { key in
|
||||
if key.hasPrefix(path) {
|
||||
self.files.renameKey(
|
||||
fromKey: key,
|
||||
toKey: key.replacingOccurrences(of: path, with: newPath)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
self.files.renameKey(fromKey: path, toKey: newPath)
|
||||
}
|
||||
|
||||
func remove(_ path: String) throws {
|
||||
// Remove recursively
|
||||
self.files.keys.forEach { key in
|
||||
if key.hasPrefix(path) {
|
||||
self.files.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
self.files.removeValue(forKey: path)
|
||||
}
|
||||
|
||||
// MARK: — Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
file.type = .binary
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path.replacingTildeWithHomeDirectory] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .binary
|
||||
}
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path.replacingTildeWithHomeDirectory] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !file.readOnly
|
||||
}
|
||||
|
||||
func anyExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
return files.keys.contains(path)
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return [.binary, .symlink, .text].contains(file.type)
|
||||
}
|
||||
|
||||
func directoryExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return [.directory].contains(file.type)
|
||||
}
|
||||
|
||||
func isSymlink(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .symlink
|
||||
}
|
||||
|
||||
func isDirectory(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .directory
|
||||
}
|
||||
|
||||
public func printContents() {
|
||||
for key in self.files.keys.sorted() {
|
||||
print("\(key) -> \(self.files[key]!.type)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createIntermediateDirectories(_ path: String) {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
let items = path.components(separatedBy: "/")
|
||||
|
||||
var preceding = ""
|
||||
|
||||
for item in items {
|
||||
let key = preceding == "/"
|
||||
? "/\(item)"
|
||||
: "\(preceding)/\(item)"
|
||||
|
||||
if !self.files.keys.contains(key) {
|
||||
self.files[key] = .fake(.directory)
|
||||
}
|
||||
|
||||
preceding = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FakeFileType: Codable {
|
||||
case binary, text, directory, symlink
|
||||
}
|
||||
|
||||
class FakeFile: Codable {
|
||||
var type: FakeFileType
|
||||
var content: String?
|
||||
var readOnly: Bool = false
|
||||
|
||||
init(type: FakeFileType, content: String?, readOnly: Bool = false) {
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.readOnly = readOnly
|
||||
}
|
||||
|
||||
public static func fake(
|
||||
_ type: FakeFileType,
|
||||
_ content: String? = nil,
|
||||
readOnly: Bool = false
|
||||
) -> FakeFile {
|
||||
return FakeFile(
|
||||
type: type,
|
||||
content: content,
|
||||
readOnly: readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum TestableFileSystemError: Error {
|
||||
case fileMissing
|
||||
case alreadyExists
|
||||
case notSymlink
|
||||
case invalidSymlink
|
||||
}
|
||||
123
phpmon/Common/Testables/TestableShell.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// TestableShell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class TestableShell: ShellProtocol {
|
||||
var PATH: String {
|
||||
return "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin"
|
||||
}
|
||||
|
||||
init(expectations: [String: BatchFakeShellOutput]) {
|
||||
self.expectations = expectations
|
||||
}
|
||||
|
||||
var expectations: [String: BatchFakeShellOutput] = [:]
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
}
|
||||
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
return output
|
||||
}
|
||||
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> (Process, ShellOutput) {
|
||||
|
||||
// Seriously slow down the shell's return rate 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)
|
||||
}
|
||||
|
||||
// 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 (Process(), .err("No Expected Output"))
|
||||
}
|
||||
|
||||
let output = await expectation.output(didReceiveOutput: { output, type in
|
||||
didReceiveOutput(output, type)
|
||||
}, ignoreDelay: isRunningTests)
|
||||
|
||||
return (Process(), output)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeShellOutput: Codable {
|
||||
let delay: TimeInterval
|
||||
let output: String
|
||||
let stream: ShellStream
|
||||
|
||||
static func instant(_ output: String, _ stream: ShellStream = .stdOut) -> FakeShellOutput {
|
||||
return FakeShellOutput(delay: 0, output: output, stream: stream)
|
||||
}
|
||||
|
||||
static func delayed(_ delay: TimeInterval, _ output: String, _ stream: ShellStream = .stdOut) -> FakeShellOutput {
|
||||
return FakeShellOutput(delay: delay, output: output, stream: stream)
|
||||
}
|
||||
}
|
||||
|
||||
struct BatchFakeShellOutput: Codable {
|
||||
var items: [FakeShellOutput]
|
||||
|
||||
static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: items)
|
||||
}
|
||||
|
||||
static func instant(_ output: String, _ stream: ShellStream = .stdOut) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: [.instant(output, stream)])
|
||||
}
|
||||
|
||||
static func delayed(
|
||||
_ delay: TimeInterval,
|
||||
_ output: String,
|
||||
_ stream: ShellStream = .stdOut
|
||||
) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: [.delayed(delay, output, stream)])
|
||||
}
|
||||
|
||||
/**
|
||||
Outputs the fake shell output as expected.
|
||||
*/
|
||||
public func output(
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
ignoreDelay: Bool = false
|
||||
) async -> ShellOutput {
|
||||
let output = ShellOutput.empty()
|
||||
|
||||
for item in items {
|
||||
if !ignoreDelay {
|
||||
await delay(seconds: 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.
|
||||
*/
|
||||
public func outputInstantaneously(
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void = { _, _ in }
|
||||
) async -> ShellOutput {
|
||||
return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
<p><b>Do you enjoy using the app?</b> Leave a <a href="https://phpmon.app/github">star on GitHub</a>!</p>
|
||||
<p><b>Having issues?</b> Consult the <a href="https://phpmon.app/faq">FAQ</a> section, I did my best to ensure everything is documented.</p>
|
||||
<p><b>Want to support further development of PHP Monitor?</b> You can <a href="https://phpmon.app/sponsor">financially support</a> the continued development of this app.</p>
|
||||
<p><b>Get the latest on Twitter</b> Give me a <a href="https://twitter.com/nicoverbruggen">follow on Twitter</a> to learn about the latest and greatest updates of this app.</p>
|
||||
<p><b>Get the latest on Mastodon.</b> Give me a <a href="https://phpc.social/@nicoverbruggen">follow on Mastodon</a> to learn about what's brewing and when new updates drop.</p>
|
||||
<br>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 05/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 05/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// StateManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -31,10 +31,18 @@ class App {
|
||||
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
|
||||
}
|
||||
|
||||
/** Just the bundle name. */
|
||||
static var identifier: String {
|
||||
Bundle.main.infoDictionary?["CFBundleIdentifier"] as! String
|
||||
}
|
||||
|
||||
/** The system architecture. Paths differ based on this value. */
|
||||
static var architecture: String {
|
||||
if fakeArchitecture != nil { return fakeArchitecture! }
|
||||
|
||||
var systeminfo = utsname()
|
||||
uname(&systeminfo)
|
||||
let machine = withUnsafeBytes(of: &systeminfo.machine) {bufPtr->String in
|
||||
let machine = withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
|
||||
let data = Data(bufPtr)
|
||||
if let lastIndex = data.lastIndex(where: {$0 != 0}) {
|
||||
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
|
||||
@@ -45,8 +53,18 @@ class App {
|
||||
return machine
|
||||
}
|
||||
|
||||
/**
|
||||
A fake architecture.
|
||||
When set, the real machine's system architecture is not used,
|
||||
but this fixed value is used instead.
|
||||
*/
|
||||
static var fakeArchitecture: String?
|
||||
|
||||
// MARK: Variables
|
||||
|
||||
/** Technical information about the current environment. */
|
||||
var environment = EnvironmentManager()
|
||||
|
||||
/** The list of preferences that are currently active. */
|
||||
var preferences: [PreferenceName: Bool]!
|
||||
|
||||
@@ -65,9 +83,6 @@ class App {
|
||||
/** List of detected (installed) applications that PHP Monitor can work with. */
|
||||
var detectedApplications: [Application] = []
|
||||
|
||||
/** The services manager, responsible for figuring out what services are active/inactive. */
|
||||
var services = ServicesManager.shared
|
||||
|
||||
/** The warning manager, responsible for keeping track of warnings. */
|
||||
var warnings = WarningManager.shared
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 20/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -20,8 +20,7 @@ extension AppDelegate {
|
||||
|
||||
Please note that PHP Monitor needs to be running in the background for this to work.
|
||||
*/
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
|
||||
@MainActor func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
|
||||
Log.info("Acting on commands via phpmon:// has been disabled.")
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 05/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -33,15 +33,17 @@ extension AppDelegate {
|
||||
}
|
||||
|
||||
@IBAction func reloadDomainListPressed(_ sender: Any) {
|
||||
let vc = App.shared.domainListWindowController?
|
||||
.window?.contentViewController as? DomainListVC
|
||||
Task { // Reload domains
|
||||
let vc = App.shared.domainListWindowController?
|
||||
.window?.contentViewController as? DomainListVC
|
||||
|
||||
if vc != nil {
|
||||
// If the view exists, directly reload the list of sites
|
||||
vc!.reloadDomains()
|
||||
} else {
|
||||
// If the view does not exist, reload the cached data that was populated when the app initially launched.
|
||||
Valet.shared.reloadSites()
|
||||
if vc != nil {
|
||||
// If the view exists, directly reload the list of sites.
|
||||
await vc!.reloadDomains()
|
||||
} else {
|
||||
// If the view does not exist, reload the cached data that was populated when the app launched.
|
||||
await Valet.shared.reloadSites()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 06/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// AppDelegate.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -13,13 +13,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
/**
|
||||
The Shell singleton that keeps track of the history of all
|
||||
(invoked by PHP Monitor) shell commands. It is used to
|
||||
invoke all commands in this application.
|
||||
*/
|
||||
let sharedShell: Shell
|
||||
|
||||
/**
|
||||
The App singleton contains information about the state of
|
||||
the application and global variables.
|
||||
@@ -64,18 +57,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
*/
|
||||
override init() {
|
||||
logger.verbosity = .info
|
||||
|
||||
#if DEBUG
|
||||
logger.verbosity = .performance
|
||||
logger.verbosity = .performance
|
||||
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
|
||||
Self.initializeTestingProfile(profile.replacingOccurrences(of: "--configuration:", with: ""))
|
||||
}
|
||||
#endif
|
||||
|
||||
if CommandLine.arguments.contains("--v") {
|
||||
logger.verbosity = .performance
|
||||
Log.info("Extra verbose mode has been activated.")
|
||||
}
|
||||
|
||||
Log.separator(as: .info)
|
||||
Log.info("PHP MONITOR by Nico Verbruggen")
|
||||
Log.info("Version \(App.version)")
|
||||
Log.separator(as: .info)
|
||||
self.sharedShell = Shell.user
|
||||
|
||||
self.state = App.shared
|
||||
self.menu = MainMenu.shared
|
||||
self.paths = Paths.shared
|
||||
@@ -87,6 +86,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
self.phpEnvironment = PhpEnv.shared
|
||||
}
|
||||
|
||||
static func initializeTestingProfile(_ path: String) {
|
||||
Log.info("The configuration with path `\(path)` is being requested...")
|
||||
TestableConfiguration.loadFrom(path: path).apply()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/**
|
||||
@@ -97,8 +101,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Make sure notifications will work
|
||||
setupNotifications()
|
||||
// Make sure the menu performs its initial checks
|
||||
Task { await menu.startup() }
|
||||
Task { // Make sure the menu performs its initial checks
|
||||
await paths.loadUser()
|
||||
await menu.startup()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 09/05/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -21,7 +21,7 @@ class AppUpdateChecker {
|
||||
|
||||
public static func retrieveVersionFromCask(
|
||||
_ initiatedFromBackground: Bool = true
|
||||
) -> String {
|
||||
) async -> String {
|
||||
let caskFile = App.version.contains("-dev")
|
||||
? Constants.Urls.DevBuildCaskFile.absoluteString
|
||||
: Constants.Urls.StableBuildCaskFile.absoluteString
|
||||
@@ -32,14 +32,14 @@ class AppUpdateChecker {
|
||||
command = "curl -s --max-time 5"
|
||||
}
|
||||
|
||||
return Shell.pipe(
|
||||
return await Shell.pipe(
|
||||
"\(command) '\(caskFile)' | grep version"
|
||||
)
|
||||
).out
|
||||
}
|
||||
|
||||
public static func checkIfNewerVersionIsAvailable(
|
||||
initiatedFromBackground: Bool = true
|
||||
) {
|
||||
) async {
|
||||
if initiatedFromBackground {
|
||||
if !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
||||
Log.info("Automatic updates are disabled. No check will be performed.")
|
||||
@@ -49,7 +49,7 @@ class AppUpdateChecker {
|
||||
Log.info("Automatic updates are enabled, a check will be performed.")
|
||||
}
|
||||
|
||||
let versionString = retrieveVersionFromCask(initiatedFromBackground)
|
||||
let versionString = await retrieveVersionFromCask(initiatedFromBackground)
|
||||
|
||||
guard let onlineVersion = AppVersion.from(versionString) else {
|
||||
Log.err("We couldn't check for updates!")
|
||||
@@ -119,13 +119,13 @@ class AppUpdateChecker {
|
||||
}
|
||||
|
||||
private static func notifyVersionDoesNotNeedUpgrade() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.is_latest_version.title".localized,
|
||||
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
|
||||
description: ""
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ class AppUpdateChecker {
|
||||
let devSuffix = isDev ? "-dev" : ""
|
||||
let command = isDev ? "brew upgrade phpmon-dev" : "brew upgrade phpmon"
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.newer_version_available.title".localized(version.humanReadable),
|
||||
subtitle: "updater.alerts.newer_version_available.subtitle".localized,
|
||||
@@ -160,7 +160,7 @@ class AppUpdateChecker {
|
||||
}
|
||||
|
||||
private static func notifyAboutConnectionIssue() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.cannot_check_for_update.title".localized,
|
||||
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
|
||||
@@ -174,7 +174,7 @@ class AppUpdateChecker {
|
||||
NSWorkspace.shared.open(Constants.Urls.GitHubReleases)
|
||||
}
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 10/05/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 10/08/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
36
phpmon/Domain/App/EnvironmentManager.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// EnvironmentManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 14/09/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class EnvironmentManager {
|
||||
var values: [EnvironmentProperty: Bool] = [:]
|
||||
|
||||
public func process() async {
|
||||
self.values[.hasValetInstalled] = await !{
|
||||
let output = await Shell.pipe("valet --version").out
|
||||
|
||||
// Failure condition #1: does not contain Laravel Valet
|
||||
if !output.contains("Laravel Valet") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Extract the version number
|
||||
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
|
||||
|
||||
// Get the actual version
|
||||
return Valet.shared.version == nil
|
||||
|
||||
}() // returns true if none of the failure conditions are met
|
||||
}
|
||||
}
|
||||
|
||||
public enum EnvironmentProperty {
|
||||
case hasHomebrewInstalled
|
||||
case hasValetInstalled
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 28/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -21,51 +21,39 @@ class InterApp {
|
||||
let action: (String) -> Void
|
||||
}
|
||||
|
||||
static func getCommands() -> [InterApp.Action] { return [
|
||||
@MainActor static func getCommands() -> [InterApp.Action] { return [
|
||||
InterApp.Action(command: "list", action: { _ in
|
||||
DomainListVC.show()
|
||||
}),
|
||||
InterApp.Action(command: "services/stop", action: { _ in
|
||||
MainMenu.shared.stopValetServices()
|
||||
Task { MainMenu.shared.stopValetServices() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/all", action: { _ in
|
||||
MainMenu.shared.restartValetServices()
|
||||
Task { MainMenu.shared.restartValetServices() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/nginx", action: { _ in
|
||||
MainMenu.shared.restartNginx()
|
||||
Task { MainMenu.shared.restartNginx() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/php", action: { _ in
|
||||
MainMenu.shared.restartPhpFpm()
|
||||
Task { MainMenu.shared.restartPhpFpm() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/dnsmasq", action: { _ in
|
||||
MainMenu.shared.restartDnsMasq()
|
||||
Task { MainMenu.shared.restartDnsMasq() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/config", action: { _ in
|
||||
MainMenu.shared.openActiveConfigFolder()
|
||||
Task { MainMenu.shared.openActiveConfigFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/composer", action: { _ in
|
||||
MainMenu.shared.openGlobalComposerFolder()
|
||||
Task { MainMenu.shared.openGlobalComposerFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/valet", action: { _ in
|
||||
MainMenu.shared.openValetConfigFolder()
|
||||
Task { MainMenu.shared.openValetConfigFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "phpinfo", action: { _ in
|
||||
MainMenu.shared.openPhpInfo()
|
||||
Task { MainMenu.shared.openPhpInfo() }
|
||||
}),
|
||||
InterApp.Action(command: "switch/php/", action: { version in
|
||||
if PhpEnv.shared.availablePhpVersions.contains(version) {
|
||||
MainMenu.shared.switchToPhpVersion(version)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
BetterAlert().withInformation(
|
||||
title: "alert.php_switch_unavailable.title".localized,
|
||||
subtitle: "alert.php_switch_unavailable.subtitle".localized(version)
|
||||
).withPrimary(
|
||||
text: "alert.php_switch_unavailable.ok".localized
|
||||
).show()
|
||||
}
|
||||
}
|
||||
Task { MainMenu.shared.switchToAnyPhpVersion(version) }
|
||||
})
|
||||
]}
|
||||
|
||||
}
|
||||
|
||||
94
phpmon/Domain/App/Services/FakeServicesManager.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// FakeServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FakeServicesManager: ServicesManager {
|
||||
var fixedFormulae: [String] = []
|
||||
var fixedStatus: Service.Status = .active
|
||||
|
||||
override init() {}
|
||||
|
||||
init(
|
||||
formulae: [String] = ["php", "nginx", "dnsmasq"],
|
||||
status: Service.Status = .active,
|
||||
loading: Bool = false
|
||||
) {
|
||||
super.init()
|
||||
|
||||
Log.warn("A fake services manager is being used, so Homebrew formula resolver is set to act in fake mode.")
|
||||
Log.warn("If you do not want this behaviour, do not make use of a `FakeServicesManager`!")
|
||||
|
||||
self.fixedFormulae = formulae
|
||||
self.fixedStatus = status
|
||||
|
||||
self.services = []
|
||||
self.reapplyServices()
|
||||
|
||||
if loading {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.firstRunComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
private func reapplyServices() {
|
||||
let services = self.formulae.map {
|
||||
let dummy = HomebrewService.dummy(
|
||||
named: $0.name,
|
||||
enabled: self.fixedStatus == .active,
|
||||
status: self.fixedStatus == .error ? "error" : nil
|
||||
)
|
||||
let wrapper = Service(
|
||||
formula: $0,
|
||||
service: dummy
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.services = services
|
||||
}
|
||||
}
|
||||
|
||||
override var formulae: [HomebrewFormula] {
|
||||
return fixedFormulae.map { formula in
|
||||
return HomebrewFormula.init(formula, elevated: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadServicesStatus() async {
|
||||
await delay(seconds: 0.3)
|
||||
|
||||
self.reapplyServices()
|
||||
}
|
||||
|
||||
override func toggleService(named: String) async {
|
||||
await delay(seconds: 0.3)
|
||||
|
||||
let services = services.map({ service in
|
||||
let newServiceEnabled = service.name == named
|
||||
? service.status != .active // inverse (i.e. if active -> becomes inactive)
|
||||
: service.status == .active // service remains unmodified if it's not the named one we change
|
||||
|
||||
return Service(
|
||||
formula: service.formula,
|
||||
service: HomebrewService.dummy(
|
||||
named: service.name,
|
||||
enabled: newServiceEnabled
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
Task { @MainActor in
|
||||
self.services = services
|
||||
}
|
||||
}
|
||||
}
|
||||
55
phpmon/Domain/App/Services/Service.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// ServiceWrapper.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/** Service linked to a Homebrew formula and whether it is currently (in)active or missing. */
|
||||
public struct Service: Hashable {
|
||||
var formula: HomebrewFormula
|
||||
var status: Status = .missing
|
||||
|
||||
public var name: String {
|
||||
return formula.name
|
||||
}
|
||||
|
||||
init(formula: HomebrewFormula, service: HomebrewService? = nil) {
|
||||
self.formula = formula
|
||||
|
||||
guard let service else { return }
|
||||
|
||||
self.status = service.running ? .active : .inactive
|
||||
|
||||
if service.status == "error" {
|
||||
self.status = .error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocols
|
||||
|
||||
public static func == (lhs: Service, rhs: Service) -> Bool {
|
||||
return lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(formula)
|
||||
hasher.combine(status)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
public enum Status: String {
|
||||
case active
|
||||
case inactive
|
||||
case error
|
||||
case missing
|
||||
|
||||
var asBool: Bool {
|
||||
return self == .active
|
||||
}
|
||||
}
|
||||
}
|
||||
131
phpmon/Domain/App/Services/ServicesManager.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// ServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/06/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ServicesManager: ObservableObject {
|
||||
|
||||
@ObservedObject static var shared: ServicesManager = ValetServicesManager()
|
||||
|
||||
@Published var services = [Service]()
|
||||
|
||||
@Published var firstRunComplete: Bool = false
|
||||
|
||||
public static func useFake() {
|
||||
ServicesManager.shared = FakeServicesManager.init(
|
||||
formulae: ["php", "nginx", "dnsmasq", "mysql"],
|
||||
status: .active
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
The order of services is important, so easy access is accomplished
|
||||
without much fanfare through subscripting.
|
||||
*/
|
||||
subscript(name: String) -> Service? {
|
||||
return self.services.first { wrapper in
|
||||
wrapper.name == name
|
||||
}
|
||||
}
|
||||
|
||||
public var hasError: Bool {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return false
|
||||
}
|
||||
|
||||
return self.services[0...2]
|
||||
.map { $0.status }
|
||||
.contains(.error)
|
||||
}
|
||||
|
||||
public var statusMessage: String {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
let statuses = self.services[0...2].map { $0.status }
|
||||
|
||||
if statuses.contains(.missing) {
|
||||
return "A key service is not installed."
|
||||
}
|
||||
if statuses.contains(.error) {
|
||||
return "A key service is reporting an error state."
|
||||
}
|
||||
if statuses.contains(.inactive) {
|
||||
return "A key service is not running."
|
||||
}
|
||||
|
||||
return "All Valet services are OK."
|
||||
}
|
||||
|
||||
public var statusColor: Color {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return Color("StatusColorYellow")
|
||||
}
|
||||
|
||||
let statuses = self.services[0...2].map { $0.status }
|
||||
|
||||
if statuses.contains(.missing)
|
||||
|| statuses.contains(.inactive)
|
||||
|| statuses.contains(.error) {
|
||||
return Color("StatusColorRed")
|
||||
}
|
||||
|
||||
return Color("StatusColorGreen")
|
||||
}
|
||||
|
||||
/**
|
||||
This method is called when the system configuration has changed
|
||||
and all the status of one or more services may need to be determined.
|
||||
*/
|
||||
public func reloadServicesStatus() async {
|
||||
fatalError("This method `\(#function)` has not been implemented")
|
||||
}
|
||||
|
||||
/**
|
||||
This method is called when a service needs to be toggled (on/off).
|
||||
*/
|
||||
public func toggleService(named: String) async {
|
||||
fatalError("This method `\(#function)` has not been implemented")
|
||||
}
|
||||
|
||||
/**
|
||||
This method will notify all publishers that subscribe to notifiable objects.
|
||||
The notified objects include this very ServicesManager as well as any individual service instances.
|
||||
*/
|
||||
public func broadcastServicesUpdated() {
|
||||
Task { @MainActor in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
var formulae: [HomebrewFormula] {
|
||||
var formulae = [
|
||||
Homebrew.Formulae.php,
|
||||
Homebrew.Formulae.nginx,
|
||||
Homebrew.Formulae.dnsmasq
|
||||
]
|
||||
|
||||
let additionalFormulae = (Preferences.custom.services ?? []).map({ item in
|
||||
return HomebrewFormula(item, elevated: false)
|
||||
})
|
||||
|
||||
formulae.append(contentsOf: additionalFormulae)
|
||||
|
||||
return formulae
|
||||
}
|
||||
|
||||
init() {
|
||||
Log.info("The services manager will determine which Valet services exist on this system.")
|
||||
|
||||
services = formulae.map {
|
||||
Service(formula: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
157
phpmon/Domain/App/Services/ValetServicesManager.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// ValetServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class ValetServicesManager: ServicesManager {
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
// Load the initial services state
|
||||
Task {
|
||||
await self.reloadServicesStatus()
|
||||
|
||||
Task { @MainActor in
|
||||
firstRunComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
The last known state of all Homebrew services.
|
||||
*/
|
||||
var homebrewServices: [HomebrewService] = []
|
||||
|
||||
/**
|
||||
This method allows us to reload the Homebrew services, but we run this command
|
||||
twice (once for user services, and once for root services). Please note that
|
||||
these two commands are executed concurrently.
|
||||
*/
|
||||
override func reloadServicesStatus() async {
|
||||
await withTaskGroup(of: [HomebrewService].self, body: { group in
|
||||
// First, retrieve the status of the formulae that run as root
|
||||
group.addTask {
|
||||
let rootServiceNames = self.formulae
|
||||
.filter { $0.elevated }
|
||||
.map { $0.name }
|
||||
|
||||
let rootJson = await Shell
|
||||
.pipe("sudo \(Paths.brew) services info --all --json")
|
||||
.out.data(using: .utf8)!
|
||||
|
||||
return try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: rootJson)
|
||||
.filter({ return rootServiceNames.contains($0.name) })
|
||||
}
|
||||
|
||||
// At the same time, retrieve the status of the formulae that run as user
|
||||
group.addTask {
|
||||
let userServiceNames = self.formulae
|
||||
.filter { !$0.elevated }
|
||||
.map { $0.name }
|
||||
|
||||
let normalJson = await Shell
|
||||
.pipe("\(Paths.brew) services info --all --json")
|
||||
.out.data(using: .utf8)!
|
||||
|
||||
return try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: normalJson)
|
||||
.filter({ return userServiceNames.contains($0.name) })
|
||||
}
|
||||
|
||||
// Ensure that Homebrew services' output is stored
|
||||
self.homebrewServices = []
|
||||
for await services in group {
|
||||
homebrewServices.append(contentsOf: services)
|
||||
}
|
||||
|
||||
// Dispatch the update of the new service wrappers
|
||||
Task { @MainActor in
|
||||
// Ensure both commands complete (but run concurrently)
|
||||
services = formulae.map { formula in
|
||||
Service(
|
||||
formula: formula,
|
||||
service: homebrewServices.first(where: { service in
|
||||
service.name == formula.name
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Broadcast that all services have been updated
|
||||
self.broadcastServicesUpdated()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func toggleService(named: String) async {
|
||||
guard let wrapper = self[named] else {
|
||||
return Log.err("The wrapper for '\(named)' is missing.")
|
||||
}
|
||||
|
||||
// Normally, we allow starting and stopping
|
||||
var action = wrapper.status == .active ? "stop" : "start"
|
||||
|
||||
// However, if we've encountered an error, attempt to restart
|
||||
if wrapper.status == .error {
|
||||
action = "restart"
|
||||
}
|
||||
|
||||
// Run the command
|
||||
await brew(
|
||||
"services \(action) \(wrapper.formula.name)",
|
||||
sudo: wrapper.formula.elevated
|
||||
)
|
||||
|
||||
// Reload the services status to confirm this worked
|
||||
await ServicesManager.shared.reloadServicesStatus()
|
||||
|
||||
Task {
|
||||
await presentTroubleshootingForService(named: named)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func presentTroubleshootingForService(named: String) {
|
||||
let after = self.homebrewServices.first { service in
|
||||
return service.name == named
|
||||
}
|
||||
|
||||
guard let after else { return }
|
||||
|
||||
if after.status == "error" {
|
||||
Log.err("The service '\(named)' is now reporting an error.")
|
||||
|
||||
guard let errorLogPath = after.error_log_path else {
|
||||
return BetterAlert().withInformation(
|
||||
title: "alert.service_error.title".localized(named),
|
||||
subtitle: "alert.service_error.subtitle.no_error_log".localized(named),
|
||||
description: "alert.service_error.extra".localized
|
||||
)
|
||||
.withPrimary(text: "alert.service_error.button.close".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
BetterAlert().withInformation(
|
||||
title: "alert.service_error.title".localized(named),
|
||||
subtitle: "alert.service_error.subtitle.error_log".localized(named),
|
||||
description: "alert.service_error.extra".localized
|
||||
)
|
||||
.withPrimary(text: "alert.service_error.button.close".localized)
|
||||
.withTertiary(text: "alert.service_error.button.show_log".localized, action: { alert in
|
||||
let url = URL(fileURLWithPath: errorLogPath)
|
||||
if errorLogPath.hasSuffix(".log") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
alert.close(with: .OK)
|
||||
})
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Environment.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -29,7 +29,7 @@ class Startup {
|
||||
|
||||
// If we get here, something's gone wrong and the check has failed...
|
||||
Log.info("[FAIL] \(check.name)")
|
||||
showAlert(for: check)
|
||||
await showAlert(for: check)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -45,29 +45,27 @@ class Startup {
|
||||
- ones that require an app restart, which prompt the user to exit the app
|
||||
- ones that allow the app to continue, which allow the user to retry
|
||||
*/
|
||||
private func showAlert(for check: EnvironmentCheck) {
|
||||
DispatchQueue.main.async {
|
||||
if check.requiresAppRestart {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: check.buttonText, action: { _ in
|
||||
exit(1)
|
||||
}).show()
|
||||
}
|
||||
|
||||
@MainActor private func showAlert(for check: EnvironmentCheck) {
|
||||
if check.requiresAppRestart {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.show()
|
||||
.withPrimary(text: check.buttonText, action: { _ in
|
||||
exit(1)
|
||||
}).show()
|
||||
}
|
||||
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +73,7 @@ class Startup {
|
||||
initialized when it is done working. The switcher must be initialized on the main thread.
|
||||
*/
|
||||
private func initializeSwitcher() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
let appDelegate = NSApplication.shared.delegate as! AppDelegate
|
||||
appDelegate.initializeSwitcher()
|
||||
}
|
||||
@@ -88,7 +86,7 @@ class Startup {
|
||||
// The Homebrew binary must exist.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !FileManager.default.fileExists(atPath: Paths.brew) },
|
||||
command: { return !FileSystem.fileExists(Paths.brew) },
|
||||
name: "`\(Paths.brew)` exists",
|
||||
titleText: "alert.homebrew_missing.title".localized,
|
||||
subtitleText: "alert.homebrew_missing.subtitle".localized,
|
||||
@@ -105,7 +103,7 @@ class Startup {
|
||||
// The PHP binary must exist.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Filesystem.fileExists(Paths.php) },
|
||||
command: { return !FileSystem.fileExists(Paths.php) },
|
||||
name: "`\(Paths.php)` exists",
|
||||
titleText: "startup.errors.php_binary.title".localized,
|
||||
subtitleText: "startup.errors.php_binary.subtitle".localized,
|
||||
@@ -115,7 +113,9 @@ class Startup {
|
||||
// Make sure we can detect one or more PHP installations.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("ls \(Paths.optPath) | grep php").contains("php") },
|
||||
command: {
|
||||
return await !Shell.pipe("ls \(Paths.optPath) | grep php").out.contains("php")
|
||||
},
|
||||
name: "`ls \(Paths.optPath) | grep php` returned php result",
|
||||
titleText: "startup.errors.php_opt.title".localized,
|
||||
subtitleText: "startup.errors.php_opt.subtitle".localized(
|
||||
@@ -128,7 +128,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return !(Filesystem.fileExists(Paths.valet) || Filesystem.fileExists("~/.composer/vendor/bin/valet"))
|
||||
return !(FileSystem.fileExists(Paths.valet) || FileSystem.fileExists("~/.composer/vendor/bin/valet"))
|
||||
},
|
||||
name: "`valet` binary exists",
|
||||
titleText: "startup.errors.valet_executable.title".localized,
|
||||
@@ -143,14 +143,14 @@ class Startup {
|
||||
// functioning correctly. Let the user know that they need to run `valet trust`.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("cat /private/etc/sudoers.d/brew").contains(Paths.brew) },
|
||||
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/brew").out.contains(Paths.brew) },
|
||||
name: "`/private/etc/sudoers.d/brew` contains brew",
|
||||
titleText: "startup.errors.sudoers_brew.title".localized,
|
||||
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
|
||||
descriptionText: "startup.errors.sudoers_brew.desc".localized
|
||||
),
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("cat /private/etc/sudoers.d/valet").contains(Paths.valet) },
|
||||
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/valet").out.contains(Paths.valet) },
|
||||
name: "`/private/etc/sudoers.d/valet` contains valet",
|
||||
titleText: "startup.errors.sudoers_valet.title".localized,
|
||||
subtitleText: "startup.errors.sudoers_valet.subtitle".localized,
|
||||
@@ -160,7 +160,10 @@ class Startup {
|
||||
// Verify if the Homebrew services are running (as root).
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return HomebrewDiagnostics.cannotLoadService("dnsmasq") },
|
||||
command: {
|
||||
await HomebrewDiagnostics.loadInstalledTaps()
|
||||
return await HomebrewDiagnostics.cannotLoadService("dnsmasq")
|
||||
},
|
||||
name: "`sudo \(Paths.brew) services info` JSON loaded",
|
||||
titleText: "startup.errors.services_json_error.title".localized,
|
||||
subtitleText: "startup.errors.services_json_error.subtitle".localized,
|
||||
@@ -171,7 +174,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return !Filesystem.directoryExists("~/.config/valet")
|
||||
return !FileSystem.directoryExists("~/.config/valet")
|
||||
},
|
||||
name: "`.config/valet` not empty (Valet installed)",
|
||||
titleText: "startup.errors.valet_not_installed.title".localized,
|
||||
@@ -200,10 +203,10 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
let nodePath = await Shell.pipe("which node").out
|
||||
return App.architecture == "x86_64"
|
||||
&& FileManager.default.fileExists(atPath: "/usr/local/bin/which")
|
||||
&& Shell.pipe("which node", requiresPath: false)
|
||||
.contains("env: node: No such file or directory")
|
||||
&& FileSystem.fileExists("/usr/local/bin/which")
|
||||
&& nodePath.contains("env: node: No such file or directory")
|
||||
},
|
||||
name: "`env: node` issue does not apply",
|
||||
titleText: "startup.errors.which_alias_issue.title".localized,
|
||||
@@ -215,7 +218,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return valet("--version", sudo: false)
|
||||
return await Shell.pipe("valet --version").out
|
||||
.contains("Composer detected issues in your platform")
|
||||
},
|
||||
name: "`no global composer issues",
|
||||
@@ -228,7 +231,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
let output = valet("--version", sudo: false)
|
||||
let output = await Shell.pipe("valet --version").out
|
||||
// Failure condition #1: does not contain Laravel Valet
|
||||
if !output.contains("Laravel Valet") {
|
||||
return true
|
||||
@@ -239,7 +242,7 @@ class Startup {
|
||||
.components(separatedBy: "Laravel Valet")[1]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
// Extract the version number
|
||||
Valet.shared.version = VersionExtractor.from(output)
|
||||
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
|
||||
// Get the actual version
|
||||
return Valet.shared.version == nil
|
||||
},
|
||||
@@ -247,6 +250,19 @@ class Startup {
|
||||
titleText: "startup.errors.valet_version_unknown.title".localized,
|
||||
subtitleText: "startup.errors.valet_version_unknown.subtitle".localized,
|
||||
descriptionText: "startup.errors.valet_version_unknown.desc".localized
|
||||
),
|
||||
// =================================================================================
|
||||
// Ensure the Valet version is supported.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
// We currently support Valet 2, 3 or 4. Any other version should get an alert.
|
||||
return ![2, 3, 4].contains(Valet.shared.version.major)
|
||||
},
|
||||
name: "valet version is supported",
|
||||
titleText: "startup.errors.valet_version_not_supported.title".localized,
|
||||
subtitleText: "startup.errors.valet_version_not_supported.subtitle".localized,
|
||||
descriptionText: "startup.errors.valet_version_not_supported.desc".localized
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 24/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -65,17 +65,20 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
|
||||
@IBAction func pressedCreateProxy(_ sender: Any) {
|
||||
let domain = self.inputDomainName.stringValue
|
||||
let proxyName = self.inputProxySubject.stringValue
|
||||
let secure = self.buttonSecure.state == .on ? " --secure" : ""
|
||||
let secure = (self.buttonSecure.state == .on)
|
||||
|
||||
dismissView(outcome: .OK)
|
||||
|
||||
App.shared.domainListWindowController?.contentVC.setUIBusy()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
Shell.run("\(Paths.valet) proxy \(domain) \(proxyName)\(secure)", requiresPath: true)
|
||||
Actions.restartNginx()
|
||||
Task { // Ensure we proxy the site asynchronously and reload UI on main thread again
|
||||
try! await ValetInteractor.shared.proxy(
|
||||
domain: domain,
|
||||
proxy: proxyName,
|
||||
secure: secure
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
App.shared.domainListWindowController?.contentVC.setUINotBusy()
|
||||
App.shared.domainListWindowController?.pressedReload(nil)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 24/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -51,11 +51,11 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
|
||||
|
||||
// MARK: - Outlet Interactions
|
||||
|
||||
@IBAction func pressedCreateLink(_ sender: Any) {
|
||||
func createLink() async {
|
||||
let path = pathControl.url!.path
|
||||
let name = inputDomainName.stringValue
|
||||
|
||||
if !Filesystem.exists(path) {
|
||||
if !FileSystem.anyExists(path) {
|
||||
Alert.confirm(
|
||||
onWindow: view.window!,
|
||||
messageText: "domain_list.alert.folder_missing.title".localized,
|
||||
@@ -70,23 +70,28 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
|
||||
}
|
||||
|
||||
// Adding `valet links` is a workaround for Valet malforming the config.json file
|
||||
// TODO: I will have to investigate and report this behaviour if possible
|
||||
Shell.run("cd '\(path)' && \(Paths.valet) link '\(name)' && valet links", requiresPath: true)
|
||||
Task {
|
||||
try! await ValetInteractor.shared.link(path: path, domain: name)
|
||||
|
||||
dismissView(outcome: .OK)
|
||||
dismissView(outcome: .OK)
|
||||
|
||||
// Reset search
|
||||
App.shared.domainListWindowController?
|
||||
.searchToolbarItem
|
||||
.searchField.stringValue = ""
|
||||
// Reset search
|
||||
App.shared.domainListWindowController?
|
||||
.searchToolbarItem
|
||||
.searchField.stringValue = ""
|
||||
|
||||
// Add the new item and scrolls to it
|
||||
App.shared.domainListWindowController?
|
||||
.contentVC
|
||||
.addedNewSite(
|
||||
name: name,
|
||||
secure: buttonSecure.state == .on
|
||||
)
|
||||
// Add the new item and scrolls to it
|
||||
await App.shared.domainListWindowController?
|
||||
.contentVC
|
||||
.addedNewSite(
|
||||
name: name,
|
||||
secureAfterLinking: buttonSecure.state == .on
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func pressedCreateLink(_ sender: Any) {
|
||||
Task { await createLink() }
|
||||
}
|
||||
|
||||
@IBAction func pressedCancel(_ sender: Any) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 03/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -28,7 +28,7 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
|
||||
|
||||
imageViewPhpVersionOK.toolTip = nil
|
||||
|
||||
imageViewPhpVersionOK.contentTintColor = site.composerPhpCompatibleWithLinked
|
||||
imageViewPhpVersionOK.contentTintColor = site.isCompatibleWithPreferredPhpVersion
|
||||
? NSColor(named: "IconColorGreen")
|
||||
: NSColor(named: "IconColorRed")
|
||||
|
||||
@@ -37,9 +37,9 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
|
||||
imageViewPhpVersionOK.image = NSImage(named: "Isolated")
|
||||
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.isolated".localized(site.servingPhpVersion)
|
||||
} else {
|
||||
imageViewPhpVersionOK.isHidden = (site.composerPhp == "???" || !site.composerPhpCompatibleWithLinked)
|
||||
imageViewPhpVersionOK.isHidden = (site.preferredPhpVersion == "???" || !site.isCompatibleWithPreferredPhpVersion)
|
||||
imageViewPhpVersionOK.image = NSImage(named: "Checkmark")
|
||||
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.checkmark".localized(site.composerPhp)
|
||||
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.checkmark".localized(site.preferredPhpVersion)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
|
||||
@IBAction func pressedPhpVersion(_ sender: Any) {
|
||||
guard let site = self.site else { return }
|
||||
|
||||
var validPhpSuggestions: [PhpVersionNumber] {
|
||||
var validPhpSuggestions: [VersionNumber] {
|
||||
if site.isolatedPhpVersion != nil {
|
||||
return []
|
||||
}
|
||||
|
||||
return PhpEnv.shared.validVersions(for: site.composerPhp).filter({ version in
|
||||
version.homebrewVersion != PhpEnv.phpInstall.version.short
|
||||
return PhpEnv.shared.validVersions(for: site.preferredPhpVersion).filter({ version in
|
||||
version.short != PhpEnv.phpInstall.version.short
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/03/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -25,7 +25,7 @@ class DomainListTypeCell: NSTableCellView, DomainListCellProtocol {
|
||||
}
|
||||
|
||||
// PHP version
|
||||
labelPhpVersion.stringValue = site.composerPhp == "???" ? "Any PHP" : "PHP \(site.composerPhp)"
|
||||
labelPhpVersion.stringValue = site.preferredPhpVersion == "???" ? "Any PHP" : "PHP \(site.preferredPhpVersion)"
|
||||
}
|
||||
|
||||
func populateCell(with proxy: ValetProxy) {
|
||||
|
||||