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

Compare commits

...

153 Commits
v6.2 ... v7.2

Author SHA1 Message Date
4827c4a44b 🚀 Version 7.2 2025-03-22 12:05:54 +01:00
201554b237 🔧 Bump version to 7.2 2025-03-22 11:33:52 +01:00
e4bf6a9655 📝 Bump build, update credits and add sponsors 2025-03-22 11:32:59 +01:00
f9ea654ffc 🔧 Tweak brew output parsing
Improved the accuracy of the brew output. Often, when multiple console
messages were returned, the progress prompt in the PHP version manager
would display the earliest found step, not the latest, thus unfortunately
misrepresenting the progress of the installation steps.

This fixes that by reversing the return order, but also extracts
relevant information from the commands, too, so that contextual info
is now included (for pouring, installing and downloading steps).

(This makes it a little bit more transparent for the end user to find
out what is taking up all this time. I wish that Homebrew was faster,
too, but there's a reason I'm not using statically compiled PHP for
this project. Either way, this is a nice QoL change.)
2025-03-22 11:15:47 +01:00
8677628850 🔧 Bump build for new EAP release 2025-03-01 12:59:09 +01:00
e09b0156df 📝 Update README 2025-03-01 12:58:36 +01:00
41a83b1d91 🔨 Update .gitignore 2025-02-28 13:20:32 +01:00
a9e97b0bc9 🌐 Add Indonesian translations
These translations were added via a locally ran LLM.
If you identify any mistakes, please get in touch!
2025-02-21 13:27:39 +01:00
e4e12799ef 🌐 Add Spanish translations
These translations were added via a locally ran LLM. I know some Spanish, but
if you identify any mistakes, please get in touch!
2025-02-20 19:54:04 +01:00
4e25fffa7d 🌐 Add missing translations 2025-02-20 19:21:25 +01:00
3e319cd50f 🚀 Version 7.1 2024-11-26 15:24:18 +01:00
595dc8c028 Add info about test failures 2024-11-26 15:22:03 +01:00
f7b1679e97 🐛 Serial dispatch queue for test FS 2024-11-26 14:17:06 +01:00
9f1761d68e Checked and updated tests 2024-11-26 13:44:08 +01:00
871480d70c 📝 Updated README.md, SECURITY.md 2024-11-26 12:56:38 +01:00
2b1c1c12f8 Add info button for PHP upgrades 2024-11-25 16:43:19 +01:00
a22346ed35 🐛 Fix issue with formulae upgrades for tap 2024-11-25 14:04:17 +01:00
e3fa34d4f9 🔨 Adjust URL for unavailable PHP (wiki) 2024-11-25 13:40:02 +01:00
3d225ea79f ✍️ Clarify text about upgrading PHP (EN only) 2024-11-22 19:09:10 +01:00
d2cd387c18 Add button that redirects to wiki 2024-11-22 18:23:28 +01:00
48bb782e33 🚧 WIP: Changes related to unavailable formulae 2024-11-22 17:42:28 +01:00
9710ffa8da 🚧 WIP: Handle temporarily unavailable formulae 2024-11-22 13:26:09 +01:00
46408f5ee5 Indicate DEV builds in PHP Version Manager 2024-11-15 16:43:59 +01:00
2c39f1db8b 🔧 Upgrade checks for Xcode 16.1 2024-11-15 16:10:25 +01:00
f20286cbd9 Notify user if startup takes too long (> 30s) 2024-11-15 16:03:44 +01:00
f1fe42e563 Updated constants for PHP 8.4 & 8.5 support
Thankfully, these changes are simple. Before releasing, I will be
testing the new build, though.

Here's what constants I changed, and why:

- Homebrew PHP formulae are now consistently sourced from the
  `shivammathur/php` tap. This should make the transition to new PHP
  releases a little bit easier, but I need to verify this works without
  issues before publishing this update.

- Bumped the PHP formulae cutoff date to Nov 30, 2025.
  At this point, PHP 8.5 should be released.

