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

Compare commits

...

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

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

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

(It was possible for work in another thread to complete and fail to set
the icon to "not busy" because the work was done faster than the icon
could be set to busy.)
2023-10-30 19:34:36 +01:00
541378f3f9 🔧 Mark PHP 8.3 as stable for official release 2023-10-29 12:36:19 +01:00
e6f1d7e834 📝 Update SECURITY.md 2023-10-29 12:35:09 +01:00
20d19f2f92 🚀 Version 6.2.0 2023-10-27 17:10:43 +02:00
91bc347e57 🐛 Fix tests, fix issue with window to front 2023-10-26 19:31:55 +02:00
e05300b25b 🔧 Bump build 2023-10-26 18:55:21 +02:00
1ae7a20870 👌 Adjust error message 2023-10-26 17:14:23 +02:00
5594130ccd 👌 Cleanup filesystem watcher code 2023-10-24 18:24:18 +02:00
b9c7cdb3cc Generate Fish-friendly scripts (#264) 2023-10-20 17:34:19 +02:00
00b4760b85 🔧 Bump build to 1330 2023-10-07 13:02:56 +02:00
9a35014d2a Warn about Laravel Herd running & conflicts
It's perfectly possible to run Laravel Valet (standalone) and Laravel
Herd side-by-side or mix usage, but it's not recommended and/or
supported (especially since Herd recommends migrating away from regular
Valet and may terminate Valet's services).

To avoid issues, PHP Monitor won't work if Herd is currently running.
It's totally fine to relaunch Herd after PHP Monitor's done starting
up, since the check only happens at launch.

Also, PHP Monitor warns about the PATH changes in `~/.zshrc` after 
installing Laravel Herd, because that may impact the usage of
PHP aliases/global PHP version in terminal provided by PHP Monitor.
2023-10-07 13:02:22 +02:00
7cba25b52e 👌 Cleanup 2023-10-03 20:40:59 +02:00
c6c3996c7b 🐛 Fixed detection order (#263)
Dictionary key order in Swift isn't a thing, so the process is
now a two-pronged approach: 1) check for specific apps, 2) check for
specific broad frameworks after no other matches were made.

Previously, detection would only work correctly some of the time.

Also cleaned up the `getNotableDependencies()` method.
2023-10-03 20:37:47 +02:00
03c96a1d16 👌 Fixes after upgrading to Xcode 15 2023-09-19 12:40:43 +02:00
a6fa4b240f Allow editing of limits 2023-09-13 18:13:51 +02:00
7e78026d06 🏗️ WIP: PHP Config Editor
- Has UI height rendering issues (w/ SwiftUI)
- Needs debounce on UI elements
- Cannot currently persist modified settings
- Cannot display On/Off settings
- Cannot display regular text settings
2023-09-12 19:26:10 +02:00
d5888c1c7a 🚀 Version 6.1.0 2023-09-10 11:15:14 +02:00
e40b9fe45a Add UI test for PHP version manager 2023-09-10 11:14:38 +02:00
f5d0ad20cd 🔧 New EAP build 2023-09-07 19:17:36 +02:00
0615927f2f 📝 Update README.md 2023-09-07 19:16:41 +02:00
3d1806c094 📝 Update SECURITY.md 2023-09-07 19:16:07 +02:00
8a57557074 🐛 Avoid duplicate site insertion (#261) 2023-09-07 18:55:54 +02:00
19f4819450 🌐 Formal Dutch, initial TL pass (je -> u) 2023-09-06 19:07:16 +02:00
aa8309dd9a 🌐 Localization fixes
- Adds a fallback for missing keys
- Make type column resizable on domains
- Localized "Open with" text in context menu
- Updated some Dutch translations
2023-09-06 18:53:26 +02:00
7977a4e177 🏗️ WIP: Reading of bitsize config entries 2023-09-04 18:39:32 +02:00
51c100f6fe 👌 Ensure all fields error out (#258) 2023-09-01 01:11:38 +02:00
aebfc9dd09 👌 Domain column now sorts based on name (#259) 2023-09-01 01:09:59 +02:00
f9acbd34d0 👌 Various fixes
- Fixed issue with Version Manager
- Separate behaviour for previews for Version Manager
- Remove verbose logging when previews are in use

(Note: The latter change may break various other previews. Will need to
investigate this particular issue.)
2023-09-01 01:01:06 +02:00
eb566bb523 Allow pre-release builds to be installed 2023-08-31 19:05:40 +02:00
528f213f17 Finalized initial config UI without bindings 2023-08-30 21:05:37 +02:00
f8e6aa988e 🏗️ Mapping more PHP configuration values 2023-08-30 20:20:08 +02:00
93e841735e 🏗️ SwiftUI and config views 2023-08-30 19:59:21 +02:00
cb28243181 🔧 Bump build, fix layout issue for prefs 2023-07-29 20:45:20 +02:00
fc68e37458 🌐 Updated translations
- Portuguese translations provided by @joseborges
2023-07-29 20:40:55 +02:00
ae6736102a 🏗️ Further experiments with SwiftUI 2023-07-25 19:53:37 +02:00
3ef1a6e60d 🏗️ Example of what a preference view might be 2023-07-25 19:24:04 +02:00
5e7c7bc903 🔧 Always use module name PHP_Monitor 2023-07-25 19:06:47 +02:00
94f3c1c7c5 📝 Fix FAQ ("PHP Version Manager") 2023-07-20 18:18:30 +02:00
20aad90ba9 📝 Update README 2023-07-20 18:17:37 +02:00
8bd85d8354 📝 Add info about Laravel Herd 2023-07-20 18:14:21 +02:00
90b068d200 ♻️ Modular approach 2023-07-18 19:56:09 +02:00
943b5aa6af ♻️ Code reorganization
It was necessary to do some summer cleaning. Here's what's changed:

* First, I'm taking a new modular approach to Swift-based components
  that are part of PHP Monitor.

* I've fixed the naming of various parts of the app. I plan on doing
  an even deeper check in the future. The following are affected:
  - "PHP Formulae Status" is now known as "PHP Version Manager".
  - "Warnings List" is now known as "PHP Doctor".
  - The associated window controllers have also been updated.

(I've also added a new module: "PHP Config Editor". We'll see what that
brings in the future... but the main purpose will be to edit key PHP
configuration values without needing to go to the .ini files.)
2023-07-18 19:52:15 +02:00
4bf475bae2 👌 Remove appcast since it is no longer expected 2023-07-09 15:22:47 +02:00
125b9bb198 Add message about failing to load info (#258) 2023-07-07 20:10:41 +02:00
72cbf6996d 🔧 Removed usage of Base Internationalization 2023-06-26 21:29:28 +02:00
e7cc940f65 🌐 Updated translations
- German translations provided by @dsturm
- Vietnamese update provided by @xuandung38
2023-06-26 21:23:00 +02:00
c8323a8c27 🔧 Bump build to 1300, remove launch language 2023-06-26 14:45:06 +02:00
6805855f03 🌐 Add translations for preferences tabs 2023-06-26 14:43:45 +02:00
db101f5a66 🔧 Bump to version 6.1 2023-06-26 14:33:37 +02:00
2302d5a5ee 🌐 Consistency in Dutch translations (WIP) 2023-06-26 14:33:29 +02:00
5cfb0f452c 🌐 Localized columns in domains list 2023-06-26 14:31:57 +02:00
7da20b4f20 🌐 Updated localization 2023-06-26 14:14:37 +02:00
f1b037ce26 🌐 Localization
- Dutch WIP by me (project now runs debug with Dutch localization)
- Vietnamese provided by @xuandung38
- Added `phpman.services` status to TL
2023-06-26 10:43:45 +02:00
e59347ed7f 📝 Update README 2023-06-02 21:31:51 +02:00
206dff289f 🚀 Version 6.0.1 2023-05-30 17:26:09 +02:00
02f579fe81 🐛 Don't load services info when standalone (#253) 2023-05-30 17:04:03 +02:00
2a74b11462 🐛 Ensure Valet check occurs (#252) 2023-05-29 20:45:05 +02:00
371f98b875 🚀 Version 6.0 2023-05-28 10:28:02 +02:00
7955c777e7 📝 Updated credits w/ sponsors
The list of sponsors includes a list of all public sponsors.

(Private sponsors have been omitted from the list.)
2023-05-27 12:46:39 +02:00
5c9b06d83b 👌 Missing localized strings 2023-05-27 12:43:31 +02:00
3c7bed0a9b 🔧 Bump build number for release version 2023-05-27 12:11:59 +02:00
54f83a0aed 🐛 Prevent operations when PHP Monitor is busy 2023-05-27 11:57:41 +02:00
b041ca37be 🐛 Disable Sites menu item when Standalone 2023-05-27 11:53:11 +02:00
2b2b027317 📝 Update README 2023-05-26 21:27:36 +02:00
cdbd959159 👌 Update strings 2023-05-26 20:49:18 +02:00
6fc613ac4c 🐛 Own /bin and /sbin folders specifically 2023-05-24 19:29:38 +02:00
8240b676c1 🐛 Own the entire Homebrew formula directory 2023-05-24 19:19:13 +02:00
cbebf75b48 🐛 Fix error message, check sbin folder ownership 2023-05-24 19:17:41 +02:00
40c24793f5 🐛 Show "Please wait" text when running command 2023-05-24 19:08:41 +02:00
6a921d8e3e 🐛 Use PHP Guard when removing a PHP version 2023-05-24 19:05:58 +02:00
a3368effec 🐛 Fix async issue when PHP Guard reset kicks in
Whenever PHP Guard is used to reset the PHP version when a different PHP
version is installed using the PHP Version Manager, it would previously
kick its version switching process off asynchronously as a separate task
which meant that the app would go into "ready" state too soon. Now this
is considered a blocking task that the app will wait for (async) before
turning the app back into its "ready" state again.
2023-05-24 18:59:49 +02:00
7f4c6878e4 📝 Update README 2023-05-22 20:12:49 +02:00
0c3b68734c 🔧 New bug report format 2023-04-12 13:48:22 +02:00
8b0aeef2e6 🚀 Version 5.8.1 2023-03-29 18:05:03 +02:00
aa406434d0 🔧 Bump build 2023-03-29 18:04:27 +02:00
d320c49092 👌 Avoid force unwrapping try (may crash) 2023-03-23 19:08:39 +01:00
966033e052 🐛 Prevent crash upon parsing invalid Valet directories
This fixes #247, which can be caused when certain folders are not
accessible for some reason. This can occur due to network reasons,
but also because the linked folder in question is iCloud Drive.
2023-03-23 18:00:45 +01:00
7c192730e1 📝 Update SECURITY 2023-03-15 20:09:32 +01:00
158 changed files with 8634 additions and 1684 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

@ -3,7 +3,7 @@
<p align="center"><img src="./docs/logo.png" alt="PHP Monitor Logo" width="500px" /></p>
**PHP Monitor** (or *phpmon*) is a lightweight macOS utility app that runs on your Mac and displays the active PHP version in your status bar. It's tightly integrated with [Laravel Valet](https://github.com/laravel/valet), so <u>you need to have it set up before you can use this app</u> (consult the FAQ below with info about how to set up your environment).
**PHP Monitor** (or *phpmon*) is a lightweight macOS utility app that runs on your Mac and displays the active PHP version in your status bar. It's tightly integrated with [Laravel Valet](https://github.com/laravel/valet), so <u>you need to have it set up if you want to use all of the functionality of the app</u> (consult the FAQ below with info about how to set up your environment).
<img src="./docs/screenshot.jpg" width="1280px" alt="phpmon screenshot (menu bar app)"/>
@ -22,9 +22,10 @@ You can also add new domains as links, isolate sites, manage various services, a
PHP Monitor is a universal application that runs natively on Apple Silicon **and** Intel-based Macs.
* Your user account can administer your computer (required for some functionality, e.g. certificate generation)
* macOS 12.4 or later (Monterey and Ventura are supported)
* Homebrew is installed in `/usr/local/homebrew` or `/opt/homebrew`
* macOS 12.4 or later (Monterey, Ventura and Sonoma are supported)
* Homebrew is installed in the default location (`/usr/local/homebrew` or `/opt/homebrew`)
* Homebrew `php` formula is installed
* Optional but recommended: Laravel Valet
_Starting with PHP Monitor 6.0, you do not need to have Laravel Valet installed for PHP Monitor to work. To get access to all features of PHP Monitor however, installing Valet is **recommended**._
@ -81,6 +82,12 @@ I wanted to be able to **see at a glance** which version of PHP was linked, and
Initially, I had an Alfred workflow for this — but it has now been replaced with this utility, which also does a good job at displaying additional information at a glance, like the current PHP version, memory limits, and more.
## 🐘 Why not use Laravel Herd?
If you don't need to customize your local PHP setup and just want an easy and ready-to-go environment to start coding, [Laravel Herd](https://herd.laravel.com) is probably more than sufficient for many use cases.
If you need more customization and flexibility I encourage you to consider PHP Monitor in combination with Laravel Valet or some other solution like Docker (with Laravel Sail, for example).
## 🤬 The app won't start?!
PHP Monitor performs some integrity checks to ensure a good experience when using the app. You'll get a message telling you that PHP Monitor won't work correctly in a variety of scenarios.
@ -103,7 +110,9 @@ All stable and supported PHP versions are also supported by PHP Monitor. However
> **Note**
> If you have versions of PHP installed that can be detected by PHP Monitor but is *not* supported by the currently active version of Valet, you will be alerted by an item in the menu with an exclamation mark emoji. (⚠️)
Backports that are installable via PHP Monitor's **PHP Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php).
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>
@ -111,11 +120,11 @@ For maximum compatibility with older PHP versions, you may wish to keep using Va
<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 Manager**. (You can manually install or upgrade PHP versions too, but this is not recommended.)
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.)
Please keep in mind that installing or updating PHP versions, even when done via PHP Monitor's **PHP Manager**, may cause other required formula dependencies (required software needed to keep those PHP versions functional) to be upgraded. It might not be very transparent when this happens, but this is likely the cause if installing a PHP version takes longer than expected: usually other dependencies are also being installed.
Please keep in mind that installing or updating PHP versions, even when done via PHP Monitor's **PHP Version Manager**, may cause other required formula dependencies (required software needed to keep those PHP versions functional) to be upgraded. It might not be very transparent when this happens, but this is likely the cause if installing a PHP version takes longer than expected: usually other dependencies are also being installed.
Additionally, upgrading one specific version of PHP may also cause other installed versions of PHP to *also* be updated in one go, if the dependencies for that one version also apply to the other (newer) version(s) of PHP. It's a bit tricky to manage PHP versions via Homebrew, and even PHP Monitor may encounter some difficulties.
@ -134,6 +143,14 @@ If you are on an older version of macOS, you can do this by dragging *PHP Monito
Super convenient!
</details>
<details>
<summary><strong>What features are unavailable in Standalone Mode?</strong></summary>
The services manager is disabled, and all other obvious Laravel Valet integrations (configuration finder, domains list, Fix My Valet) are also disabled.
(Most other features remain available.)
</details>
<details>
<summary><strong>I want to set up PHP Monitor from scratch! I don't have Homebrew installed either, where do I begin?</strong></summary>
@ -160,7 +177,7 @@ If you're on an Apple Silicon-based Mac, you'll need to add:
and add the following to your `.zshrc` file, but add this BEFORE the homebrew PATH additions:
export PATH=$HOME/bin:~/.composer/vendor/bin:$PATH
If you're adding `composer` and Homebrew binaries, ensure that Homebrew binaries are preferred by adding these to the path last. On my system, that looks like this:
export PATH=$HOME/bin:/usr/local/bin:$PATH
@ -179,8 +196,12 @@ Make sure PHP is linked correctly:
should return: `/usr/local/bin/php` (or `/opt/homebrew/bin/php` if you are on Apple Silicon)
**If you don't need Laravel Valet, you can stop here. PHP Monitor will work like this in Standalone Mode.**
If you'd like to have Valet as well, continue and install Valet with Composer, like this.
composer global require laravel/valet
For optimal results, you should lock your PHP platform for global dependencies to the oldest version of PHP you intend to run. If that version is PHP 7.0, your `~/.composer/composer.json` file could look like this (please adjust the version accordingly!):
```
@ -199,18 +220,13 @@ For optimal results, you should lock your PHP platform for global dependencies t
Run `composer global update` again. This ensures that when you switch to a different global PHP version, [Valet won't break](https://github.com/nicoverbruggen/phpmon/issues/178). If it does, PHP Monitor will let you know what you can do about this.
Then, install Valet:
valet install
This should install `dnsmasq` and set up Valet. Great, almost there!
valet trust
You can now install PHP Monitor, if you haven't already:
brew tap nicoverbruggen/homebrew-cask
brew install --cask phpmon
Finally, run PHP Monitor. Since the app is notarized and signed with a developer ID, it should work. You will need to approve the initial launch of the app, but you should be ready to go now.
</details>
@ -219,13 +235,17 @@ Finally, run PHP Monitor. Since the app is notarized and signed with a developer
PHP Monitor will check if an update is available every time you start the app.
You can disable this behaviour by going to Preferences (via the PHP Monitor icon in the menu bar) and unchecking "Automatically check for updates". You can always check for updates manually.
You can disable this behaviour by going to Preferences (via the PHP Monitor icon in the menu bar) and unchecking "Automatically check for updates". (You can always check for updates manually.)
</details>
<details>
<summary><strong>I have PHP Monitor installed, and it works. I want to upgrade my PHP installations to the latest version, what's the best way to do this?</strong></summary>
The easiest way is to simply use the built-in **PHP Version Manager**, which will allow you to upgrade your PHP versions with one click.
If you want to do this manually, you can follow the instructions below.
It's easy to make a mistake here, and end up with an unlinked version of PHP or have versions missing from PHP Monitor.
Here's what I usually do:
@ -255,7 +275,7 @@ This should resolve the issue! If that does not fix the issue, run `brew link ph
brew install php
brew link php --force
</details>
<details>
@ -310,12 +330,14 @@ Make sure you have at least **Valet 3.0** installed, since support for isolation
<details>
<summary><strong>One of the limits (memory limit, max POST size, max upload size) shows an exclamation mark!</strong></summary>
The value you provided in your INI file is invalid. If that is the case, PHP will attempt to parse your value as bytes, which is usually unintended. (`1GB` will resolve to merely a few bytes, and all of your applications will run out of memory!)
The value you provided in your `.ini` file is invalid. If that is the case, PHP will attempt to parse your value as bytes, which is usually unintended. (`1GB` will resolve to merely a few bytes, and all of your applications will run out of memory!)
You must a provide a value like so: `1024K`, `256M`, `1G`. Alternatively, `-1` is also allowed, or just an integer (which will result in N amount of bytes being the limit).
**Example**: Trying to use `1GB` as the memory limit, for example, will result in this exclamation mark. The correct way to set a 1GB limit is by using `1G` as the value. (Note: The displayed value will append `B` for clarity, so if you set `1G`, the value reported by PHP Monitor will be 1 GB.)
(If you are using Valet, you can adjust these limits in the `.conf.d/php-memory-limits.ini` file. Otherwise, you may need to adjust `php.ini`.)
</details>
<details>
@ -404,6 +426,9 @@ You can omit the `php` key in the preset if you do not wish for the preset to sw
<details>
<summary><strong>How do I ensure additional Homebrew services are shown in the app?</strong></summary>
> **Info**
> Homebrew services aren't displayed if you are using Valet in Standalone Mode.
You must set these services up in a JSON file, located in `~/.config/phpmon/config.json`.
You can specify custom services in the configuration file for Homebrew services that run as your own user (not root).
@ -584,9 +609,9 @@ Thank you very much for your contributions, kind words and support.
### Loading info about PHP in the background
This utility runs `php-config --version` in the background periodically. It also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit).
This app runs `php-config --version` in the background periodically, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
In order to save power, this only happens once every 60 seconds.
PHP Monitor also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit). See also the section on *Config change detection* below.
### Switching PHP versions
@ -594,7 +619,7 @@ This utility will detect which PHP versions you have installed via Homebrew, and
The switcher will disable all PHP-FPM services not belonging to the version you wish to use, and link the desired version of PHP. Then, it'll restart your desired PHP version's FPM process. This all happens in parallel, so this should be a bit faster than Valets switcher.
If you're using Valet 3, versions of PHP-FPM required to keep isolated sites up and running will also be started or stopped as needed.
If you're using Valet 3 or newer, versions of PHP-FPM required to keep isolated sites up and running will also be started or stopped as needed.
### Config change detection

View File

@ -4,17 +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.0 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
| 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 |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 5.8 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.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: 627 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,53 @@ 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"
* to a release version and is no longer marked as experimental.
*/
static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [
"8.4": Date.fromString("2024-12-01") // PLACEHOLDER DATE
]
return Set(releaseDates
.filter { (_: String, date: Date?) in
guard let date else {
return false
}
return date > Date.now
}.map { (version: String, _: Date?) in
return version
})
}
/**
The Homebrew services that should be automatically
detected and show up in the list of managed services.
*/
static let DetectedHomebrewServices: Set = [
"mailhog",
"mysql@",
"postgresql@",
"redis"
]
/**
* The PHP versions supported by this application.
* Any other PHP versions are considered invalid.
@ -25,7 +72,8 @@ struct Constants {
static let DetectedPhpVersions: Set = [
"5.6",
"7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2", "8.3"
"8.0", "8.1", "8.2", "8.3",
"8.4"
]
/**
@ -41,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" // 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" // dev
"8.0", "8.1", "8.2", "8.3",
"8.4" // dev
]
]
@ -82,6 +130,8 @@ struct Constants {
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb"
)!
// EAP URLs
static let EarlyAccessCaskFile = URL(
string: "https://phpmon.app/builds/early-access/sponsors/phpmon-eap.rb"
)!

View File

@ -17,6 +17,7 @@ public class Paths {
internal var baseDir: Paths.HomebrewDir
private var userName: String
private var preferredShell: String
init() {
// Assume the default directory is correct
@ -31,7 +32,12 @@ public class Paths {
}
userName = identity()
Log.info("The current username is `\(userName)`.")
preferredShell = preferred_shell()
if !isRunningSwiftUIPreview {
Log.info("The current username is `\(userName)`.")
Log.info("The user's shell is `\(preferredShell)`.")
}
}
public func detectBinaryPaths() {
@ -96,11 +102,23 @@ 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")
}
public static var shell: String {
return shared.preferredShell
}
// MARK: - Flexible Binaries
// (these can be in multiple locations, so we scan common places because)
// (PHP Monitor will not use the user's own PATH)

View File

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

View File

@ -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,13 +44,30 @@ struct Localization {
extension String {
var localized: String {
if #available(macOS 13, *) {
return NSLocalizedString(
self, tableName: nil, bundle: Localization.bundle, value: "", comment: ""
).replacingOccurrences(of: "Preferences", with: "Settings")
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
}
return NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "")
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 {
guard let path = Localization.bundle.path(forResource: "en", ofType: "lproj"),
let bundle = Bundle(path: path)
else { return self }
return NSLocalizedString(self, bundle: bundle, comment: "")
}
// Ensure that on more recent versions of macOS, "Preferences" is replaced with "Settings"
if #available(macOS 13, *) {
return string.replacingOccurrences(of: "Preferences", with: "Settings")
}
return string
}
var localizedForSwiftUI: LocalizedStringKey {
@ -131,4 +160,10 @@ extension String {
return ""
}
}
var isNumber: Bool {
return self.range(
of: "^[0-9]*$", // 1
options: .regularExpression) != nil
}
}

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)")
}
@ -37,7 +39,7 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
extension NSWindowController {
public func positionWindowInTopLeftCorner(offsetY: CGFloat = 0, offsetX: CGFloat = 0) {
public func positionWindowInTopRightCorner(offsetY: CGFloat = 0, offsetX: CGFloat = 0) {
guard let frame = NSScreen.main?.frame else { return }
guard let window = self.window else { return }

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()
@ -65,3 +64,11 @@ public func identity() -> String {
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
/**
Retrieves the user's preferred shell.
*/
public func preferred_shell() -> String {
return system("dscl . -read ~/ UserShell | sed 's/UserShell: //'")
.trimmingCharacters(in: .whitespacesAndNewlines)
}

View File

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

View File

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

View File

@ -49,8 +49,10 @@ class PhpHelper {
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
.resolvingSymlinksInPath().path
// The contents of the script!
let script = script(path, keyPhrase, version, dotless)
// Check if the user uses Fish
let script = Paths.shell.contains("/fish")
? fishScript(path, keyPhrase, version, dotless)
: zshScript(path, keyPhrase, version, dotless)
Task { @MainActor in
try FileSystem.writeAtomicallyToFile(destination, content: script)
@ -78,7 +80,7 @@ class PhpHelper {
}
}
private static func script(
private static func zshScript(
_ path: String,
_ keyPhrase: String,
_ version: String,
@ -96,6 +98,22 @@ class PhpHelper {
"""
}
private static func fishScript(
_ path: String,
_ keyPhrase: String,
_ version: String,
_ dotless: String
) -> String {
return """
#!\(Paths.binPath)/fish
# \(keyPhrase)
# It reflects the location of PHP \(version)'s binaries on your system.
# Usage: . pm\(dotless)
echo "PHP Monitor has enabled this terminal to use PHP \(version)."; \\
set -x PATH \(path) $PATH
"""
}
private static func createSymlink(_ dotless: String) async {
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
let destination = "/usr/local/bin/pm\(dotless)"

View File

@ -69,8 +69,9 @@ class PhpConfigurationFile: CreatedFromFile {
return nil
}
enum ReplacementErrors: Error {
public enum ReplacementErrors: Error {
case missingKey
case missingFile
}
/**
@ -95,10 +96,16 @@ class PhpConfigurationFile: CreatedFromFile {
// Replace the specific line
self.lines[item.lineIndex] = components.joined(separator: "=")
// Ensure the watchers aren't tripped up by config changes
ConfigWatchManager.ignoresModificationsToConfigValues = true
// Finally, join the string and save the file atomatically again
try self.lines.joined(separator: "\n")
.write(toFile: self.filePath, atomically: true, encoding: .utf8)
// Ensure watcher behaviour is reverted
ConfigWatchManager.ignoresModificationsToConfigValues = false
// Reload the original file
self.reload()
}

View File

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

View File

@ -12,19 +12,46 @@ class PhpInstallation {
var versionNumber: VersionNumber
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,
@ -34,9 +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`)
isMissingBinary = true
}
}
private func determineHealth(_ phpExecutablePath: String) {
if FileSystem.fileExists(phpExecutablePath) {
let testCommand = Command.execute(
path: phpExecutablePath,
@ -53,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

@ -43,6 +43,7 @@ public struct TestableConfiguration: Codable {
private var primaryPhpVersion: VersionNumber?
private var secondaryPhpVersions: [VersionNumber] = []
// swiftlint:disable function_body_length
mutating func addPhpVersion(_ version: VersionNumber, primary: Bool) {
if primary {
if primaryPhpVersion != nil {
@ -56,6 +57,8 @@ public struct TestableConfiguration: Codable {
self.filesystem = self.filesystem.merging([
"/opt/homebrew/opt/php@\(version.short)/bin/php"
: .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php"),
"/opt/homebrew/opt/php@\(version.short)/bin/php-config"
: .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php-config"),
"/opt/homebrew/Cellar/php/\(version.long)/bin/php"
: .fake(.binary),
"/opt/homebrew/Cellar/php/\(version.long)/bin/php-config"
@ -70,9 +73,26 @@ public struct TestableConfiguration: Codable {
: .fake(.text)
]) { (_, new) in new }
// 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"]
@ -80,22 +100,20 @@ public struct TestableConfiguration: Codable {
self.filesystem["/opt/homebrew/bin/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php")
self.filesystem["/opt/homebrew/bin/php-config"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)/bin/php-config")
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.short)/bin/php-config")
self.commandOutput["/opt/homebrew/bin/php-config --version"]
= version.long
self.commandOutput["/opt/homebrew/bin/php -r echo php_ini_scanned_files();"] =
"""
/opt/homebrew/etc/php/\(version.short)/conf.d/php-memory-limits.ini,
"""
} else {
// Output expected to be present for non-linked PHP versions
self.shellOutput["ls /opt/homebrew/opt | grep php@"] =
BatchFakeShellOutput.instant(
self.secondaryPhpVersions
.map { "php@\($0.short)" }
.joined(separator: "\n")
)
BatchFakeShellOutput.instant(
self.secondaryPhpVersions
.map { "php@\($0.short)" }
.joined(separator: "\n")
)
}
}
// 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

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

View File

@ -46,8 +46,10 @@ extension App {
}
hotkey.keyDownHandler = {
MainMenu.shared.statusItem.button?.performClick(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
Task { @MainActor in
MainMenu.shared.statusItem.button?.performClick(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
}

View File

@ -74,11 +74,17 @@ class App {
/** The window controller of the onboarding window. */
var onboardingWindowController: OnboardingWindowController?
/** The window controller of the warnings window. */
var warningsWindowController: WarningsWindowController?
/** The window controller of the config manager window. */
var phpConfigManagerWindowController: PhpConfigManagerWindowController?
/** The window controller of the warnings window. */
var versionManagerWindowController: PhpVersionManagerWindowController?
var phpDoctorWindowController: PhpDoctorWindowController?
/** 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] = []
@ -86,9 +92,6 @@ class App {
/** The warning manager, responsible for keeping track of warnings. */
var warnings = WarningManager.shared
/** The filesystem watchers, responsible for keeping track of changes to the PHP installation. */
var watchers: [FSNotifier.Kind: FSNotifier] = [:]
/** Timer that will periodically reload info about the user's PHP installation. */
var timer: Timer?
@ -117,8 +120,12 @@ class App {
// MARK: - App Watchers
/**
The `PhpConfigWatcher` is responsible for watching the `.ini` files and the `.conf.d` folder.
/** Individual filesystem watchers, which are, i.e. responsible for watching the Homebrew folders. */
var watchers: [String: FSNotifier] = [:]
/**
The `ConfigWatchManager` is responsible for watching the `.ini` files and the `.conf.d` folder.
This manager object can immediately start or stop all watchers (or pause them) all at once.
*/
var watcher: PhpConfigWatcher!
var watchManager: ConfigWatchManager!
}

View File

@ -11,6 +11,10 @@ import UserNotifications
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
static var instance: AppDelegate {
return NSApplication.shared.delegate as! AppDelegate
}
// MARK: - Variables
/**
@ -19,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.
@ -84,13 +82,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
Log.info("Extra CLI mode is on (`~/.config/phpmon/verbose` exists).")
}
Log.separator(as: .info)
Log.info("PHP MONITOR by Nico Verbruggen")
Log.info("Version \(App.version)")
Log.separator(as: .info)
if !isRunningSwiftUIPreview {
Log.separator(as: .info)
Log.info("PHP MONITOR by Nico Verbruggen")
Log.info("Version \(App.version)")
Log.separator(as: .info)
}
self.state = App.shared
self.menu = MainMenu.shared
self.paths = Paths.shared
self.valet = Valet.shared
self.brew = Brew.shared
@ -103,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()
}
@ -114,11 +116,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
startup procedure.
*/
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Prevent previews from kicking off a costly boot
if isRunningSwiftUIPreview {
return
}
// Make sure notifications will work
setupNotifications()
Task { // Make sure the menu performs its initial checks
await menu.startup()
await MainMenu.shared.startup()
}
}
// MARK: - Menu Items
@IBOutlet weak var menuItemSites: NSMenuItem!
/**
Ensure relevant menu items in the main menu bar (not the pop-up menu)
are disabled or hidden when needed.
*/
public func configureMenuItems(standalone: Bool) {
if standalone {
menuItemSites.isHidden = true
}
}
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="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"/>
@ -34,18 +34,6 @@
</items>
</menu>
</menuItem>
<menuItem title="File" id="XRy-v5-KNb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" id="zA7-mh-f1x">
<items>
<menuItem title="Close" keyEquivalent="w" id="2FI-pQ-tuO">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="ZHq-so-Sba"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Sites" id="9gy-d3-Pos">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Sites" id="YTZ-bb-TOG">
@ -82,12 +70,12 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="8Pm-83-BlM">
<items>
<menuItem title="Undo" keyEquivalent="z" id="jCt-Yf-FSE">
<menuItem title="Undo" enabled="NO" keyEquivalent="z" id="jCt-Yf-FSE">
<connections>
<action selector="undo:" target="Ady-hI-5gd" id="O3z-27-Ug0"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="fCh-1M-Qyg">
<menuItem title="Redo" enabled="NO" keyEquivalent="Z" id="fCh-1M-Qyg">
<connections>
<action selector="redo:" target="Ady-hI-5gd" id="utE-Bv-fdY"/>
</connections>
@ -297,6 +285,18 @@
</items>
</menu>
</menuItem>
<menuItem title="Window" id="XRy-v5-KNb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" id="zA7-mh-f1x">
<items>
<menuItem title="Close" keyEquivalent="w" id="2FI-pQ-tuO">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="ZHq-so-Sba"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
@ -317,7 +317,11 @@
</application>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
<connections>
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
</connections>
</customObject>
</objects>
<point key="canvasLocation" x="-360" y="-94"/>
</scene>
@ -517,9 +521,6 @@
<subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="8zu-cF-KCX">
<rect key="frame" x="383" y="13" width="104" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="4Uf-fh-jWJ"/>
</constraints>
<buttonCell key="cell" type="push" title="Primary" bezelStyle="rounded" alignment="center" borderStyle="border" inset="2" id="F26-vf-hFH">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@ -527,15 +528,15 @@
DQ
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="4Uf-fh-jWJ"/>
</constraints>
<connections>
<action selector="primaryButtonAction:" target="hkw-9V-NxP" id="W7d-3b-pZT"/>
</connections>
</button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="TCp-nS-HN2">
<rect key="frame" x="281" y="13" width="104" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="QWZ-BA-0g9"/>
</constraints>
<buttonCell key="cell" type="push" title="Secondary" bezelStyle="rounded" alignment="center" borderStyle="border" inset="2" id="eCk-FC-9Zr">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@ -543,6 +544,9 @@ DQ
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="QWZ-BA-0g9"/>
</constraints>
<connections>
<action selector="secondaryButtonAction:" target="hkw-9V-NxP" id="YJs-Hu-lFP"/>
</connections>
@ -681,9 +685,6 @@ DQ
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SwS-o8-pbl">
<rect key="frame" x="13" y="13" width="114" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="qCP-Sp-gxm"/>
</constraints>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="WHE-HW-jwp">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@ -691,6 +692,9 @@ DQ
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="qCP-Sp-gxm"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
</connections>
@ -933,13 +937,13 @@ Gw
<subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZXQ-bg-Xba">
<rect key="frame" x="27" y="18" width="70" height="18"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="70" id="MBa-bB-DTB"/>
</constraints>
<buttonCell key="cell" type="inline" title="PHP X.X" bezelStyle="inline" alignment="center" borderStyle="border" inset="2" id="Tfk-YR-L4B">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="smallSystemBold"/>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="70" id="MBa-bB-DTB"/>
</constraints>
<connections>
<action selector="pressedPhpVersion:" target="T49-0U-d58" id="jVO-TS-F6d"/>
</connections>
@ -969,7 +973,7 @@ Gw
</tableCellView>
</prototypeCellViews>
</tableColumn>
<tableColumn identifier="KIND" width="36" minWidth="36" maxWidth="36" id="7EV-ZL-92u">
<tableColumn identifier="KIND" width="50" minWidth="50" maxWidth="120" id="7EV-ZL-92u">
<tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" title="Kind">
<color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
@ -983,11 +987,11 @@ Gw
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
<prototypeCellViews>
<tableCellView identifier="domainListKindCell" wantsLayer="YES" id="AhT-xR-16a" customClass="DomainListKindCell" customModule="PHP_Monitor" customModuleProvider="target">
<rect key="frame" x="403" y="0.0" width="36" height="54"/>
<rect key="frame" x="403" y="0.0" width="50" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="sYR-vb-OW1">
<rect key="frame" x="9" y="18" width="18" height="18"/>
<rect key="frame" x="16" y="18" width="18" height="18"/>
<constraints>
<constraint firstAttribute="width" constant="18" id="XcB-uw-szU"/>
<constraint firstAttribute="height" constant="18" id="bGN-Vh-Sh0"/>
@ -1020,7 +1024,7 @@ Gw
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
<prototypeCellViews>
<tableCellView identifier="domainListTypeCell" wantsLayer="YES" id="ntU-Rl-ciP" customClass="DomainListTypeCell" customModule="PHP_Monitor" customModuleProvider="target">
<rect key="frame" x="456" y="0.0" width="97" height="54"/>
<rect key="frame" x="470" y="0.0" width="97" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key">
@ -1088,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"/>
@ -1099,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>
@ -1190,9 +1204,6 @@ DQ
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nC0-dk-QaF">
<rect key="frame" x="13" y="13" width="114" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="uCc-fF-wS2"/>
</constraints>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="D8g-GE-7TU">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@ -1200,6 +1211,9 @@ DQ
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="uCc-fF-wS2"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
</connections>
@ -1336,9 +1350,6 @@ Gw
<subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="FhN-AM-SkI">
<rect key="frame" x="13" y="13" width="114" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="Zhu-D8-cLK"/>
</constraints>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="LxP-t4-H2W">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@ -1346,6 +1357,9 @@ Gw
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="Zhu-D8-cLK"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="gOD-Gu-zDG" id="wMp-sM-0A4"/>
</connections>

View File

@ -23,13 +23,13 @@ class InterApp {
@MainActor static func getCommands() -> [InterApp.Action] { return [
InterApp.Action(command: "list", action: { _ in
DomainListVC.show()
if Valet.installed { DomainListVC.show() }
}),
InterApp.Action(command: "services/stop", action: { _ in
Task { MainMenu.shared.stopValetServices() }
if Valet.installed { Task { MainMenu.shared.stopValetServices() } }
}),
InterApp.Action(command: "services/restart/all", action: { _ in
Task { MainMenu.shared.restartValetServices() }
if Valet.installed { Task { MainMenu.shared.restartValetServices() } }
}),
InterApp.Action(command: "services/restart/nginx", action: { _ in
Task { MainMenu.shared.restartNginx() }
@ -47,7 +47,7 @@ class InterApp {
Task { MainMenu.shared.openGlobalComposerFolder() }
}),
InterApp.Action(command: "locate/valet", action: { _ in
Task { MainMenu.shared.openValetConfigFolder() }
if Valet.installed { Task { MainMenu.shared.openValetConfigFolder() } }
}),
InterApp.Action(command: "phpinfo", action: { _ in
Task { MainMenu.shared.openPhpInfo() }

View File

@ -46,22 +46,22 @@ class ServicesManager: ObservableObject {
public var statusMessage: String {
if self.services.isEmpty || !self.firstRunComplete {
return "Loading..."
return "phpman.services.loading".localized
}
let statuses = self.services[0...2].map { $0.status }
if statuses.contains(.missing) {
return "A key service is not installed."
return "phpman.services.not_installed".localized
}
if statuses.contains(.error) {
return "A key service is reporting an error state."
return "phpman.services.error".localized
}
if statuses.contains(.inactive) {
return "A key service is not running."
return "phpman.services.inactive".localized
}
return "All Valet services are OK."
return "phpman.services.all_ok".localized
}
public var statusColor: Color {

View File

@ -34,6 +34,10 @@ class ValetServicesManager: ServicesManager {
these two commands are executed concurrently.
*/
override func reloadServicesStatus() async {
if !Valet.installed {
return Log.info("Not reloading services because running in Standalone Mode.")
}
await withTaskGroup(of: [HomebrewService].self, body: { group in
// First, retrieve the status of the formulae that run as root
group.addTask {

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
@ -241,6 +241,20 @@ class Startup {
descriptionText: "startup.errors.which_alias_issue.desc".localized
),
// =================================================================================
// Determine that Laravel Herd is not running (may cause conflicts)
// =================================================================================
EnvironmentCheck(
command: {
return NSWorkspace.shared.runningApplications.contains(where: { app in
return app.bundleIdentifier == "de.beyondco.herd"
})
},
name: "Herd is not running",
titleText: "startup.errors.herd_running.title".localized,
subtitleText: "startup.errors.herd_running.subtitle".localized,
descriptionText: "startup.errors.herd_running.desc".localized
),
// =================================================================================
// Determine that Valet works correctly (no issues in platform detected)
// =================================================================================
EnvironmentCheck(

View File

@ -62,12 +62,13 @@ struct ComposerJson: Decodable {
public func getNotableDependencies() -> [String: String] {
var notable: [String: String] = [:]
var scan = Array(PhpFrameworks.DependencyList.keys)
scan.append("php")
let scan = Array(ProjectTypeDetection.CommonDependencyList.keys) +
Array(ProjectTypeDetection.SpecificDependencyList.keys) +
["php"]
scan.forEach { dependency in
if dependencies?[dependency] != nil {
notable[dependency] = dependencies![dependency]
if let resolvedDependency = dependencies?[dependency] {
notable[dependency] = resolvedDependency
}
}

View File

@ -28,8 +28,6 @@ import Foundation
}
PhpEnvironments.shared.isBusy = true
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
window = TerminalProgressWindowController.display(
title: "alert.composer_progress.title".localized,
@ -106,14 +104,11 @@ import Foundation
private func removeBusyStatus() {
PhpEnvironments.shared.isBusy = false
Task { @MainActor in
MainMenu.shared.updatePhpVersionInStatusBar()
}
}
// MARK: Alert
@MainActor private func presentMissingAlert() {
private func presentMissingAlert() {
BetterAlert()
.withInformation(
title: "alert.composer_missing.title".localized,

View File

@ -8,20 +8,20 @@
import Foundation
struct PhpFrameworks {
struct ProjectTypeDetection {
/**
This list should probably be reversed when checked, because some of these
will also require either `laravel/framework` or `symfony/symfony`.
This list is only checked if the specific dependency list doesn't report a match.
*/
public static let DependencyList = [
// COMMON FRAMEWORKS
public static let CommonDependencyList = [
"laravel/framework": "Laravel",
"symfony/symfony": "Symfony",
"laravel/lumen": "Lumen",
"laravel/lumen": "Lumen"
]
// VARIOUS CMS
/**
This list is checked first to see if a project dependency can be mapped to a certain project type.
*/
public static let SpecificDependencyList = [
"roots/bedrock": "Bedrock",
"cakephp/app": "CakePHP",
"craftcms/craft": "Craft",
@ -37,30 +37,8 @@ struct PhpFrameworks {
"johnpbloch/wordpress-core": "WordPress",
"zendframework/zendframework": "Zend",
"zendframework/zend-mvc": "Zend",
"typo3/cms-core": "Typo3"
// "magento/*": "Magento",
// "concrete5/*": "Concrete5",
// "contao/*": "Contao",
// "slim/*": "Slim",
]
public static let FileMapping: [String: [String]] = [
"Drupal": [
// Legacy installations
"/misc/drupal.js",
"/core/lib/Drupal.php",
// The default (new) installation w/ Composer puts the modules in /web
"/web/misc/drupal.js",
"/web/core/lib/Drupal.php"
],
"WordPress": [
"/wp-config.php",
"/wp-config-sample.php"
],
"Typo3": [
"/typo3",
"/public/typo3"
]
"typo3/cms-core": "Typo3",
"slim/slim": "Slim"
]
/**
@ -82,4 +60,25 @@ struct PhpFrameworks {
return nil
}
/**
File mapping is used as a fallback if neither specific nor framework matches could be done.
*/
public static let FileMapping: [String: [String]] = [
"Drupal": [
// Legacy installations
"/misc/drupal.js",
"/core/lib/Drupal.php",
// The default (new) installation w/ Composer puts the modules in /web
"/web/misc/drupal.js",
"/web/core/lib/Drupal.php"
],
"WordPress": [
"/wp-config.php",
"/wp-config-sample.php"
],
"Typo3": [
"/typo3",
"/public/typo3"
]
]
}

View File

@ -61,17 +61,25 @@ class BrewPermissionFixer {
? "php"
: "php@\(formula)"
let binaryPath = "\(Paths.optPath)/\(realFormula)/bin"
if isOwnedByRoot(path: binaryPath) {
let borked = DueOwnershipFormula(
formula: realFormula,
path: binaryPath
)
let binFolderOwned = isOwnedByRoot(path: "\(Paths.optPath)/\(realFormula)/bin")
let sbinFolderOwned = isOwnedByRoot(path: "\(Paths.optPath)/\(realFormula)/sbin")
if binFolderOwned || sbinFolderOwned {
Log.warn("\(formula) is owned by root")
broken.append(borked)
if binFolderOwned {
broken.append(DueOwnershipFormula(
formula: realFormula,
path: "\(Paths.optPath)/\(realFormula)/bin"
))
}
if sbinFolderOwned {
broken.append(DueOwnershipFormula(
formula: realFormula,
path: "\(Paths.optPath)/\(realFormula)/sbin"
))
}
}
}
}

View File

@ -8,16 +8,6 @@
import Foundation
class BrewFormulaeObservable: ObservableObject {
@Published var phpVersions: [BrewFormula] = []
var upgradeable: [BrewFormula] {
return phpVersions.filter { formula in
formula.hasUpgrade
}
}
}
class Brew {
static let shared = Brew()
@ -45,9 +35,11 @@ class Brew {
/// Each formula for each PHP version that can be installed.
public static let phpVersionFormulae = [
"8.4": "shivammathur/php/php@8.4",
"8.3": "php@8.3",
"8.2": "php@8.2",
"8.1": "php@8.1",
"8.0": "php@8.0",
"8.0": "shivammathur/php/php@8.0",
"7.4": "shivammathur/php/php@7.4",
"7.3": "shivammathur/php/php@7.3",
"7.2": "shivammathur/php/php@7.2",

View File

@ -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 {
struct BrewPhpFormula: Equatable {
/// Name of the formula.
let name: String
@ -21,16 +21,54 @@ struct BrewFormula {
/// 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
/// Whether the formula is currently installed.
var isInstalled: Bool {
return installedVersion != nil
}
init(
name: String,
displayName: String,
installedVersion: String?,
upgradeVersion: String?,
prerelease: Bool = false
) {
self.name = name
self.displayName = displayName
self.installedVersion = installedVersion
self.upgradeVersion = upgradeVersion
self.prerelease = prerelease
}
/// Whether the formula can be upgraded.
var hasUpgrade: Bool {
return upgradeVersion != nil
}
/// Whether this formula alias is different.
var hasUpgradedFormulaAlias: Bool {
return self.shortVersion == PhpEnvironments.homebrewBrewPhpAlias
&& PhpEnvironments.homebrewBrewPhpAlias != PhpEnvironments.brewPhpAlias
}
var unavailableAfterUpgrade: Bool {
if installedVersion == nil || upgradeVersion == nil {
return false
}
if let installed = try? VersionNumber.parse(self.installedVersion!),
let upgrade = try? VersionNumber.parse(self.upgradeVersion!) {
return upgrade.short != installed.short
}
return false
}
/// The associated Homebrew folder with this PHP formula.
var homebrewFolder: String {
let resolved = name
@ -43,7 +81,7 @@ struct BrewFormula {
/// The short version associated with this formula, if installed.
var shortVersion: String? {
guard let version = self.installedVersion else {
return nil
return self.displayName.replacingOccurrences(of: "PHP ", with: "")
}
return VersionNumber.make(from: version)?.short ?? nil
@ -64,6 +102,7 @@ struct BrewFormula {
return nil
}
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?.isHealthy ?? nil
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?
.isHealthy ?? nil
}
}

View File

@ -8,22 +8,23 @@
import Foundation
protocol HandlesBrewFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula]
protocol HandlesBrewPhpFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula]
func refreshPhpVersions(loadOutdated: Bool) async
}
extension HandlesBrewFormulae {
extension HandlesBrewPhpFormulae {
public func refreshPhpVersions(loadOutdated: Bool) async {
let items = await loadPhpVersions(loadOutdated: loadOutdated)
Task { @MainActor in
await PhpEnvironments.shared.determinePhpAlias()
Brew.shared.formulae.phpVersions = items
}
}
}
class BrewFormulaeHandler: HandlesBrewFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] {
class BrewPhpFormulaeHandler: HandlesBrewPhpFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula] {
var outdated: [OutdatedFormula]?
if loadOutdated {
@ -43,20 +44,22 @@ 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
}
return BrewFormula(
return BrewPhpFormula(
name: formula,
displayName: "PHP \(version)",
installedVersion: fullVersion,
upgradeVersion: upgradeVersion
upgradeVersion: upgradeVersion,
prerelease: Constants.ExperimentalPhpVersions.contains(version)
)
}.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,9 +43,32 @@ class InstallAndUpgradeCommand: BrewCommand {
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// Try to run all upgrade and installation operations
try await self.upgradePackages(onProgress)
try await self.installPackages(onProgress)
let progressTitle = "phpman.steps.wait".localized
onProgress(.create(
value: 0.2,
title: progressTitle,
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
})
// Make sure the tap is installed
try await self.checkPhpTap(onProgress)
if unavailable == nil {
// Try to run all upgrade and installation operations
try await self.upgradePackages(onProgress)
try await self.installPackages(onProgress)
} else {
// Simply upgrade `php` to the latest version
try await self.upgradeMainPhpFormula(unavailable!, onProgress)
await PhpEnvironments.shared.determinePhpAlias()
}
// Re-check the installed versions
await PhpEnvironments.detectPhpVersions()
@ -47,6 +80,27 @@ class InstallAndUpgradeCommand: BrewCommand {
await self.completedOperations(onProgress)
}
private func upgradeMainPhpFormula(
_ unavailable: BrewPhpFormula,
_ onProgress: @escaping (BrewCommandProgress) -> Void
) async throws {
// Determine which version was previously available (that will become unavailable)
guard let short = try? VersionNumber
.parse(unavailable.installedVersion!).short else {
return
}
// Upgrade the main formula
let command = """
export HOMEBREW_NO_INSTALL_CLEANUP=true; \
\(Paths.brew) upgrade php;
\(Paths.brew) install php@\(short);
"""
// Run the upgrade command
try await run(command, onProgress)
}
private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit
if self.upgrading.isEmpty {
@ -109,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()
@ -147,7 +178,7 @@ class InstallAndUpgradeCommand: BrewCommand {
// If a PHP version was active prior to running the operations, attempt to restore it
if let version = phpGuard.currentVersion {
await MainMenu.shared.switchToAnyPhpVersion(version, silently: true)
await MainMenu.shared.switchToPhpVersionAndWait(version, silently: true)
}
// Also rebuild the content of the main menu
@ -156,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

@ -11,21 +11,25 @@ import Foundation
class RemovePhpVersionCommand: BrewCommand {
let formula: String
let version: String
let phpGuard: PhpGuard
init(formula: String) {
self.version = formula
.replacingOccurrences(of: "php@", with: "")
.replacingOccurrences(of: "shivammathur/php/", with: "")
self.formula = formula
self.phpGuard = PhpGuard()
}
func getCommandTitle() -> String {
return "phpman.steps.removing".localized("PHP \(version)...")
}
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Removing PHP \(version)..."
onProgress(.create(
value: 0.2,
title: progressTitle,
description: "Please wait while Homebrew removes PHP \(version)..."
title: getCommandTitle(),
description: "phpman.steps.wait".localized
))
let command = """
@ -54,10 +58,17 @@ 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()
await MainMenu.shared.refreshActiveInstallation()
onProgress(.create(value: 1, title: progressTitle, description: "The operation has succeeded."))
if let version = phpGuard.currentVersion {
await MainMenu.shared.switchToPhpVersionAndWait(version, silently: true)
}
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

@ -141,7 +141,7 @@ class ValetSite: ValetListable {
self.determineDriverViaComposer()
if self.driver == nil {
self.driver = PhpFrameworks.detectFallbackDependency(self.absolutePath)
self.driver = ProjectTypeDetection.detectFallbackDependency(self.absolutePath)
}
}
@ -155,10 +155,16 @@ class ValetSite: ValetListable {
private func determineDriverViaComposer() {
self.driverDeterminedByComposer = true
PhpFrameworks.DependencyList.reversed().forEach { (key: String, value: String) in
if self.notableComposerDependencies.keys.contains(key) {
self.driver = value
}
for (key, value) in ProjectTypeDetection.SpecificDependencyList
where notableComposerDependencies.keys.contains(key) {
self.driver = value
return
}
for (key, value) in ProjectTypeDetection.CommonDependencyList
where notableComposerDependencies.keys.contains(key) {
self.driver = value
return
}
}

View File

@ -233,8 +233,14 @@ class Valet {
)
if let defaultPath = Valet.shared.config.defaultSite,
let site = ValetScanner.active.resolveSite(path: defaultPath) {
sites.insert(site, at: 0)
let defaultSite = ValetScanner.active.resolveSite(path: defaultPath) {
// Only insert the default site if it isn't already included in the list
if !sites.contains(where: { site in
site.absolutePath == defaultSite.absolutePath
&& site.name == defaultSite.name
}) {
sites.insert(defaultSite, at: 0)
}
}
Log.info("\(sites.count) sites & \(proxies.count) proxies have been scanned.")

View File

@ -273,14 +273,36 @@ extension MainMenu {
}
}
func switchToPhpVersionAndWait(_ version: String, silently: Bool = false) async {
if silently {
MainMenu.shared.shouldSwitchSilently = true
}
if !PhpEnvironments.shared.availablePhpVersions.contains(version) {
Log.warn("This PHP version is currently unavailable, not switching!")
return
}
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
PhpEnvironments.shared.currentInstall = ActivePhpInstallation()
App.shared.handlePhpConfigWatcher()
PhpEnvironments.shared.delegate?.switcherDidCompleteSwitch(to: version)
}
@objc func switchToPhpVersion(_ version: String) {
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
Task(priority: .userInitiated) { [unowned self] in
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)
@ -301,13 +323,12 @@ extension MainMenu {
*/
func switchToPhp(_ version: String) async {
Task { @MainActor [self] in
setBusyImage()
PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
}
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
await PhpEnvironments.switcher.performSwitch(to: version)

View File

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

View File

@ -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
@ -88,6 +79,9 @@ extension MainMenu {
// Load the global hotkey
App.shared.loadGlobalHotkey()
// Set up menu items
AppDelegate.instance.configureMenuItems(standalone: !Valet.installed)
if Valet.installed {
// Preload all sites
await Valet.shared.startPreloadingSites()
@ -102,9 +96,33 @@ extension MainMenu {
Valet.shared.notifyAboutUnsupportedTLD()
}
// Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
// Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// Post-launch stats and update check, but only if not running tests
await performPostLaunchActions()
// Check if the linked version has changed between launches of phpmon
PhpGuard().compareToLastGlobalVersion()
// We are ready!
PhpEnvironments.shared.isBusy = false
// Finally!
Log.info("PHP Monitor is ready to serve!")
// 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()
@ -118,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

@ -16,7 +16,9 @@ extension MainMenu {
nonisolated func switcherDidCompleteSwitch(to version: String) {
// Mark as no longer busy
PhpEnvironments.shared.isBusy = false
Task { @MainActor in
PhpEnvironments.shared.isBusy = false
}
Task { // Things to do after reloading domain list data
if Valet.installed {
@ -25,7 +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,13 +79,15 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func refreshActiveInstallation() {
if !PhpEnvironments.shared.isBusy {
PhpEnvironments.shared.currentInstall = ActivePhpInstallation.load()
updatePhpVersionInStatusBar()
refreshIcon()
rebuild()
} else {
Log.perf("Skipping version refresh due to busy status!")
}
}
/** Updates the icon (refresh icon) and rebuilds the menu. */
@available(*, deprecated, message: "Use the busy status instead")
@objc func updatePhpVersionInStatusBar() {
refreshIcon()
rebuild()
@ -139,7 +140,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func reloadPhpMonitorMenuInBackground() {
asyncExecution({
// This automatically reloads the menu
Log.info("Reloading information about the PHP installation (in the background)...")
Log.perf("Reloading information about the PHP installation (in the background)...")
}, behaviours: [
.setsBusyUI,
.reloadsPhpInstallation,
@ -150,13 +151,16 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
/** Refreshes the icon with the PHP version. */
@objc func refreshIcon() {
Task { @MainActor [self] in
if PhpEnvironments.shared.isBusy {
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
Log.perf("Refreshing icon: currently busy")
setStatusBar(image: NSImage.statusBarIcon)
} else {
Log.perf("Refreshing icon: no longer busy")
if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false {
// Static icon has been requested
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIconStatic"))!)
setStatusBar(image: NSImage.statusBarIconStatic)
} else {
// The dynamic icon has been requested
let long = Preferences.preferences[.fullPhpVersionDynamicIcon] as! Bool
@ -172,13 +176,6 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
}
}
/** Updates the icon to be displayed as busy. */
@objc func setBusyImage() {
Task { @MainActor [self] in
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!)
}
}
// MARK: - Menu Item Functionality
@objc func openAbout() {
@ -203,7 +200,11 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
}
@objc func openWarnings() {
WarningsWindowController.show()
PhpDoctorWindowController.show()
}
@objc func openConfigGUI() {
PhpConfigManagerWindowController.show()
}
@objc func openDomainList() {
@ -214,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)
}
@ -231,7 +236,13 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
func menuWillOpen(_ menu: NSMenu) {
// Make sure the shortcut key does not trigger this when the menu is open
App.shared.shortcutHotkey?.isPaused = true
Task { // Reload Homebrew services information asynchronously
// Exit early if Valet is not detected (i.e. standalone mode)
if !Valet.installed {
return
}
Task { // Reload Homebrew services information asynchronously, but only if Valet is enabled
await ServicesManager.shared.reloadServicesStatus()
}
}

View File

@ -12,7 +12,7 @@ import Cocoa
extension StatusMenu {
func addPhpVersionMenuItems() {
@MainActor func addPhpVersionMenuItems() {
if PhpEnvironments.phpInstall == nil {
addItem(HeaderView.asMenuItem(text: "⚠️ " + "mi_no_php_linked".localized, minimumWidth: 280))
addItems([
@ -34,7 +34,7 @@ extension StatusMenu {
))
}
func addPhpActionMenuItems() {
@MainActor func addPhpActionMenuItems() {
if PhpEnvironments.shared.isBusy {
addItem(NSMenuItem(title: "mi_busy".localized))
return
@ -54,7 +54,7 @@ extension StatusMenu {
self.addItem(NSMenuItem.separator())
}
func addServicesManagerMenuItem() {
@MainActor func addServicesManagerMenuItem() {
if PhpEnvironments.shared.isBusy {
return
}
@ -65,7 +65,7 @@ extension StatusMenu {
])
}
func addSwitchToPhpMenuItems() {
@MainActor func addSwitchToPhpMenuItems() {
var shortcutKey = 1
for index in (0..<PhpEnvironments.shared.availablePhpVersions.count) {
// Get the short and long version
@ -102,14 +102,14 @@ extension StatusMenu {
}
}
func addLiteModeMenuItem() {
@MainActor func addLiteModeMenuItem() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_lite_mode".localized, action: #selector(MainMenu.openLiteModeInfo))
])
}
func addPreferencesMenuItems() {
@MainActor func addPreferencesMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_preferences".localized,
@ -119,7 +119,7 @@ extension StatusMenu {
])
}
func addCoreMenuItems() {
@MainActor func addCoreMenuItems() {
addItems([
NSMenuItem.separator(),
NSMenuItem(title: "mi_about".localized,
@ -131,7 +131,7 @@ extension StatusMenu {
// MARK: - Valet
func addValetMenuItems() {
@MainActor func addValetMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_valet".localized),
NSMenuItem(title: "mi_valet_config".localized,
@ -146,12 +146,15 @@ extension StatusMenu {
// MARK: - PHP Configuration
func addConfigurationMenuItems() {
@MainActor func addConfigurationMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_configuration".localized),
NSMenuItem(title: "mi_php_version_manager".localized,
action: #selector(MainMenu.openPhpVersionManager),
keyEquivalent: "m"),
NSMenuItem(title: "mi_php_ext_manager".localized,
action: #selector(MainMenu.openPhpExtensionManager),
keyEquivalent: "e"),
NSMenuItem(title: "mi_php_config".localized,
action: #selector(MainMenu.openActiveConfigFolder),
keyEquivalent: "c"),
@ -166,7 +169,7 @@ extension StatusMenu {
// MARK: - Composer
func addComposerMenuItems() {
@MainActor func addComposerMenuItems() {
addItems([
HeaderView.asMenuItem(text: "mi_composer".localized),
NSMenuItem(
@ -187,7 +190,7 @@ extension StatusMenu {
// MARK: - Stats
func addStatsMenuItem() {
@MainActor func addStatsMenuItem() {
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing stats menu item if no PHP version is linked.")
return
@ -204,7 +207,7 @@ extension StatusMenu {
// MARK: - Extensions
func addExtensionsMenuItems() {
@MainActor func addExtensionsMenuItems() {
guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing extensions menu items if no PHP version is linked.")
return
@ -225,7 +228,7 @@ extension StatusMenu {
// MARK: - Presets
func addPresetsMenuItem() {
@MainActor func addPresetsMenuItem() {
guard let presets = Preferences.custom.presets else {
addEmptyPresetHelp()
return
@ -239,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(),
@ -248,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)
@ -263,7 +266,7 @@ extension StatusMenu {
// MARK: - Xdebug
func addXdebugMenuItem() {
@MainActor func addXdebugMenuItem() {
if !Xdebug.enabled {
addItem(NSMenuItem.separator())
return
@ -283,7 +286,7 @@ extension StatusMenu {
// MARK: - PHP Doctor
func addPhpDoctorMenuItem() {
@MainActor func addPhpDoctorMenuItem() {
if !Preferences.isEnabled(.showPhpDoctorSuggestions) ||
!WarningManager.shared.hasWarnings() {
return
@ -299,7 +302,7 @@ extension StatusMenu {
// MARK: - First Aid & Services
func addFirstAidAndServicesMenuItems() {
@MainActor func addFirstAidAndServicesMenuItems() {
let services = NSMenuItem(title: "mi_other".localized)
var items: [NSMenuItem] = [
@ -356,7 +359,7 @@ extension StatusMenu {
// MARK: - Other helper methods to generate menu items
func addExtensionItem(_ phpExtension: PhpExtension, _ shortcutKey: Int) {
@MainActor func addExtensionItem(_ phpExtension: PhpExtension, _ shortcutKey: Int) {
let keyEquivalent = shortcutKey < 9 ? "\(shortcutKey)" : ""
let menuItem = ExtensionMenuItem(

View File

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

View File

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

@ -65,9 +65,11 @@ class PreferencesWindowController: PMWindowController {
App.shared.preferencesWindowController?.showWindow(self)
if justCreated {
App.shared.preferencesWindowController?.positionWindowInTopLeftCorner()
App.shared.preferencesWindowController?.positionWindowInTopRightCorner()
}
App.shared.preferencesWindowController?.window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}
@ -83,22 +85,22 @@ class PreferencesWindowController: PMWindowController {
return [
PrefTabView(
viewController: GeneralPreferencesVC.fromStoryboard(),
label: "General",
label: "prefs.tabs.general".localized,
icon: "gearshape"
),
PrefTabView(
viewController: AppearancePreferencesVC.fromStoryboard(),
label: "Appearance",
label: "prefs.tabs.appearance".localized,
icon: "paintbrush"
),
PrefTabView(
viewController: MenuStructurePreferencesVC.fromStoryboard(),
label: "Visibility",
label: "prefs.tabs.visibility".localized,
icon: "eye"
),
PrefTabView(
viewController: NotificationPreferencesVC.fromStoryboard(),
label: "Notifications",
label: "prefs.tabs.notifications".localized,
icon: "bell.badge"
)
]

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="19529" 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="19529"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -13,7 +13,7 @@
<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">
<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"/>
@ -21,7 +21,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="29" width="154" height="16"/>
<constraints>
<constraint firstAttribute="width" constant="150" id="euj-t0-xv4"/>
@ -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" 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

@ -18,5 +18,6 @@ struct SectionHeaderView: View {
.fontWeight(.medium)
.foregroundColor(.appSecondary)
.background(Color.debug)
.minimumScaleFactor(0.8)
}
}

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

@ -29,38 +29,80 @@ struct StatsView: View {
@State var maxPostSize: String
@State var maxUploadSize: String
init(memoryLimit: String, maxPostSize: String, maxUploadSize: String) {
self.memoryLimit = memoryLimit
self.maxPostSize = maxPostSize
self.maxUploadSize = maxUploadSize
}
public func hasErrorState() -> Bool {
return self.memoryLimit == "⚠️"
&& self.maxPostSize == "⚠️"
&& self.maxUploadSize == "⚠️"
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 30) {
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_memory_limit".localized.uppercased())
Text(memoryLimit)
.fontWeight(.medium)
if self.hasErrorState() {
HStack {
Text("⚠️")
.frame(maxWidth: 20, alignment: .center)
.font(.system(size: 16))
VStack {
Text("warnings.limits_error.title".localizedForSwiftUI)
.frame(maxWidth: .infinity, alignment: .center)
.font(.system(size: 11))
Text("warnings.limits_error.steps".localizedForSwiftUI)
.frame(maxWidth: .infinity, alignment: .center)
.font(.system(size: 11))
}
}
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_post_max_size".localized.uppercased())
Text(maxPostSize)
.fontWeight(.medium)
.font(.system(size: 16))
}
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_upload_max_filesize".localized.uppercased())
Text(maxUploadSize)
.fontWeight(.medium)
.font(.system(size: 16))
.padding(10)
.padding(.leading, 30)
.padding(.trailing, 30)
} else {
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_memory_limit".localized.uppercased())
Text(memoryLimit)
.fontWeight(.medium)
.font(.system(size: 16))
}
Divider()
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_post_max_size".localized.uppercased())
Text(maxPostSize)
.fontWeight(.medium)
.font(.system(size: 16))
}
Divider()
VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_upload_max_filesize".localized.uppercased())
Text(maxUploadSize)
.fontWeight(.medium)
.font(.system(size: 16))
}
Divider().hidden()
Button {
Task { @MainActor in
MainMenu.shared.openConfigGUI()
}
} label: {
Image(systemName: "gearshape.fill")
}
.accessibility(identifier: "phpConfigButton")
.focusable(false)
.frame(minWidth: 30, alignment: .center)
}
.padding(5)
.background(Color.debug)
}
.padding(10)
.background(Color.debug)
}
}
struct StatsView_Previews: PreviewProvider {
static var previews: some View {
StatsView(
memoryLimit: "1024 MB",
maxPostSize: "1024 MB",
maxUploadSize: "1024 MB"
)
}
#Preview {
StatsView(
memoryLimit: "1024 MB",
maxPostSize: "1024 MB",
maxUploadSize: "1024 MB"
).frame(height: 100)
}

View File

@ -1,396 +0,0 @@
//
// PhpFormulaeView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/03/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
import SwiftUI
// swiftlint:disable type_body_length
struct PhpFormulaeView: View {
@ObservedObject var formulae: BrewFormulaeObservable
@ObservedObject var status: PhpFormulaeStatus
var handler: HandlesBrewFormulae
init(
formulae: BrewFormulaeObservable,
handler: HandlesBrewFormulae
) {
self.formulae = formulae
self.handler = handler
self.status = PhpFormulaeStatus(
busy: true,
title: "phpman.busy.title".localized,
description: "phpman.busy.description.outdated".localized
)
Task { [self] in
await self.initialLoad()
}
}
private func initialLoad() async {
guard let version = Brew.shared.version else {
return
}
await delay(seconds: 1)
if version.major != 4 {
Task { @MainActor in
self.presentErrorAlert(
title: "phpman.warnings.unsupported.title".localized,
description: "phpman.warnings.unsupported.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)
self.status.busy = false
}
private func reload() async {
Task { @MainActor in
self.status.busy = true
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
}
}
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)
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)
} 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)
}
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 {
Image(systemName: formula.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(formula.iconColor)
.padding(.horizontal, 5)
VStack(alignment: .leading, spacing: 2) {
Text(formula.displayName).bold()
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) }
}
}
}
.listRowBackground(index % 2 == 0
? Color.gray.opacity(0)
: Color.gray.opacity(0.08)
)
.padding(.vertical, 10)
}
}
}.frame(width: 600, height: 600)
}
public func runCommand(_ command: InstallAndUpgradeCommand) async {
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 {
self.setBusyStatus(false)
await self.handler.refreshPhpVersions(loadOutdated: 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(InstallAndUpgradeCommand(
title: "Repairing installations...",
upgrading: [],
installing: []
))
}
public func upgradeAll(_ formulae: [BrewFormula]) async {
await self.runCommand(InstallAndUpgradeCommand(
title: "Installing updates...",
upgrading: formulae,
installing: []
))
}
public func install(_ formula: BrewFormula) async {
await self.runCommand(InstallAndUpgradeCommand(
title: "Installing \(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.versionManagerWindowController!.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: BrewFormula) 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) --force"
),
button: "generic.ok".localized
)
}
}
public func setBusyStatus(_ busy: Bool) {
PhpEnvironments.shared.isBusy = busy
if busy {
Task { @MainActor in
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
self.status.busy = busy
}
} else {
Task { @MainActor in
MainMenu.shared.updatePhpVersionInStatusBar()
self.status.busy = busy
}
}
}
public func presentErrorAlert(
title: String,
description: String,
button: String,
style: NSAlert.Style = .critical
) {
Alert.confirm(
onWindow: App.shared.versionManagerWindowController!.window!,
messageText: title,
informativeText: description,
buttonTitle: button,
secondButtonTitle: "",
style: style,
onFirstButtonPressed: {}
)
}
var hasUpdates: Bool {
return self.formulae.phpVersions.contains { formula in
return formula.hasUpgrade
}
}
}
// swiftlint:enable type_body_length
struct PhpFormulaeView_Previews: PreviewProvider {
static var previews: some View {
PhpFormulaeView(
formulae: Brew.shared.formulae,
handler: FakeBrewFormulaeHandler()
).frame(width: 600, height: 600)
}
}
class FakeBrewFormulaeHandler: HandlesBrewFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] {
return [
BrewFormula(
name: "php",
displayName: "PHP 8.2",
installedVersion: "8.2.3",
upgradeVersion: "8.2.4"
),
BrewFormula(
name: "php@8.1",
displayName: "PHP 8.1",
installedVersion: "8.1.17",
upgradeVersion: nil
),
BrewFormula(
name: "php@8.0",
displayName: "PHP 8.0",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
name: "php@7.4",
displayName: "PHP 7.4",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
name: "php@7.3",
displayName: "PHP 7.3",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
name: "php@7.2",
displayName: "PHP 7.2",
installedVersion: nil,
upgradeVersion: nil
),
BrewFormula(
name: "php@7.1",
displayName: "PHP 7.1",
installedVersion: nil,
upgradeVersion: nil
)
]
}
}

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.positionWindowInTopLeftCorner()
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

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>

View File

@ -20,7 +20,7 @@ class TerminalProgressWindowController: NSWindowController, NSWindowDelegate {
windowController.showWindow(windowController)
windowController.window?.makeKeyAndOrderFront(nil)
windowController.positionWindowInTopLeftCorner()
windowController.positionWindowInTopRightCorner()
windowController.progressView?.labelTitle.stringValue = title
windowController.progressView?.labelDescription.stringValue = description

View File

@ -17,13 +17,13 @@ extension App {
onChange: { Task { await self.onHomebrewPhpModification() } }
)
App.shared.watchers[.homebrewBinaries] = notifier
App.shared.watchers["homebrewBinaries"] = notifier
}
public func destroyHomebrewWatchers() {
// Removing requires termination and then removing reference
self.watchers[.homebrewBinaries]?.terminate()
self.watchers[.homebrewBinaries] = nil
self.watchers["homebrewBinaries"]?.terminate()
self.watchers["homebrewBinaries"] = nil
}
public func onHomebrewPhpModification() async {
@ -31,10 +31,13 @@ extension App {
Log.info("Something changed in the Homebrew binary directory...")
await PhpEnvironments.detectPhpVersions()
await MainMenu.shared.refreshActiveInstallation()
// let new = PhpEnvironments.shared.currentInstall?.version.text
// TODO:
// Check if the new and previous version are different
// if so, we can show a notification if needed
//
// TODO: PHP Guard 2.0
// Check if the new and previous version of PHP are different
// if so, we can show a notification if needed or alert the user
//
// let new = PhpEnvironments.shared.currentInstall?.version.text
//
}
}

View File

@ -10,52 +10,52 @@ import Foundation
extension App {
func startWatcher(_ url: URL) {
Log.perf("No watcher currently active...")
self.watcher = PhpConfigWatcher(for: url)
func startWatchManager(_ url: URL) {
Log.perf("Starting config watch manager...")
self.watchManager = ConfigWatchManager(for: url)
self.watcher.didChange = { url in
self.watchManager.didChange = { url in
Log.perf("Something has changed in: \(url)")
// Check if the watcher has last updated the menu less than 0.75s ago
let distance = self.watcher.lastUpdate?.distance(to: Date().timeIntervalSince1970)
let distance = self.watchManager.lastUpdate?.distance(to: Date().timeIntervalSince1970)
if distance == nil || distance != nil && distance! > 0.75 {
Log.perf("Refreshing menu...")
Task { @MainActor in MainMenu.shared.reloadPhpMonitorMenuInBackground() }
self.watcher.lastUpdate = Date().timeIntervalSince1970
self.watchManager.lastUpdate = Date().timeIntervalSince1970
}
}
}
func handlePhpConfigWatcher(forceReload: Bool = false) {
if ActiveFileSystem.shared is TestableFileSystem {
Log.warn("FS watcher is disabled when using testable filesystem.")
Log.warn("Config watch manager is disabled when using testable filesystem.")
return
}
guard let install = PhpEnvironments.phpInstall else {
Log.info("It appears as if no PHP installation is currently active.")
Log.info("The FS watcher will be disabled until a PHP install is active.")
Log.info("The config watch manager be disabled until a PHP install is active.")
return
}
let url = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(install.version.short)")
// Check whether the watcher exists and schedule on the main thread
// Check whether the manager exists and schedule on the main thread
// if we don't consistently do this, the app will create duplicate watchers
// due to timing issues, which creates retain cycles.
// due to timing issues, which creates retain cycles
Task { @MainActor in
// Watcher needs to be created
if self.watcher == nil {
self.startWatcher(url)
if self.watchManager == nil {
self.startWatchManager(url)
}
// Watcher needs to be updated
if self.watcher.url != url || forceReload {
self.watcher.disable()
self.watcher = nil
if self.watchManager.url != url || forceReload {
self.watchManager.disable()
self.watchManager = nil
Log.perf("Watcher has stopped watching files. Starting new one...")
self.startWatcher(url)
self.startWatchManager(url)
}
}
}

View File

@ -0,0 +1,76 @@
//
// ConfigFSNotifier.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 24/10/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class ConfigFSNotifier {
enum Behaviour {
case reloadsMenu
case reloadsWatchers
}
private var parent: ConfigWatchManager!
private var monitoredFolderFileDescriptor: CInt = -1
private var folderMonitorSource: DispatchSourceFileSystemObject?
let url: URL
init(
for url: URL,
eventMask: DispatchSource.FileSystemEvent,
parent: ConfigWatchManager,
behaviour: ConfigFSNotifier.Behaviour = .reloadsMenu
) {
self.url = url
self.parent = parent
self.startMonitoring(eventMask, behaviour: behaviour)
}
func startMonitoring(
_ eventMask: DispatchSource.FileSystemEvent,
behaviour: ConfigFSNotifier.Behaviour
) {
guard folderMonitorSource == nil && monitoredFolderFileDescriptor == -1 else {
return
}
monitoredFolderFileDescriptor = open(url.path, O_EVTONLY)
folderMonitorSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: monitoredFolderFileDescriptor,
eventMask: eventMask,
queue: parent.folderMonitorQueue
)
folderMonitorSource?.setEventHandler { [weak self] in
if behaviour == .reloadsWatchers
&& !ConfigWatchManager.ignoresModificationsToConfigValues {
// Reload all configuration watchers
return App.shared.handlePhpConfigWatcher(forceReload: true)
}
self?.parent.didChange?(self!.url)
}
folderMonitorSource?.setCancelHandler { [weak self] in
guard let self = self else { return }
close(self.monitoredFolderFileDescriptor)
self.monitoredFolderFileDescriptor = -1
self.folderMonitorSource = nil
}
folderMonitorSource?.resume()
}
func stopMonitoring() {
folderMonitorSource?.cancel()
self.parent = nil
}
}

View File

@ -0,0 +1,82 @@
//
// ConfigWatchManager.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 30/03/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class ConfigWatchManager {
static var ignoresModificationsToConfigValues: Bool = false
let folderMonitorQueue = DispatchQueue(label: "FolderMonitorQueue", attributes: .concurrent)
let url: URL
var didChange: ((URL) -> Void)?
var lastUpdate: TimeInterval?
var watchers: [ConfigFSNotifier] = []
init(for url: URL) {
if FileSystem is TestableFileSystem {
fatalError("""
ConfigWatchManager is currently incompatible with a testable filesystem!"
You are not allowed to instantiate these while using a testable filesystem.
""")
}
self.url = url
// Add a watcher for php.ini
self.addWatcher(for: self.url.appendingPathComponent("php.ini"), eventMask: .write)
// Add a watcher for conf.d (in case a new file is added or a file is deleted)
// This watcher, when triggered, will restart all watchers
self.addWatcher(for: self.url.appendingPathComponent("conf.d"), eventMask: .all, behaviour: .reloadsWatchers)
// Scan the conf.d folder for .ini files, and add a watcher for each file
let filePaths = FileManager.default.enumerator(
atPath: self.url.appendingPathComponent("conf.d").path
)?.allObjects as! [String]
// Loop over the .ini files that we discovered
filePaths.filter { $0.contains(".ini") }.forEach { (file) in
// Add a watcher for each file we have discovered
self.addWatcher(for: self.url.appendingPathComponent("conf.d/\(file)"), eventMask: .write)
}
Log.perf("A watcher exists for the following config paths:")
Log.perf(self.watchers.map({ watcher in
return watcher.url.relativePath
}))
}
func addWatcher(
for url: URL,
eventMask: DispatchSource.FileSystemEvent,
behaviour: ConfigFSNotifier.Behaviour = .reloadsMenu
) {
if !FileSystem.anyExists(url.path) {
Log.warn("No watcher was created for \(url.path) because the requested file does not exist.")
return
}
let watcher = ConfigFSNotifier(for: url, eventMask: eventMask, parent: self, behaviour: behaviour)
self.watchers.append(watcher)
}
func disable() {
Log.perf("Turning off all individual existing watchers...")
self.watchers.forEach { (watcher) in
watcher.stopMonitoring()
}
}
deinit {
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
}

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