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

🚀 Version 6.0

This commit is contained in:
2023-05-27 13:13:42 +02:00
138 changed files with 5294 additions and 1213 deletions

2
.gitignore vendored
View File

@ -2,5 +2,5 @@ phpmon.xcodeproj/project.xcworkspace
phpmon.xcodeproj/xcuserdata
PHP Monitor.xcodeproj/project.xcworkspace
PHP Monitor.xcodeproj/xcuserdata
phpmon-updater/PHP Monitor Self-Updater.app
phpmon-updater/PHP Monitor Self-Updater.app/
.DS_Store

View File

@ -28,15 +28,22 @@ defaults delete com.nicoverbruggen.phpmon && killall cfprefsd
<img src="./docs/build.png" width="404px" alt="build button in Xcode"/>
### PHP Monitor
If you'd like to build PHP Monitor yourself, you need:
* Xcode (usually the latest version)
* *PHP Monitor Self-Updater.app* in the `phpmon-updater` directory (You can build it yourself, it is included as a target OR copy the signed app so it is included w/ PHP Monitor)
* The contents of this repository
Once you have downloaded this repository, open `PHP Monitor.xcodeproj`, and you should be able to immediately build the app for your system by pressing Cmd-R. This will create a debug build. (If Xcode complains about code signing, you can turn it off.)
Once you have downloaded this repository, open `PHP Monitor.xcodeproj`, and you should be able to build the app for your system by pressing Cmd-R. This will create a debug build. (If Xcode complains about code signing, you can turn it off.)
If you'd like to create a production build, choose "Any Mac" as the target and select Product > Archive.
### PHP Monitor Updater
Select the separate target and build. You can then copy the product to the `phpmon-updater` directory. The binary will be re-signed when distributing the main build.
## 🚀 Release procedure
1. Merge into `main`

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1320"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -97,6 +97,10 @@
argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_broken.json"
isEnabled = "NO">

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug.EA"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
BuildableName = "Unit Tests.xctest"
BlueprintName = "Unit Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7BB28F9B90F0021E251"
BuildableName = "UI Tests.xctest"
BlueprintName = "UI Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7AC28F9B4940021E251"
BuildableName = "Feature Tests.xctest"
BlueprintName = "Feature Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug.EA"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "--v"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--cli"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_broken.json"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "EXTREME_DOCTOR_MODE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release.EA"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug.EA">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release.EA"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C406A5EF298AD2CE00B5B85A"
BuildableName = "PHP Monitor Self-Updater.app"
BlueprintName = "PHP Monitor Self-Updater"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C406A5EF298AD2CE00B5B85A"
BuildableName = "PHP Monitor Self-Updater.app"
BlueprintName = "PHP Monitor Self-Updater"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C406A5EF298AD2CE00B5B85A"
BuildableName = "PHP Monitor Self-Updater.app"
BlueprintName = "PHP Monitor Self-Updater"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -23,17 +23,17 @@ PHP Monitor is a universal application that runs natively on Apple Silicon **and
* Your user account can administer your computer (required for some functionality, e.g. certificate generation)
* macOS 12.4 or later (Monterey and Ventura are supported)
* Homebrew is installed in `/usr/local/homebrew` or `/opt/homebrew`
* Homebrew is installed in the default location (`/usr/local/homebrew` or `/opt/homebrew`)
* Homebrew `php` formula is installed
* Laravel Valet (works with Valet v2, v3 and v4)
* Optional but recommended: Laravel Valet
_You may need to update your Valet installation to keep everything working if a major version update of PHP has been released. You can do this by running `composer global update && valet install`. Some features are not supported when running Valet 2._
_Starting with PHP Monitor 6.0, you do not need to have Laravel Valet installed for PHP Monitor to work. To get access to all features of PHP Monitor however, installing Valet is **recommended**._
For more information, please see [SECURITY.md](./SECURITY.md) to find out which version of the app is currently supported.
## 🚀 How to install
Again, make sure you have **[Laravel Valet](https://laravel.com/docs/master/valet)** installed first:
Again, if you want to have access to *all features* of PHP Monitor, I recommend installing **[Laravel Valet](https://laravel.com/docs/master/valet)** first:
```sh
composer global require laravel/valet
@ -41,6 +41,8 @@ valet install
valet trust
```
Currently, PHP Monitor is compatible with Laravel Valet v2, v3 and v4. Each of these versions of Valet support slightly different PHP versions, which is why legacy versions remain supported. Please note that some features are not available in older versions of Valet, like site isolation.
#### Manual installation (recommended, first time only)
Once that's done, you can [download the latest release](https://github.com/nicoverbruggen/phpmon/releases/latest), unzip it and place it in `/Applications`.
@ -102,10 +104,9 @@ All stable and supported PHP versions are also supported by PHP Monitor. However
> **Note**
> If you have versions of PHP installed that can be detected by PHP Monitor but is *not* supported by the currently active version of Valet, you will be alerted by an item in the menu with an exclamation mark emoji. (⚠️)
Backports are available via [this tap](https://github.com/shivammathur/homebrew-php). For more information about those backports, please see the next FAQ entry.
Backports that are installable via PHP Monitor's **PHP Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php).
For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet.
</details>
<details>
@ -113,37 +114,35 @@ For maximum compatibility with older PHP versions, you may wish to keep using Va
Assuming you have installed the `php` formula, the latest stable version of PHP is installed. At the time of writing, this is PHP 8.2.
You can install other supported versions of PHP out of the box, so `php@8.0` and `php@8.1` at the time of writing.
You can install other supported versions of PHP via PHP Monitor's **PHP Manager**. (You can manually install or upgrade PHP versions too, but this is not recommended.)
If you wish to install older (officially unsupported) versions of PHP for local use, you can do so by using [Shivam Mathur's tap](https://github.com/shivammathur/homebrew-php):
Please keep in mind that installing or updating PHP versions, even when done via PHP Monitor's **PHP Manager**, may cause other required formula dependencies (required software needed to keep those PHP versions functional) to be upgraded. It might not be very transparent when this happens, but this is likely the cause if installing a PHP version takes longer than expected: usually other dependencies are also being installed.
```sh
brew tap shivammathur/php
```
Additionally, upgrading one specific version of PHP may also cause other installed versions of PHP to *also* be updated in one go, if the dependencies for that one version also apply to the other (newer) version(s) of PHP. It's a bit tricky to manage PHP versions via Homebrew, and even PHP Monitor may encounter some difficulties.
You may find that this tap is already in use: if you've used Valet before, it automatically uses this tap for legacy versions of PHP.
If you encounter a strange scenario or a malfunction, please open an issue on the issue tracker and get in touch. I'd like to keep enhancing this process to make it as foolproof as possible.
```sh
brew install shivammathur/php/php@7.4
brew install shivammathur/php/php@7.3
brew install shivammathur/php/php@7.2
brew install shivammathur/php/php@7.1
brew install shivammathur/php/php@7.0
```
**Always make sure to restart PHP Monitor after installing or upgrading PHP versions!**
> *Note*: Using this tap may cause [temporary alias conflicts](https://github.com/nicoverbruggen/phpmon/issues/54#issuecomment-979789724) while the core tap alias and the tap's alias refer to a different version of PHP, but this is generally speaking a minor inconvenience, since this normally only applies when a new PHP version releases.
> *Note*: Using PHP Monitor when managing PHP versions may cause [temporary alias conflicts](https://github.com/nicoverbruggen/phpmon/issues/54#issuecomment-979789724) while the core tap alias and the tap's alias refer to a different version of PHP, but this is generally speaking a minor inconvenience, since this normally only applies when a new PHP version releases.
</details>
<details>
<summary><strong>I want PHP Monitor to start up when I boot my Mac!</strong></summary>
You can do this by dragging *PHP Monitor.app* into the **Login Items** section in **System Preferences > Users & Groups** for your account.
If you are running macOS Ventura or newer, there's an option in the Settings menu that you can select: "Start PHP Monitor at login".
If you are on an older version of macOS, you can do this by dragging *PHP Monitor.app* into the **Login Items** section in **System Preferences > Users & Groups** for your account.
Super convenient!
</details>
<details>
<summary><strong>What features are unavailable in Standalone Mode?</strong></summary>
The services manager is disabled, and all other obvious Laravel Valet integrations (configuration finder, domains list, Fix My Valet) are also disabled.
(Most other features remain available.)
</details>
<details>
<summary><strong>I want to set up PHP Monitor from scratch! I don't have Homebrew installed either, where do I begin?</strong></summary>
@ -189,6 +188,10 @@ Make sure PHP is linked correctly:
should return: `/usr/local/bin/php` (or `/opt/homebrew/bin/php` if you are on Apple Silicon)
**If you don't need Laravel Valet, you can stop here. PHP Monitor will work like this in Standalone Mode.**
If you'd like to have Valet as well, continue and install Valet with Composer, like this.
composer global require laravel/valet
For optimal results, you should lock your PHP platform for global dependencies to the oldest version of PHP you intend to run. If that version is PHP 7.0, your `~/.composer/composer.json` file could look like this (please adjust the version accordingly!):
@ -216,11 +219,6 @@ This should install `dnsmasq` and set up Valet. Great, almost there!
valet trust
You can now install PHP Monitor, if you haven't already:
brew tap nicoverbruggen/homebrew-cask
brew install --cask phpmon
Finally, run PHP Monitor. Since the app is notarized and signed with a developer ID, it should work. You will need to approve the initial launch of the app, but you should be ready to go now.
</details>
@ -229,13 +227,17 @@ Finally, run PHP Monitor. Since the app is notarized and signed with a developer
PHP Monitor will check if an update is available every time you start the app.
You can disable this behaviour by going to Preferences (via the PHP Monitor icon in the menu bar) and unchecking "Automatically check for updates". You can always check for updates manually.
You can disable this behaviour by going to Preferences (via the PHP Monitor icon in the menu bar) and unchecking "Automatically check for updates". (You can always check for updates manually.)
</details>
<details>
<summary><strong>I have PHP Monitor installed, and it works. I want to upgrade my PHP installations to the latest version, what's the best way to do this?</strong></summary>
The easiest way is to simply use the built-in **PHP Version Manager**, which will allow you to upgrade your PHP versions with one click.
If you want to do this manually, you can follow the instructions below.
It's easy to make a mistake here, and end up with an unlinked version of PHP or have versions missing from PHP Monitor.
Here's what I usually do:
@ -320,12 +322,14 @@ Make sure you have at least **Valet 3.0** installed, since support for isolation
<details>
<summary><strong>One of the limits (memory limit, max POST size, max upload size) shows an exclamation mark!</strong></summary>
The value you provided in your INI file is invalid. If that is the case, PHP will attempt to parse your value as bytes, which is usually unintended. (`1GB` will resolve to merely a few bytes, and all of your applications will run out of memory!)
The value you provided in your `.ini` file is invalid. If that is the case, PHP will attempt to parse your value as bytes, which is usually unintended. (`1GB` will resolve to merely a few bytes, and all of your applications will run out of memory!)
You must a provide a value like so: `1024K`, `256M`, `1G`. Alternatively, `-1` is also allowed, or just an integer (which will result in N amount of bytes being the limit).
**Example**: Trying to use `1GB` as the memory limit, for example, will result in this exclamation mark. The correct way to set a 1GB limit is by using `1G` as the value. (Note: The displayed value will append `B` for clarity, so if you set `1G`, the value reported by PHP Monitor will be 1 GB.)
(If you are using Valet, you can adjust these limits in the `.conf.d/php-memory-limits.ini` file. Otherwise, you may need to adjust `php.ini`.)
</details>
<details>
@ -414,6 +418,9 @@ You can omit the `php` key in the preset if you do not wish for the preset to sw
<details>
<summary><strong>How do I ensure additional Homebrew services are shown in the app?</strong></summary>
> **Info**
> Homebrew services aren't displayed if you are using Valet in Standalone Mode.
You must set these services up in a JSON file, located in `~/.config/phpmon/config.json`.
You can specify custom services in the configuration file for Homebrew services that run as your own user (not root).
@ -594,9 +601,9 @@ Thank you very much for your contributions, kind words and support.
### Loading info about PHP in the background
This utility runs `php-config --version` in the background periodically. It also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit).
This app runs `php-config --version` in the background periodically, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
In order to save power, this only happens once every 60 seconds.
PHP Monitor also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit). See also the section on *Config change detection* below.
### Switching PHP versions
@ -604,7 +611,7 @@ This utility will detect which PHP versions you have installed via Homebrew, and
The switcher will disable all PHP-FPM services not belonging to the version you wish to use, and link the desired version of PHP. Then, it'll restart your desired PHP version's FPM process. This all happens in parallel, so this should be a bit faster than Valets switcher.
If you're using Valet 3, versions of PHP-FPM required to keep isolated sites up and running will also be started or stopped as needed.
If you're using Valet 3 or newer, versions of PHP-FPM required to keep isolated sites up and running will also be started or stopped as needed.
### Config change detection

View File

@ -6,7 +6,7 @@ Generally speaking, only the latest version of **PHP Monitor** is supported, exc
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 5.8 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.0 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
## Legacy versions
@ -14,7 +14,8 @@ These versions of PHP Monitor are no longer supported, but if youre using an
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 5.7 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0) | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x) | 3.0 recommended<br/> 2.16.2 minimum |
| 5.8 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
| 5.7 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0) | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
| 5.6 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0) | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x) | 3.0 recommended<br/> 2.16.2 minimum |
| 4.1 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 11+ | PHP 5.6—PHP 8.2 | 2.16.2 |
| 4.0 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 10.14+ | PHP 5.6—PHP 8.2 | 2.13 |

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 674 KiB

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,46 @@
//
// LaunchControl.swift
// PHP Monitor Self-Updater
//
// Created by Nico Verbruggen on 02/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
class LaunchControl {
public static func smartRestart(priority: [String]) async {
for appPath in priority {
if FileManager.default.fileExists(atPath: appPath) {
let app = await LaunchControl.startApplication(at: appPath)
if app != nil {
return
}
}
}
}
public static func terminateApplications(bundleIds: [String]) async {
let runningApplications = NSWorkspace.shared.runningApplications
// Terminate all instances found
for id in bundleIds {
if let phpmon = runningApplications.first(where: {
(application) in return application.bundleIdentifier == id
}) {
phpmon.terminate()
}
}
}
public static func startApplication(at path: String) async -> NSRunningApplication? {
await withCheckedContinuation { continuation in
let url = NSURL(fileURLWithPath: path, isDirectory: true) as URL
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { phpmon, error in
continuation.resume(returning: phpmon)
}
}
}
}

View File

