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

Compare commits

...

85 Commits

Author SHA1 Message Date
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
23cf575026 ⬆️ Upstream PHP 8.3 upgrade changes to v7.0 2023-11-24 22:57:33 +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
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
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
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
110 changed files with 2878 additions and 1071 deletions

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 = "1530"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

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

View File

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

View File

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

View File

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

View File

@ -112,13 +112,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,19 +4,20 @@
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.2 | ✅ 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.0 | ✅ 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 |
## 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.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 |
| 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

@ -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,8 +14,6 @@ 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 {

View File

@ -18,6 +18,20 @@ struct Constants {
*/
static let MinimumRecommendedValetVersion = "2.16.2"
/**
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.4, 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 PhpFormulaeCutoffDate = "2024-11-01" // YYYY-MM-DD
/**
* The PHP versions that are considered pre-release versions.
* Past a certain date, an experimental version "graduates"
@ -25,8 +39,7 @@ struct Constants {
*/
static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [
"8.4": Date.fromString("2024-12-01"), // PLACEHOLDER DATE
"8.3": Date.fromString("2023-11-23") // OFFICIAL RELEASE
"8.4": Date.fromString("2024-12-01") // PLACEHOLDER DATE
]
return Set(releaseDates
@ -41,6 +54,17 @@ struct Constants {
})
}
/**
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"
]
/**
* The PHP versions supported by this application.
* Any other PHP versions are considered invalid.
@ -48,8 +72,7 @@ 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.0", "8.1", "8.2", "8.3",
"8.4"
]
@ -66,14 +89,14 @@ 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" // dev
],
4: // Valet v4 dropped support for v7.0
[
"7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2",
"8.3", "8.4" // dev
"8.0", "8.1", "8.2", "8.3",
"8.4" // dev
]
]

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,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

@ -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

@ -87,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.
@ -173,7 +180,7 @@ class PhpEnvironments {
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.missingBinary {
if !phpAliasInstall.isMissingBinary {
supportedVersions.insert(phpAlias)
}
}

View File

@ -12,21 +12,46 @@ class PhpInstallation {
var versionNumber: VersionNumber
var missingBinary: Bool = false
var iniFiles: [PhpConfigurationFile] = []
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,
@ -36,13 +61,15 @@ class PhpInstallation {
// 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`)
missingBinary = true
// 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,
@ -59,4 +86,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

@ -86,14 +86,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 +127,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 +149,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 {

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

@ -63,8 +63,8 @@ public struct TestableConfiguration: Codable {
: .fake(.binary),
"/opt/homebrew/Cellar/php/\(version.long)/bin/php-config"
: .fake(.binary),
// "/opt/homebrew/etc/php/\(version.short)/php-fpm.d/www.conf"
// : .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/php-fpm.d/www.conf"
: .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/php-fpm.d/valet-fpm.conf"
: .fake(.text),
"/opt/homebrew/etc/php/\(version.short)/php.ini"
@ -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 {
self.shellOutput["sudo /opt/homebrew/bin/brew services stop php@\(version.short)"] = .instant("")
// 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

@ -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

@ -83,6 +83,9 @@ 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] = []

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

@ -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="22689"/>
<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"/>
@ -429,7 +429,7 @@
</toolbarItem>
<searchToolbarItem implicitItemIdentifier="7C834FBE-7118-4082-A09F-7CBECEC1356A" label="Search" paletteLabel="Search" visibilityPriority="1001" id="G2g-jS-RVc">
<nil key="toolTip"/>
<searchField key="view" focusRingType="none" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy">
<searchField key="view" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy">
<rect key="frame" x="0.0" y="0.0" width="100" height="21"/>
<autoresizingMask key="autoresizingMask"/>
<searchFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" usesSingleLineMode="YES" bezelStyle="round" sendsSearchStringImmediately="YES" id="vp9-vH-goQ">
@ -575,7 +575,7 @@ Gw
<constraint firstAttribute="bottom" secondItem="8zu-cF-KCX" secondAttribute="bottom" constant="20" symbolic="YES" id="wIl-uw-y3p"/>
</constraints>
</visualEffectView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm">
<rect key="frame" x="98" y="153" width="384" height="19"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="WgB-hj-d4P"/>
@ -586,7 +586,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf">
<rect key="frame" x="98" y="127" width="384" height="16"/>
<textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="rY3-Nd-Iit">
<font key="font" metaFont="system"/>
@ -610,7 +610,7 @@ Gw
</constraints>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="7eT-Hw-EL9"/>
</imageView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah">
<rect key="frame" x="98" y="70" width="384" height="42"/>
<textFieldCell key="cell" selectable="YES" id="7iW-Lc-DqO">
<font key="font" metaFont="smallSystem"/>
@ -699,7 +699,7 @@ Gw
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
</connections>
</button>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
<rect key="frame" x="20" y="150" width="440" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4">
<font key="font" metaFont="system"/>
@ -710,7 +710,7 @@ Gw
<outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/>
</connections>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
<rect key="frame" x="18" y="128" width="444" height="14"/>
<textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP">
<font key="font" metaFont="smallSystem"/>
@ -728,7 +728,7 @@ Gw
<action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
<rect key="frame" x="18" y="60" width="444" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu">
<font key="font" metaFont="smallSystem"/>
@ -743,7 +743,7 @@ Gw
<url key="url" string="file:///Users/"/>
</pathCell>
</pathControl>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
<rect key="frame" x="18" y="209" width="128" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT">
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
@ -751,7 +751,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
<rect key="frame" x="140" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf">
<font key="font" metaFont="smallSystem"/>
@ -886,7 +886,7 @@ Gw
<rect key="frame" x="69" y="0.0" width="200" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD">
<rect key="frame" x="3" y="26" width="145" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="SGC-Gm-Mxd">
<font key="font" metaFont="systemSemibold" size="13"/>
@ -894,7 +894,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO">
<rect key="frame" x="3" y="12" width="75" height="14"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="fe7-Ha-mR9">
<font key="font" metaFont="smallSystem"/>
@ -1027,7 +1027,7 @@ Gw
<rect key="frame" x="470" y="0.0" width="97" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key">
<rect key="frame" x="6" y="26" width="93" height="14"/>
<textFieldCell key="cell" alignment="left" title="Laravel" id="0lu-L6-oKr">
<font key="font" metaFont="smallSystem"/>
@ -1035,7 +1035,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B">
<rect key="frame" x="6" y="15" width="93" height="11"/>
<textFieldCell key="cell" alignment="left" title="PHP 8.0" id="puf-Jh-ham">
<font key="font" metaFont="miniSystem"/>
@ -1092,9 +1092,18 @@ Gw
<constraint firstAttribute="height" constant="30" id="lfW-dB-Eu3"/>
</constraints>
</progressIndicator>
<customView translatesAutoresizingMaskIntoConstraints="NO" id="wcV-ed-8Bv">
<rect key="frame" x="113" y="5" width="400" height="300"/>
<constraints>
<constraint firstAttribute="width" constant="400" id="HCo-LG-x3N"/>
<constraint firstAttribute="height" constant="300" id="Xpi-Rl-xmb"/>
</constraints>
</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,6 +1112,7 @@ 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>
@ -1125,7 +1135,7 @@ Gw
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<rect key="frame" x="20" y="196" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
<font key="font" metaFont="system"/>
@ -1136,7 +1146,7 @@ Gw
<outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/>
</connections>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
<rect key="frame" x="18" y="221" width="325" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL">
<font key="font" metaFont="systemMedium" size="11"/>
@ -1144,7 +1154,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
<rect key="frame" x="18" y="172" width="112" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e">
<font key="font" metaFont="systemMedium" size="11"/>
@ -1152,7 +1162,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<rect key="frame" x="20" y="147" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
<font key="font" metaFont="system"/>
@ -1208,7 +1218,7 @@ Gw
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<rect key="frame" x="18" y="128" width="504" height="14"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
@ -1229,7 +1239,7 @@ Gw
<action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<rect key="frame" x="18" y="60" width="504" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
<font key="font" metaFont="smallSystem"/>
@ -1237,7 +1247,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
<rect key="frame" x="18" y="250" width="123" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl">
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
@ -1245,7 +1255,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<rect key="frame" x="191" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
<font key="font" metaFont="smallSystem"/>
@ -1389,7 +1399,7 @@ Gw
<real value="3.4028234663852886e+38"/>
</customSpacing>
</stackView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
<rect key="frame" x="18" y="138" width="504" height="19"/>
<textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd">
<font key="font" metaFont="systemBold" size="15"/>
@ -1397,7 +1407,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
<textField wantsLayer="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
<rect key="frame" x="18" y="60" width="504" height="70"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/>

View File

@ -142,7 +142,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

@ -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()

View File

@ -27,6 +27,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 +61,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`).

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: Equatable {
struct BrewPhpFormula: Equatable {
/// Name of the formula.
let name: String
@ -21,6 +21,8 @@ struct BrewFormula: Equatable {
/// The upgrade that is currently available, if it exists.
let upgradeVersion: String?
// TODO: A rebuild attribute could be checked, to check if a Tap update exists for a pre-release version
/// Whether this formula is a stable version of PHP.
let prerelease: Bool
@ -100,6 +102,7 @@ struct BrewFormula: Equatable {
return nil
}
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?.isHealthy ?? nil
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?
.isHealthy ?? nil
}
}

View File

@ -8,12 +8,12 @@
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
@ -23,8 +23,8 @@ extension HandlesBrewFormulae {
}
}
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 {
@ -44,26 +44,23 @@ class BrewFormulaeHandler: HandlesBrewFormulae {
}
return Brew.phpVersionFormulae.map { (version, formula) in
let fullVersion = PhpEnvironments.shared.cachedPhpInstallations[version]?
.versionNumber.text
var fullVersion: String?
var upgradeVersion: String?
if let version = fullVersion {
if let install = PhpEnvironments.shared.cachedPhpInstallations[version] {
fullVersion = install.versionNumber.text
upgradeVersion = outdated?.first(where: { formula in
return formula.installed_versions.contains(version)
return formula.name == install.formulaName
})?.current_version
}
let formula = BrewFormula(
return BrewPhpFormula(
name: formula,
displayName: "PHP \(version)",
installedVersion: fullVersion,
upgradeVersion: upgradeVersion,
prerelease: Constants.ExperimentalPhpVersions.contains(version)
)
return formula
}.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,6 +10,8 @@ import Foundation
protocol BrewCommand {
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws
func getCommandTitle() -> String
}
extension BrewCommand {
@ -31,6 +33,44 @@ extension BrewCommand {
}
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,81 @@
//
// 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))
// 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,89 @@
//
// 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 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,14 +43,16 @@ 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
))
// 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
})
@ -69,7 +81,7 @@ class InstallAndUpgradeCommand: BrewCommand {
}
private func upgradeMainPhpFormula(
_ unavailable: BrewFormula,
_ unavailable: BrewPhpFormula,
_ onProgress: @escaping (BrewCommandProgress) -> Void
) async throws {
// Determine which version was previously available (that will become unavailable)
@ -89,18 +101,6 @@ class InstallAndUpgradeCommand: BrewCommand {
try await run(command, onProgress)
}
private 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)
}
}
private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit
if self.upgrading.isEmpty {
@ -163,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()
@ -210,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

@ -287,7 +287,7 @@ extension MainMenu {
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
@ -302,7 +302,7 @@ extension MainMenu {
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
Task(priority: .userInitiated) { [unowned self] in
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
@ -328,7 +328,7 @@ extension MainMenu {
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
}
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)

View File

@ -63,7 +63,8 @@ extension MainMenu {
}
if behaviours.contains(.updatesMenuBarContents) {
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
}
if behaviours.contains(.broadcastServicesUpdate) {

View File

@ -15,7 +15,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 +32,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 +58,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 +68,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
@ -106,11 +97,32 @@ extension MainMenu {
}
// Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions: \(Constants.ExperimentalPhpVersions)")
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!")
// 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()
@ -124,15 +136,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()
}
/**

View File

@ -27,7 +27,7 @@ extension MainMenu {
// Perform UI updates on main thread
Task { @MainActor [self] in
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
if Valet.installed && !PhpEnvironments.shared.validate(version) {

View File

@ -37,8 +37,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,7 +79,8 @@ 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!")
}
@ -155,12 +155,12 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
Task { @MainActor [self] in
if PhpEnvironments.shared.isBusy {
Log.perf("Refreshing icon: currently busy")
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
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
@ -215,6 +215,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

@ -152,6 +152,9 @@ extension StatusMenu {
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"),
@ -200,16 +203,6 @@ 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
@ -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

@ -91,7 +91,7 @@ class BetterAlert {
}
NSApp.activate(ignoringOtherApps: true)
windowController.window?.makeKeyAndOrderFront(nil)
windowController.window?.setCenterPosition(offsetY: 70)
return NSApplication.shared.runModal(for: windowController.window!)

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

@ -51,6 +51,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

@ -84,6 +84,10 @@ class Stats {
)
}
public static func clearCurrentGlobalPhpVersion() {
UserDefaults.standard.removeObject(forKey: InternalStats.lastGlobalPhpVersion.rawValue)
}
/**
Determine if the sponsor message should be displayed.

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

@ -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()
@ -44,7 +44,8 @@ struct BlockingOverlayView<Content: View>: View {
.padding(.top, -4)
}.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

@ -27,15 +27,15 @@ struct HelpButton: View {
.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

@ -172,23 +172,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)
}

View File

@ -89,6 +89,7 @@ struct StatsView: View {
} label: {
Image(systemName: "gearshape.fill")
}
.accessibility(identifier: "phpConfigButton")
.focusable(false)
.frame(minWidth: 30, alignment: .center)
}
@ -98,12 +99,10 @@ struct StatsView: View {
}
}
struct StatsView_Previews: PreviewProvider {
static var previews: some View {
StatsView(
memoryLimit: "1024 MB",
maxPostSize: "1024 MB",
maxUploadSize: "1024 MB"
).frame(height: 100)
}
#Preview {
StatsView(
memoryLimit: "1024 MB",
maxPostSize: "1024 MB",
maxUploadSize: "1024 MB"
).frame(height: 100)
}

View File

@ -1,22 +0,0 @@
//
// ProgressViewSubject.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 11/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import SwiftUI
class ProgressViewSubject: ObservableObject {
@Published var title: String
@Published var description: String?
@Published var progress: Double
init(title: String, description: String) {
self.title = title
self.description = description
self.progress = 0
}
}

View File

@ -1,65 +0,0 @@
//
// ProgressWindowView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 11/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct ProgressWindowView: View {
@ObservedObject var subject: ProgressViewSubject
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text(subject.title)
.font(.system(size: 14))
.bold()
if subject.description != nil {
Text(subject.description!)
.font(.system(size: 13))
}
}
.padding(.leading, 20)
.padding(.top, 12)
ProgressView(value: subject.progress)
.padding(.top, 0)
.padding(.bottom, 12)
.padding(.horizontal, 20)
}
}
@MainActor static func display(_ subject: ProgressViewSubject) async -> NSWindowController {
let view = ProgressWindowView(subject: subject)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 240),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = ""
window.titlebarAppearsTransparent = true
window.contentView = NSHostingView(rootView: view)
let controller = NSWindowController(window: window)
controller.showWindow(nil)
controller.positionWindowInTopRightCorner()
controller.window?.makeKeyAndOrderFront(self)
// NSApp.activate(ignoringOtherApps: true)
return controller
}
}
struct ProgressWindowView_Previews: PreviewProvider {
static var previews: some View {
ProgressWindowView(
subject: ProgressViewSubject(
title: "Long running task",
description: "Please be patient"
)
)
}
}

View File

@ -56,7 +56,6 @@ class ConfigFSNotifier {
return App.shared.handlePhpConfigWatcher(forceReload: true)
}
self?.parent.didChange?(self!.url)
}

View File

@ -16,21 +16,19 @@ class DomainListKindCell: NSTableCellView, DomainListCellProtocol {
func populateCell(with site: ValetSite) {
// If the `aliasPath` is nil, we're dealing with a parked site (otherwise: linked).
imageViewType.image = NSImage(
named: site.aliasPath == nil
? "IconParked"
: "IconLinked"
)
imageViewType.image = site.aliasPath == nil
? NSImage.iconParked
: NSImage.iconLinked
// Unless, of course, this is a default site
if site.absolutePath == Valet.shared.config.defaultSite {
imageViewType.image = NSImage(named: "IconDefault")
imageViewType.image = NSImage.iconDefault
}
imageViewType.contentTintColor = NSColor.tertiaryLabelColor
}
func populateCell(with proxy: ValetProxy) {
imageViewType.image = NSImage(named: "IconProxy")
imageViewType.image = NSImage.iconProxy
}
}

View File

@ -34,14 +34,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
if site.isolatedPhpVersion != nil {
imageViewPhpVersionOK.isHidden = false
imageViewPhpVersionOK.image = NSImage(named: "Isolated")
imageViewPhpVersionOK.image = NSImage.isolated
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.isolated".localized(site.servingPhpVersion)
} else {
imageViewPhpVersionOK.isHidden = (site.preferredPhpVersion == "???"
|| !site.isCompatibleWithPreferredPhpVersion)
imageViewPhpVersionOK.image = NSImage(named: "Checkmark")
imageViewPhpVersionOK.image = NSImage.checkmark
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.checkmark".localized(site.preferredPhpVersion)
}
}
@ -71,7 +70,12 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
let button = self.buttonPhpVersion!
let popover = NSPopover()
let view = VersionPopoverView(site: site, validPhpVersions: validPhpSuggestions, parent: popover)
let view = VersionPopoverView(
site: site,
validPhpVersions: validPhpSuggestions,
prefersIsolationSuggestions: Valet.enabled(feature: .isolatedSites),
parent: popover
)
popover.contentViewController = NSHostingController(rootView: view)
popover.behavior = .transient

View File

@ -110,17 +110,33 @@ extension DomainListVC {
}
}
@objc func isolateSite(sender: PhpMenuItem) {
guard let site = selectedSite else {
return
}
@objc func toggleExtension(sender: ExtensionMenuItem) {
Task {
self.setUIBusy()
await sender.phpExtension?.toggle()
if Preferences.isEnabled(.autoServiceRestartAfterExtensionToggle) {
await Actions.restartPhpFpm()
}
reloadContextMenu()
self.setUINotBusy()
}
}
public func isolateSite(site: ValetSite, version: String) {
waitAndExecute {
do {
// Instruct Valet to isolate a given PHP version
try await site.isolate(version: sender.version)
// Reload the UI
self.reloadSelectedRow()
try await site.isolate(version: version)
// Reload the UI if it's the same site
if self.selectedSite?.absolutePath == site.absolutePath {
self.reloadSelectedRow()
} else {
await self.reloadDomains()
}
} catch {
// Notify the user about a failed command
let error = error as! ValetInteractionError
@ -129,7 +145,15 @@ extension DomainListVC {
}
}
@objc func removeIsolatedSite() {
@objc func isolateSiteViaMenuItem(sender: PhpMenuItem) {
guard let site = selectedSite else {
return
}
self.isolateSite(site: site, version: sender.version)
}
@objc func removeIsolatedSiteViaMenuItem() {
guard let site = selectedSite else {
return
}

View File

@ -42,7 +42,20 @@ extension DomainListVC {
addDisabledIsolation(to: menu)
}
addSeparator(to: menu)
if let extensions = site.isolatedPhpVersion?.extensions ?? PhpEnvironments.phpInstall?.extensions,
let version = site.isolatedPhpVersion?.versionNumber.short ?? PhpEnvironments.phpInstall?.version.short {
menu.addItem(HeaderView.asMenuItem(text: "mi_detected_extensions".localized))
addMenuItemsForExtensions(
to: menu,
for: extensions,
version: version
)
}
menu.addItem(HeaderView.asMenuItem(text: "domain_list.actions".localized))
addToggleSecure(to: menu, secured: site.secured)
addUnlink(to: menu, with: site)
@ -108,7 +121,7 @@ extension DomainListVC {
for version in PhpEnvironments.shared.availablePhpVersions.reversed() {
let item = PhpMenuItem(
title: "domain_list.always_use_php".localized(version),
action: #selector(self.isolateSite),
action: #selector(self.isolateSiteViaMenuItem),
keyEquivalent: ""
)
if site.servingPhpVersion == version && site.isolatedPhpVersion != nil {
@ -124,7 +137,7 @@ extension DomainListVC {
items.append(NSMenuItem.separator())
items.append(NSMenuItem(
title: "domain_list.remove_isolation".localized,
action: #selector(self.removeIsolatedSite)
action: #selector(self.removeIsolatedSiteViaMenuItem)
))
}
@ -150,6 +163,28 @@ extension DomainListVC {
)
}
private func addMenuItemsForExtensions(to menu: NSMenu, for extensions: [PhpExtension], version: String) {
var items: [NSMenuItem] = [
NSMenuItem(title: "domain_list.applies_to".localized(version))
]
for phpExtension in extensions {
let item = ExtensionMenuItem(
title: "\(phpExtension.name) (\(phpExtension.fileNameOnly))",
action: #selector(self.toggleExtension),
keyEquivalent: ""
)
item.state = phpExtension.enabled ? .on : .off
item.phpExtension = phpExtension
items.append(item)
}
menu.addItem(NSMenuItem(title: "domain_list.extensions".localized, submenu: items))
menu.addItem(NSMenuItem.separator())
}
// MARK: - Menu Items for Proxy
private func addMenuItemsForProxy(_ proxy: ValetProxy) {

View File

@ -8,12 +8,14 @@
import Cocoa
import Carbon
import SwiftUI
class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource {
// MARK: - Outlets
@IBOutlet weak var tableView: PMTableView!
@IBOutlet weak var noResultsView: NSView!
@IBOutlet weak var progressIndicator: NSProgressIndicator!
// MARK: - Variables
@ -93,6 +95,8 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
override func viewDidLoad() {
tableView.doubleAction = #selector(self.doubleClicked(sender:))
addNoResultsView()
let mapping = [
"SECURE": "domain_list.columns.secure",
"DOMAIN": "domain_list.columns.domain",
@ -115,6 +119,25 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
}
}
private func addNoResultsView() {
let child = NSHostingController(
rootView: 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: {
App.shared.domainListWindowController?
.pressedAddLink(nil)
}
)
.frame(width: 400, height: 300)
).view
self.noResultsView.addSubview(child)
child.frame = self.noResultsView.bounds
}
// MARK: - Async Operations
/**
@ -132,6 +155,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
tableView.alphaValue = 0.3
tableView.isEnabled = false
tableView.selectRowIndexes([], byExtendingSelection: true)
noResultsView.isHidden = true
}
/**
@ -142,6 +166,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
progressIndicator.stopAnimation(nil)
tableView.alphaValue = 1.0
tableView.isEnabled = true
updateNoResultsView()
}
/**
@ -282,9 +307,14 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
Task { @MainActor in
self.tableView.reloadData()
updateNoResultsView()
}
}
func updateNoResultsView() {
self.noResultsView.isHidden = !domains.isEmpty
}
func searchedFor(text: String) {
lastSearchedFor = text

View File

@ -139,10 +139,6 @@ struct OnboardingView: View {
}
}
struct OnboardingView_Previews: PreviewProvider {
static var previews: some View {
Group {
OnboardingView()
}
}
#Preview {
OnboardingView()
}

View File

@ -98,19 +98,19 @@ struct ByteLimitView: View {
}
}
struct ByteLimitView_Previews: PreviewProvider {
static var previews: some View {
PreferenceContainer(
name: "Max Size",
description:
"Here's an extensive description that is obviously way too long but it should wrap." +
"The point of the wrapping text is that is allows us to see what's going on with the layout here."
) {
ByteLimitView(preference: BytePhpPreference(key: "max_memory"))
}.frame(width: 600, height: 200)
ConfigManagerView()
.frame(width: 600, height: .infinity)
.previewDisplayName("Config Manager")
}
#Preview("Byte Limit View") {
PreferenceContainer(
name: "Max Size",
description:
"Here's an extensive description that is obviously way too long but it should wrap." +
"The point of the wrapping text is that is allows us to see what's going on with the layout here."
) {
ByteLimitView(preference: BytePhpPreference(key: "max_memory"))
}.frame(width: 600, height: 200)
}
#Preview("Config Manager") {
ConfigManagerView()
.frame(width: 600, height: .infinity)
.previewDisplayName("Config Manager")
}

View File

@ -84,10 +84,6 @@ struct ConfigManagerView: View {
}
}
struct ConfigManagerView_Previews: PreviewProvider {
static var previews: some View {
ConfigManagerView()
.frame(width: 600)
.previewDisplayName("Live Preview")
}
#Preview {
ConfigManagerView().frame(width: 600)
}

View File

@ -34,8 +34,8 @@ class WarningManager: ObservableObject {
.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
},
name: "Running PHP Monitor with Rosetta on M1",
title: "warnings.arm_compatibility.title".localized,
paragraphs: { return ["warnings.arm_compatibility.description".localized] },
title: "warnings.arm_compatibility.title",
paragraphs: { return ["warnings.arm_compatibility.description"] },
url: "https://github.com/nicoverbruggen/phpmon/wiki/PHP-Monitor-and-Apple-Silicon"
),
Warning(
@ -44,11 +44,11 @@ class WarningManager: ObservableObject {
!FileSystem.isWriteableFile("/usr/local/bin/")
},
name: "Helpers cannot be symlinked and not in PATH",
title: "warnings.helper_permissions.title".localized,
title: "warnings.helper_permissions.title",
paragraphs: { return [
"warnings.helper_permissions.description".localized,
"warnings.helper_permissions.unavailable".localized,
"warnings.helper_permissions.symlink".localized
"warnings.helper_permissions.description",
"warnings.helper_permissions.unavailable",
"warnings.helper_permissions.symlink"
] },
url: "https://github.com/nicoverbruggen/phpmon/wiki/PHP-Monitor-helper-binaries"
),
@ -58,7 +58,7 @@ class WarningManager: ObservableObject {
return !PhpConfigChecker.shared.missing.isEmpty
},
name: "Your PHP installation is missing configuration files",
title: "warnings.files_missing.title".localized,
title: "warnings.files_missing.title",
paragraphs: { return [
"warnings.files_missing.description".localized(
PhpConfigChecker.shared.missing.joined(separator: "\n")

View File

@ -25,8 +25,6 @@ struct NoWarningsView: View {
}
}
struct NoWarningsView_Previews: PreviewProvider {
static var previews: some View {
NoWarningsView()
}
#Preview {
NoWarningsView().padding()
}

View File

@ -96,14 +96,12 @@ struct PhpDoctorView: View {
}
}
struct WarningListView_Previews: PreviewProvider {
static var previews: some View {
PhpDoctorView(empty: true, fake: true, manager: WarningManager())
.frame(width: 600, height: 480)
.previewDisplayName("Empty List")
PhpDoctorView(empty: false, fake: true, manager: WarningManager())
.frame(width: 600, height: 480)
.previewDisplayName("List With All Warnings")
}
#Preview("Empty List") {
PhpDoctorView(empty: true, fake: true, manager: WarningManager())
.frame(width: 600, height: 480)
}
#Preview("List With All Warnings") {
PhpDoctorView(empty: false, fake: true, manager: WarningManager())
.frame(width: 600, height: 480)
}

View File

@ -26,7 +26,7 @@ struct WarningView: View {
Text(title.localizedForSwiftUI)
.fontWeight(.bold)
ForEach(paragraphs, id: \.self) { paragraph in
Text(paragraph)
Text(paragraph.localizedForSwiftUI)
.font(.system(size: 13))
}
}
@ -47,23 +47,23 @@ struct WarningView: View {
}
}
struct WarningView_Previews: PreviewProvider {
static var previews: some View {
WarningView(
title: "warnings.helper_permissions.title",
paragraphs: ["warnings.helper_permissions.description"],
documentationUrl: "https://nicoverbruggen.be"
)
.frame(width: 600, height: 105)
WarningView(
title: "warnings.helper_permissions.title",
paragraphs: ["warnings.helper_permissions.description"],
documentationUrl: "https://nicoverbruggen.be"
)
.preferredColorScheme(.dark)
.frame(width: 600, height: 105)
// WarningListView().frame(width: 600, height: 580)
}
#Preview("Light Mode") {
WarningView(
title: "warnings.helper_permissions.title",
paragraphs: ["warnings.helper_permissions.description"],
documentationUrl: "https://nicoverbruggen.be"
)
.frame(width: 600, height: 105)
.padding(25)
}
#Preview("Dark Mode") {
WarningView(
title: "warnings.helper_permissions.title",
paragraphs: ["warnings.helper_permissions.description"],
documentationUrl: "https://nicoverbruggen.be"
)
.preferredColorScheme(.dark)
.frame(width: 600, height: 105)
.padding(25)
}

View File

@ -0,0 +1,32 @@
//
// BrewExtensionsObservable.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class BrewExtensionsObservable: ObservableObject {
@Published var phpVersion: String {
didSet {
self.loadExtensionData(for: phpVersion)
}
}
@Published var extensions: [BrewPhpExtension] = []
init(phpVersion: String) {
self.phpVersion = phpVersion
self.loadExtensionData(for: phpVersion)
}
public func loadExtensionData(for version: String) {
let tapFormulae = BrewTapFormulae.from(tap: "shivammathur/homebrew-extensions")
if let filteredTapFormulae = tapFormulae[version] {
self.extensions = filteredTapFormulae
}
}
}

View File

@ -0,0 +1,90 @@
//
// PhpExtensionManagerView+Actions.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 21/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
extension PhpExtensionManagerView {
public func presentErrorAlert(
title: String,
description: String,
button: String,
style: NSAlert.Style = .critical
) {
Alert.confirm(
onWindow: App.shared.phpExtensionManagerWindowController!.window!,
messageText: title,
informativeText: description,
buttonTitle: button,
secondButtonTitle: "",
style: style,
onFirstButtonPressed: {}
)
}
public func install(_ ext: BrewPhpExtension) {
Task {
await self.runCommand(InstallPhpExtensionCommand(install: [ext]))
}
}
public func confirmUninstall(_ ext: BrewPhpExtension) {
Alert.confirm(
onWindow: App.shared.phpExtensionManagerWindowController!.window!,
messageText: "phpextman.warnings.removal.title".localized(ext.name),
informativeText: "phpextman.warnings.removal.desc".localized(ext.name),
buttonTitle: "phpextman.warnings.removal.button".localized,
buttonIsDestructive: true,
secondButtonTitle: "generic.cancel".localized,
style: .warning,
onFirstButtonPressed: {
Task {
await self.runCommand(RemovePhpExtensionCommand(remove: ext))
}
}
)
}
public func runCommand(_ command: BrewCommand) async {
if PhpEnvironments.shared.isBusy {
self.presentErrorAlert(
title: "phpman.action_prevented_busy.title".localized,
description: "phpman.action_prevented_busy.desc".localized,
button: "generic.ok".localized
)
return
}
let phpVersionManaged = self.manager.phpVersion
do {
self.status.busy = true
try await command.execute { progress in
Task { @MainActor in
self.status.title = progress.title
self.status.description = progress.description
self.status.busy = progress.value != 1
}
}
self.manager.phpVersion = phpVersionManaged
self.status.busy = false
} catch let error {
let error = error as! BrewCommandError
let messages = error.log.suffix(2).joined(separator: "\n")
self.status.busy = false
self.presentErrorAlert(
title: "phpman.failures.install.title".localized,
description: "phpman.failures.install.desc".localized(messages),
button: "generic.ok".localized
)
}
}
}

View File

@ -0,0 +1,202 @@
//
// PhpExtensionManagerView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 13/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import SwiftUI
struct PhpExtensionManagerView: View {
@ObservedObject var manager: BrewExtensionsObservable
@ObservedObject var status: BusyStatus
@State var searchText: String
init() {
self.searchText = ""
self.status = BusyStatus.busy()
let version = PhpEnvironments.shared.currentInstall!.version.short
self.manager = BrewExtensionsObservable(phpVersion: version)
self.status.busy = false
}
var filteredExtensions: [BrewPhpExtension] {
guard !searchText.isEmpty else {
return manager.extensions
}
return manager.extensions.filter { $0.name.contains(searchText) }
}
var body: some View {
VStack(spacing: 0) {
header.padding(20)
HStack(spacing: 0) {
Text("phpextman.list.showing_count".localized("\(filteredExtensions.count)"))
.padding(10)
.font(.system(size: 12))
phpVersionPicker.disabled(self.status.busy)
}
.frame(maxWidth: .infinity, maxHeight: 35)
.background(Color.blue.opacity(0.3))
.padding(.bottom, 0)
BlockingOverlayView(
busy: self.status.busy,
title: self.status.title,
text: self.status.description
) {
List(Array(self.filteredExtensions.enumerated()), id: \.1.name) { (_, ext) in
listContent(for: ext)
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
.edgesIgnoringSafeArea(.top)
.listStyle(PlainListStyle())
.searchable(text: $searchText)
}
}
.frame(minWidth: 600, minHeight: 600)
.onAppear {
Task {
await delay(seconds: 1)
if self.manager.extensions.isEmpty {
self.presentErrorAlert(
title: "phpextman.errors.not_found.title".localized,
description: "phpextman.errors.not_found.desc".localized,
button: "generic.ok".localized
)
}
}
}
}
// MARK: View Variables
private var phpVersionPicker: some View {
Picker("",
selection: $manager.phpVersion) {
ForEach(PhpEnvironments.shared.availablePhpVersions, id: \.self) {
Text("PHP \($0)")
.tag($0)
.font(.system(size: 12))
}
}
.focusable(false)
.labelsHidden()
.pickerStyle(MenuPickerStyle())
.font(.system(size: 12))
.frame(width: 100)
}
private var header: some View {
HStack(alignment: .center, spacing: 15) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.frame(width: 40, height: 40)
.foregroundColor(Color.blue)
.padding(12)
VStack(alignment: .leading, spacing: 5) {
Text("phpextman.description".localizedForSwiftUI)
.font(.system(size: 12))
.frame(maxWidth: .infinity, alignment: .leading)
Text("phpextman.disclaimer".localizedForSwiftUI)
.font(.system(size: 12))
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func dependency(named name: String) -> some View {
return Text(name)
.font(.system(size: 9))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.appPrimary)
.foregroundColor(Color.white)
.clipShape(Capsule())
.fixedSize(horizontal: true, vertical: true)
}
private func extensionLabel(for ext: BrewPhpExtension) -> some View {
return Group {
if ext.isInstalled {
if let dependent = ext.firstDependent(in: self.manager.extensions) {
Text("phpextman.list.status.dependent".localized(dependent.name))
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
Text("phpextman.list.status.can_manage".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
} else {
if ext.hasAlternativeInstall {
Text("phpextman.list.status.external".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundStyle(.orange)
} else {
Text("phpextman.list.status.installable".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
}
}
private func listContent(for ext: BrewPhpExtension) -> some View {
HStack(alignment: .center, spacing: 7.0) {
VStack(alignment: .center, spacing: 0) {
HStack {
HStack {
Image(systemName: ext.isInstalled || ext.hasAlternativeInstall
? "puzzlepiece.extension.fill"
: "puzzlepiece.extension")
.resizable()
.frame(width: 24, height: 20)
.foregroundColor(ext.hasAlternativeInstall ? Color.gray : Color.blue)
}.frame(width: 36, height: 24)
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(ext.name).bold()
}
if !ext.extensionDependencies.isEmpty {
HStack(spacing: 3) {
Text("phpextman.list.depends_on".localizedForSwiftUI)
.font(.system(size: 10))
ForEach(ext.extensionDependencies, id: \.self) {
dependency(named: $0)
}
}
}
extensionLabel(for: ext)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
if ext.isInstalled {
Button("phpman.buttons.uninstall".localizedForSwiftUI, role: .destructive) {
self.confirmUninstall(ext)
}.disabled(ext.firstDependent(in: self.manager.extensions) != nil)
} else {
Button("phpman.buttons.install".localizedForSwiftUI) {
self.install(ext)
}.disabled(ext.hasAlternativeInstall)
}
}
}
}
}
#Preview {
PhpExtensionManagerView()
.frame(width: 600, height: 600)
}

View File

@ -0,0 +1,52 @@
//
// PhpExtensionManagerWindowController.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 13/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
import SwiftUI
class PhpExtensionManagerWindowController: PMWindowController {
// MARK: - Window Identifier
var view: PhpExtensionManagerView!
override var windowName: String {
return "PhpExtensionManager"
}
public static func create(delegate: NSWindowDelegate?) {
let windowController = Self()
windowController.window = NSWindow()
windowController.view = PhpExtensionManagerView()
guard let window = windowController.window else { return }
window.title = "phpextman.window.title".localized
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.titlebarAppearsTransparent = false
window.delegate = delegate ?? windowController
window.contentView = NSHostingView(rootView: windowController.view)
window.setContentSize(NSSize(width: 600, height: 800))
App.shared.phpExtensionManagerWindowController = windowController
}
public static func show(delegate: NSWindowDelegate? = nil) {
if App.shared.phpExtensionManagerWindowController == nil {
Self.create(delegate: delegate)
}
App.shared.phpExtensionManagerWindowController?.showWindow(self)
App.shared.phpExtensionManagerWindowController?.positionWindowInTopRightCorner()
NSApp.activate(ignoringOtherApps: true)
App.shared.phpExtensionManagerWindowController?.window?.orderFrontRegardless()
}
}

View File

@ -1,5 +1,5 @@
//
// BrewFormula+UI.swift
// BrewPhpFormula+UI.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 02/05/2023.
@ -9,7 +9,7 @@
import Foundation
import SwiftUI
extension BrewFormula {
extension BrewPhpFormula {
var icon: String {
if self.hasUpgrade {
return "arrow.up.square.fill"

View File

@ -0,0 +1,19 @@
//
// BrewPhpFormula.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 13/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class BrewFormulaeObservable: ObservableObject {
@Published var phpVersions: [BrewPhpFormula] = []
var upgradeable: [BrewPhpFormula] {
return phpVersions.filter { formula in
formula.hasUpgrade
}
}
}

View File

@ -8,61 +8,68 @@
import Foundation
class FakeBrewFormulaeHandler: HandlesBrewFormulae {
// swiftlint:disable function_body_length
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] {
// swiftlint:disable function_body_length
class FakeBrewFormulaeHandler: HandlesBrewPhpFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula] {
return [
BrewFormula(
BrewPhpFormula(
name: "php@9.9",
displayName: "PHP 9.9",
installedVersion: nil,
upgradeVersion: "9.9.0",
prerelease: true
),
BrewFormula(
name: "php@8.3",
BrewPhpFormula(
name: "php@8.4",
displayName: "PHP 8.4",
installedVersion: nil,
upgradeVersion: "8.4.0",
prerelease: true
),
BrewPhpFormula(
name: "php",
displayName: "PHP 8.3",
installedVersion: nil,
upgradeVersion: "8.3.0",
prerelease: true
),
BrewFormula(
name: "php",
BrewPhpFormula(
name: "php@8.2",
displayName: "PHP 8.2",
installedVersion: "8.2.3",
upgradeVersion: "8.2.4"
),
BrewFormula(
BrewPhpFormula(
name: "php@8.1",
displayName: "PHP 8.1",
installedVersion: "8.1.17",
upgradeVersion: nil
),
BrewFormula(
BrewPhpFormula(
name: "php@8.0",
displayName: "PHP 8.0",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
BrewPhpFormula(
name: "php@7.4",
displayName: "PHP 7.4",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
BrewPhpFormula(
name: "php@7.3",
displayName: "PHP 7.3",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
BrewPhpFormula(
name: "php@7.2",
displayName: "PHP 7.2",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
BrewPhpFormula(
name: "php@7.1",
displayName: "PHP 7.1",
installedVersion: nil,
@ -71,3 +78,4 @@ class FakeBrewFormulaeHandler: HandlesBrewFormulae {
]
}
}
// swiftlint:enable function_body_length

View File

@ -0,0 +1,163 @@
//
// PhpVersionManagerView+Interactivity.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 07/11/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import SwiftUI
extension PhpVersionManagerView {
public func runCommand(_ command: ModifyPhpVersionCommand) async {
if PhpEnvironments.shared.isBusy {
self.presentErrorAlert(
title: "phpman.action_prevented_busy.title".localized,
description: "phpman.action_prevented_busy.desc".localized,
button: "generic.ok".localized
)
return
}
do {
self.setBusyStatus(true)
try await command.execute { progress in
Task { @MainActor in
self.status.title = progress.title
self.status.description = progress.description
self.status.busy = progress.value != 1
// Whenever a key step is finished, refresh the PHP versions
if progress.value == 1 {
await self.handler.refreshPhpVersions(loadOutdated: false)
}
}
}
// Finally, after completing the command, also refresh PHP versions
await self.handler.refreshPhpVersions(loadOutdated: false)
// and mark the app as no longer busy
self.setBusyStatus(false)
} catch let error {
let error = error as! BrewCommandError
let messages = error.log.suffix(2).joined(separator: "\n")
self.setBusyStatus(false)
await self.handler.refreshPhpVersions(loadOutdated: false)
self.presentErrorAlert(
title: "phpman.failures.install.title".localized,
description: "phpman.failures.install.desc".localized(messages),
button: "generic.ok".localized
)
}
}
public func repairAll() async {
await self.runCommand(ModifyPhpVersionCommand(
title: "phpman.operations.repairing".localized,
upgrading: [],
installing: []
))
}
public func upgradeAll(_ formulae: [BrewPhpFormula]) async {
await self.runCommand(ModifyPhpVersionCommand(
title: "phpman.operations.updating".localized,
upgrading: formulae,
installing: []
))
}
public func install(_ formula: BrewPhpFormula) async {
await self.runCommand(ModifyPhpVersionCommand(
title: "phpman.operations.installing".localized(formula.displayName),
upgrading: [],
installing: [formula]
))
}
public func confirmUninstall(_ formula: BrewPhpFormula) async {
// Disallow removal of the currently active versipn
if formula.installedVersion == PhpEnvironments.shared.currentInstall?.version.text {
self.presentErrorAlert(
title: "phpman.uninstall_prevented.title".localized,
description: "phpman.uninstall_prevented.desc".localized,
button: "generic.ok".localized
)
return
}
Alert.confirm(
onWindow: App.shared.phpVersionManagerWindowController!.window!,
messageText: "phpman.warnings.removal.title".localized(formula.displayName),
informativeText: "phpman.warnings.removal.desc".localized(formula.displayName),
buttonTitle: "phpman.warnings.removal.button".localized,
buttonIsDestructive: true,
secondButtonTitle: "generic.cancel".localized,
style: .warning,
onFirstButtonPressed: {
Task { await self.uninstall(formula) }
}
)
}
public func uninstall(_ formula: BrewPhpFormula) async {
let command = RemovePhpVersionCommand(formula: formula.name)
do {
self.setBusyStatus(true)
try await command.execute { progress in
Task { @MainActor in
self.status.title = progress.title
self.status.description = progress.description
self.status.busy = progress.value != 1
if progress.value == 1 {
await self.handler.refreshPhpVersions(loadOutdated: false)
self.setBusyStatus(false)
}
}
}
} catch {
self.setBusyStatus(false)
self.presentErrorAlert(
title: "phpman.failures.uninstall.title".localized,
description: "phpman.failures.uninstall.desc".localized(
"brew uninstall \(formula.name) --force"
),
button: "generic.ok".localized
)
}
}
public func setBusyStatus(_ busy: Bool) {
Task { @MainActor in
PhpEnvironments.shared.isBusy = busy
self.status.busy = busy
}
}
public func presentErrorAlert(
title: String,
description: String,
button: String,
style: NSAlert.Style = .critical
) {
Alert.confirm(
onWindow: App.shared.phpVersionManagerWindowController!.window!,
messageText: title,
informativeText: description,
buttonTitle: button,
secondButtonTitle: "",
style: style,
onFirstButtonPressed: {}
)
}
var hasUpdates: Bool {
return self.formulae.phpVersions.contains { formula in
return formula.hasUpgrade
}
}
}

View File

@ -9,37 +9,38 @@
import Foundation
import SwiftUI
// swiftlint:disable type_body_length
struct PhpVersionManagerView: View {
@ObservedObject var formulae: BrewFormulaeObservable
@ObservedObject var status: PhpFormulaeStatus
var handler: HandlesBrewFormulae
@ObservedObject var status: BusyStatus
var handler: HandlesBrewPhpFormulae
init(
formulae: BrewFormulaeObservable,
handler: HandlesBrewFormulae
handler: HandlesBrewPhpFormulae
) {
self.formulae = formulae
self.handler = handler
self.status = PhpFormulaeStatus(
self.status = BusyStatus(
busy: true,
title: "phpman.busy.title".localized,
description: "phpman.busy.description.outdated".localized
)
if handler is FakeBrewFormulaeHandler {
Task { [self] in
await self.handler.refreshPhpVersions(loadOutdated: false)
self.status.busy = false
}
} else {
Task { [self] in
Task { [self] in
if handler is FakeBrewFormulaeHandler {
await self.fakeInitialLoad()
} else {
await self.initialLoad()
}
}
}
private func fakeInitialLoad() async {
await self.handler.refreshPhpVersions(loadOutdated: false)
self.status.busy = false
}
private func initialLoad() async {
guard let version = Brew.shared.version else {
return
@ -47,6 +48,7 @@ struct PhpVersionManagerView: View {
await delay(seconds: 1)
// PHP formulae may not be installable with older Homebrew version
if version.major != 4 {
Task { @MainActor in
self.presentErrorAlert(
@ -58,6 +60,16 @@ struct PhpVersionManagerView: View {
}
}
// PHP formulae may be out of date past the cutoff date
if Date.fromString(Constants.PhpFormulaeCutoffDate)! < Date.now {
self.presentErrorAlert(
title: "phpman.warnings.outdated.title".localized,
description: "phpman.warnings.outdated.desc".localized(version.text),
button: "generic.ok".localized,
style: .warning
)
}
await PhpEnvironments.detectPhpVersions()
await self.handler.refreshPhpVersions(loadOutdated: false)
await self.handler.refreshPhpVersions(loadOutdated: true)
@ -70,7 +82,9 @@ struct PhpVersionManagerView: View {
self.status.title = "phpman.busy.title".localized
self.status.description = "phpman.busy.description.outdated".localized
}
await self.handler.refreshPhpVersions(loadOutdated: true)
Task { @MainActor in
self.status.busy = false
}
@ -78,298 +92,210 @@ struct PhpVersionManagerView: View {
var body: some View {
VStack {
HStack(alignment: .center, spacing: 15) {
Image(systemName: "arrow.down.to.line.circle.fill")
.resizable()
.frame(width: 40, height: 40)
.foregroundColor(Color.blue)
.padding(12)
VStack(alignment: .leading, spacing: 5) {
Text("phpman.description".localizedForSwiftUI)
.font(.system(size: 12))
.frame(maxWidth: .infinity, alignment: .leading)
Text("phpman.disclaimer".localizedForSwiftUI)
.font(.system(size: 12))
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(10)
header.padding(10)
Divider()
if self.hasUpdates {
Divider()
HStack(alignment: .center, spacing: 15) {
Text("phpman.has_updates.description".localizedForSwiftUI)
.foregroundColor(.gray)
.font(.system(size: 11))
Button("phpman.has_updates.button".localizedForSwiftUI, action: {
Task { await self.upgradeAll(self.formulae.upgradeable) }
})
.focusable(false)
.disabled(self.status.busy)
}
.padding(10)
hasUpdatesView
} else {
Divider()
HStack(alignment: .center, spacing: 15) {
Button {
Task { await self.reload() }
} label: {
Image(systemName: "arrow.clockwise")
.buttonStyle(.automatic)
.controlSize(.large)
}
.focusable(false)
.disabled(self.status.busy)
Text("phpman.refresh.button.description".localizedForSwiftUI)
.foregroundColor(.gray)
.font(.system(size: 11))
}
.padding(10)
noUpdatesView
}
BlockingOverlayView(busy: self.status.busy, title: self.status.title, text: self.status.description) {
List(Array(formulae.phpVersions.enumerated()), id: \.1.name) { (index, formula) in
HStack(alignment: .center, spacing: 7.0) {
Image(systemName: formula.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(formula.iconColor)
.padding(.horizontal, 5)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(formula.displayName).bold()
if formula.prerelease {
Text("phpman.version.prerelease".localized.uppercased())
.font(.system(size: 9))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.appPrimary)
.foregroundColor(Color.white)
.clipShape(Capsule())
.fixedSize(horizontal: true, vertical: true)
}
}
if formula.hasUpgradedFormulaAlias {
Text("phpman.version.automatic_upgrade".localized(formula.shortVersion!))
.font(.system(size: 11))
.foregroundColor(.gray)
} else if formula.isInstalled && formula.hasUpgrade {
Text("phpman.version.has_update".localized(
formula.installedVersion!,
formula.upgradeVersion!
))
.font(.system(size: 11))
.foregroundColor(.gray)
} else if formula.isInstalled && formula.installedVersion != nil {
Text("phpman.version.installed".localized(formula.installedVersion!))
.font(.system(size: 11))
.foregroundColor(.gray)
} else {
Text("phpman.version.available_for_installation".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundColor(.gray)
}
if !formula.healthy {
Text("phpman.version.broken".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundColor(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if !formula.healthy {
Button("phpman.buttons.repair".localizedForSwiftUI, role: .destructive) {
Task { await self.repairAll() }
}
}
if formula.isInstalled {
Button("phpman.buttons.uninstall".localizedForSwiftUI, role: .destructive) {
Task { await self.confirmUninstall(formula) }
}
} else {
Button("phpman.buttons.install".localizedForSwiftUI) {
Task { await self.install(formula) }
}.disabled(formula.hasUpgradedFormulaAlias)
}
BlockingOverlayView(
busy: self.status.busy,
title: self.status.title,
text: self.status.description
) {
if #available(macOS 13, *) {
List(Array(formulae.phpVersions.enumerated()), id: \.1.name) { (index, formula) in
listContent(for: formula)
.listRowBackground(
index % 2 == 0
? Color.gray.opacity(0)
: Color.gray.opacity(0.08)
)
.padding(.vertical, 8)
.padding(.horizontal, 8)
.listRowSeparator(.hidden)
}
.listRowBackground(index % 2 == 0 ? Color.gray.opacity(0): Color.gray.opacity(0.08))
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
.edgesIgnoringSafeArea(.top)
.listStyle(PlainListStyle())
}
}.frame(width: 600, height: 600)
}
public func runCommand(_ command: InstallAndUpgradeCommand) async {
if PhpEnvironments.shared.isBusy {
self.presentErrorAlert(
title: "phpman.action_prevented_busy.title".localized,
description: "phpman.action_prevented_busy.desc".localized,
button: "generic.ok".localized
)
return
}
do {
self.setBusyStatus(true)
try await command.execute { progress in
Task { @MainActor in
self.status.title = progress.title
self.status.description = progress.description
self.status.busy = progress.value != 1
// Whenever a key step is finished, refresh the PHP versions
if progress.value == 1 {
await self.handler.refreshPhpVersions(loadOutdated: false)
.edgesIgnoringSafeArea(.top)
.listStyle(PlainListStyle())
} else {
List(Array(formulae.phpVersions.enumerated()), id: \.1.name) { (index, formula) in
listContent(for: formula)
.listRowBackground(
index % 2 == 0
? Color.gray.opacity(0)
: Color.gray.opacity(0.08)
)
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
.edgesIgnoringSafeArea(.top)
.listStyle(PlainListStyle())
}
}
// Finally, after completing the command, also refresh PHP versions
await self.handler.refreshPhpVersions(loadOutdated: false)
// and mark the app as no longer busy
self.setBusyStatus(false)
} catch let error {
let error = error as! BrewCommandError
let messages = error.log.suffix(2).joined(separator: "\n")
self.setBusyStatus(false)
await self.handler.refreshPhpVersions(loadOutdated: false)
self.presentErrorAlert(
title: "phpman.failures.install.title".localized,
description: "phpman.failures.install.desc".localized(messages),
button: "generic.ok".localized
)
}
.frame(minWidth: 600, minHeight: 600)
}
public func repairAll() async {
await self.runCommand(InstallAndUpgradeCommand(
title: "phpman.operations.repairing".localized,
upgrading: [],
installing: []
))
}
// MARK: View Variables
public func upgradeAll(_ formulae: [BrewFormula]) async {
await self.runCommand(InstallAndUpgradeCommand(
title: "phpman.operations.updating".localized,
upgrading: formulae,
installing: []
))
}
public func install(_ formula: BrewFormula) async {
await self.runCommand(InstallAndUpgradeCommand(
title: "phpman.operations.installing".localized(formula.displayName),
upgrading: [],
installing: [formula]
))
}
public func confirmUninstall(_ formula: BrewFormula) async {
// Disallow removal of the currently active versipn
if formula.installedVersion == PhpEnvironments.shared.currentInstall?.version.text {
self.presentErrorAlert(
title: "phpman.uninstall_prevented.title".localized,
description: "phpman.uninstall_prevented.desc".localized,
button: "generic.ok".localized
)
return
}
Alert.confirm(
onWindow: App.shared.phpVersionManagerWindowController!.window!,
messageText: "phpman.warnings.removal.title".localized(formula.displayName),
informativeText: "phpman.warnings.removal.desc".localized(formula.displayName),
buttonTitle: "phpman.warnings.removal.button".localized,
buttonIsDestructive: true,
secondButtonTitle: "generic.cancel".localized,
style: .warning,
onFirstButtonPressed: {
Task { await self.uninstall(formula) }
private var header: some View {
HStack(alignment: .center, spacing: 15) {
Image.init(.php)
.resizable()
.frame(width: 50, height: 50)
.foregroundColor(Color.blue)
.padding(12)
VStack(alignment: .leading, spacing: 5) {
Text("phpman.description".localizedForSwiftUI)
.font(.system(size: 12))
.frame(maxWidth: .infinity, alignment: .leading)
Text("phpman.disclaimer".localizedForSwiftUI)
.font(.system(size: 12))
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
}
)
}
}
public func uninstall(_ formula: BrewFormula) async {
let command = RemovePhpVersionCommand(formula: formula.name)
private var hasUpdatesView: some View {
Group {
HStack(alignment: .center, spacing: 15) {
Text("phpman.has_updates.description".localizedForSwiftUI)
.font(.system(size: 11))
do {
self.setBusyStatus(true)
try await command.execute { progress in
Task { @MainActor in
self.status.title = progress.title
self.status.description = progress.description
self.status.busy = progress.value != 1
Button(action: {
Task { await self.upgradeAll(self.formulae.upgradeable) }
}, label: {
Label("phpman.has_updates.button".localizedForSwiftUI,
systemImage: "arrowshape.up.circle.fill")
})
.buttonStyle(.custom)
.focusable(false)
.disabled(self.status.busy)
}
.padding(10)
}
.background(.statusColorYellowTranslucent)
.cornerRadius(5)
}
if progress.value == 1 {
await self.handler.refreshPhpVersions(loadOutdated: false)
self.setBusyStatus(false)
}
private var noUpdatesView: some View {
HStack(alignment: .center, spacing: 15) {
Button {
Task { await self.reload() }
} label: {
Image(systemName: "arrow.clockwise")
.buttonStyle(.automatic)
.controlSize(.large)
}
.focusable(false)
.disabled(self.status.busy)
Text("phpman.refresh.button.description".localizedForSwiftUI)
.foregroundColor(.gray)
.font(.system(size: 11))
}
.padding(10)
}
private var prereleaseBadge: some View {
Text("phpman.version.prerelease".localized.uppercased())
.font(.system(size: 9))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.statusColorBlue)
.foregroundColor(Color.white)
.clipShape(Capsule())
.fixedSize(horizontal: true, vertical: true)
}
// MARK: View Builders
private func listContent(for formula: BrewPhpFormula) -> some View {
HStack(alignment: .center, spacing: 7.0) {
formulaIcon(for: formula)
formulaDescription(for: formula)
formulaButtons(for: formula)
}
}
private func formulaButtons(for formula: BrewPhpFormula) -> some View {
HStack {
if !formula.healthy {
Button("phpman.buttons.repair".localizedForSwiftUI, role: .destructive) {
Task { await self.repairAll() }
}
}
} catch {
self.setBusyStatus(false)
self.presentErrorAlert(
title: "phpman.failures.uninstall.title".localized,
description: "phpman.failures.uninstall.desc".localized(
"brew uninstall \(formula.name) --force"
),
button: "generic.ok".localized
)
if formula.isInstalled {
Button("phpman.buttons.uninstall".localizedForSwiftUI, role: .destructive) {
Task { await self.confirmUninstall(formula) }
}
} else {
Button("phpman.buttons.install".localizedForSwiftUI) {
Task { await self.install(formula) }
}.disabled(formula.hasUpgradedFormulaAlias)
}
}
}
public func setBusyStatus(_ busy: Bool) {
Task { @MainActor in
PhpEnvironments.shared.isBusy = busy
self.status.busy = busy
private func formulaDescription(for formula: BrewPhpFormula) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(formula.displayName).bold()
if formula.prerelease {
prereleaseBadge
}
}
if formula.hasUpgradedFormulaAlias {
Text("phpman.version.automatic_upgrade".localized(formula.shortVersion!))
.font(.system(size: 11))
.foregroundColor(.gray)
} else if formula.isInstalled && formula.hasUpgrade {
Text("phpman.version.has_update".localized(
formula.installedVersion!,
formula.upgradeVersion!
))
.font(.system(size: 11))
.foregroundColor(.gray)
} else if formula.isInstalled && formula.installedVersion != nil {
Text("phpman.version.installed".localized(formula.installedVersion!))
.font(.system(size: 11))
.foregroundColor(.gray)
} else {
Text("phpman.version.available_for_installation".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundColor(.gray)
}
if !formula.healthy {
Text("phpman.version.broken".localizedForSwiftUI)
.font(.system(size: 11))
.foregroundColor(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
public func presentErrorAlert(
title: String,
description: String,
button: String,
style: NSAlert.Style = .critical
) {
Alert.confirm(
onWindow: App.shared.phpVersionManagerWindowController!.window!,
messageText: title,
informativeText: description,
buttonTitle: button,
secondButtonTitle: "",
style: style,
onFirstButtonPressed: {}
)
}
var hasUpdates: Bool {
return self.formulae.phpVersions.contains { formula in
return formula.hasUpgrade
}
private func formulaIcon(for formula: BrewPhpFormula) -> some View {
Image(systemName: formula.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(formula.iconColor)
.padding(.horizontal, 5)
}
}
// swiftlint:enable type_body_length
struct PhpVersionManagerView_Previews: PreviewProvider {
static var previews: some View {
PhpVersionManagerView(
formulae: Brew.shared.formulae,
handler: FakeBrewFormulaeHandler()
).frame(width: 600, height: 600)
}
#Preview {
PhpVersionManagerView(
formulae: Brew.shared.formulae,
handler: FakeBrewFormulaeHandler()
).frame(width: 600, height: 600)
}

View File

@ -25,12 +25,12 @@ class PhpVersionManagerWindowController: PMWindowController {
windowController.window = NSWindow()
windowController.view = PhpVersionManagerView(
formulae: Brew.shared.formulae,
handler: BrewFormulaeHandler()
handler: BrewPhpFormulaeHandler()
)
guard let window = windowController.window else { return }
window.title = ""
window.styleMask = [.titled, .closable, .miniaturizable]
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.titlebarAppearsTransparent = true
window.delegate = delegate ?? windowController
window.contentView = NSHostingView(rootView: windowController.view)

View File

@ -12,7 +12,11 @@
"mi_no_php_linked" = "Keine PHP-Version verknüpft!";
"mi_fix_php_link" = "Automatisch beheben...";
"mi_no_php_linked_explain" = "Was ist das?";
"mi_php_version_manager" = "PHP Versionen Manager...";
"mi_php_version_manager" = "PHP-Installationen verwalten...";
"mi_php_ext_manager" = "PHP-Erweiterungen verwalten...";
"mi_php_config_manager" = "PHP-Konfigurationseditor...";
"mi_manage_limits" = "Limits verwalten...";
"mi_diagnostics" = "Diagnosen";
"mi_active_services" = "Aktive Dienste";
@ -81,6 +85,33 @@
"mi_xdebug_actions" = "Aktionen";
"mi_xdebug_disable_all" = "Alle Modi deaktivieren";
// PHPEXTMAN
"phpextman.window.title" = "Erweiterungen";
"phpextman.description" = "**PHP Extension Manager** ermöglicht Ihnen die Verwaltung verschiedener PHP-Erweiterungen mit einem einfachen Klick auf die Schaltfläche. Da Homebrew verwendet wird, müssen Erweiterungen nicht on-the-fly mit `pecl` kompiliert werden.";
"phpextman.disclaimer" = "Bestimmte Erweiterungen erfordern möglicherweise die Installation anderer Abhängigkeiten, im Allgemeinen sollte jedoch die Installation von Erweiterungen viel schneller sein als die Installation von PHP-Versionen.";
"phpextman.warnings.removal.title" = "Die Erweiterung `%@` deinstallieren?";
"phpextman.warnings.removal.desc" = "Die Erweiterung und ihre einzigartige Konfigurationsdatei werden entfernt. Die Funktionalität der Erweiterung wird für diese PHP-Installation nicht mehr verfügbar sein. Sind Sie sicher?
(Wenn die Erweiterung mit einem nicht standardmäßigen Dateinamen aktiviert ist, wird sie nicht entfernt. Wenn Sie also nicht möchten, dass die .ini-Datei entfernt wird, benennen Sie sie am besten einfach um. In diesem Fall wird die Erweiterung nur als Teil des Bereinigungsprozesses deaktiviert.)";
"phpextman.warnings.removal.button" = "Deinstallieren";
"phpextman.list.showing_count" = "Aktuell werden %@ Erweiterungen für folgendes angezeigt:";
"phpextman.list.depends_on" = "Hängt ab von:";
"phpextman.list.status.external" = "Diese Erweiterung ist bereits über eine andere Quelle installiert und kann nicht verwaltet werden.";
"phpextman.list.status.installable" = "Diese Erweiterung kann installiert werden.";
"phpextman.list.status.dependent" = "Sie können diese nicht deinstallieren, bevor Sie %@ deinstallieren.";
"phpextman.list.status.can_manage" = "Diese Erweiterung ist installiert und kann von PHP Monitor verwaltet werden.";
"phpextman.errors.not_found.title" = "Oh oh. Keine Erweiterungen gefunden!";
"phpextman.errors.not_found.desc" = "Das sollte eigentlich nicht passieren. Sie müssen möglicherweise den folgenden Befehl in Ihrem Terminal ausführen:
`brew tap shivammathur/extensions`
und PHP Monitor neu starten, damit die Erweiterungen sichtbar werden. Wenn das Problem nach Ausführen des Befehls und Neustart von PHP Monitor weiterhin besteht, ziehen Sie bitte in Erwägung, ein Issue auf GitHub zu eröffnen.";
// PHPMAN
"phpman.busy.title" = "Suche nach Aktualisierungen...";
@ -103,7 +134,7 @@
"phpman.refresh.button" = "Aktualisierungen suchen";
"phpman.refresh.button.description" = "Sie können auf die Schaltfläche Aktualisieren klicken, um zu prüfen, ob Aktualisierungen für die installierten PHP-Versionen verfügbar sind.";
"phpman.has_updates.description" = "Ein oder mehrere Aktualisierungen sind verfügbar. (Bitte beachten Sie, dass PHP Monitor PHP-Versionen immer im Ganzen installiert oder aktualisiert, so dass Sie immer alle Installationen auf einmal aktualisieren).";
"phpman.has_updates.description" = "**Ein oder mehrere Aktualisierungen sind verfügbar.** Bitte beachten Sie, dass PHP Monitor PHP-Versionen immer im Ganzen installiert oder aktualisiert, so dass Sie immer alle Installationen auf einmal aktualisieren.";
"phpman.has_updates.button" = "Alle aktualisieren";
"phpman.warnings.unsupported.title" = "Ihre Version von Homebrew kann Probleme verursachen";
@ -213,6 +244,9 @@ Möglicherweise werden Sie während des Deinstallationsvorgangs nach Ihrem Passw
"domain_list.columns.kind" = "Art";
"domain_list.columns.project_type" = "Projekttyp";
"domain_list.extensions" = "Erweiterungen umschalten";
"domain_list.applies_to" = "Gilt für PHP %@";
// CHOOSE WHAT TO ADD
"selection.title" = "Welche Art von Domain möchten Sie einrichten?";
@ -789,3 +823,11 @@ Bitte beachten Sie, dass einige Funktionen (unten ausgegraut) derzeit nicht verf
"onboarding.tour.feature_unavailable" = "Diese Funktion ist derzeit nicht verfügbar und erfordert die Installation von Laravel Valet.";
"onboarding.tour.once" = "Sie sehen die Willkommenstour nur einmal. Sie können die Willkommenstour später über das Symbol in der Menüleiste (im Menü unter Erste Hilfe & Dienste) erneut öffnen.";
"onboarding.tour.close" = "Tour beenden";
// LANGUAGE CHOICE
"prefs.language" = "Sprache:";
"prefs.language_options_desc" = "Wählen Sie eine andere Sprache für die Verwendung mit PHP Monitor. Um diese Änderung vollständig anzuwenden, müssen Sie die App neu starten.";
"alert.language_changed.title" = "Sie sollten PHP Monitor neu starten!";
"alert.language_changed.subtitle" = "Sie haben soeben die Anzeigesprache von PHP Monitor geändert. Das Menü wird sofort die korrekte Sprache verwenden, aber Sie müssen die App möglicherweise neu starten, damit alle Texte in der App Ihre neue Sprachwahl widerspiegeln.";

View File

@ -12,7 +12,9 @@
"mi_no_php_linked" = "No PHP version linked!";
"mi_fix_php_link" = "Fix Automatically...";
"mi_no_php_linked_explain" = "What's This?";
"mi_php_version_manager" = "PHP Version Manager...";
"mi_php_version_manager" = "Manage PHP Installations...";
"mi_php_ext_manager" = "Manage PHP Extensions...";
"mi_php_config_manager" = "PHP Configuration Editor...";
"mi_manage_limits" = "Manage Limits...";
@ -98,6 +100,33 @@
"php_ini.upload_max_filesize.title" = "Upload Max Size";
"php_ini.upload_max_filesize.description" = "The maximum size of an uploaded file. POST Max Size must be larger than this value.";
// PHPEXTMAN
"phpextman.window.title" = "Extensions";
"phpextman.description" = "**PHP Extension Manager** lets you manage different PHP extensions with a simple click of the button. Because Homebrew is used, extensions won't need to be compiled on the fly using `pecl`.";
"phpextman.disclaimer" = "Certain extensions may require other dependencies to be installed, but generally speaking installing extensions should be much faster than installing PHP versions.";
"phpextman.warnings.removal.title" = "Uninstall the extension `%@`?";
"phpextman.warnings.removal.desc" = "The extension and its unique configuration file will be removed. The extension's functionality will no longer be available for this PHP installation. Are you sure?
(If the extension is enabled using a non-standard filename, it will not be removed. So if you don't want the .ini file to be removed, it's best to simply rename it to something else. In that case, the extension will only be disabled as part of the clean-up process.)";
"phpextman.warnings.removal.button" = "Uninstall";
"phpextman.list.showing_count" = "Currently showing %@ extensions for:";
"phpextman.list.depends_on" = "Depends on:";
"phpextman.list.status.external" = "This extension is already installed via another source, and cannot be managed.";
"phpextman.list.status.installable" = "This extension can be installed.";
"phpextman.list.status.dependent" = "You cannot uninstall this before uninstalling %@.";
"phpextman.list.status.can_manage" = "This extension is installed and can be managed by PHP Monitor.";
"phpextman.errors.not_found.title" = "Uh oh. No extensions discovered!";
"phpextman.errors.not_found.desc" = "This is not supposed to happen. You may need to run the following command in your terminal:
`brew tap shivammathur/extensions`
and restart PHP Monitor for extensions to become visible. If the problem persists after running the command and restarting PHP Monitor, please consider opening an issue on GitHub.";
// PHPMAN
"phpman.busy.title" = "Checking for updates!";
@ -114,15 +143,27 @@
"phpman.buttons.repair" = "Repair";
"phpman.version.prerelease" = "Pre-release";
"phpman.steps.installing" = "Installing %@";
"phpman.steps.removing" = "Removing %@";
"phpman.steps.reloading" = "Reloading PHP versions...";
"phpman.steps.preparing" = "PHP Monitor is preparing Homebrew...";
"phpman.steps.wait" = "Please wait...";
"phpman.steps.completed" = "Operation completed!";
"phpman.steps.success" = "The operation has succeeded.";
"phpman.steps.failure" = "The command failed to run correctly.";
"phpman.title" = "PHP Version Manager";
"phpman.description" = "**PHP Version Manager** lets you install, upgrade and delete different PHP versions via Homebrew without needing to run the commands in the terminal yourself.";
"phpman.disclaimer" = "Please note that installing or upgrading PHP versions may cause other Homebrew packages to be upgraded as well. Most installation steps usually take some time, so please be patient while Homebrew does its job.";
"phpman.refresh.button" = "Search for Updates";
"phpman.refresh.button.description" = "You can press the refresh button to check if any updates are available to installed PHP versions.";
"phpman.has_updates.description" = "One or more updates are available. (Please note that PHP Monitor will always install or update PHP versions in bulk, so you will always upgrade all installations at once.)";
"phpman.has_updates.description" = "**One or more updates are available.** PHP Monitor will always install or update PHP versions in bulk, so you will always upgrade all installations at once.";
"phpman.has_updates.button" = "Upgrade All";
"phpman.warnings.outdated.title" = "This version of PHP Monitor is outdated";
"phpman.warnings.outdated.desc" = "It is highly likely that the Homebrew formulae have changed since this version of PHP Monitor was created. I highly recommend updating the application before using the version manager to install, remove or upgrade PHP versions.";
"phpman.warnings.unsupported.title" = "Your version of Homebrew may cause issues";
"phpman.warnings.unsupported.desc" = "No functionality is disabled, but some commands may not work as expected. You are currently running Homebrew %@.
@ -198,8 +239,9 @@ You may be asked for your password during the uninstallation process if file per
"domain_list.title" = "Domains";
"domain_list.subtitle" = "";
"domain_list.no_domains" = "You have not set up any domains or proxies yet.";
"domain_list.no_domains_for_search_query" = "There are no results for your search query.";
"domain_list.domains_empty.title" = "No domains available.";
"domain_list.domains_empty.desc" = "No domains were found for this search query or you haven't linked any domains yet.";
"domain_list.domains_empty.button" = "Add domain...";
"domain_list.tooltips.isolated" = "This domain is isolated and using PHP %@ instead of the globally linked PHP.";
"domain_list.tooltips.checkmark" = "This domain is being served with a version of PHP that is compatible with this requirement (PHP %@). Click on the PHP version next to this checkmark to find out more information about how this requirement was determined.";
@ -220,6 +262,7 @@ You may be asked for your password during the uninstallation process if file per
"domain_list.confirm_unlink_desc" = "No files will be removed. You can always link the folder again by clicking on the + button and selecting the original folder.";
"site_link.close" = "Close";
"site_link.switch_to_php" = "Switch to PHP %@";
"site_link.isolate_php" = "Isolate PHP %@";
"domain_list.confirm_unproxy" = "Are you sure you want to remove the proxy '%@'?";
"domain_list.confirm_unproxy_desc" = "You can always recreate proxy the again by clicking on the + button.";
@ -230,6 +273,9 @@ You may be asked for your password during the uninstallation process if file per
"domain_list.columns.kind" = "Kind";
"domain_list.columns.project_type" = "Project Type";
"domain_list.extensions" = "Toggle Extensions";
"domain_list.applies_to" = "Applies to PHP %@";
// CHOOSE WHAT TO ADD
"selection.title" = "What kind of domain would you like to set up?";
@ -806,3 +852,11 @@ Please note that some features (greyed out below) are currently unavailable beca
"onboarding.tour.feature_unavailable" = "This feature is currently unavailable and requires Laravel Valet to be installed.";
"onboarding.tour.once" = "You will only see the Welcome Tour once. You can re-open the Welcome Tour later via the menu bar icon (available in the menu, under First Aid & Services).";
"onboarding.tour.close" = "Close Tour";
// LANGUAGE CHOICE
"prefs.language" = "Language:";
"prefs.language_options_desc" = "Choose a different language to use with PHP Monitor. To fully apply this change, you must restart the app.";
"alert.language_changed.title" = "You must restart PHP Monitor!";
"alert.language_changed.subtitle" = "You just changed the display language of PHP Monitor. The menu will immediately use the correct language, but you may need to restart the app for all text throughout the app to reflect your new language choice.";

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