- Added support for pre-release (daily) versions of PHP 8.5.
2024-11-15 15:22:53 +01:00
94abfe4b49 🐛 Fix crash bug (oops) 2024-10-31 22:51:49 +01:00
9778fd5c7b 🚀 Version 7.0.6 2024-10-31 22:43:03 +01:00
dba2ce5bf3 🚀 Version 7.0.5 2024-10-31 22:15:11 +01:00
4644c1ada4 👌 Move cut-off date to PHP 8.4 release day 2024-10-31 22:14:53 +01:00
cef19243ee 🔧 Bump build 2024-10-31 21:59:42 +01:00
b319ecab59 👌 Move cut-off date to PHP 8.4 release day 2024-10-31 21:56:13 +01:00
a47b139d92 👌 Cleanup 2024-08-31 22:14:56 +02:00
e026ecf60d ♻️ Various extension list improvements (#274)
Installing and removing extensions now scrolls to the extension afterwards, and animates this. This is done to emphasise that the operation succeeded.
2024-08-31 15:57:08 +02:00
3c0a4a6142 🌐 Updated localization 2024-08-26 15:04:37 +02:00
87ebb20284 ♻️ Use alternate cell identifier for favorite 2024-08-25 16:21:16 +02:00
d60c26c9b2 UserDefaults-backed storage for Favorites 2024-08-25 14:35:11 +02:00
5c9c51f580 Mark domain as favorite (UI only)
Please note that this functionality is currently not persistent.
As such, reloading the domain list will reset any changes you have made.
2024-08-25 13:35:27 +02:00
0c320074da ♻️ Avoid using non-Sendable Timer 2024-08-06 14:15:37 +02:00
e3ea712a99 Ensure all tests run and pass 2024-07-16 18:38:56 +02:00
4db478ca64 👌 Update copyright information 2024-07-16 18:30:56 +02:00
3064a07d69 📦 Use NVAppUpdater and NVAlert packages 2024-07-16 18:29:11 +02:00
f3e1b4de6f 🚀 Version 7.0.4 2024-06-28 11:47:25 +02:00
a3226b632f 🐛 Restart PHP-FPM after managing extensions
This is done to prevent issues like #292.
2024-06-28 11:37:01 +02:00
652878d97f 👌 Remove unused WIP class 2024-06-28 11:14:20 +02:00
032610ad5c 🌐 Add localization for "Actions" 2024-06-28 11:12:55 +02:00
2c2627dc9f Add AppMenu class for easy access to main menu 2024-06-27 21:23:59 +02:00
62587bdf65 WIP: Add context menu to main menu 2024-06-11 18:53:55 +02:00
5e9dae78f5 🐛 Fix Bedrock project detection (#288) 2024-06-06 21:30:06 +02:00
949ba5b559 🔧 Build self-updater as part of main target
The self-updater is now a requirement for the main app to be able to be
built. You no longer need the existing binary. This makes it easier for
anyone to just try out the app locally and makes reproducible builds
also possible.

(This is done because the self-updater code will soon be moved to a
separate package, and I want to make this entire updater process
as simple as possible.)

In order to avoid the self-updater app from appearing as a product when
archiving a build, SKIP_INSTALL is set to true. This avoids a variety
of annoying issues including the archive appearing under "Other Items".
2024-05-31 23:54:42 +02:00
ce88f897ef 🌐 Added Chinese translations, credits
- Chinese translations contributed by @guanguans (via #285).
- Updated the credits so that all translators are now also listed
  separately, since the GitHub issue has been closed.
2024-05-02 10:24:47 +02:00
fa9b51aaab 🚀 Version 7.0.3 2024-04-10 13:07:58 +02:00
b8affad5ee 📝 Updated SECURITY.md 2024-04-10 13:07:30 +02:00
41e5f5b4c3 🔧 Bump build for EAP release 2024-04-09 12:33:55 +02:00
79f6a60a16 👌 Cleanup dependencies for site isolation 2024-04-03 14:50:05 +02:00
06bc4ddb9a 👌 Improve removing indirect dependency
Mind you, the interaction with the domain list controller also needs to
be abstracted away, but this is fine, for now.
2024-04-03 14:11:45 +02:00
bf728a24f0 👌 Fix PHP version suggestions in popover
- If your Valet installation supports using site isolation, that will
  be displayed as the suggestion. If not, the traditional "Switch to
  PHP" options will be available.

- Overflowing buttons have been fixed. Three columns are now used with
  a LazyVGrid, to prevent text from being unreadable.
2024-04-02 15:25:06 +02:00
b7cad3af62 👌 Fix Swift 5.10 concurrency warnings 2024-03-30 17:40:09 +01:00
4a3dee3c50 🚀 Version 7.0.2 2024-03-30 17:14:04 +01:00
9d5a0ed745 🔧 Xcode upgrade check 2024-03-20 13:46:56 +01:00
b3b509409a Fix test 2024-03-20 10:47:52 +01:00
4934f35d0b 🍱 Bump width of no results view 2024-03-19 14:23:19 +01:00
92e7418158 👌 Cleanup 2024-03-19 14:22:10 +01:00
52ea64db40 👌 Use base translation for no domain results 2024-03-19 14:20:34 +01:00
f66e9b7340 Add UnavailableContentView to domains 2024-03-19 14:10:08 +01:00
2bf28fe247 👌 Allow resizable windows 2024-03-16 00:32:05 +01:00
c6e4f785bc 🍱 Use PHP icon for phpman 2024-03-15 23:38:12 +01:00
94fe7df3bd 🍱 Custom button for "upgrade all" 2024-03-15 23:18:09 +01:00
f373621a4a 👌 Polish PHP installation management
- Make spinner view opaque to hide incorrect info
  when refreshing PHP installations
- Resolve each PHP installation to a Homebrew version
- Fix an issue where certain PHP upgrades don't show up

In this build, resolving PHP upgrades happens based on the formula name.

This avoids issues where available PHP version upgrades would *not* be
detected correctly (based on the installed version).
2024-03-15 22:09:59 +01:00
5104a865fb 🚀 Version 7.0.1 2024-02-12 18:28:45 +01:00
7b10973330 🔧 Bump build 2024-02-12 16:33:01 +01:00
bc208bddf9 👌 Notify if list of extensions is empty (#275) 2024-02-12 16:30:18 +01:00
321b4aaf8b 🐛 Fix extension detection on Intel (#275) 2024-02-12 15:26:48 +01:00
b26fc3bc4b 🚀 Version 7.0 2024-02-11 21:35:40 +01:00
f758c5d63a 👌 Cut off bottom of marketing screenshot 2024-02-10 16:18:58 +01:00
c7510d778d 🍱 Update marketing screenshot 2024-02-10 16:17:24 +01:00
70c5aadb7f 🔧 Bump build for PHP Monitor EAP 2024-01-25 13:48:39 +01:00
a731f15cf7 🐛 Prevent PHP dropdown from resetting 2024-01-21 14:42:06 +01:00
ab4c436202 🔧 Bump build for PHP Monitor EAP 2024-01-21 14:23:52 +01:00
c0231690d4 🐛 Fix alternative installation check 2024-01-21 14:22:51 +01:00
988e9d3351 👌 Cleanup and add comments 2024-01-13 13:24:06 +01:00
2f119d4332 Fix even more tests 2024-01-13 13:22:21 +01:00
d83c629a7b Fix some tests 2024-01-13 12:46:33 +01:00
e7d98dbeae 🔧 Bump build for PHP Monitor EAP 2024-01-09 21:24:50 +01:00
f3d5946743 🌐 Localize extension management (for domains) 2024-01-09 21:20:43 +01:00
7728a1125c 📝 Update README to reflect php = PHP 8.3 2024-01-09 21:00:06 +01:00
3612351df7 📝 Update README to reflect php = PHP 8.3 2024-01-09 20:59:52 +01:00
8e912151fb Allow toggling extensions via site list 2024-01-09 20:55:16 +01:00
3a2209e604 🌐 Add translations for language choice 2024-01-09 20:06:40 +01:00
1f0b56cab6 Language choice, prompt user to restart app 2024-01-09 20:01:08 +01:00
e08d970edd 🏗️ WIP: Load preferred language strings 2024-01-09 19:44:29 +01:00
32c757e711 🏗️ WIP: Language override option 2024-01-09 19:28:39 +01:00
480cdb94ae 🏗️ WIP: Language picker, fix SelectPreferenceView 2024-01-09 18:58:02 +01:00
7fbcac5dc2 👌 Check symlinks after PHP version modification 2023-12-27 14:47:21 +01:00
4edb5f5015 👌 Use generated asset catalog symbol extensions 2023-12-27 13:03:24 +01:00
294f84ccb2 👌 Further cleanup 2023-12-27 12:57:08 +01:00
155b57eb9e 🐛 Add incorrect PHP symlink purge (#270)
This commit introduces a new diagnostics feature which is executed
when the app boots. When the app boots, the integrity of the PHP
symlinks are checked to ensure that all symlinks correctly link to
a valid PHP version. If any links to an incorrect PHP version,
then those outdated or incorrect symlinks will be removed.

For example, if `php@8.2` links to `../Cellar/php/8.3.0` then that is
an obvious reason for that symlink to be purged because it links to
the incorrect version. (This example behaviour has been noted in #270.)

This occurs prior to the rest of the startup process, so this way
no incorrect PHP versions can pop up in the version switcher, and
no incorrect PHP version is reported as being installed when
managing installed PHP versions.
2023-12-27 12:40:35 +01:00
a459f015e1 🌐 Added translations for extension manager
(These were generated using OpenAI's API.)
2023-11-30 17:31:35 +01:00
27676f13f4 🔧 Fix build number 2023-11-30 17:14:36 +01:00
b4b2d7052f 🌐 Replace untranslated text w/ keys 2023-11-29 18:59:50 +01:00
6d25cf585e 🌐 Localize extension manager (English only) 2023-11-29 18:55:36 +01:00
ba04c94c05 👌 Cleanup UI code 2023-11-29 18:42:14 +01:00
13447ba533 Disallow installation if dependent exists 2023-11-29 18:35:31 +01:00
6f2e8f4b20 Check cutoff date for PHP version management 2023-11-27 21:39:41 +01:00
dc860074ef Parse extension dependencies 2023-11-27 21:38:38 +01:00
f586b8fcbe Allow choosing which PHP version to manage 2023-11-27 00:33:45 +01:00
94714c3e7a Extension manager responds to PHP change 2023-11-27 00:07:47 +01:00
904d05bdce 👌 Cleanup, fix loading issue 2023-11-26 23:51:39 +01:00
ec30bee72b Ask for confirmation before removing extensions 2023-11-26 22:39:44 +01:00
2fe3a4b7eb Perform cleanup when removing extensions 2023-11-26 22:03:25 +01:00
a7d5950aa0 Test synchronous shell output 2023-11-26 21:48:40 +01:00
e8306289ce Load extension info for all PHP versions
In order to make this possible, I've added a new `sync()` method to the
Shellable protocol, which now should allow us to run shell commands
synchronously. Back to basics, as this was how *all* commands were
run in legacy versions of PHP Monitor.

The advantage here is that there is now a choice. Previously, you'd
have to use the `system()` helper that I added.

Usage of that helper is now discouraged, as is the synchronous
shell method, but it may be useful for some methods where waiting
for the outcome of the output is required.

(Important: the `system()` helper is still required when determining
what the preferred terminal is during the initialization of the
`Paths` class.)
2023-11-26 21:26:48 +01:00
ff61d8c52e 🚀 Version 6.2.2 2023-11-24 23:30:12 +01:00
da41673855 Fix broken tests 2023-11-24 23:29:25 +01:00
5bda727981 🔧 Bump version 2023-11-24 22:58:04 +01:00
23cf575026 ⬆️ Upstream PHP 8.3 upgrade changes to v7.0 2023-11-24 22:57:33 +01:00
d3053b8fe3 Allow for seamless upgrade to PHP 8.3
The older version (8.2 in most cases) that becomes obsolete will also
be reinstalled and the app will attempt to switch to the last active
version as well. It is likely that PHP Monitor will have to repair
your older PHP installations after upgrading the `php` formula, but
this too should be a seamless process.
2023-11-24 22:26:33 +01:00
7159ca8612 🐛 Correctly handle mismatches when upgrading PHP 2023-11-24 20:23:12 +01:00
141c06d14b 🐛 Update PHP alias 2023-11-24 18:35:36 +01:00
94c84aaab3 👌 Tweak text 2023-11-22 21:37:11 +01:00
9ca16e72d5 Show external extensions 2023-11-22 21:29:51 +01:00
67a00f979a 📝 TODO items as warnings 2023-11-21 22:37:28 +01:00
1e4c45dcbd 🍱 Tweak UI of extension list 2023-11-21 22:36:05 +01:00
87c44f3ae3 Allow installation and removal of extensions (#266) 2023-11-21 22:18:28 +01:00
f39732a0e6 🏗 WIP: UI for searchable extensions 2023-11-21 20:03:50 +01:00
3b78ac43d7 👌 Fixed various lint issues 2023-11-21 18:19:17 +01:00
1f19b81530 🔀 Merge branch 'dev/6.x' into dev/7.x 2023-11-21 18:12:10 +01:00
d714d7ad4c 👌 Install additional taps
The following taps are now automatically installed:

- `shivammathur/php`
- `shivammathur/extensions`
2023-11-21 18:11:18 +01:00
4dce6c033e 🌐 French localization fixes, onboarding size fix 2023-11-21 17:41:47 +01:00
72a8a1e382 🔧 Tweak which formulae to install for PHP 8.3 and PHP 8.0
- Since PHP 8.0 is now EOL, it will be installed via the tap.
- Since PHP 8.3 is now stable, it will be installed without the tap.
2023-11-21 17:41:41 +01:00
07b17f3f84 🌐 French localization fixes, onboarding size fix 2023-11-21 17:40:53 +01:00
7f0f7ff3e9 🔧 Tweak which formulae to install for PHP 8.3 and PHP 8.0
- Since PHP 8.0 is now EOL, it will be installed via the tap.
- Since PHP 8.3 is now stable, it will be installed without the tap.
2023-11-21 17:15:36 +01:00
c7c143c760 🌐 Added French translation
With contributions from @tplesnar and @nhedger
2023-11-21 17:12:44 +01:00
ee050af364 🌐 Added French translation
With contributions from @tplesnar and @nhedger
2023-11-21 17:12:29 +01:00
f7e2551587 🏗 WIP: Adjust extension manager view 2023-11-21 17:11:28 +01:00
cc0cc21e5f 🏗️ WIP: Cleanup 2023-11-13 17:44:11 +01:00
883ea05bd1 🏗️ WIP: Extension manager UI (rough) 2023-11-13 13:14:27 +01:00
641bddfce7 Add UI test for PHP config editor 2023-11-07 18:21:14 +01:00
2f7223fba5 🔥 Remove unused ProgressWindowView 2023-11-07 18:08:12 +01:00
3b23ce7805 👌 Even more cleanup 2023-11-07 18:04:13 +01:00
a634d083a6 ⬆️ Adopt #Preview, cleanup PHP Version Manager 2023-11-07 17:56:38 +01:00
9a3dd2fa22 🐛 Fix extensions toggle (#265) 2023-11-02 17:26:46 +01:00
8790b30706 🚀 Version 6.2.1 2023-11-02 17:17:40 +01:00
c42188b717 🔧 Bump build 2023-11-02 17:07:42 +01:00
cc251686f9 🐛 Fix extensions toggle (#265) 2023-11-02 13:33:24 +01:00
6fd6241567 🔧 Start of v7.x branch, updated version number 2023-11-01 12:35:24 +01:00
c8ab2e67f6 ♻️ Various refactoring 2023-11-01 12:33:34 +01:00
f82ab913c6 Detect which extensions are available 2023-10-31 20:40:11 +01:00
58943148fa 🏗️ WIP: Detect which extensions are available 2023-10-30 20:21:50 +01:00
8a46b9d374 Experimental versions can graduate to stable 2023-10-30 19:56:32 +01:00
a62ebcff92 🐛 Ensure isBusy is isolated to main thread
There was an issue where updating a configuration file (including .ini)
or having an action occur that marked PHP Monitor as busy would prevent
the icon from updating correctly. This happened because access to the
busy boolean state variable could happen from various threads.

Since adding main thread isolation to the variable, you must access
`PhpEnvironments.shared.isBusy` via the main thread, therefore ensuring
that the busy state that is read from the app is always synchronized
and accurate whenever it is checked, making it so that going from
busy to no longer busy can no longer fail.

(It was possible for work in another thread to complete and fail to set
the icon to "not busy" because the work was done faster than the icon
could be set to busy.)
2023-10-30 19:34:36 +01:00
541378f3f9 🔧 Mark PHP 8.3 as stable for official release 2023-10-29 12:36:19 +01:00
e6f1d7e834 📝 Update SECURITY.md 2023-10-29 12:35:09 +01:00
151 changed files with 7504 additions and 1901 deletions

4
.gitignore vendored
View File

@ -1,6 +1,4 @@
phpmon.xcodeproj/project.xcworkspace
phpmon.xcodeproj/xcuserdata
PHP Monitor.xcodeproj/project.xcworkspace
PHP Monitor.xcodeproj/xcuserdata
phpmon-updater/PHP Monitor Self-Updater.app/
.DS_Store

View File

@ -14,6 +14,17 @@ It also automatically runs when you try to build the project. You'll get a warni
swiftlint --fix
```
## 📦 Swift Packages
Starting from PHP Monitor 7.1, the app now uses various first-party package dependencies.
The following package dependencies are in use:
* [`NVAppUpdater`](https://github.com/nicoverbruggen/NVAppUpdater)
* [`NVAlert`](https://github.com/nicoverbruggen/NVAlert)
You may need an internet connection to download these dependencies, or you can also clone the dependencies and include them manually.
## ⚙️ Preferences
You can find the persisted configuration file in `~/Library/Preferences/com.nicoverbruggen.phpmon.plist`
@ -33,16 +44,18 @@ defaults delete com.nicoverbruggen.phpmon && killall cfprefsd
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 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.)
**Important**: The updater now gets automatically built and included as part of the main target.
If you'd like to create a production build, choose "Any Mac" as the target and select Product > Archive.
### PHP Monitor Updater
## ✅ Testing
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.
In order to properly test everything, you will want to use the _PHP Monitor DEV_ target. There are unit and UI tests both.
You may sporadically see failures in UI tests due to the following error: `Invalid parameter not satisfying: point.x != INFINITY && point.y != INFINITY`. This seems to be an issue with Xcode that Apple may need to resolve? You can retry the tests in question and they should eventually pass.
## 🚀 Release procedure

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 = "1430"
LastUpgradeVersion = "1620"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ You can also add new domains as links, isolate sites, manage various services, a
PHP Monitor is a universal application that runs natively on Apple Silicon **and** Intel-based Macs.
* Your user account can administer your computer (required for some functionality, e.g. certificate generation)
* macOS 12.4 or later (Monterey, Ventura and Sonoma are supported)
* macOS 12.4 or later
* Homebrew is installed in the default location (`/usr/local/homebrew` or `/opt/homebrew`)
* Homebrew `php` formula is installed
* Optional but recommended: Laravel Valet
@ -84,9 +84,13 @@ Initially, I had an Alfred workflow for this — but it has now been replaced wi
## 🐘 Why not use Laravel Herd?
If you don't need to customize your local PHP setup and just want an easy and ready-to-go environment to start coding, [Laravel Herd](https://herd.laravel.com) is probably more than sufficient for many use cases.
_**Disclaimer**: The author is not affiliated with Laravel or the Laravel team, nor Beyond Code, who maintain Laravel Herd. PHP Monitor is an independent project._
If you need more customization and flexibility I encourage you to consider PHP Monitor in combination with Laravel Valet or some other solution like Docker (with Laravel Sail, for example).
If you don't need to customize your local PHP setup and just want an easy and ready-to-go environment to start coding, [Laravel Herd](https://herd.laravel.com) is probably more than sufficient for many use cases. They also offer paid features that may be useful to you or your team.
At this point, many people enjoy using Herd. However, Herd may not be for everyone, which is why other solutions to run PHP locally exist. If you need more customization and flexibility I encourage you to consider PHP Monitor in combination with Laravel Valet.
If you want to get as close as you can to a real server environment your best bet is probably to use a Docker container. I _highly_ recommend that you try different setups, and use what you like best.
## 🤬 The app won't start?!
@ -112,13 +116,15 @@ All stable and supported PHP versions are also supported by PHP Monitor. However
Backports that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php).
PHP extensions that are installable via PHP Monitor's **PHP Extension Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-extensions).
For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet.
</details>
<details>
<summary><strong>How do I install additional versions of PHP, including legacy versions?</strong></summary>
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.
Assuming you have installed the `php` formula, the latest stable version of PHP is installed. At the time of writing, this is PHP 8.3.
You can install other supported versions of PHP via PHP Monitor's **PHP Version Manager**. (You can manually install or upgrade PHP versions too, but this is not recommended.)

View File

@ -4,18 +4,21 @@
Generally speaking, only the latest version of **PHP Monitor** is supported, except during transition periods (for example, when particular system requirements go up):
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
| Version | Apple Silicon | Supported | Supported macOS | Minimum Deployment | Detected PHP Versions | Recommended Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 6.1 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
| 7.1 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0+)<br/>Sequoia (15.0+) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.5 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
## Legacy versions
These versions of PHP Monitor are no longer supported, but if youre using an older computer with an older version of Homebrew, Valet or macOS, you might want to use one of these versions.
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version |
| Version | Apple Silicon | Supported | Supported macOS | Minimum Deployment | Detected PHP Versions | Minimum Required Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 6.0 | ✅ Universal binary | ❌ | 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.8 | ✅ Universal binary | ❌ | 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 |
| 7.0 | ✅ Universal binary | ❌ | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.2 | ✅ Universal binary | ❌ | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.1 | ✅ Universal binary | ❌ | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.0 | ✅ Universal binary | ❌ | 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.8 | ✅ Universal binary | ❌ | 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 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

After

Width:  |  Height:  |  Size: 723 KiB

View File

@ -1,46 +0,0 @@
//
// 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

@ -1,162 +0,0 @@
//
// 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

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

View File

@ -7,8 +7,17 @@
//
import Cocoa
import NVAppUpdater
let app = NSApplication.shared
let delegate = Updater()
app.delegate = delegate
let delegate = SelfUpdater(
appName: "PHP Monitor",
bundleIdentifiers: [
"com.nicoverbruggen.phpmon.eap",
"com.nicoverbruggen.phpmon.dev",
"com.nicoverbruggen.phpmon"
],
selfUpdaterPath: "~/.config/phpmon/updater"
)
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

View File

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

View File

@ -24,7 +24,7 @@
"components" : {
"alpha" : "1.000",
"blue" : "0.988",
"green" : "0.723",
"green" : "0.444",
"red" : "0.277"
}
},

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.300",
"blue" : "0.180",
"green" : "0.841",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.300",
"blue" : "0.426",
"green" : "0.809",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "php.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 24 24"><path d="M7.01 10.207h-.944l-.515 2.648h.838c.556 0 .97-.105 1.242-.314.272-.21.455-.559.55-1.049.092-.47.05-.802-.124-.995-.175-.193-.523-.29-1.047-.29zM12 5.688C5.373 5.688 0 8.514 0 12s5.373 6.313 12 6.313S24 15.486 24 12c0-3.486-5.373-6.312-12-6.312zm-3.26 7.451c-.261.25-.575.438-.917.551-.336.108-.765.164-1.285.164H5.357l-.327 1.681H3.652l1.23-6.326h2.65c.797 0 1.378.209 1.744.628.366.418.476 1.002.33 1.752a2.836 2.836 0 0 1-.305.847c-.143.255-.33.49-.561.703zm4.024.715.543-2.799c.063-.318.039-.536-.068-.651-.107-.116-.336-.174-.687-.174H11.46l-.704 3.625H9.388l1.23-6.327h1.367l-.327 1.682h1.218c.767 0 1.295.134 1.586.401s.378.7.263 1.299l-.572 2.944h-1.389zm7.597-2.265a2.782 2.782 0 0 1-.305.847c-.143.255-.33.49-.561.703a2.44 2.44 0 0 1-.917.551c-.336.108-.765.164-1.286.164h-1.18l-.327 1.682h-1.378l1.23-6.326h2.649c.797 0 1.378.209 1.744.628.366.417.477 1.001.331 1.751zm-2.595-1.382h-.943l-.516 2.648h.838c.557 0 .971-.105 1.242-.314.272-.21.455-.559.551-1.049.092-.47.049-.802-.125-.995s-.524-.29-1.047-.29z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -14,14 +14,17 @@ class Actions {
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 \(HomebrewFormulae.php)", sudo: HomebrewFormulae.php.elevated)
}
public static func restartPhpFpm(version: String) async {
let formula = (version == PhpEnvironments.brewPhpAlias) ? "php" : "php@\(version)"
await brew("services restart \(formula)", sudo: HomebrewFormulae.php.elevated)
}
public static func restartNginx() async {
await brew("services restart \(HomebrewFormulae.nginx)", sudo: HomebrewFormulae.nginx.elevated)
}

View File

@ -19,10 +19,51 @@ struct Constants {
static let MinimumRecommendedValetVersion = "2.16.2"
/**
* The PHP versions that are considered pre-release versions.
PHP Monitor supplies a hardcoded list of PHP packages in its own
PHP Version Manager.
This hardcoded list will expire and will need to be modified when
the cutoff date occurs, which is when the `php` formula will
become PHP 8.5, and a new build will need to be made.
If users launch an older version of the app, then a warning
will be displayed to let them know that certain operations
will not work correctly and that they need to update their app.
*/
static let ExperimentalPhpVersions: Set = [
"8.3", "8.4"
static let PhpFormulaeCutoffDate = "2025-11-30" // YYYY-MM-DD
/**
* The PHP versions that are considered pre-release versions.
* Past a certain date, an experimental version "graduates"
* to a release version and is no longer marked as experimental.
*/
static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [
"8.5": Date.fromString(Self.PhpFormulaeCutoffDate),
"8.4": Date.fromString("2024-11-22")
]
return Set(releaseDates
.filter { (_: String, date: Date?) in
guard let date else {
return false
}
return date > Date.now
}.map { (version: String, _: Date?) in
return version
})
}
/**
The Homebrew services that should be automatically
detected and show up in the list of managed services.
*/
static let DetectedHomebrewServices: Set = [
"mailhog",
"mysql@",
"postgresql@",
"redis"
]
/**
@ -32,9 +73,8 @@ struct Constants {
static let DetectedPhpVersions: Set = [
"5.6",
"7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2",
"8.3",
"8.4"
"8.0", "8.1", "8.2", "8.3", "8.4",
"8.5" // DEV
]
/**
@ -50,14 +90,13 @@ struct Constants {
3: // Valet v3 dropped support for v5.6
[
"7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2",
"8.3", "8.4" // dev
"8.0", "8.1", "8.2", "8.3", "8.4"
],
4: // Valet v4 dropped support for v7.0
[
"7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2",
"8.3", "8.4" // dev
"8.0", "8.1", "8.2", "8.3", "8.4",
"8.5" // DEV
]
]
@ -73,6 +112,14 @@ struct Constants {
string: "https://phpmon.app/faq"
)!
static let WikiPhpUnavailable = URL(
string: "https://phpmon.app/php-unavailable"
)!
static let WikiPhpUpgrade = URL(
string: "https://phpmon.app/php-upgrade"
)!
static let DonationPayment = URL(
string: "https://phpmon.app/sponsor/now"
)!
@ -91,6 +138,8 @@ struct Constants {
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb"
)!
// EAP URLs
static let EarlyAccessCaskFile = URL(
string: "https://phpmon.app/builds/early-access/sponsors/phpmon-eap.rb"
)!

View File

@ -45,7 +45,6 @@ func grepContains(file: String, query: String) async -> Bool {
/**
Attempts to introduce sleep for a particular duration. Use with caution.
Only intended for testing purposes.
*/
func delay(seconds: Double) async {
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))

View File

@ -102,6 +102,14 @@ public class Paths {
return "\(shared.baseDir.rawValue)/etc"
}
public static var tapPath: String {
if shared.baseDir == .usr {
return "\(shared.baseDir.rawValue)/homebrew/Library/Taps"
}
return "\(shared.baseDir.rawValue)/Library/Taps"
}
public static var caskroomPath: String {
return "\(shared.baseDir.rawValue)/Caskroom/"
+ (App.identifier.contains(".dev") ? "phpmon-dev" : "phpmon")

View File

@ -8,6 +8,6 @@
import Foundation
protocol AlertableError {
public protocol AlertableError {
func getErrorMessageKey() -> String
}

View File

@ -15,4 +15,10 @@ extension Date {
return dateFormatter.string(from: self)
}
static func fromString(_ string: String) -> Date? {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
return dateFormatter.date(from: string)
}
}

View File

@ -0,0 +1,23 @@
//
// NVAlertExtension.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 16/07/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Foundation
import NVAlert
extension NVAlert {
/**
Shows the modal for a particular error.
*/
@MainActor public static func show(for error: Error & AlertableError) {
let key = error.getErrorMessageKey()
return NVAlert().withInformation(
title: "\(key).title".localized,
subtitle: "\(key).description".localized
).withPrimary(text: "generic.ok".localized).show()
}
}

View File

@ -8,6 +8,18 @@ import Foundation
import SwiftUI
struct Localization {
static var preferredLanguage: String? {
guard let language = Preferences.preferences[.languageOverride] as? String else {
return nil
}
if language.isEmpty {
return nil
}
return language
}
static var bundle: Bundle = {
if !isRunningTests {
return Bundle.main
@ -32,7 +44,15 @@ struct Localization {
extension String {
var localized: String {
let string = NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "")
var preferredBundle: Bundle = Localization.bundle
if let preferred = Localization.preferredLanguage,
let path = Localization.bundle.path(forResource: preferred, ofType: "lproj"),
let bundle = Bundle(path: path) {
preferredBundle = bundle
}
let string = NSLocalizedString(self, tableName: nil, bundle: preferredBundle, value: "", comment: "")
// Fallback to English translation if the localized value is equal to the key (should not happen)
if string == self {

View File

@ -64,7 +64,7 @@ class RealFileSystem: FileSystemProtocol {
// MARK: FS Attributes
func makeExecutable(_ path: String) throws {
_ = system("chmod +x \(path.replacingTildeWithHomeDirectory)")
_ = ActiveShell.shared.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
}
// MARK: - Checks

View File

@ -75,7 +75,7 @@ class MenuBarImageGenerator {
// Then we'll fetch the image we want on the left
var iconType = Preferences.preferences[.iconTypeToDisplay] as? String
if iconType == nil {
if iconType == nil || !MenuBarIcon.allCases.map({ $0.rawValue }).contains(iconType) {
Log.warn("Invalid icon type found, using the default")
iconType = MenuBarIcon.iconPhp.rawValue
}

View File

@ -29,6 +29,8 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
App.shared.remove(window: windowName)
}
func windowDidResize(_ notification: Notification) {}
deinit {
Log.perf("deinit: \(String(describing: self)).\(#function)")
}

View File

@ -10,7 +10,6 @@ import Foundation
/**
Run a simple blocking Shell command on the user's own system.
Avoid using this method in favor of the fakeable Shell class unless needed for express system operations.
*/
public func system(_ command: String) -> String {
let task = Process()

View File

@ -1,17 +0,0 @@
//
// WIP.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 01/11/2022.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
func todo(_ context: String = "") {
if !context.isEmpty {
fatalError("To be implemented: \(context)")
}
fatalError("To be implemented")
}

View File

@ -62,13 +62,6 @@ class ActivePhpInstallation {
return
}
// Load extension information
let mainConfigurationFileUrl = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(version.short)/php.ini")
if let file = PhpConfigurationFile.from(filePath: mainConfigurationFileUrl.path) {
iniFiles.append(file)
}
// Get configuration values
limits = Limits(
memory_limit: getByteCount(key: "memory_limit"),
@ -76,15 +69,10 @@ class ActivePhpInstallation {
post_max_size: getByteCount(key: "post_max_size")
)
// Return a list of .ini files parsed after php.ini
let paths = Command.execute(
path: Paths.php,
arguments: ["-r", "echo php_ini_scanned_files();"],
trimNewlines: false
)
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
let paths = ActiveShell.shared
.sync("\(Paths.php) --ini | grep -E -o '(/[^ ]+\\.ini)'").out
.split(separator: "\n")
.map { String($0) }
// See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in

View File

@ -13,7 +13,7 @@ class PhpEnvironments {
// MARK: - Initializer
/**
Loads the currently active PHP installation upon startup. May be empty.
*/
init() {
self.currentInstall = ActivePhpInstallation.load()
@ -29,7 +29,7 @@ class PhpEnvironments {
/**
Determine which PHP version the `php` formula is aliased to.
*/
func determinePhpAlias() async {
@MainActor func determinePhpAlias() async {
let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
self.homebrewPackage = try! JSONDecoder().decode(
@ -37,7 +37,28 @@ class PhpEnvironments {
from: brewPhpAlias.data(using: .utf8)!
).first!
Log.info("[BREW] On your system, the `php` formula means version \(homebrewPackage.version)!")
PhpEnvironments.brewPhpAlias = self.homebrewPackage.version
Log.info("[BREW] On your system, the `php` formula means version \(homebrewPackage.version).")
// Check if that version actually corresponds to an older version
let phpConfigExecutablePath = "\(Paths.optPath)/php/bin/php-config"
if FileSystem.fileExists(phpConfigExecutablePath) {
let longVersionString = Command.execute(
path: phpConfigExecutablePath,
arguments: ["--version"],
trimNewlines: false
).trimmingCharacters(in: .whitespacesAndNewlines)
if let version = try? VersionNumber.parse(longVersionString) {
PhpEnvironments.brewPhpAlias = version.short
if version.short != homebrewPackage.version {
Log.info("[BREW] An older version of `php` is actually installed (\(version.short)).")
}
} else {
Log.warn("Could not determine the actual version of the php binary; assuming Homebrew is correct.")
PhpEnvironments.brewPhpAlias = homebrewPackage.version
}
}
}
// MARK: - Properties
@ -49,12 +70,10 @@ class PhpEnvironments {
static let shared = PhpEnvironments()
/** Whether the switcher is busy performing any actions. */
var isBusy: Bool = false {
@MainActor var isBusy: Bool = false {
didSet {
Task { @MainActor in
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
}
MainMenu.shared.refreshIcon()
MainMenu.shared.rebuild()
}
}
@ -68,7 +87,14 @@ class PhpEnvironments {
var cachedPhpInstallations: [String: PhpInstallation] = [:]
/** Information about the currently linked PHP installation. */
var currentInstall: ActivePhpInstallation?
var currentInstall: ActivePhpInstallation? {
didSet {
// Let the PHP extension manager, if it exists, know the version changed
if let version = currentInstall?.version.short {
App.shared.phpExtensionManagerWindowController?.view?.manager.phpVersion = version
}
}
}
/**
The version that the `php` formula via Brew is aliased to on the current system.
@ -79,7 +105,12 @@ class PhpEnvironments {
As such, we take that information from Homebrew.
*/
static var brewPhpAlias: String {
static var brewPhpAlias: String = ""
/**
It's possible for the alias to be newer than the actual installed version of PHP.
*/
static var homebrewBrewPhpAlias: String {
if PhpEnvironments.shared.homebrewPackage == nil { return "8.2" }
return PhpEnvironments.shared.homebrewPackage.version
@ -146,7 +177,12 @@ class PhpEnvironments {
// Avoid inserting a duplicate
if !supportedVersions.contains(phpAlias) && FileSystem.fileExists("\(Paths.optPath)/php/bin/php") {
supportedVersions.insert(phpAlias)
let phpAliasInstall = PhpInstallation(phpAlias)
// Before inserting, ensure that the actual output matches the alias
// if that isn't the case, our formula remains out-of-date
if !phpAliasInstall.isMissingBinary {
supportedVersions.insert(phpAlias)
}
}
availablePhpVersions = Array(supportedVersions)

View File

@ -67,7 +67,7 @@ class PhpExtension {
self.name = String(fullPath.split(separator: "/").last!) // take last segment
self.enabled = !line.contains(";")
self.enabled = !line.starts(with: ";")
self.file = file
}
@ -76,7 +76,7 @@ class PhpExtension {
You may need to restart the other services in order for this change to apply.
*/
func toggle() async {
let newLine = enabled
let newLine = !line.starts(with: ";")
// DISABLED: Commented out line
? "; \(line)"
// ENABLED: Line where the comment delimiter (;) is removed
@ -84,14 +84,14 @@ class PhpExtension {
await sed(file: file, original: line, replacement: newLine)
enabled.toggle()
self.enabled = !newLine.starts(with: ";")
self.line = newLine
if !isRunningTests {
Task { @MainActor in
MainMenu.shared.rebuild()
}
}
}
// MARK: - Static Methods

View File

@ -12,19 +12,48 @@ class PhpInstallation {
var versionNumber: VersionNumber
var iniFiles: [PhpConfigurationFile] = []
var isPreRelease: Bool = false
var isMissingBinary: Bool = false
var isHealthy: Bool = true
var extensions: [PhpExtension] {
return self.iniFiles.flatMap({ $0.extensions })
}
var formulaName: String {
let version = self.versionNumber.short
if version == PhpEnvironments.brewPhpAlias {
return "php"
}
return "php@\(self.versionNumber.short)"
}
/**
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 phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config",
phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php"
let phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php"
versionNumber = VersionNumber.make(from: version)!
self.versionNumber = VersionNumber.make(from: version)!
determineVersion(phpConfigExecutablePath, phpExecutablePath)
determineHealth(phpExecutablePath)
determineIniFiles(phpExecutablePath)
// Find all enabled extensions
let enabled = self.extensions.filter({ $0.enabled }).map({ $0.name })
Log.info("PHP \(versionNumber.short) has the following extensions enabled: \(enabled)")
}
private func determineVersion(_ phpConfigExecutablePath: String, _ phpExecutablePath: String) {
if FileSystem.fileExists(phpConfigExecutablePath) {
let longVersionString = Command.execute(
path: phpConfigExecutablePath,
@ -32,11 +61,21 @@ class PhpInstallation {
trimNewlines: false
).trimmingCharacters(in: .whitespacesAndNewlines)
if longVersionString.contains("-dev") {
isPreRelease = true
}
// The parser should always work, or the string has to be very unusual.
// If so, the app SHOULD crash, so that the users report what's up.
self.versionNumber = try! VersionNumber.parse(longVersionString)
versionNumber = try! VersionNumber.parse(longVersionString)
} else {
// Keep track that the `php-config` binary is missing; this often means there's a mismatch between
// the `php` version alias and the actual installed version (e.g. you haven't upgraded `php`)
isMissingBinary = true
}
}
private func determineHealth(_ phpExecutablePath: String) {
if FileSystem.fileExists(phpExecutablePath) {
let testCommand = Command.execute(
path: phpExecutablePath,
@ -53,4 +92,18 @@ class PhpInstallation {
}
}
}
private func determineIniFiles(_ phpExecutablePath: String) {
let paths = ActiveShell.shared
.sync("\(phpExecutablePath) --ini | grep -E -o '(/[^ ]+\\.ini)'").out
.split(separator: "\n")
.map { String($0) }
// See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in
if let file = PhpConfigurationFile.from(filePath: iniFilePath) {
iniFiles.append(file)
}
}
}
}

View File

@ -27,7 +27,7 @@ extension InternalSwitcher {
return corrections.contains(true)
}
// MARK: - PHP FPM pool
// MARK: - Corrections
public func disableDefaultPhpFpmPool(_ version: String) async -> FixApplied {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
@ -54,37 +54,7 @@ extension InternalSwitcher {
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 {
public func ensureConfigurationFilesExist(_ version: String) async -> FixApplied {
let files = self.getExpectedConfigurationFiles(for: version)
// For each of the files, attempt to fix anything that is wrong
@ -124,6 +94,38 @@ extension InternalSwitcher {
return outcomes.contains(true)
}
// MARK: - Internals
private 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 }
)
]
}
}
public struct ExpectedConfigurationFile {

View File

@ -8,9 +8,6 @@
import Foundation
extension Process: @unchecked Sendable {}
extension Timer: @unchecked Sendable {}
class RealShell: ShellProtocol {
/**
The launch path of the terminal in question that is used.
@ -86,14 +83,37 @@ class RealShell: ShellProtocol {
// MARK: - Shellable Protocol
func sync(_ command: String) -> ShellOutput {
let task = getShellProcess(for: command)
let outputPipe = Pipe()
let errorPipe = Pipe()
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
sleep(3)
}
task.standardOutput = outputPipe
task.standardError = errorPipe
task.launch()
task.waitUntilExit()
let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
if Log.shared.verbosity == .cli {
log(task: task, stdOut: stdOut, stdErr: stdErr)
}
return .out(stdOut, stdErr)
}
func pipe(_ command: String) async -> ShellOutput {
let task = getShellProcess(for: command)
let outputPipe = Pipe()
let errorPipe = Pipe()
// Seriously slow down how long it takes for the shell to return output
// (in order to debug or identify async issues)
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
Log.info("[SLOW SHELL] \(command)")
await delay(seconds: 3.0)
@ -104,20 +124,20 @@ class RealShell: ShellProtocol {
task.launch()
task.waitUntilExit()
let stdOut = String(
data: outputPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)!
let stdErr = String(
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)!
let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
if Log.shared.verbosity == .cli {
var args = task.arguments ?? []
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
log(task: task, stdOut: stdOut, stdErr: stdErr)
}
return .out(stdOut, stdErr)
}
private func log(task: Process, stdOut: String, stdErr: String) {
var args = task.arguments ?? []
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
<~~~~~~~~~~~~~~~~~~~~~~~
$ \(([self.launchPath] + args + [last]).joined(separator: " "))
@ -126,22 +146,19 @@ class RealShell: ShellProtocol {
\(stdOut)
"""
if !stdErr.isEmpty {
log.append("""
if !stdErr.isEmpty {
log.append("""
[ERR]:
\(stdErr)
""")
}
}
log.append("""
log.append("""
~~~~~~~~~~~~~~~~~~~~~~~~>
""")
Log.info(log)
}
return .out(stdOut, stdErr)
Log.info(log)
}
func quiet(_ command: String) async {
@ -164,25 +181,26 @@ class RealShell: ShellProtocol {
}
return try await withCheckedThrowingContinuation({ continuation in
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in
let task = Task {
try await Task.sleep(nanoseconds: timeout.nanoseconds)
// Only terminate if the process is still running
if process.isRunning {
process.terminationHandler = nil
process.terminate()
return continuation.resume(throwing: ShellError.timedOut)
continuation.resume(throwing: ShellError.timedOut)
}
}
process.terminationHandler = { [timer, output] process in
timer.invalidate()
process.terminationHandler = { [output] process in
task.cancel()
process.haltListening()
if !output.err.isEmpty {
return continuation.resume(returning: (process, .err(output.err)))
continuation.resume(returning: (process, .err(output.err)))
} else {
continuation.resume(returning: (process, .out(output.out)))
}
return continuation.resume(returning: (process, .out(output.out)))
}
process.launch()
@ -190,3 +208,9 @@ class RealShell: ShellProtocol {
})
}
}
extension TimeInterval {
var nanoseconds: UInt64 {
return UInt64(self * 1_000_000_000)
}
}

View File

@ -14,6 +14,16 @@ protocol ShellProtocol {
*/
var PATH: String { get }
/**
Run a command synchronously. Use with caution.
Common usage:
```
let output = Shell.sync("php -v")
```
*/
func sync(_ command: String) -> ShellOutput
/**
Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists).

View File

@ -1,5 +1,5 @@
//
// PhpFormulaeStatus.swift
// BusyStatus.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 02/05/2023.
@ -8,7 +8,7 @@
import Foundation
class PhpFormulaeStatus: ObservableObject {
class BusyStatus: ObservableObject {
@Published var busy: Bool
@Published var title: String
@Published var description: String
@ -18,4 +18,12 @@ class PhpFormulaeStatus: ObservableObject {
self.title = title
self.description = description
}
public static func notBusy() -> BusyStatus {
return BusyStatus(busy: false, title: "", description: "")
}
public static func busy() -> BusyStatus {
return BusyStatus(busy: false, title: "", description: "")
}
}

View File

@ -73,12 +73,26 @@ public struct TestableConfiguration: Codable {
: .fake(.text)
]) { (_, new) in new }
self.commandOutput["/opt/homebrew/opt/php@\(version.short)/bin/php-config --version"]
= version.long
// PHP configuration files
self.shellOutput["/opt/homebrew/opt/php@\(version.short)/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"] =
.instant("/opt/homebrew/etc/php/\(version.short)/conf.d/php-memory-limits.ini")
// PHP Homebrew operations
self.shellOutput["/opt/homebrew/bin/brew unlink php@\(version.short)"] = .delayed(0.2, "OK")
self.shellOutput["sudo /opt/homebrew/bin/brew services stop php@\(version.short)"] = .delayed(0.2, "OK")
self.shellOutput["sudo /opt/homebrew/bin/brew services start php@\(version.short)"] = .delayed(0.2, "OK")
self.shellOutput["/opt/homebrew/bin/brew link php@\(version.short) --overwrite --force"] = .delayed(0.2, "OK")
// PHP version output
self.commandOutput["/opt/homebrew/opt/php@\(version.short)/bin/php-config --version"] = version.long
self.commandOutput["/opt/homebrew/opt/php@\(version.short)/bin/php -v"] = "OK"
if primary {
self.shellOutput["ls /opt/homebrew/opt | grep php"]
= .instant("php")
// Files expected to be present for currently linked PHP version
self.shellOutput["ls /opt/homebrew/opt | grep php"] =
.instant("php")
self.shellOutput["/opt/homebrew/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"] =
.instant("/opt/homebrew/etc/php/\(version.short)/conf.d/php-memory-limits.ini")
self.filesystem["/opt/homebrew/opt/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)")
self.filesystem["/opt/homebrew/opt/php/bin/php"]
@ -89,12 +103,8 @@ public struct TestableConfiguration: Codable {
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.short)/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 {
// Output expected to be present for non-linked PHP versions
self.shellOutput["ls /opt/homebrew/opt | grep php@"] =
BatchFakeShellOutput.instant(
self.secondaryPhpVersions
@ -103,6 +113,7 @@ public struct TestableConfiguration: Codable {
)
}
}
// swiftlint:enable function_body_length
// MARK: Interactions

View File

@ -18,11 +18,11 @@ class TestableFileSystem: FileSystemProtocol {
self.files = files
// Ensure that each of the ~ characters are replaced with the home directory path
for key in self.files.keys where key.contains("~") {
self.files.renameKey(
fromKey: key,
toKey: key.replacingOccurrences(of: "~", with: self.homeDirectory)
)
accessQueue.sync {
for (key, value) in files {
let adjustedKey = key.contains("~") ? key.replacingOccurrences(of: "~", with: self.homeDirectory) : key
self.files[adjustedKey] = value
}
}
// Ensure that intermediate directories are created
@ -46,38 +46,49 @@ class TestableFileSystem: FileSystemProtocol {
*/
private(set) var homeDirectory = "/Users/fake"
/**
Serial dispatch queue for ensuring thread-safe access to the `files` dictionary.
*/
private let accessQueue = DispatchQueue(label: "com.testablefilesystem.accessQueue")
// MARK: - Basics
func createDirectory(_ path: String, withIntermediateDirectories: Bool) throws {
let path = path.replacingTildeWithHomeDirectory
if files[path] != nil {
throw TestableFileSystemError.alreadyExists
try accessQueue.sync {
if files[path] != nil {
throw TestableFileSystemError.alreadyExists
}
self.createIntermediateDirectories(path)
self.files[path] = .fake(.directory)
}
self.createIntermediateDirectories(path)
self.files[path] = .fake(.directory)
}
func writeAtomicallyToFile(_ path: String, content: String) throws {
let path = path.replacingTildeWithHomeDirectory
if files[path] != nil {
throw TestableFileSystemError.alreadyExists
}
try accessQueue.sync {
if files[path] != nil {
throw TestableFileSystemError.alreadyExists
}
self.files[path] = .fake(.text, content)
self.files[path] = .fake(.text, content)
}
}
func getStringFromFile(_ path: String) throws -> String {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
return try accessQueue.sync {
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
return file.content ?? ""
return file.content ?? ""
}
}
func getShallowContentsOfDirectory(_ path: String) throws -> [String] {
@ -88,32 +99,36 @@ class TestableFileSystem: FileSystemProtocol {
seek = "\(seek)/"
}
return self.files.keys
.filter { $0.hasPrefix(seek) }
.map { $0.replacingOccurrences(of: seek, with: "") }
.filter { !$0.contains("/") }
return accessQueue.sync {
self.files.keys
.filter { $0.hasPrefix(seek) }
.map { $0.replacingOccurrences(of: seek, with: "") }
.filter { !$0.contains("/") }
}
}
func getDestinationOfSymlink(_ path: String) throws -> String {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
return try accessQueue.sync {
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
if file.type != .symlink {
throw TestableFileSystemError.notSymlink
}
if file.type != .symlink {
throw TestableFileSystemError.notSymlink
}
guard let pathToSymlink = file.content else {
throw TestableFileSystemError.invalidSymlink
}
guard let pathToSymlink = file.content else {
throw TestableFileSystemError.invalidSymlink
}
if !files.keys.contains(pathToSymlink) {
throw TestableFileSystemError.invalidSymlink
}
if !files.keys.contains(pathToSymlink) {
throw TestableFileSystemError.invalidSymlink
}
return pathToSymlink
return pathToSymlink
}
}
// MARK: - Move & Delete Files
@ -122,27 +137,31 @@ class TestableFileSystem: FileSystemProtocol {
let path = path.replacingTildeWithHomeDirectory
let newPath = newPath.replacingTildeWithHomeDirectory
self.files.keys.forEach { key in
if key.hasPrefix(path) {
self.files.renameKey(
fromKey: key,
toKey: key.replacingOccurrences(of: path, with: newPath)
)
accessQueue.sync {
self.files.keys.forEach { key in
if key.hasPrefix(path) {
self.files.renameKey(
fromKey: key,
toKey: key.replacingOccurrences(of: path, with: newPath)
)
}
}
}
self.files.renameKey(fromKey: path, toKey: newPath)
self.files.renameKey(fromKey: path, toKey: newPath)
}
}
func remove(_ path: String) throws {
// Remove recursively
self.files.keys.forEach { key in
if key.hasPrefix(path) {
self.files.removeValue(forKey: key)
accessQueue.sync {
// Remove recursively
self.files.keys.forEach { key in
if key.hasPrefix(path) {
self.files.removeValue(forKey: key)
}
}
}
self.files.removeValue(forKey: path)
self.files.removeValue(forKey: path)
}
}
// MARK: Attributes
@ -150,11 +169,13 @@ class TestableFileSystem: FileSystemProtocol {
func makeExecutable(_ path: String) throws {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
try accessQueue.sync {
guard let file = files[path] else {
throw TestableFileSystemError.fileMissing
}
file.type = .binary
file.type = .binary
}
}
// MARK: - Checks
@ -162,93 +183,107 @@ class TestableFileSystem: FileSystemProtocol {
func isExecutableFile(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path.replacingTildeWithHomeDirectory] else {
return false
}
return accessQueue.sync {
guard let file = files[path.replacingTildeWithHomeDirectory] else {
return false
}
return file.type == .binary
return file.type == .binary
}
}
func isWriteableFile(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path.replacingTildeWithHomeDirectory] else {
return false
}
return accessQueue.sync {
guard let file = files[path.replacingTildeWithHomeDirectory] else {
return false
}
return !file.readOnly
return !file.readOnly
}
}
func anyExists(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
return files.keys.contains(path)
return accessQueue.sync {
files.keys.contains(path)
}
}
func fileExists(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
return false
}
return accessQueue.sync {
guard let file = files[path] else {
return false
}
return [.binary, .symlink, .text].contains(file.type)
return [.binary, .symlink, .text].contains(file.type)
}
}
func directoryExists(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
return false
}
return accessQueue.sync {
guard let file = files[path] else {
return false
}
return [.directory].contains(file.type)
return [.directory].contains(file.type)
}
}
func isSymlink(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
return false
}
return accessQueue.sync {
guard let file = files[path] else {
return false
}
return file.type == .symlink
return file.type == .symlink
}
}
func isDirectory(_ path: String) -> Bool {
let path = path.replacingTildeWithHomeDirectory
guard let file = files[path] else {
return false
}
return accessQueue.sync {
guard let file = files[path] else {
return false
}
return file.type == .directory
return file.type == .directory
}
}
public func printContents() {
for key in self.files.keys.sorted() {
print("\(key) -> \(self.files[key]!.type)")
accessQueue.sync {
for key in self.files.keys.sorted() {
print("\(key) -> \(self.files[key]!.type)")
}
}
}
private func createIntermediateDirectories(_ path: String) {
let path = path.replacingTildeWithHomeDirectory
let items = path.components(separatedBy: "/")
var preceding = ""
var directoriesToCreate: [String] = []
for item in items {
let key = preceding == "/"
? "/\(item)"
: "\(preceding)/\(item)"
if !self.files.keys.contains(key) {
self.files[key] = .fake(.directory)
}
let key = preceding == "/" ? "/\(item)" : "\(preceding)/\(item)"
directoriesToCreate.append(key)
preceding = key
}
for key in directoriesToCreate where !self.files.keys.contains(key) {
self.files[key] = .fake(.directory)
}
}
}

View File

@ -19,6 +19,17 @@ public class TestableShell: ShellProtocol {
var expectations: [String: BatchFakeShellOutput] = [:]
func sync(_ command: String) -> ShellOutput {
// This assertion will only fire during test builds
assert(expectations.keys.contains(command), "No response declared for command: \(command)")
guard let expectation = expectations[command] else {
return .err("No Expected Output")
}
return expectation.syncOutput()
}
func quiet(_ command: String) async {
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
}
@ -112,6 +123,29 @@ struct BatchFakeShellOutput: Codable {
return output
}
/**
Outputs the fake shell output as expected, but does this synchronously.
*/
public func syncOutput(
ignoreDelay: Bool = false
) -> ShellOutput {
let output = ShellOutput.empty()
for item in items {
if !ignoreDelay {
Thread.sleep(forTimeInterval: item.delay)
}
if item.stream == .stdErr {
output.err += item.output
} else if item.stream == .stdOut {
output.out += item.output
}
}
return output
}
/**
For testing purposes (and speed) we may omit the delay, regardless of its timespan.
*/

View File

@ -16,10 +16,19 @@
<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 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>Get the latest on Bluesky or Mastodon.</b> Give me a <a href="https://bsky.app/profile/nicoverbruggen.be">follow on Bluesky</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!)
<p><b>Made possible by these GitHub Sponsors</b>: @abdusfauzi, @abicons, @adibnoh, @adrolli, @andresayej, @andyunleashed, @anzacorp, @argirisp, @ash-jc-allen, @AshPowell, @aurawindsurfing, @awsmug, @barrycarton, @BertvanHoekelen, @calebporzio, @casenxu, @caseyalee, @cgreuling, @cjcox17, @clescuyer, @codelinde, @designhammer, @Diewy, @drfraker, @driftingly, @duellsy, @e9li, @edalzell, @EYOND, @faithfm, @frankmichel, @gekich, @gpluess, @gwleuverink, @hopkins385, @incon, @intrepidws, @israaraujo, @jacksleight, @JacobBennett, @jasonvarga, @jeromegamez, @jimmyaldape, @jimmysawczuk, @joetannenbaum, @jolora, @jorisnoo, @joshuablum, @jpeinelt, @jreviews, @JustSteveKing, @Kajvdh, @KFoobar, @kholisabdullah, @Laravel-Backpack, @leganz, @lucianvacaroiu,@martinleveille, @mathiasonea, @matthewmnewman, @mcastillo1030, @megabubbletea, @megabubbleteam, @mennen-online, @mike-healy, @mostafakram, @mpociot, @MrMicky-FR, @MrMooky, @murdercode, @nckrtl, @nhedger, @ninjaparade, @ozanuzer, @pepatel, @philbraun, @pickuse2013, @pk-informatics, @Plytas, @rastitkac, @rderimay, @renecum, @richardhulbert, @richardtape, @rickyjohnston, @rico, @RobertBoes, @runofthemill, @SahinU88, @sdebacker, @sdevore, @shadracnicholas, @simonhamp, @slaFFik, @spatie, @SRWieZ, @stefanbauer, @stefanzweifel, @StriveMedia, @swilla, @Tailcode-Studio, @theutz, @ThomasEnssner, @tillkruss, @timothyrowan, @ttnppedr, @vincent-tarrit, @vintagesucks, @WheresMarco, @xPand4B, @xuandung38, @yeslandi89, @zackkatz, @zacksmash, @zaherg.<br/>(This is a historical list of sponsors, not current sponsors. Some names have been omitted due to their sponsorships being private. Thank you all!)</p>
<p><b>Localization credits:</b></br>
&dash; English, Dutch</b> by @nicoverbruggen</br>
&dash; Vietnamese</b> by @xuandung38</br>
&dash; German</b> by @dsturm</br>
&dash; Portuguese</b> by @joseborges</br>
&dash; French</b> by @nhedger, @tplesnar</br>
&dash; Chinese</b> by @guanguans</br>
</br>
Other languages are considered experimental, and were generated via a local LLM. If you have feedback or concerns, please don't hesitate to get in touch.
</p>
<br/>
</body>
</html>

View File

@ -83,9 +83,15 @@ class App {
/** The window controller of the PHP version manager window. */
var phpVersionManagerWindowController: PhpVersionManagerWindowController?
/** The window controller of the PHP extension manager window. */
var phpExtensionManagerWindowController: PhpExtensionManagerWindowController?
/** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = []
/** Favorites storage, which keeps track of favorited domains. */
var favorites = Favorites.shared
/** The warning manager, responsible for keeping track of warnings. */
var warnings = WarningManager.shared

View File

@ -23,12 +23,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
*/
let state: App
/**
The MainMenu singleton is responsible for rendering the
menu bar item and its menu, as well as its actions.
*/
let menu: MainMenu
/**
The paths singleton that determines where Homebrew is installed,
and where to look for binaries.
@ -96,7 +90,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
}
self.state = App.shared
self.menu = MainMenu.shared
self.paths = Paths.shared
self.valet = Valet.shared
self.brew = Brew.shared
@ -109,6 +102,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
static func initializeTestingProfile(_ path: String) {
Log.info("The configuration with path `\(path)` is being requested...")
// Clear for PHP Guard
Stats.clearCurrentGlobalPhpVersion()
// Load the configuration file
TestableConfiguration.loadFrom(path: path).apply()
}
@ -129,7 +125,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
setupNotifications()
Task { // Make sure the menu performs its initial checks
await menu.startup()
await MainMenu.shared.startup()
}
}

View File

@ -8,6 +8,7 @@
import Foundation
import Cocoa
import NVAlert
class AppUpdater {
var caskFile: CaskFile!
@ -72,7 +73,7 @@ class AppUpdater {
: "brew upgrade phpmon"
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "updater.alerts.newer_version_available.title"
.localized(latestVersionOnline.humanReadable),
subtitle: "updater.alerts.newer_version_available.subtitle"
@ -112,7 +113,7 @@ class AppUpdater {
public func presentNoNewerVersionAvailableAlert() {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "updater.alerts.is_latest_version.title".localized,
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
description: ""
@ -124,7 +125,7 @@ class AppUpdater {
public func presentCouldNotRetrieveUpdate() {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "updater.alerts.cannot_check_for_update.title".localized,
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
description: "updater.alerts.cannot_check_for_update.description".localized(

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="22155" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22155"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22690"/>
<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"/>
@ -63,6 +63,13 @@
<action selector="focusSearchField:" target="Voe-Tx-rLC" id="O8j-1B-hll"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bPr-YU-lg4"/>
<menuItem title="actions" enabled="NO" id="cAS-FU-WUA" userLabel="actions" customClass="LocalizedMenuItem" customModule="PHP_Monitor" customModuleProvider="target">
<modifierMask key="keyEquivalentModifierMask"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="localizationKey" value="mm_actions"/>
</userDefinedRuntimeAttributes>
</menuItem>
</items>
</menu>
</menuItem>
@ -508,10 +515,10 @@
</objects>
<point key="canvasLocation" x="-374" y="2267"/>
</scene>
<!--Better AlertVC-->
<!--AlertVC-->
<scene sceneID="y9E-bB-wIG">
<objects>
<viewController storyboardIdentifier="noticeVC" id="hkw-9V-NxP" customClass="BetterAlertVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="noticeVC" id="hkw-9V-NxP" customClass="NVAlertVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="UPH-hV-Naz">
<rect key="frame" x="0.0" y="0.0" width="500" height="212"/>
<autoresizingMask key="autoresizingMask"/>
@ -819,14 +826,21 @@ Gw
<rect key="frame" x="0.0" y="0.0" width="626" height="309"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<progressIndicator maxValue="100" displayedWhenStopped="NO" indeterminate="YES" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="ZiS-Gq-TLQ">
<rect key="frame" x="298" y="150" width="30" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="XK3-AR-Oc0"/>
<constraint firstAttribute="height" constant="30" id="lfW-dB-Eu3"/>
</constraints>
</progressIndicator>
<scrollView borderType="none" horizontalLineScroll="54" horizontalPageScroll="10" verticalLineScroll="54" verticalPageScroll="10" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p0j-eB-I2i">
<rect key="frame" x="0.0" y="0.0" width="626" height="309"/>
<clipView key="contentView" ambiguous="YES" drawsBackground="NO" id="6IL-DW-37w">
<rect key="frame" x="0.0" y="0.0" width="626" height="309"/>
<rect key="frame" x="0.0" y="0.0" width="611" height="294"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView verticalHuggingPriority="750" ambiguous="YES" allowsExpansionToolTips="YES" multipleSelection="NO" autosaveName="phpmon-sitelist-columns" rowHeight="54" headerView="xUg-Mq-OSh" viewBased="YES" id="cp3-34-pQj" customClass="PMTableView" customModule="PHP_Monitor" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="626" height="281"/>
<rect key="frame" x="0.0" y="0.0" width="611" height="266"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<size key="intercellSpacing" width="17" height="0.0"/>
<color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
@ -916,6 +930,50 @@ Gw
<outlet property="labelSiteName" destination="XJL-Uw-frD" id="f0t-vd-W68"/>
</connections>
</tableCellView>
<tableCellView identifier="domainListNameCellFavorited" wantsLayer="YES" id="Byb-te-u65" customClass="DomainListNameCell" customModule="PHP_Monitor" customModuleProvider="target">
<rect key="frame" x="69" y="54" width="200" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="aot-FJ-HIk">
<rect key="frame" x="33" y="26" width="145" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="LHu-UF-QlC">
<font key="font" metaFont="systemSemibold" size="13"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="GNH-l8-oki">
<rect key="frame" x="33" y="12" width="75" height="14"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="LNw-Ju-0Ot">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="3Wp-DX-An9">
<rect key="frame" x="5" y="4" width="20" height="47"/>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="Q76-fI-lkW">
<imageReference key="image" image="star.circle.fill" catalog="system" symbolScale="large"/>
</imageCell>
<color key="contentTintColor" name="AccentColor"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="3Wp-DX-An9" firstAttribute="leading" secondItem="Byb-te-u65" secondAttribute="leading" constant="5" id="CTd-ON-loK"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="aot-FJ-HIk" secondAttribute="trailing" constant="20" symbolic="YES" id="Csc-Dy-H4K"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="GNH-l8-oki" secondAttribute="trailing" constant="20" symbolic="YES" id="H10-MG-hCG"/>
<constraint firstItem="GNH-l8-oki" firstAttribute="leading" secondItem="aot-FJ-HIk" secondAttribute="leading" id="Hk0-x3-RyN"/>
<constraint firstItem="3Wp-DX-An9" firstAttribute="top" secondItem="Byb-te-u65" secondAttribute="top" constant="9" id="erH-dR-K7S"/>
<constraint firstItem="aot-FJ-HIk" firstAttribute="top" secondItem="Byb-te-u65" secondAttribute="top" constant="12" id="ktI-fg-qaX"/>
<constraint firstAttribute="bottom" secondItem="3Wp-DX-An9" secondAttribute="bottom" constant="9" id="uyc-26-gZb"/>
<constraint firstItem="aot-FJ-HIk" firstAttribute="leading" secondItem="Byb-te-u65" secondAttribute="leading" constant="35" id="vXE-jj-lLF"/>
<constraint firstItem="GNH-l8-oki" firstAttribute="top" secondItem="aot-FJ-HIk" secondAttribute="bottom" id="wSX-fR-O7a"/>
</constraints>
<connections>
<outlet property="labelPathName" destination="GNH-l8-oki" id="GC1-TA-lIk"/>
<outlet property="labelSiteName" destination="aot-FJ-HIk" id="HdZ-Rh-ua6"/>
</connections>
</tableCellView>
</prototypeCellViews>
</tableColumn>
<tableColumn identifier="ENVIRONMENT" width="100" minWidth="100" maxWidth="150" id="hzb-XI-Out">
@ -1073,28 +1131,30 @@ Gw
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="620" id="iRQ-sz-oyv"/>
</constraints>
<scroller key="horizontalScroller" wantsLayer="YES" verticalHuggingPriority="750" horizontal="YES" id="TDE-ff-DQT">
<rect key="frame" x="0.0" y="293" width="626" height="16"/>
<rect key="frame" x="0.0" y="294" width="611" height="15"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<scroller key="verticalScroller" wantsLayer="YES" verticalHuggingPriority="750" horizontal="NO" id="wFn-93-f10">
<rect key="frame" x="610" y="28" width="16" height="281"/>
<rect key="frame" x="611" y="28" width="15" height="266"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<tableHeaderView key="headerView" wantsLayer="YES" id="xUg-Mq-OSh">
<rect key="frame" x="0.0" y="0.0" width="626" height="28"/>
<rect key="frame" x="0.0" y="0.0" width="611" height="28"/>
<autoresizingMask key="autoresizingMask"/>
</tableHeaderView>
</scrollView>
<progressIndicator maxValue="100" displayedWhenStopped="NO" indeterminate="YES" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="ZiS-Gq-TLQ">
<rect key="frame" x="298" y="150" width="30" height="30"/>
<customView translatesAutoresizingMaskIntoConstraints="NO" id="wcV-ed-8Bv">
<rect key="frame" x="113" y="5" width="400" height="300"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="XK3-AR-Oc0"/>
<constraint firstAttribute="height" constant="30" id="lfW-dB-Eu3"/>
<constraint firstAttribute="width" constant="400" id="HCo-LG-x3N"/>
<constraint firstAttribute="height" constant="300" id="Xpi-Rl-xmb"/>
</constraints>
</progressIndicator>
</customView>
</subviews>
<constraints>
<constraint firstItem="p0j-eB-I2i" firstAttribute="leading" secondItem="rIZ-4U-bhj" secondAttribute="leading" id="2Tx-yb-xrv"/>
<constraint firstItem="wcV-ed-8Bv" firstAttribute="centerX" secondItem="rIZ-4U-bhj" secondAttribute="centerX" id="DPz-kQ-aP0"/>
<constraint firstItem="wcV-ed-8Bv" firstAttribute="centerY" secondItem="rIZ-4U-bhj" secondAttribute="centerY" id="HCW-zJ-gSY"/>
<constraint firstItem="p0j-eB-I2i" firstAttribute="top" secondItem="rIZ-4U-bhj" secondAttribute="top" id="Pst-5A-dI0"/>
<constraint firstAttribute="bottom" secondItem="p0j-eB-I2i" secondAttribute="bottom" id="QEw-5m-u1s"/>
<constraint firstItem="ZiS-Gq-TLQ" firstAttribute="centerY" secondItem="rIZ-4U-bhj" secondAttribute="centerY" constant="-10" id="XqX-Tf-8ck"/>
@ -1103,13 +1163,14 @@ Gw
</constraints>
</view>
<connections>
<outlet property="noResultsView" destination="wcV-ed-8Bv" id="K3s-fb-1aN"/>
<outlet property="progressIndicator" destination="ZiS-Gq-TLQ" id="Ylb-Vk-uub"/>
<outlet property="tableView" destination="cp3-34-pQj" id="sdw-Ac-27X"/>
</connections>
</viewController>
<customObject id="HgD-aB-bQb" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="323" y="723"/>
<point key="canvasLocation" x="323" y="722.5"/>
</scene>
<!--Add ProxyVC-->
<scene sceneID="g8z-pE-RL9">
@ -1486,6 +1547,10 @@ Gw
<image name="Lock" width="30" height="30"/>
<image name="arrow.clockwise" catalog="system" width="14" height="16"/>
<image name="plus" catalog="system" width="14" height="13"/>
<image name="star.circle.fill" catalog="system" width="20" height="20"/>
<namedColor name="AccentColor">
<color red="0.0" green="0.46000000000000002" blue="0.89000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="IconColorGreen">
<color red="0.24699999392032623" green="0.69700002670288086" blue="0.50099998712539673" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>

View File

@ -8,6 +8,7 @@
import Foundation
import Cocoa
import NVAlert
class ValetServicesManager: ServicesManager {
override init() {
@ -131,7 +132,7 @@ class ValetServicesManager: ServicesManager {
Log.err("The service '\(named)' is now reporting an error.")
guard let errorLogPath = after.error_log_path else {
return BetterAlert().withInformation(
return NVAlert().withInformation(
title: "alert.service_error.title".localized(named),
subtitle: "alert.service_error.subtitle.no_error_log".localized(named),
description: "alert.service_error.extra".localized
@ -140,7 +141,7 @@ class ValetServicesManager: ServicesManager {
.show()
}
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.service_error.title".localized(named),
subtitle: "alert.service_error.subtitle.error_log".localized(named),
description: "alert.service_error.extra".localized

View File

@ -7,9 +7,24 @@
import Foundation
import AppKit
import NVAlert
class Startup {
@MainActor static var startupTimer: Timer?
@MainActor func startTimeoutTimer() {
Self.startupTimer = Timer.scheduledTimer(
timeInterval: 30.0, target: self, selector: #selector(startupTimeout),
userInfo: nil, repeats: false
)
}
@MainActor static func invalidateTimeoutTimer() {
Self.startupTimer?.invalidate()
Self.startupTimer = nil
}
/**
Checks the user's environment and checks if PHP Monitor can be used properly.
This checks if PHP is installed, Valet is running, the appropriate permissions are set, and more.
@ -21,6 +36,11 @@ class Startup {
// Do the important system setup checks
Log.info("The user is running PHP Monitor with the architecture: \(App.architecture)")
// Set up a "background" timer on the main thread
Task { @MainActor in
startTimeoutTimer()
}
for group in self.groups {
if group.condition() {
Log.info("Now running \(group.checks.count) \(group.name) checks!")
@ -44,10 +64,34 @@ class Startup {
// If we get here, nothing has gone wrong. That's what we want!
initializeSwitcher()
Log.info("PHP Monitor has determined the application has successfully passed all checks.")
Log.separator(as: .info)
return true
}
/**
Displays an alert for when the application startup process takes too long.
*/
@MainActor @objc func startupTimeout() {
NVAlert()
.withInformation(
title: "startup.timeout.title".localized,
subtitle: "startup.timeout.subtitle".localized,
description: "startup.timeout.description".localized
)
.withPrimary(text: "alert.cannot_start.close".localized, action: { vc in
vc.close(with: .alertFirstButtonReturn)
exit(1)
})
.withSecondary(text: "startup.timeout.ignore".localized, action: { vc in
vc.close(with: .alertSecondButtonReturn)
})
.withTertiary(text: "", action: { _ in
NSWorkspace.shared.open(URL(string: "https://github.com/nicoverbruggen/phpmon/issues/294")!)
})
.show()
}
/**
Displays an alert for a particular check. There are two types of alerts:
- ones that require an app restart, which prompt the user to exit the app
@ -55,7 +99,7 @@ class Startup {
*/
@MainActor private func showAlert(for check: EnvironmentCheck) {
if check.requiresAppRestart {
BetterAlert()
NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
@ -66,7 +110,7 @@ class Startup {
}).show()
}
BetterAlert()
NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
@ -142,7 +186,7 @@ class Startup {
return await Shell.pipe("\(Paths.binPath)/php -v").err
.contains("Library not loaded")
},
name: "`no dyld issue detected",
name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized(
Paths.optPath

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
@MainActor class ComposerWindow {
private var shouldNotify: Bool! = nil
@ -28,8 +29,6 @@ import Foundation
}
PhpEnvironments.shared.isBusy = true
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
window = TerminalProgressWindowController.display(
title: "alert.composer_progress.title".localized,
@ -106,15 +105,12 @@ import Foundation
private func removeBusyStatus() {
PhpEnvironments.shared.isBusy = false
Task { @MainActor in
MainMenu.shared.updatePhpVersionInStatusBar()
}
}
// MARK: Alert
@MainActor private func presentMissingAlert() {
BetterAlert()
private func presentMissingAlert() {
NVAlert()
.withInformation(
title: "alert.composer_missing.title".localized,
subtitle: "alert.composer_missing.subtitle".localized,

View File

@ -22,7 +22,7 @@ struct ProjectTypeDetection {
This list is checked first to see if a project dependency can be mapped to a certain project type.
*/
public static let SpecificDependencyList = [
"roots/bedrock": "Bedrock",
"roots/bedrock-autoloader": "Bedrock",
"cakephp/app": "CakePHP",
"craftcms/craft": "Craft",
"drupal/core": "Drupal",

View File

@ -8,16 +8,6 @@
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()
@ -45,10 +35,12 @@ class Brew {
/// Each formula for each PHP version that can be installed.
public static let phpVersionFormulae = [
"8.5": "shivammathur/php/php@8.5",
"8.4": "shivammathur/php/php@8.4",
"8.3": "shivammathur/php/php@8.3",
"8.2": "php@8.2",
"8.1": "php@8.1",
"8.0": "php@8.0",
"8.2": "shivammathur/php/php@8.2",
"8.1": "shivammathur/php/php@8.1",
"8.0": "shivammathur/php/php@8.0",
"7.4": "shivammathur/php/php@7.4",
"7.3": "shivammathur/php/php@7.3",
"7.2": "shivammathur/php/php@7.2",

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
class BrewDiagnostics {
/**
@ -27,6 +28,21 @@ class BrewDiagnostics {
}
}
/**
Logs a bunch of useful information during startup.
*/
public static func logBootInformation() {
Log.info(BrewDiagnostics.customCaskInstalled
? "[BREW] The app has been installed via Homebrew Cask."
: "[BREW] The app has been installed directly (optimal)."
)
Log.info(BrewDiagnostics.usesNginxFullFormula
? "[BREW] The app will be using the `nginx-full` formula."
: "[BREW] The app will be using the `nginx` formula."
)
}
/**
Determines whether the PHP Monitor Cask is installed.
*/
@ -46,6 +62,43 @@ class BrewDiagnostics {
return destination.contains("/nginx-full/")
}()
/**
It is possible to have outdated symlinks for PHP installations. This can mean that certain PHP installations
are going to be reported incorrectly (e.g. `php@8.2` links to an installation in a `8.3` folder after an upgrade).
To ensure this does not cause issues, PHP Monitor will automatically remove all incorrect PHP symlinks.
*/
public static func checkForOutdatedPhpInstallationSymlinks() async {
// Set up a regular expression
let regex = try! NSRegularExpression(pattern: "^php@[0-9]+\\.[0-9]+$", options: .caseInsensitive)
// Check for incorrect versions
if let contents = try? FileSystem.getShallowContentsOfDirectory("\(Paths.optPath)")
.filter({
let range = NSRange($0.startIndex..., in: $0)
return regex.firstMatch(in: $0, options: [], range: range) != nil
}) {
for symlink in contents {
let version = symlink.replacingOccurrences(of: "php@", with: "")
if let destination = try? FileSystem.getDestinationOfSymlink("\(Paths.optPath)/\(symlink)") {
if !destination.contains("Cellar/php/\(version)")
&& !destination.contains("Cellar/php@\(version)") {
Log.err("Symlink for \(symlink) is incorrect. Removing...")
do {
try FileSystem.remove("\(Paths.optPath)/\(symlink)")
Log.info("Incorrect symlink for \(symlink) has been successfully removed.")
} catch {
Log.err("Symlink for \(symlink) was incorrect but could not be removed!")
}
}
} else {
Log.warn("Could not read symlink at: \(Paths.optPath)/\(symlink)! Symlink check skipped.")
}
}
}
}
/**
It is possible to have the `shivammathur/php` tap installed, and for the core homebrew information to be outdated.
This will then result in two different aliases claiming to point to the same formula (`php`).
@ -131,7 +184,7 @@ class BrewDiagnostics {
*/
private static func presentAlertAboutConflict() {
Task { @MainActor in
BetterAlert()
NVAlert()
.withInformation(
title: "alert.php_alias_conflict.title".localized,
subtitle: "alert.php_alias_conflict.info".localized

View File

@ -0,0 +1,98 @@
//
// BrewPhpExtension.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 27/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
struct BrewPhpExtension: Hashable, Comparable {
let name: String
let phpVersion: String
let isInstalled: Bool
let path: String
let dependencies: [String]
var extensionDependencies: [String] {
return dependencies
.filter {
$0.contains("shivammathur/extensions/") && $0.contains("@\(phpVersion)")
}
.map {
$0.replacingOccurrences(of: "shivammathur/extensions/", with: "")
.replacingOccurrences(of: "@\(phpVersion)", with: "")
}
}
var formulaName: String {
return "\(name)@\(phpVersion)"
}
init(path: String, name: String, phpVersion: String) {
self.path = path
self.name = name
self.phpVersion = phpVersion
self.isInstalled = BrewPhpExtension.hasInstallationReceipt(
for: "\(name)@\(phpVersion)"
)
self.dependencies = BrewPhpExtension.extractDependencies(from: path)
}
var hasAlternativeInstall: Bool {
guard let php = PhpEnvironments.shared.cachedPhpInstallations[self.phpVersion] else {
return false
}
let alreadyDiscovered = php.extensions.contains(where: { $0.name == self.name })
return alreadyDiscovered && !isInstalled
}
internal func firstDependent(in exts: [BrewPhpExtension]) -> BrewPhpExtension? {
return exts
.filter({ $0.isInstalled })
.first { $0.dependencies.contains("shivammathur/extensions/\(self.formulaName)") }
}
static func hasInstallationReceipt(for formulaName: String) -> Bool {
return FileSystem.fileExists("\(Paths.optPath)/\(formulaName)/INSTALL_RECEIPT.json")
}
static func < (lhs: BrewPhpExtension, rhs: BrewPhpExtension) -> Bool {
return lhs.name < rhs.name
}
static func == (lhs: BrewPhpExtension, rhs: BrewPhpExtension) -> Bool {
return lhs.name == rhs.name
}
private static func extractDependencies(from path: String) -> [String] {
let regexPattern = #"depends_on "(.*)""#
var dependencies: [String] = []
guard let content = try? FileSystem.getStringFromFile(path) else {
return []
}
do {
let regex = try NSRegularExpression(pattern: regexPattern, options: [])
let range = NSRange(content.startIndex..<content.endIndex, in: content)
let matches = regex.matches(in: content, options: [], range: range)
for match in matches {
if let range = Range(match.range(at: 1), in: content) {
let dependencyName = String(content[range])
dependencies.append(dependencyName)
}
}
} catch {
return []
}
return dependencies
}
}

View File

@ -1,5 +1,5 @@
//
// BrewFormula.swift
// BrewPhpFormula.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/03/2023.
@ -8,7 +8,7 @@
import Foundation
struct BrewFormula {
struct BrewPhpFormula: Equatable {
/// Name of the formula.
let name: String
@ -24,6 +24,9 @@ struct BrewFormula {
/// Whether this formula is a stable version of PHP.
let prerelease: Bool
/// Whether this formula's associated Homebrew file exists.
var hasFormulaFile: Bool = false
/// Whether the formula is currently installed.
var isInstalled: Bool {
return installedVersion != nil
@ -41,6 +44,7 @@ struct BrewFormula {
self.installedVersion = installedVersion
self.upgradeVersion = upgradeVersion
self.prerelease = prerelease
self.hasFormulaFile = checkFormulaFile()
}
/// Whether the formula can be upgraded.
@ -48,6 +52,25 @@ struct BrewFormula {
return upgradeVersion != nil
}
/// Whether this formula alias is different.
var hasUpgradedFormulaAlias: Bool {
return self.shortVersion == PhpEnvironments.homebrewBrewPhpAlias
&& PhpEnvironments.homebrewBrewPhpAlias != PhpEnvironments.brewPhpAlias
}
var unavailableAfterUpgrade: Bool {
if installedVersion == nil || upgradeVersion == nil {
return false
}
if let installed = try? VersionNumber.parse(self.installedVersion!),
let upgrade = try? VersionNumber.parse(self.upgradeVersion!) {
return upgrade.short != installed.short
}
return false
}
/// The associated Homebrew folder with this PHP formula.
var homebrewFolder: String {
let resolved = name
@ -60,7 +83,7 @@ struct BrewFormula {
/// The short version associated with this formula, if installed.
var shortVersion: String? {
guard let version = self.installedVersion else {
return nil
return self.displayName.replacingOccurrences(of: "PHP ", with: "")
}
return VersionNumber.make(from: version)?.short ?? nil
@ -72,6 +95,21 @@ struct BrewFormula {
return isHealthy() ?? true
}
/**
Verify whether the formula file exists (sourced via `shivammathur/homebrew-php`).
If it does not exist, the formula cannot be installed.
*/
private func checkFormulaFile() -> Bool {
guard let version = shortVersion else {
return false
}
return FileSystem.fileExists(
"\(Paths.tapPath)/shivammathur/homebrew-php/Formula/php@\(version).rb"
.replacingOccurrences(of: "php@" + PhpEnvironments.brewPhpAlias, with: "php")
)
}
/**
* Determines if this PHP installation is healthy.
* Uses the cached installation health check as basis.
@ -81,6 +119,7 @@ struct BrewFormula {
return nil
}
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?.isHealthy ?? nil
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?
.isHealthy ?? nil
}
}

View File

@ -8,22 +8,23 @@
import Foundation
protocol HandlesBrewFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula]
protocol HandlesBrewPhpFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula]
func refreshPhpVersions(loadOutdated: Bool) async
}
extension HandlesBrewFormulae {
extension HandlesBrewPhpFormulae {
public func refreshPhpVersions(loadOutdated: Bool) async {
let items = await loadPhpVersions(loadOutdated: loadOutdated)
Task { @MainActor in
await PhpEnvironments.shared.determinePhpAlias()
Brew.shared.formulae.phpVersions = items
}
}
}
class BrewFormulaeHandler: HandlesBrewFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] {
class BrewPhpFormulaeHandler: HandlesBrewPhpFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula] {
var outdated: [OutdatedFormula]?
if loadOutdated {
@ -38,27 +39,33 @@ class BrewFormulaeHandler: HandlesBrewFormulae {
OutdatedFormulae.self,
from: rawJsonText
).formulae.filter({ formula in
formula.name.starts(with: "php")
formula.name.starts(with: "shivammathur/php/php") || formula.name.starts(with: "php")
})
}
return Brew.phpVersionFormulae.map { (version, formula) in
let fullVersion = PhpEnvironments.shared.cachedPhpInstallations[version]?.versionNumber.text
var fullVersion: String?
var upgradeVersion: String?
var isPrerelease: Bool = Constants.ExperimentalPhpVersions.contains(version)
if let install = PhpEnvironments.shared.cachedPhpInstallations[version] {
fullVersion = install.versionNumber.text
fullVersion = install.isPreRelease ? "\(fullVersion!)-dev" : fullVersion
if let version = fullVersion {
upgradeVersion = outdated?.first(where: { formula in
return formula.installed_versions.contains(version)
return formula.name.replacingOccurrences(of: "shivammathur/php/", with: "")
== install.formulaName.replacingOccurrences(of: "shivammathur/php/", with: "")
})?.current_version
isPrerelease = install.isPreRelease
}
return BrewFormula(
return BrewPhpFormula(
name: formula,
displayName: "PHP \(version)",
installedVersion: fullVersion,
upgradeVersion: upgradeVersion,
prerelease: Constants.ExperimentalPhpVersions.contains(version)
prerelease: isPrerelease
)
}.sorted { $0.displayName > $1.displayName }
}

View File

@ -0,0 +1,53 @@
//
// BrewTapFormulae.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 01/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class BrewTapFormulae {
public static func from(tap: String) -> [String: [BrewPhpExtension]] {
let directory = "\(Paths.tapPath)/\(tap)/Formula"
let files = try? FileSystem.getShallowContentsOfDirectory(directory)
var availableExtensions = [String: [BrewPhpExtension]]()
guard let files = files else {
return availableExtensions
}
let regex = try! NSRegularExpression(pattern: "(\\w+)@(\\d+\\.\\d+)\\.rb")
for file in files {
let matches = regex.matches(in: file, range: NSRange(file.startIndex..., in: file))
if let match = matches.first {
if let phpExtensionRange = Range(match.range(at: 1), in: file),
let versionRange = Range(match.range(at: 2), in: file) {
// Determine what the extension's name is
let phpExtensionName = String(file[phpExtensionRange])
// Determine what PHP version this is for
let phpVersion = String(file[versionRange])
// Create a new BrewPhpExtension object (determines if installed)
let phpExtension = BrewPhpExtension(
path: "\(Paths.tapPath)/\(tap)/Formula/\(file)",
name: phpExtensionName,
phpVersion: phpVersion
)
// Append the extension to the list
var extensions = availableExtensions[phpVersion, default: []]
extensions.append(phpExtension)
availableExtensions[phpVersion] = extensions.sorted()
}
}
}
return availableExtensions
}
}

View File

@ -10,27 +10,111 @@ import Foundation
protocol BrewCommand {
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws
func getCommandTitle() -> String
}
extension BrewCommand {
internal func reportInstallationProgress(_ text: String) -> (Double, String)? {
if text.contains("Fetching") {
// Special cases: downloading a manifest is effectively fetching metadata
if text.contains("==> Downloading") && text.contains("/manifests/") {
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") {
// Logical progress evaluation (reverse order for accuracy)
if text.contains("==> Summary") {
return (0.90, "phpman.steps.summary".localized)
}
if text.contains("==> Pouring") {
if let subject = extractContext(from: text) {
return (0.80, "phpman.steps.pouring".localized + "\n(\(subject))")
}
return (0.80, "phpman.steps.pouring".localized)
}
if text.contains("==> Installing") {
if let subject = extractContext(from: text) {
return (0.60, "phpman.steps.installing".localized + "\n(\(subject))")
}
return (0.60, "phpman.steps.installing".localized)
}
if text.contains("==> Downloading") {
if let subject = extractContext(from: text) {
return (0.25, "phpman.steps.downloading".localized + "\n(\(subject))")
}
return (0.25, "phpman.steps.downloading".localized)
}
if text.contains("==> Fetching") {
return (0.1, "phpman.steps.fetching".localized)
}
return nil
}
internal func extractContext(from text: String) -> String? {
var pattern = #""#
if text.contains("==> Fetching") {
pattern = #"==> Fetching (\S+)"#
}
if text.contains("==> Downloading") {
pattern = #"==> Downloading (\S+)"#
}
if text.contains("==> Installing") {
pattern = #"==> Installing (\S+)"#
}
if text.contains("==> Pouring") {
pattern = #"==> Pouring (\S+)"#
}
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return nil
}
let range = NSRange(text.startIndex..<text.endIndex, in: text)
if let match = regex.firstMatch(in: text, options: [], range: range) {
if let formulaRange = Range(match.range(at: 1), in: text) {
return String(text[formulaRange])
}
}
return nil
}
internal 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: getCommandTitle(), description: text))
}
},
withTimeout: .minutes(15)
)
if process.terminationStatus <= 0 {
loggedMessages = []
return
} else {
throw BrewCommandError(error: "The command failed to run correctly.", log: loggedMessages)
}
}
internal func checkPhpTap(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
if !BrewDiagnostics.installedTaps.contains("shivammathur/php") {
let command = "brew tap shivammathur/php"
try await run(command, onProgress)
}
if !BrewDiagnostics.installedTaps.contains("shivammathur/extensions") {
let command = "brew tap shivammathur/extensions"
try await run(command, onProgress)
}
}
}
struct BrewCommandProgress {

View File

@ -0,0 +1,86 @@
//
// InstallPhpExtensionCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class InstallPhpExtensionCommand: BrewCommand {
let installing: [BrewPhpExtension]
func getExtensionNames() -> String {
return installing.map { $0.name }.joined(separator: ", ")
}
func getCommandTitle() -> String {
return "phpman.steps.installing".localized(getExtensionNames())
}
public init(install extensions: [BrewPhpExtension]) {
self.installing = extensions
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "phpman.steps.wait".localized
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "phpman.steps.preparing".localized
))
// Make sure the tap is installed
try await self.checkPhpTap(onProgress)
// Make sure that the extension(s) are installed
try await self.installPackages(onProgress)
// Finally, complete all operations
await self.completedOperations(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.formulaName }.joined(separator: " ")) --force
"""
try await run(command, onProgress)
}
private func completedOperations(_ onProgress: @escaping (BrewCommandProgress) -> Void) async {
// Reload and restart PHP versions
onProgress(.create(value: 0.95, title: self.getCommandTitle(), description: "phpman.steps.reloading".localized))
// Restart PHP-FPM
if let installed = self.installing.first {
await Actions.restartPhpFpm(version: installed.phpVersion)
}
// Check which version of PHP are now installed
await PhpEnvironments.detectPhpVersions()
// Keep track of the currently installed version
await MainMenu.shared.refreshActiveInstallation()
// 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: "phpman.steps.completed".localized,
description: "phpman.steps.success".localized
))
}
}

View File

@ -0,0 +1,91 @@
//
// RemovePhpExtensionCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class RemovePhpExtensionCommand: BrewCommand {
public let phpExtension: BrewPhpExtension
public init(remove formula: BrewPhpExtension) {
self.phpExtension = formula
}
func getCommandTitle() -> String {
return "phpman.steps.removing".localized(phpExtension.name)
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
onProgress(.create(
value: 0.2,
title: getCommandTitle(),
description: "phpman.steps.removing".localized("`\(phpExtension.name)`...")
))
// Keep track of the file that contains the information about the extension
let existing = PhpEnvironments.shared
.cachedPhpInstallations[phpExtension.phpVersion]?
.extensions.first(where: { ext in
ext.name == phpExtension.name
})
let command = """
export HOMEBREW_NO_INSTALL_UPGRADE=true; \
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) remove \(phpExtension.formulaName) --force --ignore-dependencies
"""
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: getCommandTitle(), description: "phpman.steps.reloading".localized))
if let ext = existing {
await performExtensionCleanup(for: ext)
}
await PhpEnvironments.detectPhpVersions()
await Actions.restartPhpFpm(version: phpExtension.phpVersion)
await MainMenu.shared.refreshActiveInstallation()
onProgress(.create(value: 1, title: getCommandTitle(), description: "phpman.steps.success".localized))
} else {
throw BrewCommandError(error: "phpman.steps.failure".localized, log: loggedMessages)
}
}
private func performExtensionCleanup(for ext: PhpExtension) async {
if ext.file.hasSuffix("20-\(ext.name).ini") {
// The extension's default configuration file can be removed
Log.info("The extension was found in a default extension .ini location. Purging that .ini file.")
do {
try FileSystem.remove(ext.file)
} catch {
Log.err("The file `\(ext.file)` could not be removed.")
}
} else {
// The extension's default configuration file cannot be removed, it should be disabled instead
Log.info("The extension was not found in a default location. Disabling the extension only.")
if ext.enabled {
await ext.toggle()
}
}
}
}

View File

@ -8,23 +8,33 @@
import Foundation
class InstallAndUpgradeCommand: BrewCommand {
class ModifyPhpVersionCommand: BrewCommand {
let title: String
let installing: [BrewFormula]
let upgrading: [BrewFormula]
let installing: [BrewPhpFormula]
let upgrading: [BrewPhpFormula]
let phpGuard: PhpGuard
func getCommandTitle() -> String {
return title
}
/**
You can pass in which PHP versions need to be upgraded and which ones need to be installed.
The process will be executed in two steps: first upgrades, then installations.
Upgrades come first because... well, otherwise installations may very well break.
Each version that is installed will need to be checked afterwards (if it is OK).
Each version that is installed will need to be checked afterwards. Installing a
newer formula may break other PHP installations, which in turn need to be fixed.
- Important: If any PHP formula is a major upgrade that causes a PHP "version" to be
uninstalled, this is remedied by running `upgradeMainPhpFormula()`. This process
will ensure that the upgrade is applied, but the also that old version is
re-installed and linked again.
*/
public init(
title: String,
upgrading: [BrewFormula],
installing: [BrewFormula]
upgrading: [BrewPhpFormula],
installing: [BrewPhpFormula]
) {
self.title = title
self.installing = installing
@ -33,17 +43,32 @@ class InstallAndUpgradeCommand: BrewCommand {
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Please wait..."
let progressTitle = "phpman.steps.wait".localized
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "PHP Monitor is preparing Homebrew..."
description: "phpman.steps.preparing".localized
))
// Try to run all upgrade and installation operations
try await self.upgradePackages(onProgress)
try await self.installPackages(onProgress)
// Determine if a formula will become unavailable
// This is the case when `php` will be bumped to a new version
let unavailable = upgrading.first(where: { formula in
formula.unavailableAfterUpgrade
})
// Make sure the tap is installed
try await self.checkPhpTap(onProgress)
if unavailable == nil {
// Try to run all upgrade and installation operations
try await self.upgradePackages(onProgress)
try await self.installPackages(onProgress)
} else {
// Simply upgrade `php` to the latest version
try await self.upgradeMainPhpFormula(unavailable!, onProgress)
await PhpEnvironments.shared.determinePhpAlias()
}
// Re-check the installed versions
await PhpEnvironments.detectPhpVersions()
@ -55,6 +80,27 @@ class InstallAndUpgradeCommand: BrewCommand {
await self.completedOperations(onProgress)
}
private func upgradeMainPhpFormula(
_ unavailable: BrewPhpFormula,
_ onProgress: @escaping (BrewCommandProgress) -> Void
) async throws {
// Determine which version was previously available (that will become unavailable)
guard let short = try? VersionNumber
.parse(unavailable.installedVersion!).short else {
return
}
// Upgrade the main formula
let command = """
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) upgrade php;
\(Paths.brew) install php@\(short);
"""
// Run the upgrade command
try await run(command, onProgress)
}
private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit
if self.upgrading.isEmpty {
@ -117,35 +163,12 @@ class InstallAndUpgradeCommand: BrewCommand {
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..."))
onProgress(.create(value: 0.95, title: self.title, description: "phpman.steps.reloading".localized))
// Ensure all symlinks are correctly linked
await BrewDiagnostics.checkForOutdatedPhpInstallationSymlinks()
// Check which version of PHP are now installed
await PhpEnvironments.detectPhpVersions()
@ -164,9 +187,8 @@ class InstallAndUpgradeCommand: BrewCommand {
// Let the UI know that the installation has been completed
onProgress(.create(
value: 1,
title: "Operation completed!",
description: "The installation has succeeded."
title: "phpman.steps.completed".localized,
description: "phpman.steps.success".localized
))
}
}

View File

@ -21,13 +21,15 @@ class RemovePhpVersionCommand: BrewCommand {
self.phpGuard = PhpGuard()
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Removing PHP \(version)..."
func getCommandTitle() -> String {
return "phpman.steps.removing".localized("PHP \(version)...")
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "Please wait while Homebrew removes PHP \(version)..."
title: getCommandTitle(),
description: "phpman.steps.wait".localized
))
let command = """
@ -56,7 +58,7 @@ class RemovePhpVersionCommand: BrewCommand {
)
if process.terminationStatus <= 0 {
onProgress(.create(value: 0.95, title: progressTitle, description: "Reloading PHP versions..."))
onProgress(.create(value: 0.95, title: getCommandTitle(), description: "phpman.steps.reloading".localized))
await PhpEnvironments.detectPhpVersions()
@ -66,7 +68,7 @@ class RemovePhpVersionCommand: BrewCommand {
await MainMenu.shared.switchToPhpVersionAndWait(version, silently: true)
}
onProgress(.create(value: 1, title: progressTitle, description: "The operation has succeeded."))
onProgress(.create(value: 1, title: getCommandTitle(), description: "phpman.steps.success".localized))
} else {
throw BrewCommandError(error: "The command failed to run correctly.", log: loggedMessages)
}

View File

@ -9,6 +9,10 @@
import Foundation
class FakeCommand: BrewCommand {
func getCommandTitle() -> String {
return "Hello"
}
let version: String
init(version: String) {

View File

@ -24,4 +24,6 @@ protocol ValetListable {
func getListableUrl() -> URL?
func getListableFavorited() -> Bool
}

View File

@ -14,6 +14,11 @@ class ValetProxy: ValetListable {
var target: String
var secured: Bool = false
var favorited: Bool = false
var favoriteSignature: String {
"proxy:domain:\(domain).\(tld)|target:\(target)"
}
init(domain: String, target: String, secure: Bool, tld: String) {
self.domain = domain
self.tld = tld
@ -28,6 +33,8 @@ class ValetProxy: ValetListable {
secure: false,
tld: configuration.tld
)
self.favorited = Favorites.shared.contains(domain: self.domain)
self.determineSecured()
}
@ -61,12 +68,21 @@ class ValetProxy: ValetListable {
return URL(string: "\(self.secured ? "https://" : "http://")\(self.domain).\(self.tld)")
}
func getListableFavorited() -> Bool {
return self.favorited
}
// MARK: - Interactions
func determineSecured() {
self.secured = FileSystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
}
func toggleFavorite() {
self.favorited.toggle()
Favorites.shared.toggle(domain: self.favoriteSignature)
}
func toggleSecure() async throws {
try await ValetInteractor.shared.toggleSecure(proxy: self)
}

View File

@ -61,6 +61,11 @@ class ValetSite: ValetListable {
?? "???"
}
var favorited: Bool = false
var favoriteSignature: String {
"site:domain:\(name).\(tld)|path:\(absolutePath)"
}
init(
name: String,
tld: String,
@ -75,6 +80,7 @@ class ValetSite: ValetListable {
self.secured = false
if makeDeterminations {
self.favorited = Favorites.shared.contains(domain: favoriteSignature)
determineSecured()
determineIsolated()
determineComposerPhpVersion()
@ -305,12 +311,21 @@ class ValetSite: ValetListable {
return URL(string: "\(self.secured ? "https://" : "http://")\(self.name).\(Valet.shared.config.tld)")
}
func getListableFavorited() -> Bool {
return self.favorited
}
// MARK: - Interactions
func toggleSecure() async throws {
try await ValetInteractor.shared.toggleSecure(site: self)
}
func toggleFavorite() {
self.favorited.toggle()
Favorites.shared.toggle(domain: self.favoriteSignature)
}
func isolate(version: String) async throws {
try await ValetInteractor.shared.isolate(site: self, version: version)
}

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
extension Valet {
@ -16,7 +17,7 @@ extension Valet {
public func notifyAboutUnsupportedTLD() {
if Valet.shared.config.tld != "test" && Preferences.isEnabled(.warnAboutNonStandardTLD) {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.warnings.tld_issue.title".localized,
subtitle: "alert.warnings.tld_issue.subtitle".localized,
description: "alert.warnings.tld_issue.description".localized
@ -33,7 +34,7 @@ extension Valet {
public func notifyAboutOutdatedValetVersion(_ version: VersionNumber) {
Task { @MainActor in
BetterAlert()
NVAlert()
.withInformation(
title: "alert.min_valet_version.title".localized,
subtitle: "alert.min_valet_version.info".localized(
@ -60,7 +61,7 @@ extension Valet {
}
Task { @MainActor in
BetterAlert()
NVAlert()
.withInformation(
title: "alert.php_fpm_broken.title".localized,
subtitle: "alert.php_fpm_broken.info".localized,

View File

@ -0,0 +1,41 @@
//
// AppMenu.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 27/06/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Cocoa
class AppMenu {
// MARK: - Main Menu
static var appMenu: NSMenu? {
return NSApplication.shared.mainMenu?.items[0].submenu
}
static var sitesMenu: NSMenu? {
return NSApplication.shared.mainMenu?.items[1].submenu
}
static var editMenu: NSMenu? {
return NSApplication.shared.mainMenu?.items[2].submenu
}
static var windowMenu: NSMenu? {
return NSApplication.shared.mainMenu?.items[3].submenu
}
static var helpMenu: NSMenu? {
return NSApplication.shared.mainMenu?.items[4].submenu
}
// MARK: - Submenu
static var actionsMenu: NSMenuItem? {
return sitesMenu?.items.last
}
}

View File

@ -7,6 +7,7 @@
//
import Cocoa
import NVAlert
extension MainMenu {
@ -20,7 +21,7 @@ extension MainMenu {
@MainActor @objc func displayUnlinkedInfo() {
Task { @MainActor in
BetterAlert()
NVAlert()
.withInformation(
title: "phpman.unlinked.title".localized,
subtitle: "phpman.unlinked.desc".localized,
@ -32,7 +33,7 @@ extension MainMenu {
}
@MainActor @objc func fixHomebrewPermissions() {
if !BetterAlert()
if !NVAlert()
.withInformation(
title: "alert.fix_homebrew_permissions.title".localized,
subtitle: "alert.fix_homebrew_permissions.subtitle".localized,
@ -47,7 +48,7 @@ extension MainMenu {
asyncExecution {
try Actions.fixHomebrewPermissions()
} success: {
BetterAlert()
NVAlert()
.withInformation(
title: "alert.fix_homebrew_permissions_done.title".localized,
subtitle: "alert.fix_homebrew_permissions_done.subtitle".localized,
@ -56,7 +57,7 @@ extension MainMenu {
.withPrimary(text: "generic.ok".localized)
.show()
} failure: { error in
BetterAlert.show(for: error as! HomebrewPermissionError)
NVAlert.show(for: error as! HomebrewPermissionError)
}
}
@ -175,7 +176,7 @@ extension MainMenu {
return
}
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.revert_description.title".localized,
subtitle: "alert.revert_description.subtitle".localized(
preset.textDescription
@ -196,7 +197,7 @@ extension MainMenu {
}
@MainActor @objc func showPresetHelp() {
BetterAlert().withInformation(
NVAlert().withInformation(
title: "preset_help_title".localized,
subtitle: "preset_help_info".localized,
description: "preset_help_desc".localized
@ -263,7 +264,7 @@ extension MainMenu {
Task { MainMenu.shared.switchToPhpVersion(version) }
} else {
Task {
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.php_switch_unavailable.title".localized,
subtitle: "alert.php_switch_unavailable.subtitle".localized(version)
).withPrimary(
@ -283,12 +284,11 @@ extension MainMenu {
return
}
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
@ -298,13 +298,12 @@ extension MainMenu {
}
@objc func switchToPhpVersion(_ version: String) {
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
Task(priority: .userInitiated) { [unowned self] in
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
@ -325,13 +324,12 @@ extension MainMenu {
*/
func switchToPhp(_ version: String) async {
Task { @MainActor [self] in
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
}
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)

View File

@ -45,21 +45,16 @@ extension MainMenu {
.broadcastServicesUpdate
]
) {
if behaviours.contains(.reloadsPhpInstallation) {
if behaviours.contains(.reloadsPhpInstallation) || behaviours.contains(.setsBusyUI) {
PhpEnvironments.shared.isBusy = true
}
if behaviours.contains(.setsBusyUI) {
setBusyImage()
}
Task(priority: .userInitiated) { [unowned self] in
var error: Error?
do { try execute() } catch let e { error = e }
if behaviours.contains(.setsBusyUI) {
PhpEnvironments.shared.isBusy = false
do { try execute() } catch let e {
error = e
Log.err(e)
}
Task { @MainActor [self, error] in
@ -68,15 +63,18 @@ extension MainMenu {
}
if behaviours.contains(.updatesMenuBarContents) {
updatePhpVersionInStatusBar()
} else if behaviours.contains(.setsBusyUI) {
refreshIcon()
rebuild()
}
if behaviours.contains(.broadcastServicesUpdate) {
Task { await ServicesManager.shared.reloadServicesStatus() }
}
if behaviours.contains(.setsBusyUI) {
PhpEnvironments.shared.isBusy = false
}
if error != nil {
return failure(error!)
}

View File

@ -8,6 +8,7 @@
import Foundation
import AppKit
import NVAlert
extension MainMenu {
@ -19,7 +20,7 @@ extension MainMenu {
return
}
if !BetterAlert()
if !NVAlert()
.withInformation(
title: "alert.fix_my_valet.title".localized,
subtitle: "alert.fix_my_valet.info".localized(PhpEnvironments.brewPhpAlias)
@ -43,7 +44,7 @@ extension MainMenu {
}
@MainActor private func presentAlertForMissingFormula() {
BetterAlert()
NVAlert()
.withInformation(
title: "alert.php_formula_missing.title".localized,
subtitle: "alert.php_formula_missing.info".localized
@ -53,7 +54,7 @@ extension MainMenu {
}
@MainActor private func presentAlertForSameVersion() {
BetterAlert()
NVAlert()
.withInformation(
title: "alert.fix_my_valet_done.title".localized,
subtitle: "alert.fix_my_valet_done.subtitle".localized,
@ -64,7 +65,7 @@ extension MainMenu {
}
@MainActor private func presentAlertForDifferentVersion(version: String) {
BetterAlert()
NVAlert()
.withInformation(
title: "alert.fix_my_valet_done.title".localized,
subtitle: "alert.fix_my_valet_done.subtitle".localized,

View File

@ -7,6 +7,7 @@
//
import Cocoa
import NVAlert
extension MainMenu {
/**
@ -15,7 +16,7 @@ extension MainMenu {
func startup() async {
// Start with the icon
Task { @MainActor in
self.setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
self.setStatusBar(image: NSImage.statusBarIcon)
}
if await Startup().checkEnvironment() {
@ -32,19 +33,14 @@ extension MainMenu {
// Determine what the `php` formula is aliased to
await PhpEnvironments.shared.determinePhpAlias()
// Make sure that broken symlinks are removed ASAP
await BrewDiagnostics.checkForOutdatedPhpInstallationSymlinks()
// Initialize preferences
_ = Preferences.shared
// Determine install method
Log.info(BrewDiagnostics.customCaskInstalled
? "[BREW] The app has been installed via Homebrew Cask."
: "[BREW] The app has been installed directly (optimal)."
)
Log.info(BrewDiagnostics.usesNginxFullFormula
? "[BREW] The app will be using the `nginx-full` formula."
: "[BREW] The app will be using the `nginx` formula."
)
// Put some useful diagnostics information in log
BrewDiagnostics.logBootInformation()
// Attempt to find out more info about Valet
if Valet.shared.version != nil {
@ -63,9 +59,6 @@ extension MainMenu {
// Check for an alias conflict
await BrewDiagnostics.checkForCaskConflict()
// Update the icon
updatePhpVersionInStatusBar()
// Attempt to find out if PHP-FPM is broken
PhpEnvironments.prepare()
@ -76,7 +69,6 @@ extension MainMenu {
WarningManager.shared.evaluateWarnings()
// Set up the config watchers on launch (updated automatically when switching)
Log.info("Setting up watchers...")
App.shared.handlePhpConfigWatcher()
// Detect built-in and custom applications
@ -105,9 +97,36 @@ extension MainMenu {
Valet.shared.notifyAboutUnsupportedTLD()
}
// Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
// Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// Post-launch stats and update check, but only if not running tests
await performPostLaunchActions()
// Check if the linked version has changed between launches of phpmon
PhpGuard().compareToLastGlobalVersion()
// We are ready!
PhpEnvironments.shared.isBusy = false
// Finally!
Log.info("PHP Monitor is ready to serve!")
// Avoid showing the "startup timeout" alert
Startup.invalidateTimeoutTimer()
// Check if we upgraded from a previous version
AppUpdater.checkIfUpdateWasPerformed()
}
/**
Performs a set of post-launch actions, like incrementing stats and checking for updates.
(This code is skipped when running SwiftUI previews.)
*/
private func performPostLaunchActions() async {
if !isRunningSwiftUIPreview {
Stats.incrementSuccessfulLaunchCount()
Stats.evaluateSponsorMessageShouldBeDisplayed()
@ -121,15 +140,6 @@ extension MainMenu {
await AppUpdater().checkForUpdates(userInitiated: false)
}
}
// Check if the linked version has changed between launches of phpmon
PhpGuard().compareToLastGlobalVersion()
// We are ready!
Log.info("PHP Monitor is ready to serve!")
// Check if we upgraded just now
AppUpdater.checkIfUpdateWasPerformed()
}
/**
@ -137,7 +147,7 @@ extension MainMenu {
*/
private func onEnvironmentFail() async {
Task { @MainActor [self] in
BetterAlert()
NVAlert()
.withInformation(
title: "alert.cannot_start.title".localized,
subtitle: "alert.cannot_start.subtitle".localized,

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
extension MainMenu {
@ -16,7 +17,9 @@ extension MainMenu {
nonisolated func switcherDidCompleteSwitch(to version: String) {
// Mark as no longer busy
PhpEnvironments.shared.isBusy = false
Task { @MainActor in
PhpEnvironments.shared.isBusy = false
}
Task { // Things to do after reloading domain list data
if Valet.installed {
@ -25,7 +28,7 @@ extension MainMenu {
// Perform UI updates on main thread
Task { @MainActor [self] in
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
if Valet.installed && !PhpEnvironments.shared.validate(version) {
@ -73,7 +76,7 @@ extension MainMenu {
}
@MainActor private func suggestFixMyValet(failed version: String) {
let outcome = BetterAlert()
let outcome = NVAlert()
.withInformation(
title: "alert.php_switch_failed.title".localized(version),
subtitle: "alert.php_switch_failed.info".localized(version),
@ -88,7 +91,7 @@ extension MainMenu {
}
@MainActor private func suggestFixMyComposer() {
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.global_composer_platform_issues.title".localized,
subtitle: "alert.global_composer_platform_issues.subtitle".localized,
description: "alert.global_composer_platform_issues.desc".localized

View File

@ -6,6 +6,7 @@
//
import Cocoa
import NVAlert
@MainActor
class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate {
@ -37,8 +38,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
// MARK: - UI related
/**
Rebuilds the menu (either asynchronously or synchronously).
Defaults to rebuilding the menu asynchronously.
Rebuilds the menu on the main thread.
*/
func rebuild() {
Task { @MainActor [self] in
@ -80,13 +80,15 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func refreshActiveInstallation() {
if !PhpEnvironments.shared.isBusy {
PhpEnvironments.shared.currentInstall = ActivePhpInstallation.load()
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
} else {
Log.perf("Skipping version refresh due to busy status!")
}
}
/** Updates the icon (refresh icon) and rebuilds the menu. */
@available(*, deprecated, message: "Use the busy status instead")
@objc func updatePhpVersionInStatusBar() {
refreshIcon()
rebuild()
@ -119,7 +121,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func showIncompatiblePhpVersionsAlert() {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "startup.unsupported_versions_explanation.title".localized,
subtitle: "startup.unsupported_versions_explanation.subtitle".localized(
PhpEnvironments.shared.incompatiblePhpVersions
@ -139,7 +141,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func reloadPhpMonitorMenuInBackground() {
asyncExecution({
// This automatically reloads the menu
Log.info("Reloading information about the PHP installation (in the background)...")
Log.perf("Reloading information about the PHP installation (in the background)...")
}, behaviours: [
.setsBusyUI,
.reloadsPhpInstallation,
@ -150,13 +152,16 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
/** Refreshes the icon with the PHP version. */
@objc func refreshIcon() {
Task { @MainActor [self] in
if PhpEnvironments.shared.isBusy {
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
Log.perf("Refreshing icon: currently busy")
setStatusBar(image: NSImage.statusBarIcon)
} else {
Log.perf("Refreshing icon: no longer busy")
if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false {
// Static icon has been requested
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIconStatic"))!)
setStatusBar(image: NSImage.statusBarIconStatic)
} else {
// The dynamic icon has been requested
let long = Preferences.preferences[.fullPhpVersionDynamicIcon] as! Bool
@ -172,23 +177,16 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
}
}
/** Updates the icon to be displayed as busy. */
@objc func setBusyImage() {
Task { @MainActor [self] in
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
}
}
// MARK: - Menu Item Functionality
@objc func openAbout() {
NSApplication.shared.activate(ignoringOtherApps: true)
NSApplication.shared.orderFrontStandardAboutPanel()
NSApplication.shared.orderFrontStandardAboutPanel(self)
}
@objc func openLiteModeInfo() {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "lite_mode_explanation.title".localized,
subtitle: "lite_mode_explanation.subtitle".localized,
description: "lite_mode_explanation.description".localized
@ -218,6 +216,10 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
PhpVersionManagerWindowController.show()
}
@objc func openPhpExtensionManager() {
PhpExtensionManagerWindowController.show()
}
@objc func openDonate() {
NSWorkspace.shared.open(Constants.Urls.DonationPage)
}

View File

@ -12,7 +12,7 @@ import Cocoa
extension StatusMenu {
func addPhpVersionMenuItems() {
@MainActor func addPhpVersionMenuItems() {
if PhpEnvironments.phpInstall == nil {
addItem(HeaderView.asMenuItem(text: "⚠️ " + "mi_no_php_linked".localized, minimumWidth: 280))
addItems([
@ -34,7 +34,7 @@ extension StatusMenu {
))
}
func addPhpActionMenuItems() {
@MainActor func addPhpActionMenuItems() {
if PhpEnvironments.shared.isBusy {
addItem(NSMenuItem(title: "mi_busy".localized))
return
@ -54,7 +54,7 @@ extension StatusMenu {
self.addItem(NSMenuItem.separator())
}
func addServicesManagerMenuItem() {
@MainActor func addServicesManagerMenuItem() {
if PhpEnvironments.shared.isBusy {
return
}
@ -65,7 +65,7 @@ extension StatusMenu {
])
}
func addSwitchToPhpMenuItems() {
@MainActor func addSwitchToPhpMenuItems() {
var shortcutKey = 1
for index in (0..<PhpEnvironments.shared.availablePhpVersions.count) {
// Get the short and long version
@ -102,14 +102,14 @@ extension StatusMenu {
}
}
func addLiteModeMenuItem() {
@MainActor func addLiteModeMenuItem() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_lite_mode".localized, action: #selector(MainMenu.openLiteModeInfo))
])
}
func addPreferencesMenuItems() {
@MainActor func addPreferencesMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_preferences".localized,
@ -119,7 +119,7 @@ extension StatusMenu {
])
}
func addCoreMenuItems() {
@MainActor func addCoreMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_about".localized,
@ -131,7 +131,7 @@ extension StatusMenu {
// MARK: - Valet
func addValetMenuItems() {
@MainActor func addValetMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_valet".localized),
NSMenuItem(title: "mi_valet_config".localized,
@ -146,12 +146,15 @@ extension StatusMenu {
// MARK: - PHP Configuration
func addConfigurationMenuItems() {
@MainActor 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_ext_manager".localized,
action: #selector(MainMenu.openPhpExtensionManager),
keyEquivalent: "e"),
NSMenuItem(title: "mi_php_config".localized,
action: #selector(MainMenu.openActiveConfigFolder),
keyEquivalent: "c"),
@ -166,7 +169,7 @@ extension StatusMenu {
// MARK: - Composer
func addComposerMenuItems() {
@MainActor func addComposerMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_composer".localized),
NSMenuItem(
@ -187,7 +190,7 @@ extension StatusMenu {
// MARK: - Stats
func addStatsMenuItem() {
@MainActor func addStatsMenuItem() {
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing stats menu item if no PHP version is linked.")
return
@ -200,21 +203,11 @@ extension StatusMenu {
post: stats.post_max_size,
upload: stats.upload_max_filesize)
)
// TODO: As soon as this does more than just edit memory limits, move this
/*
addItem(NSMenuItem.separator())
addItem(NSMenuItem(
title: "mi_manage_limits".localized,
action: #selector(MainMenu.openConfigGUI),
keyEquivalent: "l")
)
*/
}
// MARK: - Extensions
func addExtensionsMenuItems() {
@MainActor func addExtensionsMenuItems() {
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing extensions menu items if no PHP version is linked.")
return
@ -235,7 +228,7 @@ extension StatusMenu {
// MARK: - Presets
func addPresetsMenuItem() {
@MainActor func addPresetsMenuItem() {
guard let presets = Preferences.custom.presets else {
addEmptyPresetHelp()
return
@ -249,7 +242,7 @@ extension StatusMenu {
addLoadedPresets()
}
private func addEmptyPresetHelp() {
@MainActor private func addEmptyPresetHelp() {
addItem(NSMenuItem(title: "mi_presets_title".localized, submenu: [
NSMenuItem(title: "mi_no_presets".localized),
NSMenuItem.separator(),
@ -258,7 +251,7 @@ extension StatusMenu {
], target: MainMenu.shared))
}
private func addLoadedPresets() {
@MainActor private func addLoadedPresets() {
addItem(NSMenuItem(title: "mi_presets_title".localized, submenu: [
NSMenuItem.separator(),
HeaderView.asMenuItem(text: "mi_apply_presets_title".localized)
@ -273,7 +266,7 @@ extension StatusMenu {
// MARK: - Xdebug
func addXdebugMenuItem() {
@MainActor func addXdebugMenuItem() {
if !Xdebug.enabled {
addItem(NSMenuItem.separator())
return
@ -293,7 +286,7 @@ extension StatusMenu {
// MARK: - PHP Doctor
func addPhpDoctorMenuItem() {
@MainActor func addPhpDoctorMenuItem() {
if !Preferences.isEnabled(.showPhpDoctorSuggestions) ||
!WarningManager.shared.hasWarnings() {
return
@ -309,7 +302,7 @@ extension StatusMenu {
// MARK: - First Aid & Services
func addFirstAidAndServicesMenuItems() {
@MainActor func addFirstAidAndServicesMenuItems() {
let services = NSMenuItem(title: "mi_other".localized)
var items: [NSMenuItem] = [
@ -366,7 +359,7 @@ extension StatusMenu {
// MARK: - Other helper methods to generate menu items
func addExtensionItem(_ phpExtension: PhpExtension, _ shortcutKey: Int) {
@MainActor func addExtensionItem(_ phpExtension: PhpExtension, _ shortcutKey: Int) {
let keyEquivalent = shortcutKey < 9 ? "\(shortcutKey)" : ""
let menuItem = ExtensionMenuItem(

View File

@ -9,7 +9,7 @@ import Cocoa
class StatusMenu: NSMenu {
// swiftlint:disable cyclomatic_complexity
func addMenuItems() {
@MainActor func addMenuItems() {
addPhpVersionMenuItems()
addItem(NSMenuItem.separator())

View File

@ -1,122 +0,0 @@
//
// Notice.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 16/02/2022.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
@MainActor
class BetterAlert {
var windowController: NSWindowController!
var noticeVC: BetterAlertVC {
return self.windowController.contentViewController as! BetterAlertVC
}
init() {
let storyboard = NSStoryboard(name: "Main", bundle: nil)
self.windowController = storyboard.instantiateController(
withIdentifier: "noticeWindow"
) as? NSWindowController
}
public static func make() -> BetterAlert {
return BetterAlert()
}
public func withPrimary(
text: String,
action: @MainActor @escaping (BetterAlertVC) -> Void = { vc in
vc.close(with: .alertFirstButtonReturn)
}
) -> Self {
self.noticeVC.buttonPrimary.title = text
self.noticeVC.actionPrimary = action
return self
}
public func withSecondary(
text: String,
action: (@MainActor (BetterAlertVC) -> Void)? = { vc in
vc.close(with: .alertSecondButtonReturn)
}
) -> Self {
self.noticeVC.buttonSecondary.title = text
self.noticeVC.actionSecondary = action
return self
}
public func withTertiary(
text: String = "",
action: (@MainActor (BetterAlertVC) -> Void)? = nil
) -> Self {
if text == "" {
self.noticeVC.buttonTertiary.bezelStyle = .helpButton
}
self.noticeVC.buttonTertiary.title = text
self.noticeVC.actionTertiary = action
return self
}
public func withInformation(
title: String,
subtitle: String,
description: String = ""
) -> Self {
self.noticeVC.labelTitle.stringValue = title
self.noticeVC.labelSubtitle.stringValue = subtitle
self.noticeVC.labelDescription.stringValue = description
// If the description is missing, handle the excess space and change the top margin
if description == "" {
self.noticeVC.labelDescription.isHidden = true
self.noticeVC.primaryButtonTopMargin.constant = 0
}
return self
}
/**
Shows the modal and returns a ModalResponse.
If you wish to simply show the alert and disregard the outcome, use `show`.
*/
@MainActor public func runModal() -> NSApplication.ModalResponse {
if !Thread.isMainThread {
fatalError("You should always present alerts on the main thread!")
}
NSApp.activate(ignoringOtherApps: true)
windowController.window?.makeKeyAndOrderFront(nil)
windowController.window?.setCenterPosition(offsetY: 70)
return NSApplication.shared.runModal(for: windowController.window!)
}
/** Shows the modal and returns true if the user pressed the primary button. */
@MainActor public func didSelectPrimary() -> Bool {
return self.runModal() == .alertFirstButtonReturn
}
/**
Shows the modal and does not return anything.
*/
@MainActor public func show() {
_ = self.runModal()
}
/**
Shows the modal for a particular error.
*/
@MainActor public static func show(for error: Error & AlertableError) {
let key = error.getErrorMessageKey()
return BetterAlert().withInformation(
title: "\(key).title".localized,
subtitle: "\(key).description".localized
).withPrimary(text: "generic.ok".localized).show()
}
}

View File

@ -1,77 +0,0 @@
//
// NoticeVC.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 16/02/2022.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
class BetterAlertVC: NSViewController {
// MARK: - Outlets
@IBOutlet weak var labelTitle: NSTextField!
@IBOutlet weak var labelSubtitle: NSTextField!
@IBOutlet weak var labelDescription: NSTextField!
@IBOutlet weak var buttonPrimary: NSButton!
@IBOutlet weak var buttonSecondary: NSButton!
@IBOutlet weak var buttonTertiary: NSButton!
var actionPrimary: (@MainActor (BetterAlertVC) -> Void) = { _ in }
var actionSecondary: (@MainActor (BetterAlertVC) -> Void)?
var actionTertiary: (@MainActor (BetterAlertVC) -> Void)?
@IBOutlet weak var imageView: NSImageView!
@IBOutlet weak var primaryButtonTopMargin: NSLayoutConstraint!
// MARK: - Lifecycle
override func viewWillAppear() {
imageView.image = NSApp.applicationIconImage
if actionSecondary == nil {
buttonSecondary.isHidden = true
}
if actionTertiary == nil {
buttonTertiary.isHidden = true
}
}
override func viewDidAppear() {
view.window?.makeFirstResponder(buttonPrimary)
}
deinit {
// Log.perf("deinit: \(String(describing: self)).\(#function)")
}
// MARK: Outlet Actions
@IBAction func primaryButtonAction(_ sender: Any) {
self.actionPrimary(self)
}
@IBAction func secondaryButtonAction(_ sender: Any) {
if self.actionSecondary != nil {
self.actionSecondary!(self)
} else {
self.close(with: .alertSecondButtonReturn)
}
}
@IBAction func tertiaryButtonAction(_ sender: Any) {
if self.actionTertiary != nil {
self.actionTertiary!(self)
}
}
@MainActor public func close(with code: NSApplication.ModalResponse) {
self.view.window?.close()
NSApplication.shared.stopModal(withCode: code)
}
}

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
class PhpGuard {
@ -43,7 +44,7 @@ class PhpGuard {
Log.info("PHP Guard noticed a different PHP version. An alert will be displayed!")
Task { @MainActor in
BetterAlert()
NVAlert()
.withInformation(
title: "startup.version_mismatch.title".localized,
subtitle: "startup.version_mismatch.subtitle".localized(

View File

@ -20,6 +20,7 @@ enum PreferenceName: String, Codable {
case globalHotkey = "global_hotkey"
case automaticBackgroundUpdateCheck = "backgroundUpdateCheck"
case showPhpDoctorSuggestions = "show_php_doctor_suggestions"
case languageOverride = "language_override"
// APPEARANCE
case shouldDisplayDynamicIcon = "use_dynamic_icon"
@ -84,7 +85,8 @@ enum PreferenceName: String, Codable {
],
.string: [
.globalHotkey,
.iconTypeToDisplay
.iconTypeToDisplay,
.languageOverride
]
]
}

View File

@ -28,6 +28,10 @@ class Preferences {
environmentVariables: [:]
)
if isRunningSwiftUIPreview {
return
}
Task { await loadCustomPreferences() }
}
@ -51,6 +55,7 @@ class Preferences {
PreferenceName.allowProtocolForIntegrations.rawValue: true,
PreferenceName.automaticBackgroundUpdateCheck.rawValue: true,
PreferenceName.showPhpDoctorSuggestions.rawValue: true,
PreferenceName.languageOverride.rawValue: "",
/// Preferences: Appearance
PreferenceName.shouldDisplayDynamicIcon.rawValue: true,

View File

@ -17,7 +17,9 @@ class GeneralPreferencesVC: GenericPreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
_ = vc.addView(when: true, vc.getShowPhpDoctorSuggestionsPV())
_ = vc
.addView(when: true, vc.getLanguageOptionsPV())
.addView(when: true, vc.getShowPhpDoctorSuggestionsPV())
.addView(when: true, vc.getAutoRestartServicesPV())
.addView(when: true, vc.getAutomaticComposerUpdatePV())
.addView(when: true, vc.getShortcutPV())

View File

@ -48,11 +48,44 @@ class GenericPreferenceVC: NSViewController {
)
}
func getLanguageOptionsPV() -> NSView {
var options = Bundle.main.localizations
.filter({ $0 != "Base"})
.map({ lang in
return PreferenceDropdownOption(
label: Locale.current.localizedString(forLanguageCode: lang)!,
value: lang
)
})
options.insert(PreferenceDropdownOption(label: "System Default", value: ""), at: 0)
return SelectPreferenceView.make(
sectionText: "prefs.language".localized,
descriptionText: "prefs.language_options_desc".localized,
options: options,
preference: .languageOverride,
action: {
MainMenu.shared.refreshIcon()
MainMenu.shared.rebuild()
if let window = App.shared.preferencesWindowController?.window {
let alert = NSAlert()
alert.messageText = "alert.language_changed.title".localized
alert.informativeText = "alert.language_changed.subtitle".localized
alert.alertStyle = .warning
alert.addButton(withTitle: "generic.ok".localized)
alert.beginSheetModal(for: window)
}
}
)
}
func getIconOptionsPV() -> NSView {
return SelectPreferenceView.make(
sectionText: "",
descriptionText: "prefs.icon_options_desc".localized,
options: MenuBarIcon.allCases.map({ return $0.rawValue }),
options: MenuBarIcon.allCases
.map({ return PreferenceDropdownOption(label: $0.rawValue, value: $0.rawValue) }),
localizationPrefix: "prefs.icon_options",
preference: .iconTypeToDisplay,
action: {

View File

@ -68,6 +68,8 @@ class PreferencesWindowController: PMWindowController {
App.shared.preferencesWindowController?.positionWindowInTopRightCorner()
}
App.shared.preferencesWindowController?.window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}

View File

@ -8,6 +8,7 @@
import Foundation
import Cocoa
import NVAlert
class Stats {
@ -84,6 +85,10 @@ class Stats {
)
}
public static func clearCurrentGlobalPhpVersion() {
UserDefaults.standard.removeObject(forKey: InternalStats.lastGlobalPhpVersion.rawValue)
}
/**
Determine if the sponsor message should be displayed.
@ -119,7 +124,7 @@ class Stats {
}
Task { @MainActor in
let donate = BetterAlert()
let donate = NVAlert()
.withInformation(
title: "startup.sponsor_encouragement.title".localized,
subtitle: "startup.sponsor_encouragement.subtitle".localized,

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -23,7 +23,7 @@
<action selector="toggled:" target="c22-O7-iKe" id="c9y-JM-TdE"/>
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Bcg-X1-qca">
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Bcg-X1-qca">
<rect key="frame" x="168" y="5" width="410" height="14"/>
<textFieldCell key="cell" title="DESCRIPTION" id="9fH-up-Sob">
<font key="font" metaFont="smallSystem"/>
@ -31,7 +31,7 @@
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="B8f-nb-Y0A">
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="B8f-nb-Y0A">
<rect key="frame" x="-2" y="27" width="154" height="16"/>
<constraints>
<constraint firstAttribute="width" constant="150" id="euj-t0-xv4"/>

View File

@ -9,30 +9,34 @@
import Foundation
import Cocoa
class SelectPreferenceView: NSView, XibLoadable {
struct PreferenceDropdownOption {
let label: String
let value: String
}
class SelectPreferenceView: NSView, XibLoadable {
@IBOutlet weak var labelSection: NSTextField!
@IBOutlet weak var labelDescription: NSTextField!
@IBOutlet weak var popupButton: NSPopUpButton!
var localizationPrefix: String = ""
var localizationPrefix: String?
var imagePrefix: String?
var options: [String] = [] {
var options: [PreferenceDropdownOption] = [] {
didSet {
self.popupButton.removeAllItems()
self.options.forEach { value in
self.popupButton.addItem(
withTitle: "\(localizationPrefix).\(value)".localized
)
self.options.forEach { option in
if let prefix = localizationPrefix {
self.popupButton.addItem(withTitle: "\(prefix).\(option.label)".localized)
} else {
self.popupButton.addItem(withTitle: option.label)
}
}
if imagePrefix == nil {
return
}
self.popupButton.itemArray.enumerated().forEach { item in
item.element.image = NSImage(named: "\(imagePrefix!)_\(self.options[item.offset])")
if let prefix = imagePrefix {
self.popupButton.itemArray.enumerated().forEach { item in
item.element.image = NSImage(named: "\(prefix)_\(self.options[item.offset].value)")
}
}
}
}
@ -43,19 +47,18 @@ class SelectPreferenceView: NSView, XibLoadable {
didSet {
let value = Preferences.preferences[preference] as! String
self.options.enumerated().forEach { option in
if option.element == value {
if option.element.value == value {
self.popupButton.selectItem(at: option.offset)
}
}
}
}
// swiftlint:disable function_parameter_count
static func make(
sectionText: String,
descriptionText: String,
options: [String],
localizationPrefix: String,
options: [PreferenceDropdownOption],
localizationPrefix: String? = nil,
imagePrefix: String? = nil,
preference: PreferenceName,
action: @escaping () -> Void) -> NSView {
@ -72,11 +75,10 @@ class SelectPreferenceView: NSView, XibLoadable {
return view
}
// swiftlint:enable function_parameter_count
@IBAction func valueChanged(_ sender: Any) {
let index = self.popupButton.indexOfSelectedItem
Preferences.update(.iconTypeToDisplay, value: self.options[index])
Preferences.update(self.preference, value: self.options[index].value)
self.action()
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -13,16 +13,16 @@
<rect key="frame" x="0.0" y="0.0" width="596" height="50"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Bcg-X1-qca">
<rect key="frame" x="183" y="5" width="395" height="14"/>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Bcg-X1-qca">
<rect key="frame" x="168" y="5" width="410" height="14"/>
<textFieldCell key="cell" title="DESCRIPTION" id="9fH-up-Sob">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="B8f-nb-Y0A">
<rect key="frame" x="13" y="29" width="154" height="16"/>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="B8f-nb-Y0A">
<rect key="frame" x="-2" y="29" width="154" height="16"/>
<constraints>
<constraint firstAttribute="width" constant="150" id="euj-t0-xv4"/>
</constraints>
@ -33,7 +33,7 @@
</textFieldCell>
</textField>
<popUpButton verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="YaB-Tg-Ir3">
<rect key="frame" x="182" y="23" width="110" height="25"/>
<rect key="frame" x="167" y="23" width="110" height="25"/>
<popUpButtonCell key="cell" type="push" title="Icon Option" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="SaA-mm-HBo" id="Su6-C4-aGo">
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="menu"/>
@ -58,7 +58,7 @@
<constraint firstItem="Bcg-X1-qca" firstAttribute="top" secondItem="YaB-Tg-Ir3" secondAttribute="bottom" constant="8" symbolic="YES" id="Mji-pe-CNl"/>
<constraint firstAttribute="trailing" secondItem="Bcg-X1-qca" secondAttribute="trailing" constant="20" symbolic="YES" id="UPo-Il-l81"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="YaB-Tg-Ir3" secondAttribute="trailing" constant="20" symbolic="YES" id="Zlg-jj-uKY"/>
<constraint firstItem="B8f-nb-Y0A" firstAttribute="leading" secondItem="c22-O7-iKe" secondAttribute="leading" constant="15" id="Ztd-uk-4aw"/>
<constraint firstItem="B8f-nb-Y0A" firstAttribute="leading" secondItem="c22-O7-iKe" secondAttribute="leading" id="aBU-J8-gRK"/>
<constraint firstAttribute="bottom" secondItem="Bcg-X1-qca" secondAttribute="bottom" constant="5" id="hNE-mU-jcu"/>
</constraints>
<connections>

View File

@ -7,6 +7,7 @@
//
import Foundation
import NVAlert
struct Preset: Codable, Equatable {
let name: String
@ -139,7 +140,7 @@ struct Preset: Codable, Equatable {
return true
} else {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.php_switch_unavailable.title".localized,
subtitle: "alert.php_switch_unavailable.subtitle".localized(version!),
description: "alert.php_switch_unavailable.info".localized(

View File

@ -29,7 +29,7 @@ struct BlockingOverlayView<Content: View>: View {
var body: some View {
ZStack(alignment: .center) {
content().opacity(isBlocking ? 0.2 : 1)
content().opacity(isBlocking ? 0 : 1)
if isBlocking {
VStack {
ActivityIndicator()
@ -38,13 +38,16 @@ struct BlockingOverlayView<Content: View>: View {
.bold()
.foregroundColor(.primary)
.padding(.top, 8)
.multilineTextAlignment(.center)
Text(detailText)
.font(.system(size: 11))
.foregroundColor(.primary)
.padding(.top, -4)
.multilineTextAlignment(.center)
}.padding(60)
}
}.background(Color.white)
}
.background(Color.spinnerBackground)
.disabled(isBlocking)
}
}

View File

@ -0,0 +1,36 @@
//
// CustomButtonStyles.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 15/03/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import SwiftUI
public struct CustomButtonStyle: ButtonStyle {
@Environment(\.isEnabled) var isEnabled
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.vertical, 4)
.padding(.horizontal, 8)
.foregroundStyle(.white)
.background(.statusColorBlue, in: .rect(cornerRadius: 8, style: .continuous))
.opacity({
if configuration.isPressed {
return 0.4
}
if !isEnabled {
return 0.2
}
return 1.0
}())
}
}
extension ButtonStyle where Self == CustomButtonStyle {
static var custom: CustomButtonStyle { .init() }
}

View File

@ -10,6 +10,11 @@ import Foundation
import SwiftUI
struct HelpButton: View {
@State var frameSize: CGFloat = 14
@State var textSize: CGFloat = 12
@State var shadowOpacity: CGFloat = 0.3
@State var shadowRadius: CGFloat = 1
var action: () -> Void
var body: some View {
@ -18,24 +23,25 @@ struct HelpButton: View {
Circle()
.strokeBorder(Color(NSColor.separatorColor), lineWidth: 0.5)
.background(Circle().foregroundColor(Color(NSColor.controlColor)).opacity(0.7))
.shadow(color: Color(NSColor.separatorColor).opacity(0.3), radius: 1)
.frame(width: 14, height: 14)
Text("?").font(.system(size: 12, weight: .medium))
.shadow(color: Color(NSColor.separatorColor)
.opacity(shadowOpacity), radius: shadowRadius)
.frame(width: frameSize, height: frameSize)
Text("?").font(.system(size: textSize, weight: .medium))
.foregroundColor(Color(NSColor.labelColor))
}
})
.buttonStyle(BorderlessButtonStyle())
.focusable(false)
}
struct HelpButton_Previews: PreviewProvider {
static var previews: some View {
Group {
HelpButton(action: {}).padding()
.previewDisplayName("Light Mode")
HelpButton(action: {}).padding().preferredColorScheme(.dark)
.previewDisplayName("Dark Mode")
}
}
}
}
#Preview("Light Mode") {
HelpButton(action: {})
.padding(100)
}
#Preview("Dark Mode") {
HelpButton(action: {})
.padding(100)
.preferredColorScheme(.dark)
}

View File

@ -27,7 +27,9 @@ var isRunningSwiftUIPreview: Bool {
extension Color {
public static var appPrimary: Color = Color("AppColor")
public static var appSecondary: Color = Color("AppSecondary")
// This next one is generated automatically via asset catalogs now
// public static var appSecondary: Color = Color("AppSecondary")
public static var debug: Color = {
if ProcessInfo.processInfo.environment["PAINT_PHPMON_SWIFTUI_VIEWS"] != nil {

View File

@ -0,0 +1,67 @@
//
// NoDomainsView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 19/03/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct UnavailableContentView: View {
var title: String
var description: String
var icon: String
var button: String?
var action: (() -> Void)?
init(
title: String,
description: String,
icon: String,
button: String? = nil,
action: (() -> Void)? = nil
) {
self.title = title
self.description = description
self.icon = icon
self.button = button
self.action = action
}
var body: some View {
Group {
VStack(spacing: 15) {
Image(systemName: self.icon)
.resizable()
.frame(width: 48, height: 48)
.foregroundColor(Color.appPrimary)
.padding(.bottom, 10)
Text(self.title)
.font(.system(size: 18, weight: .bold))
Text(self.description)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
if self.button != nil {
Button(self.button!) {
self.action!()
}.buttonStyle(.custom)
}
}
}
.padding(30)
.frame(maxWidth: 400)
}
}
#Preview {
UnavailableContentView(
title: "domain_list.domains_empty.title".localized,
description: "domain_list.domains_empty.desc".localized,
icon: "globe",
button: "domain_list.domains_empty.button".localized,
action: {}
)
}

View File

@ -1,37 +0,0 @@
//
// NoDomainResults.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 15/08/2022.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct NoDomainResults: View {
@State var searching: Bool = false
var body: some View {
VStack(alignment: .center, spacing: 15) {
Image(systemName: searching ? "magnifyingglass.circle.fill" : "questionmark.circle.fill")
.resizable()
.renderingMode(.template)
.frame(width: 24, height: 24)
VStack(alignment: .center) {
Text(
searching
? "domain_list.no_domains_for_search_query".localizedForSwiftUI
: "domain_list.no_domains".localizedForSwiftUI
)
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding(25)
}
}
struct NoDomainResults_Previews: PreviewProvider {
static var previews: some View {
NoDomainResults()
}
}

View File

@ -14,8 +14,16 @@ struct VersionPopoverView: View {
@State var validPhpVersions: [VersionNumber]
@State var prefersIsolationSuggestions: Bool
@State var parent: NSPopover!
let rows = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(getTitleText())
@ -32,14 +40,29 @@ struct VersionPopoverView: View {
message: "alert.php_suggestions".localized,
color: Color("AppColor")
)
HStack {
ForEach(validPhpVersions, id: \.self) { version in
Button("site_link.switch_to_php".localized(version.short), action: {
MainMenu.shared.switchToPhpVersion(version.short)
parent?.close()
})
}
}.padding(EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0))
if prefersIsolationSuggestions {
// SITE ISOLATION (preferred)
LazyVGrid(columns: self.rows, alignment: .leading, spacing: 5, content: {
ForEach(validPhpVersions, id: \.self) { version in
Button("site_link.isolate_php".localized(version.short), action: {
App.shared.domainListWindowController?.contentVC
.isolateSite(site: site, version: version.short)
parent?.close()
}).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
}
}).padding(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0))
} else {
// GLOBAL SWITCHER
LazyVGrid(columns: self.rows, alignment: .leading, spacing: 5, content: {
ForEach(validPhpVersions, id: \.self) { version in
Button("site_link.switch_to_php".localized(version.short), action: {
MainMenu.shared.switchToPhpVersion(version.short)
parent?.close()
}).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
}
}).padding(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0))
}
}
} else {
if site.preferredPhpVersionSource == .unknown {
@ -126,78 +149,90 @@ struct DisclaimerView: View {
}
}
struct VersionPopoverView_Previews: PreviewProvider {
static var previews: some View {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "amazingwebsite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: ""
),
validPhpVersions: [],
parent: nil
)
.previewDisplayName("Unknown Requirement")
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "amazingwebsite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.1"
),
validPhpVersions: [],
parent: nil
)
.previewDisplayName("Requirement Matches")
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0",
isolated: "8.0"
),
validPhpVersions: [],
parent: nil
)
.previewDisplayName("Isolated")
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0",
isolated: "7.4"
),
validPhpVersions: [],
parent: nil
)
.previewDisplayName("Isolated Mismatch")
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0"
),
validPhpVersions: [
VersionNumber(major: 8, minor: 0, patch: 0),
VersionNumber(major: 8, minor: 1, patch: 0)
],
parent: nil
)
.previewDisplayName("Recommend Alternatives")
}
#Preview("Unknown Requirement") {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "amazingwebsite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: ""
),
validPhpVersions: [],
prefersIsolationSuggestions: false,
parent: nil
)
}
#Preview("Requirement Matches") {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "amazingwebsite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.1"
),
validPhpVersions: [],
prefersIsolationSuggestions: false,
parent: nil
)
}
#Preview("Isolated") {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0",
isolated: "8.0"
),
validPhpVersions: [],
prefersIsolationSuggestions: false,
parent: nil
)
}
#Preview("Isolated Mismatch") {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0",
isolated: "7.4"
),
validPhpVersions: [],
prefersIsolationSuggestions: false,
parent: nil
)
}
#Preview("Recommend Alternatives") {
VersionPopoverView(
site: FakeValetSite(
fakeWithName: "anothersite",
tld: "test",
secure: true,
path: "/path/to/site",
linked: true,
constraint: "^8.0"
),
validPhpVersions: [
VersionNumber(major: 8, minor: 0, patch: 0),
VersionNumber(major: 8, minor: 1, patch: 0),
VersionNumber(major: 8, minor: 2, patch: 0),
VersionNumber(major: 8, minor: 3, patch: 0),
VersionNumber(major: 8, minor: 4, patch: 0)
],
prefersIsolationSuggestions: true,
parent: nil
)
}

View File

@ -45,9 +45,7 @@ struct HeaderView: View {
}
}
struct HeaderView_Previews: PreviewProvider {
static var previews: some View {
HeaderView(text: "Hello world")
.frame(width: 330.0)
}
#Preview {
HeaderView(text: "Hello world")
.frame(width: 330.0)
}

View File

@ -8,6 +8,7 @@
import Foundation
import SwiftUI
import NVAlert
struct ServicesView: View {
@ -81,7 +82,7 @@ struct ServicesView: View {
: "key_service_not_running"
// Show an alert with more information
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.\(type).title".localized,
subtitle: "alert.\(type).subtitle".localized,
description: "alert.\(type).desc".localized
@ -116,7 +117,7 @@ struct ServiceView: View {
if service.status == .missing {
Button {
Task { @MainActor in
BetterAlert().withInformation(
NVAlert().withInformation(
title: "alert.warnings.service_missing.title".localized,
subtitle: "alert.warnings.service_missing.subtitle".localized,
description: "alert.warnings.service_missing.description".localized
@ -172,23 +173,21 @@ struct ServiceView: View {
}
}
struct ServicesView_Previews: PreviewProvider {
static var previews: some View {
ServicesView(manager: FakeServicesManager(
formulae: ["php", "nginx", "dnsmasq"],
status: .active
), perRow: 4)
.frame(width: 330.0)
.previewDisplayName("Active 1")
ServicesView(manager: FakeServicesManager(
formulae: [
"php", "nginx", "dnsmasq", "thing1",
"thing2", "thing3", "thing4", "thing5"
],
status: .inactive
), perRow: 4)
.frame(width: 330.0)
.previewDisplayName("Active 2")
}
#Preview("Active 1") {
ServicesView(manager: FakeServicesManager(
formulae: ["php", "nginx", "dnsmasq"],
status: .active
), perRow: 4)
.frame(width: 330.0)
}
#Preview("Active 2") {
ServicesView(manager: FakeServicesManager(
formulae: [
"php", "nginx", "dnsmasq", "thing1",
"thing2", "thing3", "thing4", "thing5"
],
status: .inactive
), perRow: 4)
.frame(width: 330.0)
}

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