@ -0,0 +1,162 @@
//
// Updater.swift
// PHP Monitor Updater
//
// Created by Nico Verbruggen on 01/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Cocoa
class Updater: NSObject, NSApplicationDelegate {
var updaterDirectory: String = ""
var manifestPath: String = ""
var manifest: ReleaseManifest! = nil
func applicationDidFinishLaunching(_ aNotification: Notification) {
Task { await self.installUpdate() }
}
func installUpdate() async {
print("PHP MONITOR SELF-UPDATER by Nico Verbruggen")
print("===========================================")
self.updaterDirectory = "~/.config/phpmon/updater"
.replacingOccurrences(of: "~", with: NSHomeDirectory())
print("Updater directory set to: \(self.updaterDirectory)")
self.manifestPath = "\(updaterDirectory)/update.json"
// Fetch the manifest on the local filesystem
let manifest = await parseManifest()!
// Download the latest file
let zipPath = await download(manifest)
// Terminate all instances of PHP Monitor first
await LaunchControl.terminateApplications(bundleIds: [
"com.nicoverbruggen.phpmon.eap",
"com.nicoverbruggen.phpmon.dev",
"com.nicoverbruggen.phpmon"
])
// Install the app based on the zip
let appPath = await extractAndInstall(zipPath: zipPath)
// Restart PHP Monitor, this will also close the updater
_ = await LaunchControl.startApplication(at: appPath)
exit(1)
}
func applicationWillTerminate(_ aNotification: Notification) {
exit(1)
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return false
}
private func parseManifest() async -> ReleaseManifest? {
// Read out the correct information from the manifest JSON
print("Checking manifest file at \(manifestPath)...")
do {
let manifestText = try String(contentsOfFile: manifestPath)
manifest = try JSONDecoder().decode(ReleaseManifest.self, from: manifestText.data(using: .utf8)!)
return manifest
} catch {
print("Parsing the manifest failed (or the manifest file doesn't exist)!")
await Alert.show(description: "The manifest file for a potential update was not found. Please try searching for updates again in PHP Monitor.")
}
return nil
}
private func download(_ manifest: ReleaseManifest) async -> String {
// Remove all zips
system_quiet("rm -rf \(updaterDirectory)/*.zip")
// Download the file (and follow redirects + no output on failure)
system_quiet("cd \"\(updaterDirectory)\" && curl \(manifest.url) -fLO --max-time 20")
// Identify the downloaded file
let filename = system("cd \"\(updaterDirectory)\" && ls | grep .zip")
.trimmingCharacters(in: .whitespacesAndNewlines)
// Ensure the zip exists
if filename.isEmpty {
print("The update has not been downloaded. Sadly, that means that PHP Monitor cannot not updated!")
await Alert.show(description: "The update could not be downloaded, or the file was not correctly written to disk. \n\nPlease try again. \n\n(Note that the download will time-out after 20 seconds, so for slow connections it is recommended to manually download the update.)")
}
// Calculate the checksum for the downloaded file
let checksum = system("openssl dgst -sha256 \"\(updaterDirectory)/\(filename)\" | awk '{print $NF}'")
.trimmingCharacters(in: .whitespacesAndNewlines)
// Compare the checksums
print("""
Comparing checksums...
Expected SHA256: \(manifest.sha256)
Actual SHA256: \(checksum)
""")
// Make sure the checksum matches before we do anything with the file
if checksum != manifest.sha256 {
print("The checksums failed to match. Cancelling!")
await Alert.show(description: "The downloaded update failed checksum validation. Please try again. If this issue persists, there may be an issue with the server and I do not recommend upgrading.")
}
// Return the path to the zip
return "\(updaterDirectory)/\(filename)"
}
private func extractAndInstall(zipPath: String) async -> String {
// Remove the directory that will contain the extracted update
system_quiet("rm -rf \"\(updaterDirectory)/extracted\"")
// Recreate the directory where we will unzip the .app file
system_quiet("mkdir -p \"\(updaterDirectory)/extracted\"")
// Make sure the updater directory exists
var isDirectory: ObjCBool = true
if !FileManager.default.fileExists(atPath: "\(updaterDirectory)/extracted", isDirectory: &isDirectory) {
await Alert.show(description: "The updater directory is missing. The automatic updater will quit. Make sure that ` ~/.config/phpmon/updater` is writeable.")
}
// Unzip the file
system_quiet("unzip \"\(zipPath)\" -d \"\(updaterDirectory)/extracted\"")
// Find the .app file
let app = system("ls \"\(updaterDirectory)/extracted\" | grep .app")
.trimmingCharacters(in: .whitespacesAndNewlines)
print("Finished extracting: \(updaterDirectory)/extracted/\(app)")
// Make sure the file was extracted
if app.isEmpty {
await Alert.show(description: "The downloaded file could not be extracted. The automatic updater will quit. Make sure that ` ~/.config/phpmon/updater` is writeable.")
}
// Remove the original app
print("Removing \(app) before replacing...")
system_quiet("rm -rf \"/Applications/\(app)\"")
// Move the new app in place
system_quiet("mv \"\(updaterDirectory)/extracted/\(app)\" \"/Applications/\(app)\"")
// Remove the zip
system_quiet("rm \"\(zipPath)\"")
// Remove the manifest
system_quiet("rm \"\(manifestPath)\"")
// Write a file that is only written when we upgraded successfully
system_quiet("touch \"\(updaterDirectory)/upgrade.success\"")
// Return the new location of the app
return "/Applications/\(app)"
}
}

View File

@ -0,0 +1,34 @@
//
// Utility.swift
// PHP Monitor Self-Updater
//
// Created by Nico Verbruggen on 02/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
class Alert {
public static func show(description: String, shouldExit: Bool = true) async {
await withUnsafeContinuation { continuation in
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "The app could not be updated."
alert.informativeText = description
alert.addButton(withTitle: "OK")
alert.alertStyle = .critical
alert.runModal()
if shouldExit {
exit(0)
}
continuation.resume()
}
}
}
}
public struct ReleaseManifest: Codable {
let url: String
let sha256: String
}

14
phpmon-updater/main.swift Normal file
View File

@ -0,0 +1,14 @@
//
// AppDelegate.swift
// PHP Monitor Self-Updater
//
// Created by Nico Verbruggen on 01/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Cocoa
let app = NSApplication.shared
let delegate = Updater()
app.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.988",
"green" : "0.580",
"red" : "0.278"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.988",
"green" : "0.723",
"red" : "0.277"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -16,7 +16,26 @@ protocol CommandProtocol {
- 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.
- Parameter withStandardError: Outputs standard error output to the same string output as well.
*/
func execute(path: String, arguments: [String], trimNewlines: Bool) -> String
func execute(
path: String,
arguments: [String],
trimNewlines: Bool,
withStandardError: Bool
) -> String
/**
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
}

View File

@ -9,13 +9,23 @@ import Cocoa
public class RealCommand: CommandProtocol {
public func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
public func execute(
path: String,
arguments: [String],
trimNewlines: Bool,
withStandardError: Bool
) -> String {
let task = Process()
task.launchPath = path
task.arguments = arguments
let pipe = Pipe()
task.standardOutput = pipe
if withStandardError {
task.standardError = pipe
}
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
@ -30,4 +40,17 @@ public class RealCommand: CommandProtocol {
return output
}
public func execute(
path: String,
arguments: [String],
trimNewlines: Bool = false
) -> String {
self.execute(
path: path,
arguments: arguments,
trimNewlines: trimNewlines,
withStandardError: false
)
}
}

View File

@ -12,37 +12,43 @@ class Actions {
// MARK: - Services
public static func linkPhp() async {
await brew("link php --overwrite --force")
// TODO: Verify that this worked, if not, notify the user
}
public static func restartPhpFpm() async {
await brew("services restart \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
await brew("services restart \(HomebrewFormulae.php)", sudo: HomebrewFormulae.php.elevated)
}
public static func restartNginx() async {
await brew("services restart \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
await brew("services restart \(HomebrewFormulae.nginx)", sudo: HomebrewFormulae.nginx.elevated)
}
public static func restartDnsMasq() async {
await brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
await brew("services restart \(HomebrewFormulae.dnsmasq)", sudo: HomebrewFormulae.dnsmasq.elevated)
}
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)
await brew("services stop \(HomebrewFormulae.php)", sudo: HomebrewFormulae.php.elevated)
await brew("services stop \(HomebrewFormulae.nginx)", sudo: HomebrewFormulae.nginx.elevated)
await brew("services stop \(HomebrewFormulae.dnsmasq)", sudo: HomebrewFormulae.dnsmasq.elevated)
}
public static func fixHomebrewPermissions() throws {
var servicesCommands = [
"\(Paths.brew) services stop \(Homebrew.Formulae.nginx)",
"\(Paths.brew) services stop \(Homebrew.Formulae.dnsmasq)"
"\(Paths.brew) services stop \(HomebrewFormulae.nginx)",
"\(Paths.brew) services stop \(HomebrewFormulae.dnsmasq)"
]
var cellarCommands = [
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.nginx)",
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.dnsmasq)"
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(HomebrewFormulae.nginx)",
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(HomebrewFormulae.dnsmasq)"
]
PhpEnv.shared.availablePhpVersions.forEach { version in
let formula = version == PhpEnv.brewPhpAlias
PhpEnvironments.shared.availablePhpVersions.forEach { version in
let formula = version == PhpEnvironments.brewPhpAlias
? "php"
: "php@\(version)"
servicesCommands.append("\(Paths.brew) services stop \(formula)")
@ -119,9 +125,9 @@ class Actions {
extensions and/or run `composer global update`.
*/
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)
await InternalSwitcher().performSwitch(to: PhpEnvironments.brewPhpAlias)
await brew("services restart \(HomebrewFormulae.dnsmasq)", sudo: HomebrewFormulae.dnsmasq.elevated)
await brew("services restart \(HomebrewFormulae.php)", sudo: HomebrewFormulae.php.elevated)
await brew("services restart \(HomebrewFormulae.nginx)", sudo: HomebrewFormulae.nginx.elevated)
}
}

View File

@ -82,6 +82,14 @@ struct Constants {
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb"
)!
static let EarlyAccessCaskFile = URL(
string: "https://phpmon.app/builds/early-access/sponsors/phpmon-eap.rb"
)!
static let EarlyAccessChangelog = URL(
string: "https://phpmon.app/early-access/release-notes"
)!
}
}

View File

@ -8,13 +8,6 @@
// MARK: Common Shell Commands
/**
Runs a `valet` command. Defaults to running as superuser.
*/
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.
*/

View File

@ -8,31 +8,27 @@
import Foundation
class Homebrew {
static var fake: Bool = false
struct Formulae {
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)
struct HomebrewFormulae {
static var php: HomebrewFormula {
if PhpEnvironments.shared.homebrewPackage == nil {
return HomebrewFormula("php", elevated: true)
}
static var nginx: HomebrewFormula {
return HomebrewDiagnostics.usesNginxFullFormula
? HomebrewFormula("nginx-full", elevated: true)
: HomebrewFormula("nginx", elevated: true)
guard let install = PhpEnvironments.phpInstall else {
return HomebrewFormula("php", elevated: true)
}
static var dnsmasq: HomebrewFormula {
return HomebrewFormula("dnsmasq", elevated: true)
}
return HomebrewFormula(install.formula, elevated: true)
}
static var nginx: HomebrewFormula {
return BrewDiagnostics.usesNginxFullFormula
? HomebrewFormula("nginx-full", elevated: true)
: HomebrewFormula("nginx", elevated: true)
}
static var dnsmasq: HomebrewFormula {
return HomebrewFormula("dnsmasq", elevated: true)
}
}

View File

@ -13,6 +13,7 @@ class Log {
static var shared = Log()
var logFilePath = "~/.config/phpmon/last_session.log"
var logExists = false
enum Verbosity: Int {
@ -29,9 +30,9 @@ class Log {
public func prepareLogFile() {
if !isRunningTests && Verbosity.cli.isApplicable() {
_ = system("mkdir -p ~/.config/phpmon 2> /dev/null")
_ = system("rm ~/.config/phpmon/last_session.log 2> /dev/null")
_ = system("touch ~/.config/phpmon/last_session.log 2> /dev/null")
system_quiet("mkdir -p ~/.config/phpmon 2> /dev/null")
system_quiet("rm ~/.config/phpmon/last_session.log 2> /dev/null")
system_quiet("touch ~/.config/phpmon/last_session.log 2> /dev/null")
self.logExists = FileSystem.fileExists(self.logFilePath)
}
}
@ -72,6 +73,12 @@ class Log {
}
}
static func line(as verbosity: Verbosity = .info) {
if verbosity.isApplicable() {
Log.shared.log("----------------------------------")
}
}
private func log(_ text: String) {
print(text)

View File

@ -19,9 +19,19 @@ public class Paths {
private var userName: String
init() {
// Assume the default directory is correct
baseDir = App.architecture != "x86_64" ? .opt : .usr
// Ensure that if a different location is used, it takes precendence
if baseDir == .usr
&& FileSystem.directoryExists("/usr/local/homebrew")
&& !FileSystem.directoryExists("/usr/local/Cellar") {
Log.warn("Using /usr/local/homebrew as base directory!")
baseDir = .usr_hb
}
userName = identity()
Log.info("[ID] The current username is `\(userName)`.")
Log.info("The current username is `\(userName)`.")
}
public func detectBinaryPaths() {
@ -100,6 +110,8 @@ public class Paths {
Paths.composer = "/usr/local/bin/composer"
} else if FileSystem.fileExists("/opt/homebrew/bin/composer") {
Paths.composer = "/opt/homebrew/bin/composer"
} else if FileSystem.fileExists("/usr/local/homebrew/bin/composer") {
Paths.composer = "/usr/local/homebrew/bin/composer"
} else {
Paths.composer = nil
Log.warn("Composer was not found.")
@ -111,6 +123,7 @@ public class Paths {
public enum HomebrewDir: String {
case opt = "/opt/homebrew"
case usr = "/usr/local"
case usr_hb = "/usr/local/homebrew"
}
}

View File

@ -14,6 +14,7 @@ class Alert {
messageText: String,
informativeText: String,
buttonTitle: String = "generic.ok".localized,
buttonIsDestructive: Bool = false,
secondButtonTitle: String = "generic.cancel".localized,
style: NSAlert.Style = .warning,
onFirstButtonPressed: @escaping (() -> Void)
@ -27,6 +28,7 @@ class Alert {
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: buttonTitle)
alert.buttons.first?.hasDestructiveAction = buttonIsDestructive
if !secondButtonTitle.isEmpty {
alert.addButton(withTitle: secondButtonTitle)
}

View File

@ -0,0 +1,69 @@
//
// FSNotifier.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 13/01/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Cocoa
class FSNotifier {
enum Kind {
case homebrewLocks, homebrewBinaries
}
public static var shared: FSNotifier! = nil
let queue = DispatchQueue(label: "FSWatch2Queue", attributes: .concurrent)
var lastUpdate: TimeInterval?
private var fileDescriptor: CInt = -1
private var dispatchSource: DispatchSourceFileSystemObject?
internal let url: URL
init(for url: URL, eventMask: DispatchSource.FileSystemEvent, onChange: @escaping () -> Void) {
self.url = url
fileDescriptor = open(url.path, O_EVTONLY)
dispatchSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: eventMask,
queue: self.queue
)
dispatchSource?.setEventHandler(handler: {
let distance = self.lastUpdate?.distance(to: Date().timeIntervalSince1970)
if distance == nil || distance != nil && distance! > 1.00 {
// FS event fired, checking in 1s, no duplicate FS events will be acted upon
self.lastUpdate = Date().timeIntervalSince1970
Task {
await delay(seconds: 1)
onChange()
}
}
})
dispatchSource?.setCancelHandler(handler: { [weak self] in
guard let self = self else { return }
close(self.fileDescriptor)
self.fileDescriptor = -1
self.dispatchSource = nil
})
dispatchSource?.resume()
}
func terminate() {
dispatchSource?.cancel()
}
deinit {
Log.perf("FSNotifier for \(self.url) will be deinitialized.")
}
}

View File

@ -0,0 +1,17 @@
//
// Measurements.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 02/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
public struct Measurement {
let started = Date()
var milliseconds: Double {
return round(Date().timeIntervalSince(started) * 1000 * 1000) / 1000
}
}

View File

@ -37,13 +37,13 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
extension NSWindowController {
public func positionWindowInTopLeftCorner() {
public func positionWindowInTopLeftCorner(offsetY: CGFloat = 0, offsetX: CGFloat = 0) {
guard let frame = NSScreen.main?.frame else { return }
guard let window = self.window else { return }
window.setFrame(NSRect(
x: frame.size.width - window.frame.size.width - 20,
y: frame.size.height - window.frame.size.height - 40,
x: frame.size.width - window.frame.size.width - 20 + offsetX,
y: frame.size.height - window.frame.size.height - 40 + offsetY,
width: window.frame.width,
height: window.frame.height
), display: true)

View File

@ -27,9 +27,20 @@ public func system(_ command: String) -> String {
return output
}
/** Same as the `system` command, but does not return the output. */
/**
Same as the `system` command, but does not return the output.
*/
public func system_quiet(_ command: String) {
_ = system(command)
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
_ = pipe.fileHandleForReading.readDataToEndOfFile()
return
}
/**

View File

@ -32,18 +32,25 @@ class ActivePhpInstallation {
// MARK: - Computed
var formula: String {
return (version.short == PhpEnv.brewPhpAlias) ? "php" : "php@\(version.short)"
return (version.short == PhpEnvironments.brewPhpAlias) ? "php" : "php@\(version.short)"
}
// MARK: - Initializer
public static func load() -> ActivePhpInstallation? {
if !FileSystem.fileExists(Paths.phpConfig) {
return nil
}
return ActivePhpInstallation()
}
init() {
// Show information about the current version
do {
try determineVersion()
} catch {
// TODO: In future versions of PHP Monitor, this should not crash
fatalError("Could not determine or parse PHP version; aborting")
fatalError("Could not determine or parse PHP version; aborting!")
}
// Initialize the list of ini files that are loaded
@ -122,29 +129,19 @@ class ActivePhpInstallation {
return ""
}
// Check if the syntax is valid otherwise
let regex = try! NSRegularExpression(pattern: #"^([0-9]*)(K|M|G|)$"#, options: [])
let match = regex.matches(in: value, options: [], range: NSRange(location: 0, length: value.count)).first
return (match == nil) ? "⚠️" : "\(value)B"
}
/**
Determine if PHP-FPM is configured correctly.
For PHP 5.6, we'll check if `valet.sock` is included in the main `php-fpm.conf` file, but for more recent
versions of PHP, we can just check for the existence of the `valet-fpm.conf` file. If the check here fails,
that means that Valet won't work properly.
*/
func checkPhpFpmStatus() 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 await Shell.pipe("cat \(fileName)").out
.contains("valet.sock")
if value.isEmpty {
return "⚠️"
}
// 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")
// Check if the syntax is valid otherwise
let regex = try! NSRegularExpression(pattern: #"^([0-9]*)(K|M|G|)$"#, options: [])
let match = regex.matches(
in: value, options: [],
range: NSRange(location: 0, length: value.count)
).first
return (match == nil) ? "⚠️" : "\(value)B"
}
// MARK: - Structs

View File

@ -12,11 +12,11 @@ import Cocoa
class Xdebug {
public static var enabled: Bool {
return PhpEnv.shared.getConfigFile(forKey: "xdebug.mode") != nil
return PhpEnvironments.shared.getConfigFile(forKey: "xdebug.mode") != nil
}
public static var activeModes: [String] {
guard let file = PhpEnv.shared.getConfigFile(forKey: "xdebug.mode") else {
guard let file = PhpEnvironments.shared.getConfigFile(forKey: "xdebug.mode") else {
return []
}

View File

@ -1,5 +1,5 @@
//
// HomebrewPackage.swift
// HomebrewDecodable.swift
// PHP Monitor
//
// Copyright © 2023 Nico Verbruggen. All rights reserved.
@ -17,7 +17,6 @@ struct HomebrewPackage: Decodable {
return aliases.first!
.replacingOccurrences(of: "php@", with: "")
}
}
struct HomebrewInstalled: Decodable {
@ -26,3 +25,15 @@ struct HomebrewInstalled: Decodable {
let installed_as_dependency: Bool
let installed_on_request: Bool
}
struct OutdatedFormulae: Decodable {
let formulae: [OutdatedFormula]
}
struct OutdatedFormula: Decodable {
let name: String
let installed_versions: [String]
let current_version: String
let pinned: Bool
let pinned_version: String?
}

View File

@ -1,5 +1,5 @@
//
// PhpSwitcher.swift
// PhpEnvironments.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/12/2021.
@ -8,14 +8,27 @@
import Foundation
class PhpEnv {
class PhpEnvironments {
// MARK: - Initializer
/**
*/
init() {
self.currentInstall = ActivePhpInstallation()
self.currentInstall = ActivePhpInstallation.load()
}
/**
Creates the shared instance. Called when starting the app.
*/
static func prepare() {
_ = Self.shared
}
/**
Determine which PHP version the `php` formula is aliased to.
*/
func determinePhpAlias() async {
let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
@ -32,11 +45,18 @@ class PhpEnv {
/** The delegate that is informed of updates. */
weak var delegate: PhpSwitcherDelegate?
/** The static app instance. Accessible at any time. */
static let shared = PhpEnv()
/** The static instance. Accessible at any time. */
static let shared = PhpEnvironments()
/** Whether the switcher is busy performing any actions. */
var isBusy: Bool = false
var isBusy: Bool = false {
didSet {
Task { @MainActor in
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
}
}
}
/** All versions of PHP that are currently supported. */
var availablePhpVersions: [String] = []
@ -48,7 +68,7 @@ class PhpEnv {
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.
@ -60,15 +80,15 @@ class PhpEnv {
As such, we take that information from Homebrew.
*/
static var brewPhpAlias: String {
if Homebrew.fake { return "8.2" }
if PhpEnvironments.shared.homebrewPackage == nil { return "8.2" }
return Self.shared.homebrewPackage.version
return PhpEnvironments.shared.homebrewPackage.version
}
/**
The currently linked and active PHP installation.
*/
static var phpInstall: ActivePhpInstallation {
static var phpInstall: ActivePhpInstallation? {
return Self.shared.currentInstall
}
@ -79,25 +99,45 @@ class PhpEnv {
// MARK: - Methods
/**
The switcher that is currently in use.
This was originally added so the Internal and Valet switcher could be swapped out,
but currently this is no longer needed.
*/
public static var switcher: PhpSwitcher {
return InternalSwitcher()
}
/**
Alias that detects which versions of PHP are installed.
See also: `detectPhpVersions()`. Please note that this method
does *not* return the set of PHP versions that are supported.
*/
public static func detectPhpVersions() async {
_ = await Self.shared.detectPhpVersions()
}
/**
Detects which versions of PHP are installed.
This step also detects which versions of PHP are incompatible with the current version of Valet.
If a PHP installation is currently broken, that will also be reflected.
Returns a `Set<String>` of installations that are considered valid.
*/
public func detectPhpVersions() async -> Set<String> {
let files = await Shell.pipe("ls \(Paths.optPath) | grep php@").out
let versions = await extractPhpVersions(from: files.components(separatedBy: "\n"))
let supportedByValet = Constants.ValetSupportedPhpVersionMatrix[Valet.shared.version.major] ?? []
let supportedByValet: Set<String> = {
guard let version = Valet.shared.version else {
return Constants.DetectedPhpVersions
}
var supportedVersions = versions.intersection(supportedByValet)
return Constants.ValetSupportedPhpVersionMatrix[version.major] ?? []
}()
var supportedVersions = Valet.installed ? versions.intersection(supportedByValet) : versions
// Make sure the aliased version is detected
// The user may have `php` installed, but not e.g. `php@8.0`
@ -167,6 +207,10 @@ class PhpEnv {
return output
}
/**
Returns a list of `VersionNumber` instances based on the available PHP versions
that are valid to switch to for a given constraint.
*/
public func validVersions(for constraint: String) -> [VersionNumber] {
constraint.split(separator: "|").flatMap {
return PhpVersionNumberCollection
@ -179,7 +223,12 @@ class PhpEnv {
Validates whether the currently running version matches the provided version.
*/
public func validate(_ version: String) -> Bool {
if self.currentInstall.version.short == version {
guard let install = PhpEnvironments.phpInstall else {
Log.info("It appears as if no PHP installation is currently active.")
return false
}
if install.version.short == version {
Log.info("Switching to version \(version) seems to have succeeded. Validation passed.")
Log.info("Keeping track that this is the new version!")
Stats.persistCurrentGlobalPhpVersion(version: version)
@ -195,7 +244,11 @@ class PhpEnv {
You can then use the configuration file instance to change values.
*/
public func getConfigFile(forKey key: String) -> PhpConfigurationFile? {
return PhpEnv.phpInstall.iniFiles
guard let install = PhpEnvironments.phpInstall else {
return nil
}
return install.iniFiles
.reversed()
.first(where: { $0.has(key: key) })
}

View File

@ -28,10 +28,12 @@ class PhpHelper {
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
)
Task { @MainActor in
try FileSystem.createDirectory(
"~/.config/phpmon/bin",
withIntermediateDirectories: true
)
}
}
if FileSystem.fileExists(destination) {
@ -48,21 +50,14 @@ class PhpHelper {
.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
"""
let script = script(path, keyPhrase, version, dotless)
try FileSystem.writeAtomicallyToFile(destination, content: script)
Task { @MainActor in
try FileSystem.writeAtomicallyToFile(destination, content: script)
if !FileSystem.isExecutableFile(destination) {
try FileSystem.makeExecutable(destination)
if !FileSystem.isExecutableFile(destination) {
try FileSystem.makeExecutable(destination)
}
}
// Create a symlink if the folder is not in the PATH
@ -83,6 +78,24 @@ class PhpHelper {
}
}
private static func script(
_ path: String,
_ keyPhrase: String,
_ version: String,
_ dotless: String
) -> String {
return """
#!/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
"""
}
private static func createSymlink(_ dotless: String) async {
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
let destination = "/usr/local/bin/pm\(dotless)"

View File

@ -12,13 +12,17 @@ class PhpInstallation {
var versionNumber: VersionNumber
var isHealthy: Bool = true
/**
In order to determine details about a PHP installation, well simply run `php-config --version`
in the relevant directory.
In order to determine details about a PHP installation,
well simply run `php-config --version` in the relevant directory.
*/
init(_ version: String) {
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
let phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php"
self.versionNumber = VersionNumber.make(from: version)!
if FileSystem.fileExists(phpConfigExecutablePath) {
@ -32,6 +36,21 @@ class PhpInstallation {
// If so, the app SHOULD crash, so that the users report what's up.
self.versionNumber = try! VersionNumber.parse(longVersionString)
}
}
if FileSystem.fileExists(phpExecutablePath) {
let testCommand = Command.execute(
path: phpExecutablePath,
arguments: ["-v"],
trimNewlines: false,
withStandardError: true
).trimmingCharacters(in: .whitespacesAndNewlines)
// If the "dyld: Library not loaded" issue pops up, we have an unhealthy PHP installation
// and we will need to reinstall this version of PHP via Homebrew.
if testCommand.contains("Library not loaded") && testCommand.contains("dyld") {
self.isHealthy = false
Log.err("The PHP installation of \(self.versionNumber.short) is not healthy!")
}
}
}
}

View File

@ -0,0 +1,134 @@
//
// InternalSwitcher+Valet.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 14/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
extension InternalSwitcher {
typealias FixApplied = Bool
public func ensureValetConfigurationIsValidForPhpVersion(_ version: String) async -> FixApplied {
// Early exit if Valet is not installed
if !Valet.installed {
assertionFailure("Cannot ensure that Valet configuration is valid if Valet is not installed.")
return false
}
let corrections = [
await self.disableDefaultPhpFpmPool(version),
await self.ensureConfigurationFilesExist(version)
]
return corrections.contains(true)
}
// MARK: - PHP FPM pool
public func disableDefaultPhpFpmPool(_ version: String) async -> FixApplied {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
if FileSystem.fileExists(pool) {
Log.info("A default `www.conf` file was found in the php-fpm.d directory for PHP \(version).")
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 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 FileSystem.remove(new)
}
try FileSystem.move(from: existing, to: new)
Log.info("Success: A default `www.conf` file was disabled for PHP \(version).")
return true
} catch {
Log.err(error)
return false
}
}
return false
}
func getExpectedConfigurationFiles(for version: String) -> [ExpectedConfigurationFile] {
return [
ExpectedConfigurationFile(
destination: "/php-fpm.d/valet-fpm.conf",
source: "/cli/stubs/etc-phpfpm-valet.conf",
replacements: [
"VALET_USER": Paths.whoami,
"VALET_HOME_PATH": "~/.config/valet".replacingTildeWithHomeDirectory,
"valet.sock": "valet\(version.replacingOccurrences(of: ".", with: "")).sock"
],
applies: { Valet.shared.version!.major > 2 }
),
ExpectedConfigurationFile(
destination: "/conf.d/error_log.ini",
source: "/cli/stubs/etc-phpfpm-error_log.ini",
replacements: [
"VALET_USER": Paths.whoami,
"VALET_HOME_PATH": "~/.config/valet".replacingTildeWithHomeDirectory
],
applies: { return true }
),
ExpectedConfigurationFile(
destination: "/conf.d/php-memory-limits.ini",
source: "/cli/stubs/php-memory-limits.ini",
replacements: [:],
applies: { return true }
)
]
}
func ensureConfigurationFilesExist(_ version: String) async -> FixApplied {
let files = self.getExpectedConfigurationFiles(for: version)
// For each of the files, attempt to fix anything that is wrong
let outcomes = files.map { file in
let configFileExists = FileSystem.fileExists("\(Paths.etcPath)/php/\(version)/" + file.destination)
if configFileExists {
return false
}
Log.info("Config file `\(file.destination)` does not exist, will attempt to automatically fix!")
if !file.applies() {
return false
}
do {
var contents = try FileSystem.getStringFromFile("~/.composer/vendor/laravel/valet" + file.source)
for (original, replacement) in file.replacements {
contents = contents.replacingOccurrences(of: original, with: replacement)
}
try FileSystem.writeAtomicallyToFile(
"\(Paths.etcPath)/php/\(version)" + file.destination,
content: contents
)
} catch {
Log.err("Automatically fixing \(file.destination) did not work.")
return false
}
return true
}
// If any fixes were applied, return true
return outcomes.contains(true)
}
}
public struct ExpectedConfigurationFile {
let destination: String
let source: String
let replacements: [String: String]
let applies: () -> Bool
}

View File

@ -25,10 +25,9 @@ class InternalSwitcher: PhpSwitcher {
let versions = getVersionsToBeHandled(version)
await withTaskGroup(of: String.self, body: { group in
for available in PhpEnv.shared.availablePhpVersions {
for available in PhpEnvironments.shared.availablePhpVersions {
group.addTask {
await self.disableDefaultPhpFpmPool(available)
await self.stopPhpVersion(available)
await self.unlinkAndStopPhpVersion(available)
return available
}
}
@ -42,12 +41,19 @@ class InternalSwitcher: PhpSwitcher {
Log.info("Linking the new version \(version)!")
for formula in versions {
if Valet.installed {
Log.info("Ensuring that the Valet configuration is valid...")
_ = await self.ensureValetConfigurationIsValidForPhpVersion(formula)
}
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
await self.startPhpVersion(formula, primary: (version == formula))
await self.linkAndStartPhpVersion(formula, primary: (version == formula))
}
Log.info("Restarting nginx, just to be sure!")
await brew("services restart nginx", sudo: true)
if Valet.installed {
Log.info("Restarting nginx, just to be sure!")
await brew("services restart nginx", sudo: true)
}
Log.info("The new version(s) have been linked!")
})
@ -69,56 +75,36 @@ class InternalSwitcher: PhpSwitcher {
return versions
}
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
return FileSystem.fileExists(pool)
func unlinkAndStopPhpVersion(_ version: String) async {
let formula = (version == PhpEnvironments.brewPhpAlias) ? "php" : "php@\(version)"
await brew("unlink \(formula)")
if Valet.installed {
await brew("services stop \(formula)", sudo: true)
Log.info("Unlinked and stopped services for \(formula)")
} else {
Log.info("Unlinked \(formula)")
}
}
func disableDefaultPhpFpmPool(_ version: String) async {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
if FileSystem.fileExists(pool) {
Log.info("A default `www.conf` file was found in the php-fpm.d directory for PHP \(version).")
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 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 FileSystem.remove(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)
func linkAndStartPhpVersion(_ version: String, primary: Bool) async {
let formula = (version == PhpEnvironments.brewPhpAlias) ? "php" : "php@\(version)"
if primary {
Log.info("\(formula) is the primary formula, linking...")
await brew("link \(formula) --overwrite --force")
} else {
Log.info("\(formula) is an isolated PHP version, not linking!")
}
if Valet.installed {
await brew("services start \(formula)", sudo: true)
if Valet.enabled(feature: .isolatedSites) && primary {
let socketVersion = version.replacingOccurrences(of: ".", with: "")
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).")
}
}
}
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) async {
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
if primary {
Log.info("\(formula) is the primary formula, linking and starting services...")
await brew("link \(formula) --overwrite --force")
} else {
Log.info("\(formula) is an isolated PHP version, starting services only...")
}
await brew("services start \(formula)", sudo: true)
if Valet.enabled(feature: .isolatedSites) && primary {
let socketVersion = version.replacingOccurrences(of: ".", with: "")
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).")
}
}
}

View File

@ -19,6 +19,10 @@ class TestableCommand: CommandProtocol {
self.execute(path: path, arguments: arguments, trimNewlines: false)
}
public func execute(path: String, arguments: [String], trimNewlines: Bool, withStandardError: Bool) -> String {
self.execute(path: path, arguments: arguments, trimNewlines: trimNewlines)
}
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")

View File

@ -15,10 +15,93 @@ public struct TestableConfiguration: Codable {
var commandOutput: [String: String]
var preferenceOverrides: [PreferenceName: Bool]
init(
architecture: String,
filesystem: [String: FakeFile],
shellOutput: [String: BatchFakeShellOutput],
commandOutput: [String: String],
preferenceOverrides: [PreferenceName: Bool],
phpVersions: [VersionNumber]
) {
self.architecture = architecture
self.filesystem = filesystem
self.shellOutput = shellOutput
self.commandOutput = commandOutput
self.preferenceOverrides = preferenceOverrides
phpVersions.enumerated().forEach { (index, version) in
self.addPhpVersion(version, primary: index == 0)
}
}
private enum CodingKeys: String, CodingKey {
case architecture, filesystem, shellOutput, commandOutput, preferenceOverrides
}
// MARK: Add PHP versions
private var primaryPhpVersion: VersionNumber?
private var secondaryPhpVersions: [VersionNumber] = []
mutating func addPhpVersion(_ version: VersionNumber, primary: Bool) {
if primary {
if primaryPhpVersion != nil {
fatalError("You cannot add multiple primary PHP versions to a testable configuration!")
}
primaryPhpVersion = version
} else {
self.secondaryPhpVersions.append(version)
}
self.filesystem = self.filesystem.merging([
"/opt/homebrew/opt/php@\(version.short)/bin/php"
: .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php"),
"/opt/homebrew/Cellar/php/\(version.long)/bin/php"
: .fake(.binary),
"/opt/homebrew/Cellar/php/\(version.long)/bin/php-config"
: .fake(.binary),
"/opt/homebrew/etc/php/\(version.short)/php-fpm.d/www.conf"
: .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/php-fpm.d/valet-fpm.conf"
: .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/php.ini"
: .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/conf.d/php-memory-limits.ini"
: .fake(.text)
]) { (_, new) in new }
if primary {
self.shellOutput["ls /opt/homebrew/opt | grep php"]
= .instant("php")
self.filesystem["/opt/homebrew/opt/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)")
self.filesystem["/opt/homebrew/opt/php/bin/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php")
self.filesystem["/opt/homebrew/bin/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php")
self.filesystem["/opt/homebrew/bin/php-config"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php-config")
self.commandOutput["/opt/homebrew/bin/php-config --version"]
= version.long
self.commandOutput["/opt/homebrew/bin/php -r echo php_ini_scanned_files();"] =
"""
/opt/homebrew/etc/php/\(version.short)/conf.d/php-memory-limits.ini,
"""
} else {
self.shellOutput["ls /opt/homebrew/opt | grep php@"] =
BatchFakeShellOutput.instant(
self.secondaryPhpVersions
.map { "php@\($0.short)" }
.joined(separator: "\n")
)
}
}
// MARK: Interactions
func apply() {
Log.separator()
Log.info("USING TESTABLE CONFIGURATION...")
Homebrew.fake = true
Log.separator()
Log.info("Applying fake shell...")
ActiveShell.useTestable(shellOutput)
@ -26,18 +109,23 @@ public struct TestableConfiguration: Codable {
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()
Log.info("Applying temporary preference overrides...")
preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in
Preferences.shared.cachedPreferences[key] = value
}
if Valet.shared.installed {
Log.info("Applying fake scanner...")
ValetScanner.useFake()
Log.info("Applying fake services manager...")
ServicesManager.useFake()
Log.info("Applying fake Valet domain interactor...")
ValetInteractor.useFake()
}
}
// MARK: Persist and load
func toJson(pretty: Bool = false) -> String {
let data = try! JSONEncoder().encode(self)

View File

@ -13,11 +13,13 @@
</head>
<body>
<br>
<p><b>Do you enjoy using the app?</b> Leave a <a href="https://phpmon.app/github">star on GitHub</a>!</p>
<p><b>Do you enjoy using the app? Is it helping you save time?</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 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>
<p><b>Get the latest on Twitter or Mastodon.</b> Give me a <a href="https://twitter.com/nicoverbruggen">follow on Twitter</a> or <a href="https://phpc.social/@nicoverbruggen">Mastodon</a> to learn about what's brewing and when new updates drop.</p>
<p><b>Special thanks</b> to all current and past <a href="https://github.com/sponsors/nicoverbruggen#sponsors"><b>sponsors</b></a> of PHP Monitor, who have helped to make further development of the app possible.</p>
<p><b>Made possible by these GitHub Sponsors</b>: @abdusfauzi, @abicons, @adrolli, @andresayej, @andyunleashed, @anzacorp, @argirisp, @AshPowell, @aurawindsurfing, @awsmug, @barrycarton, @BertvanHoekelen, @calebporzio, @caseyalee, @cgreuling, @cjcox17, @Diewy, @drfraker, @driftingly, @duellsy, @edalzell, @EYOND, @faithfm, @frankmichel, @gwleuverink, @hopkins385, @intrepidws, @jacksleight, @JacobBennett, @jasonvarga, @jeromegamez, @jimmyaldape, @jimmysawczuk, @joetannenbaum, @jolora, @joshuablum, @jpeinelt, @jreviews, @JustSteveKing, @Kajvdh, @KFoobar, @Laravel-Backpack, @leganz, @martinleveille, @mathiasonea, @matthewmnewman, @mcastillo1030, @megabubbletea, @mennen-online, @mike-healy, @mostafakram, @mpociot, @MrMicky-FR, @MrMooky, @murdercode, @nckrtl, @nhedger, @ninjaparade, @ozanuzer, @pepatel, @philbraun, @pickuse2013, @pk-informatics, @Plytas, @rderimay, @rickyjohnston, @rico, @RobertBoes, @runofthemill, @SahinU88, @sdebacker, @sdevore, @shadracnicholas, @simonhamp, @SRWieZ, @stefanbauer, @StriveMedia, @swilla, @Tailcode-Studio, @theutz, @ThomasEnssner, @tillkruss, @timothyrowan, @ttnppedr, @vincent-tarrit, @WheresMarco, @xPand4B, @xuandung38, @yeslandi89, @zackkatz, @zacksmash, @zaherg.<br/>(Some names have been omitted due to their sponsorships being private. Thank you all!)
<br/>
</body>
</html>

View File

@ -62,9 +62,6 @@ class App {
// MARK: Variables
/** Technical information about the current environment. */
var environment = EnvironmentManager()
/** The list of preferences that are currently active. */
var preferences: [PreferenceName: Bool]!
@ -80,12 +77,18 @@ class App {
/** The window controller of the warnings window. */
var warningsWindowController: WarningsWindowController?
/** The window controller of the warnings window. */
var versionManagerWindowController: PhpVersionManagerWindowController?
/** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = []
/** The warning manager, responsible for keeping track of warnings. */
var warnings = WarningManager.shared
/** The filesystem watchers, responsible for keeping track of changes to the PHP installation. */
var watchers: [FSNotifier.Kind: FSNotifier] = [:]
/** Timer that will periodically reload info about the user's PHP installation. */
var timer: Timer?

View File

@ -11,6 +11,10 @@ import UserNotifications
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
static var instance: AppDelegate {
return NSApplication.shared.delegate as! AppDelegate
}
// MARK: - Variables
/**
@ -38,11 +42,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
let valet: Valet
/**
The PhpEnv singleton that handles PHP version
The Brew singleton that contains all information about Homebrew
and its configuration on your system.
*/
let brew: Brew
/**
The PhpEnvironments singleton that handles PHP version
detection, as well as switching. It is initialized
when the app is ready and passed all checks.
*/
var phpEnvironment: PhpEnv! = nil
var phpEnvironments: PhpEnvironments! = nil
/**
The logger is responsible for different levels of logging.
@ -58,7 +68,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
override init() {
#if DEBUG
logger.verbosity = .performance
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
Self.initializeTestingProfile(profile.replacingOccurrences(of: "--configuration:", with: ""))
}
@ -88,11 +97,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
self.menu = MainMenu.shared
self.paths = Paths.shared
self.valet = Valet.shared
self.brew = Brew.shared
super.init()
}
func initializeSwitcher() {
self.phpEnvironment = PhpEnv.shared
self.phpEnvironments = PhpEnvironments.shared
}
static func initializeTestingProfile(_ path: String) {
@ -110,9 +120,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Make sure notifications will work
setupNotifications()
Task { // Make sure the menu performs its initial checks
await menu.startup()
}
}
// MARK: - Menu Items
@IBOutlet weak var menuItemSites: NSMenuItem!
/**
Ensure relevant menu items in the main menu bar (not the pop-up menu)
are disabled or hidden when needed.
*/
public func configureMenuItems(standalone: Bool) {
if standalone {
menuItemSites.isHidden = true
}
}
}

View File

@ -24,9 +24,13 @@ class AppUpdater {
Log.info("The app will search for updates...")
let caskUrl = App.identifier.contains(".dev")
? Constants.Urls.DevBuildCaskFile
: Constants.Urls.StableBuildCaskFile
var caskUrl = Constants.Urls.StableBuildCaskFile
if App.identifier.contains(".phpmon.eap") {
caskUrl = Constants.Urls.EarlyAccessCaskFile
} else if App.identifier.contains(".phpmon.dev") {
caskUrl = Constants.Urls.DevBuildCaskFile
}
guard let caskFile = await CaskFile.from(url: caskUrl) else {
Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.")
@ -73,7 +77,7 @@ class AppUpdater {
.localized(latestVersionOnline.humanReadable),
subtitle: "updater.alerts.newer_version_available.subtitle"
.localized,
description: HomebrewDiagnostics.customCaskInstalled
description: BrewDiagnostics.customCaskInstalled
? "updater.installation_source.brew".localized(command)
: "updater.installation_source.direct".localized
)
@ -88,11 +92,15 @@ class AppUpdater {
.withSecondary(
text: "updater.alerts.buttons.release_notes".localized,
action: { _ in
let urlSegments = self.caskFile.url.split(separator: "/")
let tag = urlSegments[urlSegments.count - 2] // ../download/{tag}/{file.zip}
NSWorkspace.shared.open(
Constants.Urls.GitHubReleases.appendingPathComponent("/tag/\(tag)")
)
NSWorkspace.shared.open({
if App.identifier.contains(".eap") {
return Constants.Urls.EarlyAccessChangelog
} else {
let urlSegments = self.caskFile.url.split(separator: "/")
let tag = urlSegments[urlSegments.count - 2] // ../download/{tag}/{file.zip}
return Constants.Urls.GitHubReleases.appendingPathComponent("/tag/\(tag)")
}
}())
}
)
.withTertiary(text: "updater.alerts.buttons.dismiss".localized, action: { vc in
@ -179,11 +187,19 @@ class AppUpdater {
// Cleanup the upgrade.success file
if FileSystem.fileExists("~/.config/phpmon/updater/upgrade.success") {
Task { @MainActor in
LocalNotification.send(
title: "notification.phpmon_updated.title".localized,
subtitle: "notification.phpmon_updated.desc".localized(App.shortVersion),
preference: nil
)
if App.identifier.contains(".phpmon.eap") || App.identifier.contains(".phpmon.dev") {
LocalNotification.send(
title: "notification.phpmon_updated.title".localized,
subtitle: "notification.phpmon_updated_dev.desc".localized(App.shortVersion, App.bundleVersion),
preference: nil
)
} else {
LocalNotification.send(
title: "notification.phpmon_updated.title".localized,
subtitle: "notification.phpmon_updated.desc".localized(App.shortVersion),
preference: nil
)
}
}
Log.info("The `upgrade.success` file was found! An update was installed. Cleaning up...")

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@ -34,18 +34,6 @@
</items>
</menu>
</menuItem>
<menuItem title="File" id="XRy-v5-KNb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" id="zA7-mh-f1x">
<items>
<menuItem title="Close" keyEquivalent="w" id="2FI-pQ-tuO">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="ZHq-so-Sba"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Sites" id="9gy-d3-Pos">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Sites" id="YTZ-bb-TOG">
@ -82,12 +70,12 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="8Pm-83-BlM">
<items>
<menuItem title="Undo" keyEquivalent="z" id="jCt-Yf-FSE">
<menuItem title="Undo" enabled="NO" keyEquivalent="z" id="jCt-Yf-FSE">
<connections>
<action selector="undo:" target="Ady-hI-5gd" id="O3z-27-Ug0"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="fCh-1M-Qyg">
<menuItem title="Redo" enabled="NO" keyEquivalent="Z" id="fCh-1M-Qyg">
<connections>
<action selector="redo:" target="Ady-hI-5gd" id="utE-Bv-fdY"/>
</connections>
@ -297,6 +285,18 @@
</items>
</menu>
</menuItem>
<menuItem title="Window" id="XRy-v5-KNb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" id="zA7-mh-f1x">
<items>
<menuItem title="Close" keyEquivalent="w" id="2FI-pQ-tuO">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="ZHq-so-Sba"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
@ -317,7 +317,11 @@
</application>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
<connections>
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
</connections>
</customObject>
</objects>
<point key="canvasLocation" x="-360" y="-94"/>
</scene>

View File

@ -43,3 +43,9 @@ struct EnvironmentCheck {
return await !self.command()
}
}
struct EnvironmentCheckGroup {
let name: String
let condition: () -> Bool
let checks: [EnvironmentCheck]
}

View File

@ -1,35 +0,0 @@
//
// 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 false
}
// 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
}

View File

@ -107,9 +107,9 @@ class ServicesManager: ObservableObject {
var formulae: [HomebrewFormula] {
var formulae = [
Homebrew.Formulae.php,
Homebrew.Formulae.nginx,
Homebrew.Formulae.dnsmasq
HomebrewFormulae.php,
HomebrewFormulae.nginx,
HomebrewFormulae.dnsmasq
]
let additionalFormulae = (Preferences.custom.services ?? []).map({ item in

View File

@ -19,24 +19,32 @@ class Startup {
*/
func checkEnvironment() async -> Bool {
// Do the important system setup checks
Log.info("[ARCH] The user is running PHP Monitor with the architecture: \(App.architecture)")
Log.info("The user is running PHP Monitor with the architecture: \(App.architecture)")
for check in self.checks {
if await check.succeeds() {
Log.info("[OK] \(check.name)")
continue
for group in self.groups {
if group.condition() {
Log.info("Now running \(group.checks.count) \(group.name) checks!")
for check in group.checks {
let start = Measurement()
if await check.succeeds() {
Log.info("[PASS] \(check.name) (\(start.milliseconds) ms)")
continue
}
// If we get here, something's gone wrong and the check has failed...
Log.info("[FAIL] \(check.name) (\(start.milliseconds) ms)")
await showAlert(for: check)
return false
}
} else {
Log.info("Skipping \(group.name) checks!")
}
// If we get here, something's gone wrong and the check has failed...
Log.info("[FAIL] \(check.name)")
await showAlert(for: check)
return false
}
// If we get here, nothing has gone wrong. That's what we want!
initializeSwitcher()
Log.separator(as: .info)
Log.info("PHP Monitor has determined the application has successfully passed all checks.")
Log.separator(as: .info)
return true
}
@ -81,188 +89,208 @@ class Startup {
// MARK: - Check (List)
public var checks: [EnvironmentCheck] = [
// =================================================================================
// The Homebrew binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !FileSystem.fileExists(Paths.brew) },
name: "`\(Paths.brew)` exists",
titleText: "alert.homebrew_missing.title".localized,
subtitleText: "alert.homebrew_missing.subtitle".localized,
descriptionText: "alert.homebrew_missing.info".localized(
App.architecture
.replacingOccurrences(of: "x86_64", with: "Intel")
.replacingOccurrences(of: "arm64", with: "Apple Silicon"),
Paths.brew
public var groups: [EnvironmentCheckGroup] = [
EnvironmentCheckGroup(name: "core", condition: { return true }, checks: [
// =================================================================================
// The Homebrew binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !FileSystem.fileExists(Paths.brew) },
name: "`\(Paths.brew)` exists",
titleText: "alert.homebrew_missing.title".localized,
subtitleText: "alert.homebrew_missing.subtitle".localized,
descriptionText: "alert.homebrew_missing.info".localized(
App.architecture
.replacingOccurrences(of: "x86_64", with: "Intel")
.replacingOccurrences(of: "arm64", with: "Apple Silicon"),
Paths.brew
),
buttonText: "alert.homebrew_missing.quit".localized,
requiresAppRestart: true
),
buttonText: "alert.homebrew_missing.quit".localized,
requiresAppRestart: true
),
// =================================================================================
// The PHP binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !FileSystem.fileExists(Paths.php) },
name: "`\(Paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
descriptionText: "startup.errors.php_binary.desc".localized(Paths.php)
),
// =================================================================================
// Make sure we can detect one or more PHP installations.
// =================================================================================
EnvironmentCheck(
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(
Paths.optPath
),
descriptionText: "startup.errors.php_opt.desc".localized
),
// =================================================================================
// The Valet binary must exist.
// =================================================================================
EnvironmentCheck(
command: {
return !(FileSystem.fileExists(Paths.valet) || FileSystem.fileExists("~/.composer/vendor/bin/valet"))
},
name: "`valet` binary exists",
titleText: "startup.errors.valet_executable.title".localized,
subtitleText: "startup.errors.valet_executable.subtitle".localized,
descriptionText: "startup.errors.valet_executable.desc".localized(
Paths.valet
// =================================================================================
// Make sure we can detect one or more PHP installations.
// =================================================================================
EnvironmentCheck(
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(
Paths.optPath
),
descriptionText: "startup.errors.php_opt.desc".localized
)
),
// =================================================================================
// Check if Valet and Homebrew need manual password intervention. If they do, then
// PHP Monitor will be unable to run these commands, which prevents PHP Monitor from
// functioning correctly. Let the user know that they need to run `valet trust`.
// =================================================================================
EnvironmentCheck(
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 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,
descriptionText: "startup.errors.sudoers_valet.desc".localized
),
// =================================================================================
// Verify if the Homebrew services are running (as root).
// =================================================================================
EnvironmentCheck(
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,
descriptionText: "startup.errors.services_json_error.desc".localized
),
// =================================================================================
// Determine that Valet is installed
// =================================================================================
EnvironmentCheck(
command: {
return !FileSystem.directoryExists("~/.config/valet")
},
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,
descriptionText: "startup.errors.valet_not_installed.desc".localized
),
// =================================================================================
// Determine that the Valet configuration JSON file is valid.
// =================================================================================
EnvironmentCheck(
command: {
// Detect additional binaries (e.g. Composer)
Paths.shared.detectBinaryPaths()
// Load the configuration file (config.json)
Valet.shared.loadConfiguration()
// This check fails when the config is nil
return Valet.shared.config == nil
},
name: "`config.json` was valid",
titleText: "startup.errors.valet_json_invalid.title".localized,
subtitleText: "startup.errors.valet_json_invalid.subtitle".localized,
descriptionText: "startup.errors.valet_json_invalid.desc".localized
),
// =================================================================================
// Check for `which` alias issue
// =================================================================================
EnvironmentCheck(
command: {
let nodePath = await Shell.pipe("which node").out
return App.architecture == "x86_64"
]),
EnvironmentCheckGroup(name: "valet", condition: { return Valet.installed }, checks: [
// =================================================================================
// The PHP binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !FileSystem.fileExists(Paths.php) },
name: "`\(Paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
descriptionText: "startup.errors.php_binary.desc".localized(Paths.php)
),
// =================================================================================
// Ensure that the main PHP installation is not broken.
// =================================================================================
EnvironmentCheck(
command: {
return await Shell.pipe("\(Paths.binPath)/php -v").err
.contains("Library not loaded")
},
name: "`no dyld issue detected",
titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized(
Paths.optPath
),
descriptionText: "startup.errors.dyld_library.desc".localized
),
// =================================================================================
// The Valet binary must exist.
// =================================================================================
EnvironmentCheck(
command: {
return !(FileSystem.fileExists(Paths.valet)
|| FileSystem.fileExists("~/.composer/vendor/bin/valet"))
},
name: "`valet` binary exists",
titleText: "startup.errors.valet_executable.title".localized,
subtitleText: "startup.errors.valet_executable.subtitle".localized,
descriptionText: "startup.errors.valet_executable.desc".localized(
Paths.valet
)
),
// =================================================================================
// Check if Valet and Homebrew need manual password intervention. If they do, then
// PHP Monitor will be unable to run these commands, which prevents PHP Monitor from
// functioning correctly. Let the user know that they need to run `valet trust`.
// =================================================================================
EnvironmentCheck(
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 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,
descriptionText: "startup.errors.sudoers_valet.desc".localized
),
// =================================================================================
// Determine that Valet is installed
// =================================================================================
EnvironmentCheck(
command: {
return !FileSystem.directoryExists("~/.config/valet")
},
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,
descriptionText: "startup.errors.valet_not_installed.desc".localized
),
// =================================================================================
// Determine that the Valet configuration JSON file is valid.
// =================================================================================
EnvironmentCheck(
command: {
// Detect additional binaries (e.g. Composer)
Paths.shared.detectBinaryPaths()
// Load the configuration file (config.json)
Valet.shared.loadConfiguration()
// This check fails when the config is nil
return Valet.shared.config == nil
},
name: "`config.json` was valid",
titleText: "startup.errors.valet_json_invalid.title".localized,
subtitleText: "startup.errors.valet_json_invalid.subtitle".localized,
descriptionText: "startup.errors.valet_json_invalid.desc".localized
),
// =================================================================================
// Verify if the Homebrew services are running (as root).
// =================================================================================
EnvironmentCheck(
command: {
await BrewDiagnostics.loadInstalledTaps()
return await BrewDiagnostics.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,
descriptionText: "startup.errors.services_json_error.desc".localized
),
// =================================================================================
// Check for `which` alias issue
// =================================================================================
EnvironmentCheck(
command: {
let nodePath = await Shell.pipe("which node").out
return App.architecture == "x86_64"
&& 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,
subtitleText: "startup.errors.which_alias_issue.subtitle".localized,
descriptionText: "startup.errors.which_alias_issue.desc".localized
),
// =================================================================================
// Determine that Valet works correctly (no issues in platform detected)
// =================================================================================
EnvironmentCheck(
command: {
return await Shell.pipe("valet --version").out
.contains("Composer detected issues in your platform")
},
name: "`no global composer issues",
titleText: "startup.errors.global_composer_platform_issues.title".localized,
subtitleText: "startup.errors.global_composer_platform_issues.subtitle".localized,
descriptionText: "startup.errors.global_composer_platform_issues.desc".localized
),
// =================================================================================
// Determine the Valet version and ensure it isn't unknown.
// =================================================================================
EnvironmentCheck(
command: {
let output = await Shell.pipe("valet --version").out
// Failure condition #1: does not contain Laravel Valet
if !output.contains("Laravel Valet") {
return true
}
// Failure condition #2: version cannot be parsed
let versionString = output
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "Laravel Valet")[1]
.trimmingCharacters(in: .whitespaces)
// Extract the version number
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(versionString)!)
// Get the actual version
return Valet.shared.version == nil
},
name: "`valet --version` was loaded",
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
)
},
name: "`env: node` issue does not apply",
titleText: "startup.errors.which_alias_issue.title".localized,
subtitleText: "startup.errors.which_alias_issue.subtitle".localized,
descriptionText: "startup.errors.which_alias_issue.desc".localized
),
// =================================================================================
// Determine that Valet works correctly (no issues in platform detected)
// =================================================================================
EnvironmentCheck(
command: {
return await Shell.pipe("valet --version").out
.contains("Composer detected issues in your platform")
},
name: "no global composer issues",
titleText: "startup.errors.global_composer_platform_issues.title".localized,
subtitleText: "startup.errors.global_composer_platform_issues.subtitle".localized,
descriptionText: "startup.errors.global_composer_platform_issues.desc".localized
),
// =================================================================================
// Determine the Valet version and ensure it isn't unknown.
// =================================================================================
EnvironmentCheck(
command: {
let output = await Shell.pipe("valet --version").out
// Failure condition #1: does not contain Laravel Valet
if !output.contains("Laravel Valet") {
return true
}
// Failure condition #2: version cannot be parsed
let versionString = output
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "Laravel Valet")[1]
.trimmingCharacters(in: .whitespaces)
// Extract the version number
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(versionString)!)
// Get the actual version
return Valet.shared.version == nil
},
name: "`valet --version` was loaded",
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
)
])
]
}

View File

@ -59,8 +59,12 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
return []
}
return PhpEnv.shared.validVersions(for: site.preferredPhpVersion).filter({ version in
version.short != PhpEnv.phpInstall.version.short
guard let install = PhpEnvironments.phpInstall else {
return []
}
return PhpEnvironments.shared.validVersions(for: site.preferredPhpVersion).filter({ version in
version.short != install.version.short
})
}

View File

@ -105,7 +105,7 @@ extension DomainListVC {
private func addIsolate(to menu: NSMenu, with site: ValetSite) {
var items: [NSMenuItem] = []
for version in PhpEnv.shared.availablePhpVersions.reversed() {
for version in PhpEnvironments.shared.availablePhpVersions.reversed() {
let item = PhpMenuItem(
title: "domain_list.always_use_php".localized(version),
action: #selector(self.isolateSite),
@ -133,7 +133,7 @@ extension DomainListVC {
if site.isolatedPhpVersion != nil {
menu.addItem(NSMenuItem(
title: "domain_list.use_in_terminal".localized(site.servingPhpVersion),
title: "domain_list.use_in_terminal".localized(site.isolatedPhpVersion!.versionNumber.text),
action: #selector(self.useInTerminal)
))
}

View File

@ -27,7 +27,7 @@ import Foundation
return
}
PhpEnv.shared.isBusy = true
PhpEnvironments.shared.isBusy = true
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
@ -105,7 +105,7 @@ import Foundation
// MARK: Main Menu Update
private func removeBusyStatus() {
PhpEnv.shared.isBusy = false
PhpEnvironments.shared.isBusy = false
Task { @MainActor in
MainMenu.shared.updatePhpVersionInStatusBar()
}

View File

@ -0,0 +1,125 @@
//
// BrewPermissionFixer.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 23/04/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class BrewPermissionFixer {
var broken: [DueOwnershipFormula] = []
/**
Takes ownership of the /BREW_PATH/Cellar/php/x.y.z/bin folder, for all PHP versions.
This might not be required if the user has only used that version of PHP
with site isolation, so this method checks if it's required first.
This is a required operation for *all* PHP versions when PHP Version Manager is running
operations, since any installation or upgrade may prompt the installation or upgrade
of other PHP versions, in which case the permissions need to set correctly.
*/
public func fixPermissions() async throws {
await determineBrokenFormulae()
if broken.isEmpty {
return
}
let appleScript = NSAppleScript(
source: "do shell script \"\(buildBrokenFormulaeScript())\" with administrator privileges"
)
let eventResult: NSAppleEventDescriptor? = appleScript?
.executeAndReturnError(nil)
if eventResult == nil {
throw HomebrewPermissionError(
kind: .applescriptNilError
)
}
Log.info("Ownership was taken of the folder(s) at: " + broken
.map({ $0.path })
.joined(separator: ", "))
}
/**
Determines which formulae's permissions are broken.
To do so, PHP Monitor resolves which directory needs to be checked and verifies
whether the Homebrew binary directory for the given PHP version is owned by root.
*/
private func determineBrokenFormulae() async {
let formulae = PhpEnvironments.shared.cachedPhpInstallations.keys
for formula in formulae {
let realFormula = formula == PhpEnvironments.brewPhpAlias
? "php"
: "php@\(formula)"
let binFolderOwned = isOwnedByRoot(path: "\(Paths.optPath)/\(realFormula)/bin")
let sbinFolderOwned = isOwnedByRoot(path: "\(Paths.optPath)/\(realFormula)/sbin")
if binFolderOwned || sbinFolderOwned {
Log.warn("\(formula) is owned by root")
if binFolderOwned {
broken.append(DueOwnershipFormula(
formula: realFormula,
path: "\(Paths.optPath)/\(realFormula)/bin"
))
}
if sbinFolderOwned {
broken.append(DueOwnershipFormula(
formula: realFormula,
path: "\(Paths.optPath)/\(realFormula)/sbin"
))
}
}
}
}
/**
Generates the appropriate AppleScript script required to restore permissions.
This script also stops the services prior to taking ownership, which is requirement.
*/
private func buildBrokenFormulaeScript() -> String {
return broken
.map { b in
return """
\(Paths.brew) services stop \(b.formula) \
&& chown -R \(Paths.whoami):admin \(b.path)
"""
}
.joined(
separator: " && "
)
}
/**
Checks if the directory at the path is owned by the `root` user,
by checking the FS owner account name attribute.
*/
private func isOwnedByRoot(path: String) -> Bool {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
if let owner = attributes[.ownerAccountName] as? String {
return owner == "root"
}
} catch {
return true
}
return true
}
struct DueOwnershipFormula {
let formula: String
let path: String
}
}

View File

@ -0,0 +1,58 @@
//
// Homebrew.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class BrewFormulaeObservable: ObservableObject {
@Published var phpVersions: [BrewFormula] = []
var upgradeable: [BrewFormula] {
return phpVersions.filter { formula in
formula.hasUpgrade
}
}
}
class Brew {
static let shared = Brew()
/// Formulae that can be observed.
var formulae = BrewFormulaeObservable()
/// The version of Homebrew that was detected.
var version: VersionNumber?
/// Determine which version of Homebrew is installed.
public func determineVersion() async {
let output = await Shell.pipe("\(Paths.brew) --version")
self.version = try? VersionNumber.parse(output.out)
if let version = version {
Log.info("The user has Homebrew \(version.text) installed.")
if version.major < 4 {
Log.warn("Managing PHP versions is only officially supported with Homebrew 4 or newer!")
}
} else {
Log.warn("The Homebrew version could not be determined.")
}
}
/// Each formula for each PHP version that can be installed.
public static let phpVersionFormulae = [
"8.2": "php@8.2",
"8.1": "php@8.1",
"8.0": "php@8.0",
"7.4": "shivammathur/php/php@7.4",
"7.3": "shivammathur/php/php@7.3",
"7.2": "shivammathur/php/php@7.2",
"7.1": "shivammathur/php/php@7.1",
"7.0": "shivammathur/php/php@7.0",
"5.6": "shivammathur/php/php@5.6"
]
}

View File

@ -1,5 +1,5 @@
//
// AliasConflict.swift
// BrewDiagnostics.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 28/11/2021.
@ -8,7 +8,7 @@
import Foundation
class HomebrewDiagnostics {
class BrewDiagnostics {
/**
Determines the Homebrew taps the user has installed.
*/
@ -63,30 +63,25 @@ class HomebrewDiagnostics {
It is possible to upgrade PHP, but forget running `valet install`.
This results in a scenario where a rogue www.conf file exists.
*/
public static func checkForPhpFpmPoolConflicts() {
Log.info("Checking for PHP-FPM pool conflicts...")
public static func checkForValetMisconfiguration() async {
Log.info("Checking for PHP-FPM issues with Valet...")
guard let install = PhpEnvironments.phpInstall else {
Log.info("Will skip check for issues if no PHP version is linked.")
return
}
// We'll need to know what the primary PHP version is
let primary = PhpEnv.shared.currentInstall.version.short
let primary = install.version.short
// Versions to be handled
let switcher = InternalSwitcher()
var versions = switcher.getVersionsToBeHandled(primary)
versions = versions.filter { version in
return switcher.requiresDisablingOfDefaultPhpFpmPool(version)
}
if versions.isEmpty {
Log.info("No PHP-FPM pools need to be fixed. All OK.")
}
versions.forEach { version in
Task { // Fix each pool concurrently (but perform the tasks sequentially)
await switcher.disableDefaultPhpFpmPool(version)
await switcher.stopPhpVersion(version)
await switcher.startPhpVersion(version, primary: version == primary)
}
for version in switcher.getVersionsToBeHandled(primary)
where await switcher.ensureValetConfigurationIsValidForPhpVersion(version) {
Log.info("One or more fixes were applied for PHP \(version)!")
await switcher.unlinkAndStopPhpVersion(version)
await switcher.linkAndStartPhpVersion(version, primary: version == primary)
}
}
@ -108,13 +103,13 @@ class HomebrewDiagnostics {
from: tapAlias.data(using: .utf8)!
).first!
if tapPhp.version != PhpEnv.brewPhpAlias {
if tapPhp.version != PhpEnvironments.brewPhpAlias {
Log.warn("The `php` formula alias seems to be the different between the tap and core. "
+ "This could be a problem!")
Log.info("Determining whether both of these versions are installed...")
let bothInstalled = PhpEnv.shared.availablePhpVersions.contains(tapPhp.version)
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpAlias)
let bothInstalled = PhpEnvironments.shared.availablePhpVersions.contains(tapPhp.version)
&& PhpEnvironments.shared.availablePhpVersions.contains(PhpEnvironments.brewPhpAlias)
if bothInstalled {
Log.warn("Both conflicting aliases seem to be installed, warning the user!")

View File

@ -0,0 +1,69 @@
//
// BrewFormula.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
struct BrewFormula {
/// Name of the formula.
let name: String
/// The human readable name for this formula.
let displayName: String
/// The version of the formula that is currently installed.
let installedVersion: String?
/// The upgrade that is currently available, if it exists.
let upgradeVersion: String?
/// Whether the formula is currently installed.
var isInstalled: Bool {
return installedVersion != nil
}
/// Whether the formula can be upgraded.
var hasUpgrade: Bool {
return upgradeVersion != nil
}
/// The associated Homebrew folder with this PHP formula.
var homebrewFolder: String {
let resolved = name
.replacingOccurrences(of: "shivammathur/php/", with: "")
.replacingOccurrences(of: "php@" + PhpEnvironments.brewPhpAlias, with: "php")
return "\(Paths.optPath)/\(resolved)/bin"
}
/// The short version associated with this formula, if installed.
var shortVersion: String? {
guard let version = self.installedVersion else {
return nil
}
return VersionNumber.make(from: version)?.short ?? nil
}
/// A quick variable that you can check to see if the install is unhealthy.
/// Will report true if no health information is available.
var healthy: Bool {
return isHealthy() ?? true
}
/**
* Determines if this PHP installation is healthy.
* Uses the cached installation health check as basis.
*/
public func isHealthy() -> Bool? {
guard let shortVersion = self.shortVersion else {
return nil
}
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?.isHealthy ?? nil
}
}

View File

@ -0,0 +1,63 @@
//
// BrewFormulaeHandler.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
protocol HandlesBrewFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula]
func refreshPhpVersions(loadOutdated: Bool) async
}
extension HandlesBrewFormulae {
public func refreshPhpVersions(loadOutdated: Bool) async {
let items = await loadPhpVersions(loadOutdated: loadOutdated)
Task { @MainActor in
Brew.shared.formulae.phpVersions = items
}
}
}
class BrewFormulaeHandler: HandlesBrewFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] {
var outdated: [OutdatedFormula]?
if loadOutdated {
let command = """
\(Paths.brew) update >/dev/null && \
\(Paths.brew) outdated --json --formulae
"""
let rawJsonText = await Shell.pipe(command).out
.data(using: .utf8)!
outdated = try? JSONDecoder().decode(
OutdatedFormulae.self,
from: rawJsonText
).formulae.filter({ formula in
formula.name.starts(with: "php")
})
}
return Brew.phpVersionFormulae.map { (version, formula) in
let fullVersion = PhpEnvironments.shared.cachedPhpInstallations[version]?.versionNumber.text
var upgradeVersion: String?
if let version = fullVersion {
upgradeVersion = outdated?.first(where: { formula in
return formula.installed_versions.contains(version)
})?.current_version
}
return BrewFormula(
name: formula,
displayName: "PHP \(version)",
installedVersion: fullVersion,
upgradeVersion: upgradeVersion
)
}.sorted { $0.displayName > $1.displayName }
}
}

View File

@ -0,0 +1,49 @@
//
// BrewCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
protocol BrewCommand {
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws
}
extension BrewCommand {
internal func reportInstallationProgress(_ text: String) -> (Double, String)? {
if text.contains("Fetching") {
return (0.1, "phpman.steps.fetching".localized)
}
if text.contains("Downloading") {
return (0.25, "phpman.steps.downloading".localized)
}
if text.contains("Installing") {
return (0.60, "phpman.steps.installing".localized)
}
if text.contains("Pouring") {
return (0.80, "phpman.steps.pouring".localized)
}
if text.contains("Summary") {
return (0.90, "phpman.steps.summary".localized)
}
return nil
}
}
struct BrewCommandProgress {
let value: Double
let title: String
let description: String
public static func create(value: Double, title: String, description: String) -> BrewCommandProgress {
return BrewCommandProgress(value: value, title: title, description: description)
}
}
struct BrewCommandError: Error {
let error: String
let log: [String]
}

View File

@ -0,0 +1,172 @@
//
// HomebrewOperationManager.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 28/04/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class InstallAndUpgradeCommand: BrewCommand {
let title: String
let installing: [BrewFormula]
let upgrading: [BrewFormula]
let phpGuard: PhpGuard
/**
You can pass in which PHP versions need to be upgraded and which ones need to be installed.
The process will be executed in two steps: first upgrades, then installations.
Upgrades come first because... well, otherwise installations may very well break.
Each version that is installed will need to be checked afterwards (if it is OK).
*/
public init(
title: String,
upgrading: [BrewFormula],
installing: [BrewFormula]
) {
self.title = title
self.installing = installing
self.upgrading = upgrading
self.phpGuard = PhpGuard()
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Please wait..."
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "PHP Monitor is preparing Homebrew..."
))
// Try to run all upgrade and installation operations
try await self.upgradePackages(onProgress)
try await self.installPackages(onProgress)
// Re-check the installed versions
await PhpEnvironments.detectPhpVersions()
// After performing operations, attempt to run repairs if needed
try await self.repairBrokenPackages(onProgress)
// Finally, complete all operations
await self.completedOperations(onProgress)
}
private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit
if self.upgrading.isEmpty {
return
}
let command = """
export HOMEBREW_NO_INSTALL_UPGRADE=true; \
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) upgrade \(self.upgrading.map { $0.name }.joined(separator: " "))
"""
try await run(command, onProgress)
}
private func installPackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no installations are needed, early exit
if self.installing.isEmpty {
return
}
let command = """
export HOMEBREW_NO_INSTALL_UPGRADE=true; \
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) install \(self.installing.map { $0.name }.joined(separator: " ")) --force
"""
try await run(command, onProgress)
}
private func repairBrokenPackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// Determine which PHP installations are considered unhealthy
// Build a list of formulae to reinstall
let requiringRepair = PhpEnvironments.shared
.cachedPhpInstallations.values
.filter({ !$0.isHealthy })
.map { installation in
let formula = "php@\(installation.versionNumber.short)"
if installation.versionNumber.short == PhpEnvironments.brewPhpAlias {
return "php"
}
return formula
}
// If no repairs are needed, early exit
if requiringRepair.isEmpty {
return
}
// If the health comes back as negative, attempt to reinstall
let command = """
export HOMEBREW_NO_INSTALL_UPGRADE=true; \
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=true; \
\(Paths.brew) reinstall \(requiringRepair.joined(separator: " ")) --force
"""
try await run(command, onProgress)
}
private func run(_ command: String, _ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
var loggedMessages: [String] = []
let (process, _) = try! await Shell.attach(
command,
didReceiveOutput: { text, _ in
if !text.isEmpty {
Log.perf(text)
loggedMessages.append(text)
}
if let (number, text) = self.reportInstallationProgress(text) {
onProgress(.create(value: number, title: self.title, description: text))
}
},
withTimeout: .minutes(15)
)
if process.terminationStatus <= 0 {
loggedMessages = []
return
} else {
throw BrewCommandError(error: "The command failed to run correctly.", log: loggedMessages)
}
}
private func completedOperations(_ onProgress: @escaping (BrewCommandProgress) -> Void) async {
// Reload and restart PHP versions
onProgress(.create(value: 0.95, title: self.title, description: "Reloading PHP versions..."))
// Check which version of PHP are now installed
await PhpEnvironments.detectPhpVersions()
// Keep track of the currently installed version
await MainMenu.shared.refreshActiveInstallation()
// If a PHP version was active prior to running the operations, attempt to restore it
if let version = phpGuard.currentVersion {
await MainMenu.shared.switchToPhpVersionAndWait(version, silently: true)
}
// Also rebuild the content of the main menu
await MainMenu.shared.rebuild()
// Let the UI know that the installation has been completed
onProgress(.create(
value: 1,
title: "Operation completed!",
description: "The installation has succeeded."
))
}
}

View File

@ -0,0 +1,74 @@
//
// RemovePhpVersionCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class RemovePhpVersionCommand: BrewCommand {
let formula: String
let version: String
let phpGuard: PhpGuard
init(formula: String) {
self.version = formula
.replacingOccurrences(of: "php@", with: "")
.replacingOccurrences(of: "shivammathur/php/", with: "")
self.formula = formula
self.phpGuard = PhpGuard()
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Removing PHP \(version)..."
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "Please wait while Homebrew removes PHP \(version)..."
))
let command = """
export HOMEBREW_NO_INSTALL_UPGRADE=true; \
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) remove \(formula) --force --ignore-dependencies
"""
do {
try await BrewPermissionFixer().fixPermissions()
} catch {
return
}
var loggedMessages: [String] = []
let (process, _) = try! await Shell.attach(
command,
didReceiveOutput: { text, _ in
if !text.isEmpty {
Log.perf(text)
loggedMessages.append(text)
}
},
withTimeout: .minutes(5)
)
if process.terminationStatus <= 0 {
onProgress(.create(value: 0.95, title: progressTitle, description: "Reloading PHP versions..."))
await PhpEnvironments.detectPhpVersions()
await MainMenu.shared.refreshActiveInstallation()
if let version = phpGuard.currentVersion {
await MainMenu.shared.switchToPhpVersionAndWait(version, silently: true)
}
onProgress(.create(value: 1, title: progressTitle, description: "The operation has succeeded."))
} else {
throw BrewCommandError(error: "The command failed to run correctly.", log: loggedMessages)
}
}
}

View File

@ -0,0 +1,25 @@
//
// FakeCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class FakeCommand: BrewCommand {
let version: String
init(version: String) {
self.version = version
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
onProgress(.create(value: 0.2, title: "Hello", description: "Doing the work"))
await delay(seconds: 2)
onProgress(.create(value: 0.5, title: "Hello", description: "Doing some more work"))
await delay(seconds: 1)
onProgress(.create(value: 1, title: "Hello", description: "Job's done"))
}
}

View File

@ -75,7 +75,7 @@ class FakeValetInteractor: ValetInteractor {
override func isolate(site: ValetSite, version: String) async throws {
await delay(seconds: delayTime)
site.isolatedPhpVersion = PhpEnv.shared.cachedPhpInstallations[version]
site.isolatedPhpVersion = PhpEnvironments.shared.cachedPhpInstallations[version]
site.evaluateCompatibility()
}

View File

@ -42,7 +42,7 @@ class FakeValetSite: ValetSite {
self.isolatedPhpVersion = PhpInstallation(isolated)
}
if PhpEnv.shared.currentInstall != nil {
if PhpEnvironments.shared.currentInstall != nil {
self.evaluateCompatibility()
}
}

View File

@ -57,7 +57,8 @@ class ValetSite: ValetListable {
/// Which version of PHP is actually used to serve this site.
var servingPhpVersion: String {
return self.isolatedPhpVersion?.versionNumber.short
?? PhpEnv.phpInstall.version.short
?? PhpEnvironments.phpInstall?.version.short
?? "???"
}
init(
@ -97,12 +98,12 @@ class ValetSite: ValetListable {
*/
public func determineIsolated() {
if let version = ValetSite.isolatedVersion("~/.config/valet/Nginx/\(self.name).\(self.tld)") {
if !PhpEnv.shared.cachedPhpInstallations.keys.contains(version) {
if !PhpEnvironments.shared.cachedPhpInstallations.keys.contains(version) {
Log.err("The PHP version \(version) is isolated for the site \(self.name) "
+ "but that PHP version is unavailable.")
return
}
self.isolatedPhpVersion = PhpEnv.shared.cachedPhpInstallations[version]
self.isolatedPhpVersion = PhpEnvironments.shared.cachedPhpInstallations[version]
} else {
self.isolatedPhpVersion = nil
}
@ -237,11 +238,16 @@ class ValetSite: ValetListable {
return
}
guard let linked = PhpEnvironments.phpInstall else {
self.isCompatibleWithPreferredPhpVersion = false
return
}
// Split the composer list (on "|") to evaluate multiple constraints
// For example, for Laravel 8 projects the value is "^7.3|^8.0"
self.isCompatibleWithPreferredPhpVersion = self.preferredPhpVersion.split(separator: "|").map { string in
let origin = self.isolatedPhpVersion?.versionNumber.short
?? PhpEnv.phpInstall.version.long
?? linked.version.long
let normalizedPhpVersion = string.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -0,0 +1,74 @@
//
// ActivePhpInstallation.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/12/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
extension Valet {
/**
Notify the user about a non-default TLD being set.
*/
public func notifyAboutUnsupportedTLD() {
if Valet.shared.config.tld != "test" && Preferences.isEnabled(.warnAboutNonStandardTLD) {
Task { @MainActor in
BetterAlert().withInformation(
title: "alert.warnings.tld_issue.title".localized,
subtitle: "alert.warnings.tld_issue.subtitle".localized,
description: "alert.warnings.tld_issue.description".localized
)
.withPrimary(text: "generic.ok".localized)
.withTertiary(text: "alert.do_not_tell_again".localized, action: { alert in
Preferences.update(.warnAboutNonStandardTLD, value: false)
alert.close(with: .alertThirdButtonReturn)
})
.show()
}
}
}
public func notifyAboutOutdatedValetVersion(_ version: VersionNumber) {
Task { @MainActor in
BetterAlert()
.withInformation(
title: "alert.min_valet_version.title".localized,
subtitle: "alert.min_valet_version.info".localized(
version.text,
Constants.MinimumRecommendedValetVersion
)
)
.withPrimary(text: "generic.ok".localized)
.show()
}
}
/**
It is always possible that the system configuration for PHP-FPM has not been set up for Valet.
This can occur when a user manually installs a new PHP version, but does not run `valet install`.
In that case, we should alert the user!
- Important: The underlying check is `checkPhpFpmStatus`, which can be run multiple times.
This method actively presents a modal if said checks fails, so don't call this method too many times.
*/
public func notifyAboutBrokenPhpFpm() async {
if await Valet.shared.phpFpmConfigurationValid() {
return
}
Task { @MainActor in
BetterAlert()
.withInformation(
title: "alert.php_fpm_broken.title".localized,
subtitle: "alert.php_fpm_broken.info".localized,
description: "alert.php_fpm_broken.description".localized
)
.withPrimary(text: "generic.ok".localized)
.show()
}
}
}

View File

@ -22,7 +22,7 @@ class Valet {
static let shared = Valet()
/// The version of Valet that was detected.
var version: VersionNumber! = nil
var version: VersionNumber?
/// The Valet configuration file.
var config: Valet.Configuration!
@ -57,6 +57,15 @@ class Valet {
}
}
static var installed: Bool {
return self.shared.installed
}
lazy var installed: Bool = {
return FileSystem.fileExists(Paths.binPath.appending("/valet"))
&& FileSystem.anyExists("~/.config/valet")
}()
/**
Check if a particular feature is enabled.
*/
@ -71,27 +80,6 @@ class Valet {
return self.shared.sites + self.shared.proxies
}
/**
Notify the user about a non-default TLD being set.
*/
public static func notifyAboutUnsupportedTLD() {
if Valet.shared.config.tld != "test" && Preferences.isEnabled(.warnAboutNonStandardTLD) {
Task { @MainActor in
BetterAlert().withInformation(
title: "alert.warnings.tld_issue.title".localized,
subtitle: "alert.warnings.tld_issue.subtitle".localized,
description: "alert.warnings.tld_issue.description".localized
)
.withPrimary(text: "generic.ok".localized)
.withTertiary(text: "alert.do_not_tell_again".localized, action: { alert in
Preferences.update(.warnAboutNonStandardTLD, value: false)
alert.close(with: .alertThirdButtonReturn)
})
.show()
}
}
}
/**
We don't want to load the initial config.json file as soon as the class is initialised.
@ -142,6 +130,11 @@ class Valet {
in use. This allows PHP Monitor to do different things when Valet 3.0 is enabled.
*/
public func evaluateFeatureSupport() {
guard let version = self.version else {
Log.err("Cannot determine features, as the version was not determined.")
return
}
switch version.major {
case 2:
Log.info("You are running Valet v2. Support for site isolation is disabled.")
@ -159,26 +152,24 @@ class Valet {
installed is not recent enough.
*/
public func validateVersion() {
guard let version = self.version else {
Log.err("Cannot validate Valet version if no Valet version was determined.")
return
}
if PhpEnvironments.phpInstall == nil {
Log.info("Cannot validate Valet version if no PHP version is linked.")
return
}
// 1. Evaluate feature support
Valet.shared.evaluateFeatureSupport()
// 2. Notify user if the version is too old (but major version is OK)
if version.text.versionCompare(Constants.MinimumRecommendedValetVersion) == .orderedAscending {
let version = version!
let recommended = Constants.MinimumRecommendedValetVersion
Log.warn("Valet version \(version.text) is too old! (recommended: \(recommended))")
Task { @MainActor in
BetterAlert()
.withInformation(
title: "alert.min_valet_version.title".localized,
subtitle: "alert.min_valet_version.info".localized(
version.text,
Constants.MinimumRecommendedValetVersion
)
)
.withPrimary(text: "generic.ok".localized)
.show()
}
self.notifyAboutOutdatedValetVersion(version)
} else {
Log.info("Valet version \(version.text) is recent enough, OK " +
"(recommended: \(Constants.MinimumRecommendedValetVersion))")
@ -193,6 +184,30 @@ class Valet {
.out.contains("Composer detected issues in your platform")
}
/**
Determine if PHP-FPM is configured correctly.
For PHP 5.6, we'll check if `valet.sock` is included in the main `php-fpm.conf` file, but for more recent
versions of PHP, we can just check for the existence of the `valet-fpm.conf` file. If the check here fails,
that means that Valet won't work properly.
*/
func phpFpmConfigurationValid() async -> Bool {
guard let version = PhpEnvironments.shared.currentInstall?.version else {
Log.info("Cannot check PHP-FPM status: no version of PHP is active")
return true
}
if 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 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/\(version.short)/php-fpm.d/valet-fpm.conf")
}
/**
Returns a count of how many sites are linked and parked.
*/

View File

@ -12,6 +12,25 @@ extension MainMenu {
// MARK: - Actions
@MainActor @objc func linkPhpBinary() {
Task {
await Actions.linkPhp()
}
}
@MainActor @objc func displayUnlinkedInfo() {
Task { @MainActor in
BetterAlert()
.withInformation(
title: "phpman.unlinked.title".localized,
subtitle: "phpman.unlinked.desc".localized,
description: "phpman.unlinked.detail".localized
)
.withPrimary(text: "generic.ok".localized)
.show()
}
}
@MainActor @objc func fixHomebrewPermissions() {
if !BetterAlert()
.withInformation(
@ -86,7 +105,7 @@ extension MainMenu {
}
@objc func disableAllXdebugModes() {
guard let file = PhpEnv.shared.getConfigFile(forKey: "xdebug.mode") else {
guard let file = PhpEnvironments.shared.getConfigFile(forKey: "xdebug.mode") else {
Log.info("xdebug.mode could not be found in any .ini file, aborting.")
return
}
@ -105,7 +124,7 @@ extension MainMenu {
@objc func toggleXdebugMode(sender: XdebugMenuItem) {
Log.info("Switching Xdebug to mode: \(sender.mode)")
guard let file = PhpEnv.shared.getConfigFile(forKey: "xdebug.mode") else {
guard let file = PhpEnvironments.shared.getConfigFile(forKey: "xdebug.mode") else {
return Log.info("xdebug.mode could not be found in any .ini file, aborting.")
}
@ -207,12 +226,17 @@ extension MainMenu {
}
@objc func openActiveConfigFolder() {
if PhpEnv.phpInstall.hasErrorState {
guard let install = PhpEnvironments.phpInstall else {
// TODO: Can't open the config if no PHP version is active
return
}
if install.hasErrorState {
Actions.openGenericPhpConfigFolder()
return
}
Actions.openPhpConfigFolder(version: PhpEnv.phpInstall.version.short)
Actions.openPhpConfigFolder(version: install.version.short)
}
@objc func openPhpMonitorConfigurationFile() {
@ -231,8 +255,11 @@ extension MainMenu {
self.switchToPhpVersion(sender.version)
}
public func switchToAnyPhpVersion(_ version: String) {
if PhpEnv.shared.availablePhpVersions.contains(version) {
public func switchToAnyPhpVersion(_ version: String, silently: Bool = false) {
if silently {
MainMenu.shared.shouldSwitchSilently = true
}
if PhpEnvironments.shared.availablePhpVersions.contains(version) {
Task { MainMenu.shared.switchToPhpVersion(version) }
} else {
Task {
@ -246,20 +273,44 @@ extension MainMenu {
}
}
func switchToPhpVersionAndWait(_ version: String, silently: Bool = false) async {
if silently {
MainMenu.shared.shouldSwitchSilently = true
}
if !PhpEnvironments.shared.availablePhpVersions.contains(version) {
Log.warn("This PHP version is currently unavailable, not switching!")
return
}
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
updatePhpVersionInStatusBar()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
PhpEnvironments.shared.currentInstall = ActivePhpInstallation()
App.shared.handlePhpConfigWatcher()
PhpEnvironments.shared.delegate?.switcherDidCompleteSwitch(to: version)
}
@objc func switchToPhpVersion(_ version: String) {
setBusyImage()
PhpEnv.shared.isBusy = true
PhpEnv.shared.delegate = self
PhpEnv.shared.delegate?.switcherDidStartSwitching(to: version)
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
Task(priority: .userInitiated) { [unowned self] in
updatePhpVersionInStatusBar()
rebuild()
await PhpEnv.switcher.performSwitch(to: version)
await PhpEnvironments.switcher.performSwitch(to: version)
PhpEnv.shared.currentInstall = ActivePhpInstallation()
PhpEnvironments.shared.currentInstall = ActivePhpInstallation()
App.shared.handlePhpConfigWatcher()
PhpEnv.shared.delegate?.switcherDidCompleteSwitch(to: version)
PhpEnvironments.shared.delegate?.switcherDidCompleteSwitch(to: version)
}
}
@ -275,18 +326,18 @@ extension MainMenu {
func switchToPhp(_ version: String) async {
Task { @MainActor [self] in
setBusyImage()
PhpEnv.shared.isBusy = true
PhpEnv.shared.delegate = self
PhpEnv.shared.delegate?.switcherDidStartSwitching(to: version)
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
}
updatePhpVersionInStatusBar()
rebuild()
await PhpEnv.switcher.performSwitch(to: version)
await PhpEnvironments.switcher.performSwitch(to: version)
PhpEnv.shared.currentInstall = ActivePhpInstallation()
PhpEnvironments.shared.currentInstall = ActivePhpInstallation()
App.shared.handlePhpConfigWatcher()
PhpEnv.shared.delegate?.switcherDidCompleteSwitch(to: version)
PhpEnvironments.shared.delegate?.switcherDidCompleteSwitch(to: version)
}
}

View File

@ -46,7 +46,7 @@ extension MainMenu {
]
) {
if behaviours.contains(.reloadsPhpInstallation) {
PhpEnv.shared.isBusy = true
PhpEnvironments.shared.isBusy = true
}
if behaviours.contains(.setsBusyUI) {
@ -59,12 +59,12 @@ extension MainMenu {
do { try execute() } catch let e { error = e }
if behaviours.contains(.setsBusyUI) {
PhpEnv.shared.isBusy = false
PhpEnvironments.shared.isBusy = false
}
Task { @MainActor [self, error] in
if behaviours.contains(.reloadsPhpInstallation) {
PhpEnv.shared.currentInstall = ActivePhpInstallation()
PhpEnvironments.shared.currentInstall = ActivePhpInstallation()
}
if behaviours.contains(.updatesMenuBarContents) {

View File

@ -12,9 +12,9 @@ import AppKit
extension MainMenu {
@MainActor @objc func fixMyValet() {
let previousVersion = PhpEnv.phpInstall.version.short
let previousVersion = PhpEnvironments.phpInstall?.version.short
if !PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpAlias) {
if !PhpEnvironments.shared.availablePhpVersions.contains(PhpEnvironments.brewPhpAlias) {
presentAlertForMissingFormula()
return
}
@ -22,7 +22,7 @@ extension MainMenu {
if !BetterAlert()
.withInformation(
title: "alert.fix_my_valet.title".localized,
subtitle: "alert.fix_my_valet.info".localized(PhpEnv.brewPhpAlias)
subtitle: "alert.fix_my_valet.info".localized(PhpEnvironments.brewPhpAlias)
)
.withPrimary(text: "alert.fix_my_valet.ok".localized)
.withSecondary(text: "alert.fix_my_valet.cancel".localized)
@ -34,10 +34,10 @@ extension MainMenu {
Task { @MainActor in
await Actions.fixMyValet()
if previousVersion == PhpEnv.brewPhpAlias {
if previousVersion == PhpEnvironments.brewPhpAlias || previousVersion == nil {
self.presentAlertForSameVersion()
} else {
self.presentAlertForDifferentVersion(version: previousVersion)
self.presentAlertForDifferentVersion(version: previousVersion!)
}
}
}
@ -74,7 +74,7 @@ extension MainMenu {
alert.close(with: .alertSecondButtonReturn)
MainMenu.shared.switchToPhpVersion(version)
})
.withSecondary(text: "alert.fix_my_valet_done.stay".localized(PhpEnv.brewPhpAlias))
.withSecondary(text: "alert.fix_my_valet_done.stay".localized(PhpEnvironments.brewPhpAlias))
.withTertiary(text: "", action: { _ in
NSWorkspace.shared.open(Constants.Urls.FrequentlyAskedQuestions)
})

View File

@ -18,8 +18,6 @@ extension MainMenu {
self.setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
}
await App.shared.environment.process()
if await Startup().checkEnvironment() {
await self.onEnvironmentPass()
} else {
@ -32,18 +30,18 @@ extension MainMenu {
*/
private func onEnvironmentPass() async {
// Determine what the `php` formula is aliased to
await PhpEnv.shared.determinePhpAlias()
await PhpEnvironments.shared.determinePhpAlias()
// Initialize preferences
_ = Preferences.shared
// Determine install method
Log.info(HomebrewDiagnostics.customCaskInstalled
Log.info(BrewDiagnostics.customCaskInstalled
? "[BREW] The app has been installed via Homebrew Cask."
: "[BREW] The app has been installed directly (optimal)."
)
Log.info(HomebrewDiagnostics.usesNginxFullFormula
Log.info(BrewDiagnostics.usesNginxFullFormula
? "[BREW] The app will be using the `nginx-full` formula."
: "[BREW] The app will be using the `nginx` formula."
)
@ -51,24 +49,28 @@ extension MainMenu {
// Attempt to find out more info about Valet
if Valet.shared.version != nil {
Log.info("PHP Monitor has extracted the version number of Valet: \(Valet.shared.version!.text)")
// Validate the version (this will enforce which versions of PHP are supported)
Valet.shared.validateVersion()
}
// Validate the version (this will enforce which versions of PHP are supported)
Valet.shared.validateVersion()
// Validate the Homebrew version (determines install/upgrade functionality)
await Brew.shared.determineVersion()
// Actually detect the PHP versions
await PhpEnv.detectPhpVersions()
await PhpEnvironments.detectPhpVersions()
// Check for an alias conflict
await HomebrewDiagnostics.checkForCaskConflict()
await BrewDiagnostics.checkForCaskConflict()
// Update the icon
updatePhpVersionInStatusBar()
// Attempt to find out if PHP-FPM is broken
Log.info("Determining broken PHP-FPM...")
let installation = PhpEnv.phpInstall
installation.notifyAboutBrokenPhpFpm()
PhpEnvironments.prepare()
// Set up the filesystem watcher for the Homebrew binaries
App.shared.prepareHomebrewWatchers()
// Check for other problems
WarningManager.shared.evaluateWarnings()
@ -86,21 +88,26 @@ extension MainMenu {
// Load the global hotkey
App.shared.loadGlobalHotkey()
// Preload sites
await Valet.shared.startPreloadingSites()
// Set up menu items
AppDelegate.instance.configureMenuItems(standalone: !Valet.installed)
// After preloading sites, check for PHP-FPM pool conflicts
HomebrewDiagnostics.checkForPhpFpmPoolConflicts()
if Valet.installed {
// Preload all sites
await Valet.shared.startPreloadingSites()
// A non-default TLD is not officially supported since Valet 3.2.x
Valet.notifyAboutUnsupportedTLD()
// After preloading sites, check for PHP-FPM pool conflicts
await BrewDiagnostics.checkForValetMisconfiguration()
// Check if PHP-FPM is broken (should be fixed automatically if phpmon >= 6.0)
await Valet.shared.notifyAboutBrokenPhpFpm()
// A non-default TLD is not officially supported since Valet 3.2.x
Valet.shared.notifyAboutUnsupportedTLD()
}
// Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// Start the background refresh timer
startSharedTimer()
if !isRunningSwiftUIPreview {
Stats.incrementSuccessfulLaunchCount()
Stats.evaluateSponsorMessageShouldBeDisplayed()
@ -116,13 +123,13 @@ extension MainMenu {
}
// Check if the linked version has changed between launches of phpmon
Stats.evaluateLastLinkedPhpVersion()
// Check if an update was performed earlier
AppUpdater.checkIfUpdateWasPerformed()
PhpGuard().compareToLastGlobalVersion()
// We are ready!
Log.info("PHP Monitor is ready to serve!")
// Check if we upgraded just now
AppUpdater.checkIfUpdateWasPerformed()
}
/**
@ -149,21 +156,6 @@ extension MainMenu {
}
}
/**
Schedule a request to fetch the PHP version every 60 seconds.
*/
private func startSharedTimer() {
DispatchQueue.main.async { [self] in
App.shared.timer = Timer.scheduledTimer(
timeInterval: 60,
target: self,
selector: #selector(refreshActiveInstallation),
userInfo: nil,
repeats: true
)
}
}
/**
Detect which applications are installed that can be used to open a domain's source directory.
*/

View File

@ -16,17 +16,19 @@ extension MainMenu {
nonisolated func switcherDidCompleteSwitch(to version: String) {
// Mark as no longer busy
PhpEnv.shared.isBusy = false
PhpEnvironments.shared.isBusy = false
Task { // Things to do after reloading domain list data
await self.reloadDomainListData()
if Valet.installed {
await self.reloadDomainListData()
}
// Perform UI updates on main thread
Task { @MainActor [self] in
updatePhpVersionInStatusBar()
rebuild()
if !PhpEnv.shared.validate(version) {
if Valet.installed && !PhpEnvironments.shared.validate(version) {
self.suggestFixMyValet(failed: version)
return
}
@ -44,7 +46,15 @@ extension MainMenu {
}
// Check if Valet still works correctly
self.checkForPlatformIssues()
if Valet.installed {
self.checkForPlatformIssues()
}
// Check if the silent switch occurred and reset it
if shouldSwitchSilently {
shouldSwitchSilently = false
return
}
// Update stats
Stats.incrementSuccessfulSwitchCount()
@ -112,12 +122,28 @@ extension MainMenu {
}
@MainActor private func notifyAboutVersionChange(to version: String) {
if shouldSwitchSilently {
return
}
LocalNotification.send(
title: String(format: "notification.version_changed_title".localized, version),
subtitle: String(format: "notification.version_changed_desc".localized, version),
preference: .notifyAboutVersionChange
)
Task { PhpEnv.phpInstall.notifyAboutBrokenPhpFpm() }
guard PhpEnvironments.phpInstall != nil else {
Log.err("Cannot notify about version change if PHP is unlinked")
return
}
guard Valet.installed == true else {
Log.info("Skipping check for broken PHP-FPM version, Valet is not installed")
return
}
Task {
await Valet.shared.notifyAboutBrokenPhpFpm()
}
}
}

View File

@ -26,6 +26,14 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
withLength: NSStatusItem.variableLength
)
// MARK: - State Variables
/**
You can instruct the app to switch to a given PHP version silently.
That will toggle this flag to true. Upon switching, this flag will be reset.
*/
var shouldSwitchSilently: Bool = false
// MARK: - UI related
/**
@ -70,8 +78,8 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
/** Reloads which PHP versions is currently active. */
@objc func refreshActiveInstallation() {
if !PhpEnv.shared.isBusy {
PhpEnv.shared.currentInstall = ActivePhpInstallation()
if !PhpEnvironments.shared.isBusy {
PhpEnvironments.shared.currentInstall = ActivePhpInstallation.load()
updatePhpVersionInStatusBar()
} else {
Log.perf("Skipping version refresh due to busy status!")
@ -114,7 +122,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
BetterAlert().withInformation(
title: "startup.unsupported_versions_explanation.title".localized,
subtitle: "startup.unsupported_versions_explanation.subtitle".localized(
PhpEnv.shared.incompatiblePhpVersions
PhpEnvironments.shared.incompatiblePhpVersions
.map({ version in
return "• PHP \(version)"
})
@ -143,7 +151,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
/** Refreshes the icon with the PHP version. */
@objc func refreshIcon() {
Task { @MainActor [self] in
if PhpEnv.shared.isBusy {
if PhpEnvironments.shared.isBusy {
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
} else {
if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false {
@ -152,7 +160,13 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
} else {
// The dynamic icon has been requested
let long = Preferences.preferences[.fullPhpVersionDynamicIcon] as! Bool
setStatusBarImage(version: long ? PhpEnv.phpInstall.version.long : PhpEnv.phpInstall.version.short)
guard let install = PhpEnvironments.phpInstall else {
setStatusBarImage(version: "???")
return
}
setStatusBarImage(version: long ? install.version.long : install.version.short)
}
}
}
@ -172,6 +186,18 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
NSApplication.shared.orderFrontStandardAboutPanel()
}
@objc func openLiteModeInfo() {
Task { @MainActor in
BetterAlert().withInformation(
title: "lite_mode_explanation.title".localized,
subtitle: "lite_mode_explanation.subtitle".localized,
description: "lite_mode_explanation.description".localized
)
.withPrimary(text: "generic.ok".localized)
.show()
}
}
@objc func openPrefs() {
PreferencesWindowController.show()
}
@ -184,6 +210,10 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
DomainListVC.show()
}
@objc func openPhpVersionManager() {
PhpVersionManagerWindowController.show()
}
@objc func openDonate() {
NSWorkspace.shared.open(Constants.Urls.DonationPage)
}

View File

@ -13,31 +13,49 @@ import Cocoa
extension StatusMenu {
func addPhpVersionMenuItems() {
if PhpEnv.phpInstall.hasErrorState {
if PhpEnvironments.phpInstall == nil {
addItem(HeaderView.asMenuItem(text: "⚠️ " + "mi_no_php_linked".localized, minimumWidth: 280))
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_fix_php_link".localized, action: #selector(MainMenu.linkPhpBinary)),
NSMenuItem(title: "mi_no_php_linked_explain".localized, action: #selector(MainMenu.displayUnlinkedInfo))
])
return
}
if PhpEnvironments.phpInstall!.hasErrorState {
let brokenMenuItems = ["mi_php_broken_1", "mi_php_broken_2", "mi_php_broken_3", "mi_php_broken_4"]
return addItems(brokenMenuItems.map { NSMenuItem(title: $0.localized) })
}
addItem(HeaderView.asMenuItem(
text: "\("mi_php_version".localized) \(PhpEnv.phpInstall.version.long)",
text: "\("mi_php_version".localized) \(PhpEnvironments.phpInstall!.version.long)",
minimumWidth: 280 // this ensures the menu is at least wide enough not to cause clipping
))
}
func addPhpActionMenuItems() {
if PhpEnv.shared.isBusy {
if PhpEnvironments.shared.isBusy {
addItem(NSMenuItem(title: "mi_busy".localized))
return
}
if PhpEnv.shared.availablePhpVersions.isEmpty && PhpEnv.shared.incompatiblePhpVersions.isEmpty { return }
if PhpEnvironments.shared.availablePhpVersions.isEmpty
&& PhpEnvironments.shared.incompatiblePhpVersions.isEmpty {
return
}
if PhpEnvironments.shared.currentInstall == nil {
return
}
addSwitchToPhpMenuItems()
self.addItem(NSMenuItem.separator())
}
func addServicesManagerMenuItem() {
if PhpEnv.shared.isBusy {
if PhpEnvironments.shared.isBusy {
return
}
@ -49,19 +67,20 @@ extension StatusMenu {
func addSwitchToPhpMenuItems() {
var shortcutKey = 1
for index in (0..<PhpEnv.shared.availablePhpVersions.count) {
for index in (0..<PhpEnvironments.shared.availablePhpVersions.count) {
// Get the short and long version
let shortVersion = PhpEnv.shared.availablePhpVersions[index]
let longVersion = PhpEnv.shared.cachedPhpInstallations[shortVersion]!.versionNumber
let shortVersion = PhpEnvironments.shared.availablePhpVersions[index]
let longVersion = PhpEnvironments.shared.cachedPhpInstallations[shortVersion]!.versionNumber
let long = Preferences.preferences[.fullPhpVersionDynamicIcon] as! Bool
let versionString = long ? longVersion.text : shortVersion
let action = #selector(MainMenu.switchToPhpVersion(sender:))
let brew = (shortVersion == PhpEnv.brewPhpAlias) ? "php" : "php@\(shortVersion)"
let brew = (shortVersion == PhpEnvironments.brewPhpAlias) ? "php" : "php@\(shortVersion)"
let menuItem = PhpMenuItem(
title: "\("mi_php_switch".localized) \(versionString) (\(brew))",
action: (shortVersion == PhpEnv.phpInstall.version.short)
action: (shortVersion == PhpEnvironments.phpInstall?.version.short)
? nil
: action, keyEquivalent: "\(shortcutKey)"
)
@ -72,24 +91,36 @@ extension StatusMenu {
addItem(menuItem)
}
if !PhpEnv.shared.incompatiblePhpVersions.isEmpty {
if !PhpEnvironments.shared.incompatiblePhpVersions.isEmpty {
addItem(NSMenuItem.separator())
addItem(NSMenuItem(
title: "⚠️ " + "mi_php_unsupported".localized(
"\(PhpEnv.shared.incompatiblePhpVersions.count)"
"\(PhpEnvironments.shared.incompatiblePhpVersions.count)"
),
action: #selector(MainMenu.showIncompatiblePhpVersionsAlert)
))
}
}
func addCoreMenuItems() {
func addLiteModeMenuItem() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_lite_mode".localized, action: #selector(MainMenu.openLiteModeInfo))
])
}
func addPreferencesMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_preferences".localized,
action: #selector(MainMenu.openPrefs), keyEquivalent: ","),
NSMenuItem(title: "mi_check_for_updates".localized,
action: #selector(MainMenu.checkForUpdates)),
action: #selector(MainMenu.checkForUpdates))
])
}
func addCoreMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_about".localized,
action: #selector(MainMenu.openAbout)),
@ -118,6 +149,9 @@ extension StatusMenu {
func addConfigurationMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_configuration".localized),
NSMenuItem(title: "mi_php_version_manager".localized,
action: #selector(MainMenu.openPhpVersionManager),
keyEquivalent: "m"),
NSMenuItem(title: "mi_php_config".localized,
action: #selector(MainMenu.openActiveConfigFolder),
keyEquivalent: "c"),
@ -142,7 +176,7 @@ extension StatusMenu {
),
NSMenuItem(
title: "mi_update_global_composer".localized,
action: PhpEnv.shared.isBusy
action: PhpEnvironments.shared.isBusy
? nil
: #selector(MainMenu.updateGlobalComposerDependencies),
keyEquivalent: "g",
@ -154,7 +188,12 @@ extension StatusMenu {
// MARK: - Stats
func addStatsMenuItem() {
guard let stats = PhpEnv.phpInstall.limits else { return }
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing stats menu item if no PHP version is linked.")
return
}
guard let stats = install.limits else { return }
addItem(StatsView.asMenuItem(
memory: stats.memory_limit,
@ -166,14 +205,19 @@ extension StatusMenu {
// MARK: - Extensions
func addExtensionsMenuItems() {
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing extensions menu items if no PHP version is linked.")
return
}
addItem(HeaderView.asMenuItem(text: "mi_detected_extensions".localized))
if PhpEnv.phpInstall.extensions.isEmpty {
if install.extensions.isEmpty {
addItem(NSMenuItem(title: "mi_no_extensions_detected".localized, action: nil, keyEquivalent: ""))
}
var shortcutKey = 1
for phpExtension in PhpEnv.phpInstall.extensions {
for phpExtension in install.extensions {
addExtensionItem(phpExtension, shortcutKey)
shortcutKey += 1
}
@ -258,42 +302,54 @@ extension StatusMenu {
func addFirstAidAndServicesMenuItems() {
let services = NSMenuItem(title: "mi_other".localized)
let servicesMenu = NSMenu()
servicesMenu.addItems([
var items: [NSMenuItem] = [
// FIRST AID
HeaderView.asMenuItem(text: "mi_first_aid".localized),
NSMenuItem(title: "mi_view_onboarding".localized, action: #selector(MainMenu.showWelcomeTour)),
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings)),
NSMenuItem.separator(),
NSMenuItem(title: "mi_fix_my_valet".localized(PhpEnv.brewPhpAlias),
action: #selector(MainMenu.fixMyValet),
toolTip: "mi_fix_my_valet_tooltip".localized),
NSMenuItem(title: "mi_fix_brew_permissions".localized(), action: #selector(MainMenu.fixHomebrewPermissions),
toolTip: "mi_fix_brew_permissions_tooltip".localized),
NSMenuItem.separator(),
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings))
]
// SERVICES
HeaderView.asMenuItem(text: "mi_services".localized),
NSMenuItem(title: "mi_restart_dnsmasq".localized, action: #selector(MainMenu.restartDnsMasq),
keyEquivalent: "d"),
NSMenuItem(title: "mi_restart_php_fpm".localized, action: #selector(MainMenu.restartPhpFpm),
keyEquivalent: "p"),
NSMenuItem(title: "mi_restart_nginx".localized, action: #selector(MainMenu.restartNginx),
keyEquivalent: "n"),
NSMenuItem(title: "mi_restart_valet_services".localized, action: #selector(MainMenu.restartValetServices),
keyEquivalent: "s"),
NSMenuItem(title: "mi_stop_valet_services".localized, action: #selector(MainMenu.stopValetServices),
keyEquivalent: "s",
keyModifier: [.command, .shift]),
NSMenuItem.separator(),
if Valet.installed {
items.append(contentsOf: [
NSMenuItem.separator(),
NSMenuItem(title: "mi_fix_my_valet".localized(PhpEnvironments.brewPhpAlias),
action: #selector(MainMenu.fixMyValet),
toolTip: "mi_fix_my_valet_tooltip".localized),
NSMenuItem(title: "mi_fix_brew_permissions".localized(),
action: #selector(MainMenu.fixHomebrewPermissions),
toolTip: "mi_fix_brew_permissions_tooltip".localized),
NSMenuItem.separator(),
// SERVICES
HeaderView.asMenuItem(text: "mi_services".localized),
NSMenuItem(title: "mi_restart_dnsmasq".localized, action: #selector(MainMenu.restartDnsMasq),
keyEquivalent: "d"),
NSMenuItem(title: "mi_restart_php_fpm".localized, action: #selector(MainMenu.restartPhpFpm),
keyEquivalent: "p"),
NSMenuItem(title: "mi_restart_nginx".localized, action: #selector(MainMenu.restartNginx),
keyEquivalent: "n"),
NSMenuItem(title: "mi_restart_valet_services".localized,
action: #selector(MainMenu.restartValetServices),
keyEquivalent: "s"),
NSMenuItem(title: "mi_stop_valet_services".localized, action: #selector(MainMenu.stopValetServices),
keyEquivalent: "s",
keyModifier: [.command, .shift]),
NSMenuItem.separator()
])
} else {
items.append(NSMenuItem.separator())
}
items.append(contentsOf: [
// MANUAL ACTIONS
HeaderView.asMenuItem(text: "mi_manual_actions".localized),
NSMenuItem(title: "mi_php_refresh".localized,
action: #selector(MainMenu.reloadPhpMonitorMenuInForeground),
keyEquivalent: "r")
], target: MainMenu.shared)
])
let servicesMenu = NSMenu()
servicesMenu.addItems(items, target: MainMenu.shared)
setSubmenu(servicesMenu, for: services)
addItem(services)
}

Some files were not shown because too many files have changed in this diff Show More