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

Compare commits

...

85 Commits

Author SHA1 Message Date
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
3612351df7 📝 Update README to reflect php = PHP 8.3 2024-01-09 20:59:52 +01:00
8e912151fb Allow toggling extensions via site list 2024-01-09 20:55:16 +01:00
3a2209e604 🌐 Add translations for language choice 2024-01-09 20:06:40 +01:00
1f0b56cab6 Language choice, prompt user to restart app 2024-01-09 20:01:08 +01:00
e08d970edd 🏗️ WIP: Load preferred language strings 2024-01-09 19:44:29 +01:00
32c757e711 🏗️ WIP: Language override option 2024-01-09 19:28:39 +01:00
480cdb94ae 🏗️ WIP: Language picker, fix SelectPreferenceView 2024-01-09 18:58:02 +01:00
7fbcac5dc2 👌 Check symlinks after PHP version modification 2023-12-27 14:47:21 +01:00
4edb5f5015 👌 Use generated asset catalog symbol extensions 2023-12-27 13:03:24 +01:00
294f84ccb2 👌 Further cleanup 2023-12-27 12:57:08 +01:00
155b57eb9e 🐛 Add incorrect PHP symlink purge (#270)
This commit introduces a new diagnostics feature which is executed
when the app boots. When the app boots, the integrity of the PHP
symlinks are checked to ensure that all symlinks correctly link to
a valid PHP version. If any links to an incorrect PHP version,
then those outdated or incorrect symlinks will be removed.

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

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

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

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

(Important: the `system()` helper is still required when determining
what the preferred terminal is during the initialization of the
`Paths` class.)
2023-11-26 21:26:48 +01:00
23cf575026 ⬆️ Upstream PHP 8.3 upgrade changes to v7.0 2023-11-24 22:57:33 +01:00
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
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
124 changed files with 3952 additions and 1310 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -112,13 +112,15 @@ All stable and supported PHP versions are also supported by PHP Monitor. However
Backports that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php). 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. For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet.
</details> </details>
<details> <details>
<summary><strong>How do I install additional versions of PHP, including legacy versions?</strong></summary> <summary><strong>How do I install additional versions of PHP, including legacy versions?</strong></summary>
Assuming you have installed the `php` formula, the latest stable version of PHP is installed. At the time of writing, this is PHP 8.2. Assuming you have installed the `php` formula, the latest stable version of PHP is installed. At the time of writing, this is PHP 8.3.
You can install other supported versions of PHP via PHP Monitor's **PHP Version Manager**. (You can manually install or upgrade PHP versions too, but this is not recommended.) You can install other supported versions of PHP via PHP Monitor's **PHP Version Manager**. (You can manually install or upgrade PHP versions too, but this is not recommended.)

View File

@ -6,7 +6,7 @@ Generally speaking, only the latest version of **PHP Monitor** is supported, exc
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version | | Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ---- | ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 6.1 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum | | 6.2 | ✅ Universal binary | ✅ Yes | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
## Legacy versions ## Legacy versions
@ -14,6 +14,7 @@ These versions of PHP Monitor are no longer supported, but if youre using an
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version | | Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ---- | ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 6.1 | ✅ Universal binary | ❌ | Monterey (12.4+)<br/>Ventura (13.0+)<br/>Sonoma (14.0) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
| 6.0 | ✅ Universal binary | ❌ | Monterey (12.4+)<br/>Ventura (13.0+) | macOS 12.4 | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum | | 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.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.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 |

View File

@ -14,8 +14,6 @@ class Actions {
public static func linkPhp() async { public static func linkPhp() async {
await brew("link php --overwrite --force") await brew("link php --overwrite --force")
// TODO: Verify that this worked, if not, notify the user
} }
public static func restartPhpFpm() async { public static func restartPhpFpm() async {

View File

@ -19,11 +19,40 @@ struct Constants {
static let MinimumRecommendedValetVersion = "2.16.2" static let MinimumRecommendedValetVersion = "2.16.2"
/** /**
* The PHP versions that are considered pre-release versions. PHP Monitor supplies a hardcoded list of PHP packages in its own
PHP Version Manager.
This hardcoded list will expire and will need to be modified when
the cutoff date occurs, which is when the `php` formula will
become PHP 8.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 ExperimentalPhpVersions: Set = [ static let PhpFormulaeCutoffDate = "2024-11-01"
"8.3", "8.4"
] /**
* 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 PHP versions supported by this application. * The PHP versions supported by this application.
@ -32,8 +61,7 @@ struct Constants {
static let DetectedPhpVersions: Set = [ static let DetectedPhpVersions: Set = [
"5.6", "5.6",
"7.0", "7.1", "7.2", "7.3", "7.4", "7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2", "8.0", "8.1", "8.2", "8.3",
"8.3",
"8.4" "8.4"
] ]
@ -91,6 +119,8 @@ struct Constants {
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb" string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb"
)! )!
// EAP URLs
static let EarlyAccessCaskFile = URL( static let EarlyAccessCaskFile = URL(
string: "https://phpmon.app/builds/early-access/sponsors/phpmon-eap.rb" 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 internal var baseDir: Paths.HomebrewDir
private var userName: String private var userName: String
private var preferredShell: String
init() { init() {
// Assume the default directory is correct // Assume the default directory is correct
@ -31,9 +32,11 @@ public class Paths {
} }
userName = identity() userName = identity()
preferredShell = preferred_shell()
if !isRunningSwiftUIPreview { if !isRunningSwiftUIPreview {
Log.info("The current username is `\(userName)`.") Log.info("The current username is `\(userName)`.")
Log.info("The user's shell is `\(preferredShell)`.")
} }
} }
@ -99,11 +102,19 @@ public class Paths {
return "\(shared.baseDir.rawValue)/etc" return "\(shared.baseDir.rawValue)/etc"
} }
public static var tapPath: String {
return "\(shared.baseDir.rawValue)/Library/Taps"
}
public static var caskroomPath: String { public static var caskroomPath: String {
return "\(shared.baseDir.rawValue)/Caskroom/" return "\(shared.baseDir.rawValue)/Caskroom/"
+ (App.identifier.contains(".dev") ? "phpmon-dev" : "phpmon") + (App.identifier.contains(".dev") ? "phpmon-dev" : "phpmon")
} }
public static var shell: String {
return shared.preferredShell
}
// MARK: - Flexible Binaries // MARK: - Flexible Binaries
// (these can be in multiple locations, so we scan common places because) // (these can be in multiple locations, so we scan common places because)
// (PHP Monitor will not use the user's own PATH) // (PHP Monitor will not use the user's own PATH)

View File

@ -15,4 +15,10 @@ extension Date {
return dateFormatter.string(from: self) 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 import SwiftUI
struct Localization { 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 = { static var bundle: Bundle = {
if !isRunningTests { if !isRunningTests {
return Bundle.main return Bundle.main
@ -32,7 +44,15 @@ struct Localization {
extension String { extension String {
var localized: String { var localized: String {
let string = NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "") var preferredBundle: Bundle = Localization.bundle
if let preferred = Localization.preferredLanguage,
let path = Localization.bundle.path(forResource: preferred, ofType: "lproj"),
let bundle = Bundle(path: path) {
preferredBundle = bundle
}
let string = NSLocalizedString(self, tableName: nil, bundle: preferredBundle, value: "", comment: "")
// Fallback to English translation if the localized value is equal to the key (should not happen) // Fallback to English translation if the localized value is equal to the key (should not happen)
if string == self { if string == self {

View File

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

View File

@ -75,7 +75,7 @@ class MenuBarImageGenerator {
// Then we'll fetch the image we want on the left // Then we'll fetch the image we want on the left
var iconType = Preferences.preferences[.iconTypeToDisplay] as? String 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") Log.warn("Invalid icon type found, using the default")
iconType = MenuBarIcon.iconPhp.rawValue iconType = MenuBarIcon.iconPhp.rawValue
} }

View File

@ -37,7 +37,7 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
extension NSWindowController { 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 frame = NSScreen.main?.frame else { return }
guard let window = self.window 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. 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 { public func system(_ command: String) -> String {
let task = Process() let task = Process()
@ -65,3 +64,11 @@ public func identity() -> String {
return output.trimmingCharacters(in: .whitespacesAndNewlines) 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 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 // Get configuration values
limits = Limits( limits = Limits(
memory_limit: getByteCount(key: "memory_limit"), memory_limit: getByteCount(key: "memory_limit"),
@ -76,15 +69,10 @@ class ActivePhpInstallation {
post_max_size: getByteCount(key: "post_max_size") post_max_size: getByteCount(key: "post_max_size")
) )
// Return a list of .ini files parsed after php.ini let paths = ActiveShell.shared
let paths = Command.execute( .sync("\(Paths.php) --ini | grep -E -o '(/[^ ]+\\.ini)'").out
path: Paths.php, .split(separator: "\n")
arguments: ["-r", "echo php_ini_scanned_files();"], .map { String($0) }
trimNewlines: false
)
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
// See if any extensions are present in said .ini files // See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in paths.forEach { (iniFilePath) in

View File

@ -13,7 +13,7 @@ class PhpEnvironments {
// MARK: - Initializer // MARK: - Initializer
/** /**
Loads the currently active PHP installation upon startup. May be empty.
*/ */
init() { init() {
self.currentInstall = ActivePhpInstallation.load() self.currentInstall = ActivePhpInstallation.load()
@ -29,7 +29,7 @@ class PhpEnvironments {
/** /**
Determine which PHP version the `php` formula is aliased to. 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 let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
self.homebrewPackage = try! JSONDecoder().decode( self.homebrewPackage = try! JSONDecoder().decode(
@ -37,7 +37,27 @@ class PhpEnvironments {
from: brewPhpAlias.data(using: .utf8)! from: brewPhpAlias.data(using: .utf8)!
).first! ).first!
Log.info("[BREW] On your system, the `php` formula means version \(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 // MARK: - Properties
@ -49,12 +69,10 @@ class PhpEnvironments {
static let shared = PhpEnvironments() static let shared = PhpEnvironments()
/** Whether the switcher is busy performing any actions. */ /** Whether the switcher is busy performing any actions. */
var isBusy: Bool = false { @MainActor var isBusy: Bool = false {
didSet { didSet {
Task { @MainActor in MainMenu.shared.refreshIcon()
MainMenu.shared.setBusyImage() MainMenu.shared.rebuild()
MainMenu.shared.rebuild()
}
} }
} }
@ -68,7 +86,14 @@ class PhpEnvironments {
var cachedPhpInstallations: [String: PhpInstallation] = [:] var cachedPhpInstallations: [String: PhpInstallation] = [:]
/** Information about the currently linked PHP installation. */ /** 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. The version that the `php` formula via Brew is aliased to on the current system.
@ -79,7 +104,12 @@ class PhpEnvironments {
As such, we take that information from Homebrew. 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" } if PhpEnvironments.shared.homebrewPackage == nil { return "8.2" }
return PhpEnvironments.shared.homebrewPackage.version return PhpEnvironments.shared.homebrewPackage.version
@ -146,7 +176,12 @@ class PhpEnvironments {
// Avoid inserting a duplicate // Avoid inserting a duplicate
if !supportedVersions.contains(phpAlias) && FileSystem.fileExists("\(Paths.optPath)/php/bin/php") { 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) availablePhpVersions = Array(supportedVersions)

View File

@ -49,8 +49,10 @@ class PhpHelper {
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin") let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
.resolvingSymlinksInPath().path .resolvingSymlinksInPath().path
// The contents of the script! // Check if the user uses Fish
let script = script(path, keyPhrase, version, dotless) let script = Paths.shell.contains("/fish")
? fishScript(path, keyPhrase, version, dotless)
: zshScript(path, keyPhrase, version, dotless)
Task { @MainActor in Task { @MainActor in
try FileSystem.writeAtomicallyToFile(destination, content: script) try FileSystem.writeAtomicallyToFile(destination, content: script)
@ -78,7 +80,7 @@ class PhpHelper {
} }
} }
private static func script( private static func zshScript(
_ path: String, _ path: String,
_ keyPhrase: String, _ keyPhrase: String,
_ version: 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 { private static func createSymlink(_ dotless: String) async {
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)" let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
let destination = "/usr/local/bin/pm\(dotless)" let destination = "/usr/local/bin/pm\(dotless)"

View File

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

View File

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

View File

@ -12,19 +12,36 @@ class PhpInstallation {
var versionNumber: VersionNumber var versionNumber: VersionNumber
var iniFiles: [PhpConfigurationFile] = []
var isMissingBinary: Bool = false
var isHealthy: Bool = true var isHealthy: Bool = true
var extensions: [PhpExtension] {
return self.iniFiles.flatMap({ $0.extensions })
}
/** /**
In order to determine details about a PHP installation, In order to determine details about a PHP installation,
well simply run `php-config --version` in the relevant directory. well simply run `php-config --version` in the relevant directory.
*/ */
init(_ version: String) { 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) { if FileSystem.fileExists(phpConfigExecutablePath) {
let longVersionString = Command.execute( let longVersionString = Command.execute(
path: phpConfigExecutablePath, path: phpConfigExecutablePath,
@ -34,9 +51,15 @@ class PhpInstallation {
// The parser should always work, or the string has to be very unusual. // 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. // 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) { if FileSystem.fileExists(phpExecutablePath) {
let testCommand = Command.execute( let testCommand = Command.execute(
path: phpExecutablePath, path: phpExecutablePath,
@ -53,4 +76,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) return corrections.contains(true)
} }
// MARK: - PHP FPM pool // MARK: - Corrections
public func disableDefaultPhpFpmPool(_ version: String) async -> FixApplied { public func disableDefaultPhpFpmPool(_ version: String) async -> FixApplied {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf" let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
@ -54,37 +54,7 @@ extension InternalSwitcher {
return false return false
} }
func getExpectedConfigurationFiles(for version: String) -> [ExpectedConfigurationFile] { public func ensureConfigurationFilesExist(_ version: String) async -> FixApplied {
return [
ExpectedConfigurationFile(
destination: "/php-fpm.d/valet-fpm.conf",
source: "/cli/stubs/etc-phpfpm-valet.conf",
replacements: [
"VALET_USER": Paths.whoami,
"VALET_HOME_PATH": "~/.config/valet".replacingTildeWithHomeDirectory,
"valet.sock": "valet\(version.replacingOccurrences(of: ".", with: "")).sock"
],
applies: { Valet.shared.version!.major > 2 }
),
ExpectedConfigurationFile(
destination: "/conf.d/error_log.ini",
source: "/cli/stubs/etc-phpfpm-error_log.ini",
replacements: [
"VALET_USER": Paths.whoami,
"VALET_HOME_PATH": "~/.config/valet".replacingTildeWithHomeDirectory
],
applies: { return true }
),
ExpectedConfigurationFile(
destination: "/conf.d/php-memory-limits.ini",
source: "/cli/stubs/php-memory-limits.ini",
replacements: [:],
applies: { return true }
)
]
}
func ensureConfigurationFilesExist(_ version: String) async -> FixApplied {
let files = self.getExpectedConfigurationFiles(for: version) let files = self.getExpectedConfigurationFiles(for: version)
// For each of the files, attempt to fix anything that is wrong // For each of the files, attempt to fix anything that is wrong
@ -124,6 +94,38 @@ extension InternalSwitcher {
return outcomes.contains(true) 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 { public struct ExpectedConfigurationFile {

View File

@ -86,14 +86,37 @@ class RealShell: ShellProtocol {
// MARK: - Shellable Protocol // 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 { func pipe(_ command: String) async -> ShellOutput {
let task = getShellProcess(for: command) let task = getShellProcess(for: command)
let outputPipe = Pipe() let outputPipe = Pipe()
let errorPipe = 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 { if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
Log.info("[SLOW SHELL] \(command)") Log.info("[SLOW SHELL] \(command)")
await delay(seconds: 3.0) await delay(seconds: 3.0)
@ -104,20 +127,20 @@ class RealShell: ShellProtocol {
task.launch() task.launch()
task.waitUntilExit() task.waitUntilExit()
let stdOut = String( let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
data: outputPipe.fileHandleForReading.readDataToEndOfFile(), let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
encoding: .utf8
)!
let stdErr = String(
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)!
if Log.shared.verbosity == .cli { if Log.shared.verbosity == .cli {
var args = task.arguments ?? [] log(task: task, stdOut: stdOut, stdErr: stdErr)
let last = "\"" + (args.popLast() ?? "") + "\"" }
var log = """
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: " ")) $ \(([self.launchPath] + args + [last]).joined(separator: " "))
@ -126,22 +149,19 @@ class RealShell: ShellProtocol {
\(stdOut) \(stdOut)
""" """
if !stdErr.isEmpty { if !stdErr.isEmpty {
log.append(""" log.append("""
[ERR]: [ERR]:
\(stdErr) \(stdErr)
""") """)
} }
log.append(""" log.append("""
~~~~~~~~~~~~~~~~~~~~~~~~> ~~~~~~~~~~~~~~~~~~~~~~~~>
""") """)
Log.info(log) Log.info(log)
}
return .out(stdOut, stdErr)
} }
func quiet(_ command: String) async { func quiet(_ command: String) async {

View File

@ -14,6 +14,16 @@ protocol ShellProtocol {
*/ */
var PATH: String { get } 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. Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists). Returns the most relevant output (prefers error output if it exists).

View File

@ -1,5 +1,5 @@
// //
// PhpFormulaeStatus.swift // BusyStatus.swift
// PHP Monitor // PHP Monitor
// //
// Created by Nico Verbruggen on 02/05/2023. // Created by Nico Verbruggen on 02/05/2023.
@ -8,7 +8,7 @@
import Foundation import Foundation
class PhpFormulaeStatus: ObservableObject { class BusyStatus: ObservableObject {
@Published var busy: Bool @Published var busy: Bool
@Published var title: String @Published var title: String
@Published var description: String @Published var description: String
@ -18,4 +18,12 @@ class PhpFormulaeStatus: ObservableObject {
self.title = title self.title = title
self.description = description 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 primaryPhpVersion: VersionNumber?
private var secondaryPhpVersions: [VersionNumber] = [] private var secondaryPhpVersions: [VersionNumber] = []
// swiftlint:disable function_body_length
mutating func addPhpVersion(_ version: VersionNumber, primary: Bool) { mutating func addPhpVersion(_ version: VersionNumber, primary: Bool) {
if primary { if primary {
if primaryPhpVersion != nil { if primaryPhpVersion != nil {
@ -72,12 +73,26 @@ public struct TestableConfiguration: Codable {
: .fake(.text) : .fake(.text)
]) { (_, new) in new } ]) { (_, new) in new }
self.commandOutput["/opt/homebrew/opt/php@\(version.short)/bin/php-config --version"] // PHP configuration files
= version.long 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 { if primary {
self.shellOutput["ls /opt/homebrew/opt | grep php"] // Files expected to be present for currently linked PHP version
= .instant("php") 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"] self.filesystem["/opt/homebrew/opt/php"]
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)") = .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.long)")
self.filesystem["/opt/homebrew/opt/php/bin/php"] self.filesystem["/opt/homebrew/opt/php/bin/php"]
@ -88,12 +103,8 @@ public struct TestableConfiguration: Codable {
= .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.short)/bin/php-config") = .fake(.symlink, "/opt/homebrew/Cellar/php/\(version.short)/bin/php-config")
self.commandOutput["/opt/homebrew/bin/php-config --version"] self.commandOutput["/opt/homebrew/bin/php-config --version"]
= version.long = 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 { } else {
// Output expected to be present for non-linked PHP versions
self.shellOutput["ls /opt/homebrew/opt | grep php@"] = self.shellOutput["ls /opt/homebrew/opt | grep php@"] =
BatchFakeShellOutput.instant( BatchFakeShellOutput.instant(
self.secondaryPhpVersions self.secondaryPhpVersions
@ -102,6 +113,7 @@ public struct TestableConfiguration: Codable {
) )
} }
} }
// swiftlint:enable function_body_length
// MARK: Interactions // MARK: Interactions

View File

@ -19,6 +19,17 @@ public class TestableShell: ShellProtocol {
var expectations: [String: BatchFakeShellOutput] = [:] 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 { func quiet(_ command: String) async {
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60) _ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
} }
@ -112,6 +123,29 @@ struct BatchFakeShellOutput: Codable {
return output 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. For testing purposes (and speed) we may omit the delay, regardless of its timespan.
*/ */

View File

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

View File

@ -74,21 +74,24 @@ class App {
/** The window controller of the onboarding window. */ /** The window controller of the onboarding window. */
var onboardingWindowController: OnboardingWindowController? var onboardingWindowController: OnboardingWindowController?
/** The window controller of the config manager window. */
var phpConfigManagerWindowController: PhpConfigManagerWindowController?
/** The window controller of the warnings window. */ /** The window controller of the warnings window. */
var phpDoctorWindowController: PhpDoctorWindowController? var phpDoctorWindowController: PhpDoctorWindowController?
/** The window controller of the warnings window. */ /** The window controller of the PHP version manager window. */
var phpVersionManagerWindowController: PhpVersionManagerWindowController? 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. */ /** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = [] var detectedApplications: [Application] = []
/** The warning manager, responsible for keeping track of warnings. */ /** The warning manager, responsible for keeping track of warnings. */
var warnings = WarningManager.shared 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. */ /** Timer that will periodically reload info about the user's PHP installation. */
var timer: Timer? var timer: Timer?
@ -117,8 +120,12 @@ class App {
// MARK: - App Watchers // MARK: - App Watchers
/** /** Individual filesystem watchers, which are, i.e. responsible for watching the Homebrew folders. */
The `PhpConfigWatcher` is responsible for watching the `.ini` files and the `.conf.d` folder. 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

@ -109,6 +109,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
static func initializeTestingProfile(_ path: String) { static func initializeTestingProfile(_ path: String) {
Log.info("The configuration with path `\(path)` is being requested...") 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() TestableConfiguration.loadFrom(path: path).apply()
} }

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES"> <document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22155"/>
<capability name="Image references" minToolsVersion="12.0"/> <capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/> <capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@ -429,7 +429,7 @@
</toolbarItem> </toolbarItem>
<searchToolbarItem implicitItemIdentifier="7C834FBE-7118-4082-A09F-7CBECEC1356A" label="Search" paletteLabel="Search" visibilityPriority="1001" id="G2g-jS-RVc"> <searchToolbarItem implicitItemIdentifier="7C834FBE-7118-4082-A09F-7CBECEC1356A" label="Search" paletteLabel="Search" visibilityPriority="1001" id="G2g-jS-RVc">
<nil key="toolTip"/> <nil key="toolTip"/>
<searchField key="view" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy"> <searchField key="view" focusRingType="none" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy">
<rect key="frame" x="0.0" y="0.0" width="100" height="21"/> <rect key="frame" x="0.0" y="0.0" width="100" height="21"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
<searchFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" usesSingleLineMode="YES" bezelStyle="round" sendsSearchStringImmediately="YES" id="vp9-vH-goQ"> <searchFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" usesSingleLineMode="YES" bezelStyle="round" sendsSearchStringImmediately="YES" id="vp9-vH-goQ">
@ -521,9 +521,6 @@
<subviews> <subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="8zu-cF-KCX"> <button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="8zu-cF-KCX">
<rect key="frame" x="383" y="13" width="104" height="32"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -531,15 +528,15 @@
DQ DQ
</string> </string>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="4Uf-fh-jWJ"/>
</constraints>
<connections> <connections>
<action selector="primaryButtonAction:" target="hkw-9V-NxP" id="W7d-3b-pZT"/> <action selector="primaryButtonAction:" target="hkw-9V-NxP" id="W7d-3b-pZT"/>
</connections> </connections>
</button> </button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="TCp-nS-HN2"> <button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="TCp-nS-HN2">
<rect key="frame" x="281" y="13" width="104" height="32"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -547,6 +544,9 @@ DQ
Gw Gw
</string> </string>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="QWZ-BA-0g9"/>
</constraints>
<connections> <connections>
<action selector="secondaryButtonAction:" target="hkw-9V-NxP" id="YJs-Hu-lFP"/> <action selector="secondaryButtonAction:" target="hkw-9V-NxP" id="YJs-Hu-lFP"/>
</connections> </connections>
@ -575,7 +575,7 @@ Gw
<constraint firstAttribute="bottom" secondItem="8zu-cF-KCX" secondAttribute="bottom" constant="20" symbolic="YES" id="wIl-uw-y3p"/> <constraint firstAttribute="bottom" secondItem="8zu-cF-KCX" secondAttribute="bottom" constant="20" symbolic="YES" id="wIl-uw-y3p"/>
</constraints> </constraints>
</visualEffectView> </visualEffectView>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm">
<rect key="frame" x="98" y="153" width="384" height="19"/> <rect key="frame" x="98" y="153" width="384" height="19"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="WgB-hj-d4P"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="WgB-hj-d4P"/>
@ -586,7 +586,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf">
<rect key="frame" x="98" y="127" width="384" height="16"/> <rect key="frame" x="98" y="127" width="384" height="16"/>
<textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="rY3-Nd-Iit"> <textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="rY3-Nd-Iit">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -610,7 +610,7 @@ Gw
</constraints> </constraints>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="7eT-Hw-EL9"/> <imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="7eT-Hw-EL9"/>
</imageView> </imageView>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah">
<rect key="frame" x="98" y="70" width="384" height="42"/> <rect key="frame" x="98" y="70" width="384" height="42"/>
<textFieldCell key="cell" selectable="YES" id="7iW-Lc-DqO"> <textFieldCell key="cell" selectable="YES" id="7iW-Lc-DqO">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -685,9 +685,6 @@ DQ
</button> </button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SwS-o8-pbl"> <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SwS-o8-pbl">
<rect key="frame" x="13" y="13" width="114" height="32"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -695,11 +692,14 @@ DQ
Gw Gw
</string> </string>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="qCP-Sp-gxm"/>
</constraints>
<connections> <connections>
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/> <action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
</connections> </connections>
</button> </button>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i"> <textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
<rect key="frame" x="20" y="150" width="440" height="21"/> <rect key="frame" x="20" y="150" width="440" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -710,7 +710,7 @@ Gw
<outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/> <outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/>
</connections> </connections>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
<rect key="frame" x="18" y="128" width="444" height="14"/> <rect key="frame" x="18" y="128" width="444" height="14"/>
<textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP"> <textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -728,7 +728,7 @@ Gw
<action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/> <action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/>
</connections> </connections>
</button> </button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
<rect key="frame" x="18" y="60" width="444" height="28"/> <rect key="frame" x="18" y="60" width="444" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu"> <textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -743,7 +743,7 @@ Gw
<url key="url" string="file:///Users/"/> <url key="url" string="file:///Users/"/>
</pathCell> </pathCell>
</pathControl> </pathControl>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
<rect key="frame" x="18" y="209" width="128" height="16"/> <rect key="frame" x="18" y="209" width="128" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT">
<font key="font" textStyle="headline" name=".SFNS-Bold"/> <font key="font" textStyle="headline" name=".SFNS-Bold"/>
@ -751,7 +751,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID"> <textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
<rect key="frame" x="140" y="23" width="180" height="14"/> <rect key="frame" x="140" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf"> <textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -886,7 +886,7 @@ Gw
<rect key="frame" x="69" y="0.0" width="200" height="54"/> <rect key="frame" x="69" y="0.0" width="200" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD">
<rect key="frame" x="3" y="26" width="145" height="16"/> <rect key="frame" x="3" y="26" width="145" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="SGC-Gm-Mxd"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="SGC-Gm-Mxd">
<font key="font" metaFont="systemSemibold" size="13"/> <font key="font" metaFont="systemSemibold" size="13"/>
@ -894,7 +894,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO">
<rect key="frame" x="3" y="12" width="75" height="14"/> <rect key="frame" x="3" y="12" width="75" height="14"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="fe7-Ha-mR9"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="fe7-Ha-mR9">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -937,13 +937,13 @@ Gw
<subviews> <subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZXQ-bg-Xba"> <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZXQ-bg-Xba">
<rect key="frame" x="27" y="18" width="70" height="18"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="smallSystemBold"/> <font key="font" metaFont="smallSystemBold"/>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="70" id="MBa-bB-DTB"/>
</constraints>
<connections> <connections>
<action selector="pressedPhpVersion:" target="T49-0U-d58" id="jVO-TS-F6d"/> <action selector="pressedPhpVersion:" target="T49-0U-d58" id="jVO-TS-F6d"/>
</connections> </connections>
@ -987,11 +987,11 @@ Gw
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/> <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
<prototypeCellViews> <prototypeCellViews>
<tableCellView identifier="domainListKindCell" wantsLayer="YES" id="AhT-xR-16a" customClass="DomainListKindCell" customModule="PHP_Monitor" customModuleProvider="target"> <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="48" height="54"/> <rect key="frame" x="403" y="0.0" width="50" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="sYR-vb-OW1"> <imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="sYR-vb-OW1">
<rect key="frame" x="15" y="18" width="18" height="18"/> <rect key="frame" x="16" y="18" width="18" height="18"/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="18" id="XcB-uw-szU"/> <constraint firstAttribute="width" constant="18" id="XcB-uw-szU"/>
<constraint firstAttribute="height" constant="18" id="bGN-Vh-Sh0"/> <constraint firstAttribute="height" constant="18" id="bGN-Vh-Sh0"/>
@ -1024,10 +1024,10 @@ Gw
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/> <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
<prototypeCellViews> <prototypeCellViews>
<tableCellView identifier="domainListTypeCell" wantsLayer="YES" id="ntU-Rl-ciP" customClass="DomainListTypeCell" customModule="PHP_Monitor" customModuleProvider="target"> <tableCellView identifier="domainListTypeCell" wantsLayer="YES" id="ntU-Rl-ciP" customClass="DomainListTypeCell" customModule="PHP_Monitor" customModuleProvider="target">
<rect key="frame" x="468" 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"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key">
<rect key="frame" x="6" y="26" width="93" height="14"/> <rect key="frame" x="6" y="26" width="93" height="14"/>
<textFieldCell key="cell" alignment="left" title="Laravel" id="0lu-L6-oKr"> <textFieldCell key="cell" alignment="left" title="Laravel" id="0lu-L6-oKr">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -1035,7 +1035,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B">
<rect key="frame" x="6" y="15" width="93" height="11"/> <rect key="frame" x="6" y="15" width="93" height="11"/>
<textFieldCell key="cell" alignment="left" title="PHP 8.0" id="puf-Jh-ham"> <textFieldCell key="cell" alignment="left" title="PHP 8.0" id="puf-Jh-ham">
<font key="font" metaFont="miniSystem"/> <font key="font" metaFont="miniSystem"/>
@ -1125,7 +1125,7 @@ Gw
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/> <rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g"> <textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<rect key="frame" x="20" y="196" width="500" height="21"/> <rect key="frame" x="20" y="196" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -1136,7 +1136,7 @@ Gw
<outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/> <outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/>
</connections> </connections>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
<rect key="frame" x="18" y="221" width="325" height="14"/> <rect key="frame" x="18" y="221" width="325" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL">
<font key="font" metaFont="systemMedium" size="11"/> <font key="font" metaFont="systemMedium" size="11"/>
@ -1144,7 +1144,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
<rect key="frame" x="18" y="172" width="112" height="14"/> <rect key="frame" x="18" y="172" width="112" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e">
<font key="font" metaFont="systemMedium" size="11"/> <font key="font" metaFont="systemMedium" size="11"/>
@ -1152,7 +1152,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb"> <textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<rect key="frame" x="20" y="147" width="500" height="21"/> <rect key="frame" x="20" y="147" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -1194,9 +1194,6 @@ DQ
</button> </button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nC0-dk-QaF"> <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nC0-dk-QaF">
<rect key="frame" x="13" y="13" width="114" height="32"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -1204,11 +1201,14 @@ DQ
Gw Gw
</string> </string>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="uCc-fF-wS2"/>
</constraints>
<connections> <connections>
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/> <action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
</connections> </connections>
</button> </button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<rect key="frame" x="18" y="128" width="504" height="14"/> <rect key="frame" x="18" y="128" width="504" height="14"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
@ -1229,7 +1229,7 @@ Gw
<action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/> <action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/>
</connections> </connections>
</button> </button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<rect key="frame" x="18" y="60" width="504" height="28"/> <rect key="frame" x="18" y="60" width="504" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy"> <textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -1237,7 +1237,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
<rect key="frame" x="18" y="250" width="123" height="16"/> <rect key="frame" x="18" y="250" width="123" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl">
<font key="font" textStyle="headline" name=".SFNS-Bold"/> <font key="font" textStyle="headline" name=".SFNS-Bold"/>
@ -1245,7 +1245,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4"> <textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<rect key="frame" x="191" y="23" width="180" height="14"/> <rect key="frame" x="191" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl"> <textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -1340,9 +1340,6 @@ Gw
<subviews> <subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="FhN-AM-SkI"> <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="FhN-AM-SkI">
<rect key="frame" x="13" y="13" width="114" height="32"/> <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"> <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"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@ -1350,6 +1347,9 @@ Gw
Gw Gw
</string> </string>
</buttonCell> </buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="Zhu-D8-cLK"/>
</constraints>
<connections> <connections>
<action selector="pressedCancel:" target="gOD-Gu-zDG" id="wMp-sM-0A4"/> <action selector="pressedCancel:" target="gOD-Gu-zDG" id="wMp-sM-0A4"/>
</connections> </connections>
@ -1389,7 +1389,7 @@ Gw
<real value="3.4028234663852886e+38"/> <real value="3.4028234663852886e+38"/>
</customSpacing> </customSpacing>
</stackView> </stackView>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3"> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
<rect key="frame" x="18" y="138" width="504" height="19"/> <rect key="frame" x="18" y="138" width="504" height="19"/>
<textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd"> <textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd">
<font key="font" metaFont="systemBold" size="15"/> <font key="font" metaFont="systemBold" size="15"/>
@ -1397,7 +1397,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField wantsLayer="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ"> <textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
<rect key="frame" x="18" y="60" width="504" height="70"/> <rect key="frame" x="18" y="60" width="504" height="70"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/>

View File

@ -142,7 +142,7 @@ class Startup {
return await Shell.pipe("\(Paths.binPath)/php -v").err return await Shell.pipe("\(Paths.binPath)/php -v").err
.contains("Library not loaded") .contains("Library not loaded")
}, },
name: "`no dyld issue detected", name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized, titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized( subtitleText: "startup.errors.dyld_library.subtitle".localized(
Paths.optPath Paths.optPath
@ -241,6 +241,20 @@ class Startup {
descriptionText: "startup.errors.which_alias_issue.desc".localized 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) // Determine that Valet works correctly (no issues in platform detected)
// ================================================================================= // =================================================================================
EnvironmentCheck( EnvironmentCheck(

View File

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

View File

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

View File

@ -8,20 +8,20 @@
import Foundation import Foundation
struct PhpFrameworks { struct ProjectTypeDetection {
/** /**
This list should probably be reversed when checked, because some of these This list is only checked if the specific dependency list doesn't report a match.
will also require either `laravel/framework` or `symfony/symfony`.
*/ */
public static let DependencyList = [ public static let CommonDependencyList = [
// COMMON FRAMEWORKS
"laravel/framework": "Laravel", "laravel/framework": "Laravel",
"symfony/symfony": "Symfony", "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", "roots/bedrock": "Bedrock",
"cakephp/app": "CakePHP", "cakephp/app": "CakePHP",
"craftcms/craft": "Craft", "craftcms/craft": "Craft",
@ -37,30 +37,8 @@ struct PhpFrameworks {
"johnpbloch/wordpress-core": "WordPress", "johnpbloch/wordpress-core": "WordPress",
"zendframework/zendframework": "Zend", "zendframework/zendframework": "Zend",
"zendframework/zend-mvc": "Zend", "zendframework/zend-mvc": "Zend",
"typo3/cms-core": "Typo3" "typo3/cms-core": "Typo3",
// "magento/*": "Magento", "slim/slim": "Slim"
// "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"
]
] ]
/** /**
@ -82,4 +60,25 @@ struct PhpFrameworks {
return nil 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

@ -8,16 +8,6 @@
import Foundation import Foundation
class BrewFormulaeObservable: ObservableObject {
@Published var phpVersions: [BrewFormula] = []
var upgradeable: [BrewFormula] {
return phpVersions.filter { formula in
formula.hasUpgrade
}
}
}
class Brew { class Brew {
static let shared = Brew() static let shared = Brew()
@ -45,10 +35,11 @@ class Brew {
/// Each formula for each PHP version that can be installed. /// Each formula for each PHP version that can be installed.
public static let phpVersionFormulae = [ public static let phpVersionFormulae = [
"8.3": "shivammathur/php/php@8.3", "8.4": "shivammathur/php/php@8.4",
"8.3": "php@8.3",
"8.2": "php@8.2", "8.2": "php@8.2",
"8.1": "php@8.1", "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.4": "shivammathur/php/php@7.4",
"7.3": "shivammathur/php/php@7.3", "7.3": "shivammathur/php/php@7.3",
"7.2": "shivammathur/php/php@7.2", "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. Determines whether the PHP Monitor Cask is installed.
*/ */
@ -46,6 +61,43 @@ class BrewDiagnostics {
return destination.contains("/nginx-full/") 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. 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`). 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 // PHP Monitor
// //
// Created by Nico Verbruggen on 17/03/2023. // Created by Nico Verbruggen on 17/03/2023.
@ -8,7 +8,7 @@
import Foundation import Foundation
struct BrewFormula { struct BrewPhpFormula: Equatable {
/// Name of the formula. /// Name of the formula.
let name: String let name: String
@ -21,6 +21,8 @@ struct BrewFormula {
/// The upgrade that is currently available, if it exists. /// The upgrade that is currently available, if it exists.
let upgradeVersion: String? 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. /// Whether this formula is a stable version of PHP.
let prerelease: Bool let prerelease: Bool
@ -48,6 +50,25 @@ struct BrewFormula {
return upgradeVersion != nil 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. /// The associated Homebrew folder with this PHP formula.
var homebrewFolder: String { var homebrewFolder: String {
let resolved = name let resolved = name
@ -60,7 +81,7 @@ struct BrewFormula {
/// The short version associated with this formula, if installed. /// The short version associated with this formula, if installed.
var shortVersion: String? { var shortVersion: String? {
guard let version = self.installedVersion else { guard let version = self.installedVersion else {
return nil return self.displayName.replacingOccurrences(of: "PHP ", with: "")
} }
return VersionNumber.make(from: version)?.short ?? nil return VersionNumber.make(from: version)?.short ?? nil
@ -81,6 +102,7 @@ struct BrewFormula {
return nil return nil
} }
return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?.isHealthy ?? nil return PhpEnvironments.shared.cachedPhpInstallations[shortVersion]?
.isHealthy ?? nil
} }
} }

View File

@ -8,22 +8,23 @@
import Foundation import Foundation
protocol HandlesBrewFormulae { protocol HandlesBrewPhpFormulae {
func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula]
func refreshPhpVersions(loadOutdated: Bool) async func refreshPhpVersions(loadOutdated: Bool) async
} }
extension HandlesBrewFormulae { extension HandlesBrewPhpFormulae {
public func refreshPhpVersions(loadOutdated: Bool) async { public func refreshPhpVersions(loadOutdated: Bool) async {
let items = await loadPhpVersions(loadOutdated: loadOutdated) let items = await loadPhpVersions(loadOutdated: loadOutdated)
Task { @MainActor in Task { @MainActor in
await PhpEnvironments.shared.determinePhpAlias()
Brew.shared.formulae.phpVersions = items Brew.shared.formulae.phpVersions = items
} }
} }
} }
class BrewFormulaeHandler: HandlesBrewFormulae { class BrewPhpFormulaeHandler: HandlesBrewPhpFormulae {
public func loadPhpVersions(loadOutdated: Bool) async -> [BrewFormula] { public func loadPhpVersions(loadOutdated: Bool) async -> [BrewPhpFormula] {
var outdated: [OutdatedFormula]? var outdated: [OutdatedFormula]?
if loadOutdated { if loadOutdated {
@ -43,7 +44,8 @@ class BrewFormulaeHandler: HandlesBrewFormulae {
} }
return Brew.phpVersionFormulae.map { (version, formula) in return Brew.phpVersionFormulae.map { (version, formula) in
let fullVersion = PhpEnvironments.shared.cachedPhpInstallations[version]?.versionNumber.text let fullVersion = PhpEnvironments.shared.cachedPhpInstallations[version]?
.versionNumber.text
var upgradeVersion: String? var upgradeVersion: String?
@ -53,7 +55,7 @@ class BrewFormulaeHandler: HandlesBrewFormulae {
})?.current_version })?.current_version
} }
return BrewFormula( return BrewPhpFormula(
name: formula, name: formula,
displayName: "PHP \(version)", displayName: "PHP \(version)",
installedVersion: fullVersion, installedVersion: fullVersion,

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 { protocol BrewCommand {
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws
func getCommandTitle() -> String
} }
extension BrewCommand { extension BrewCommand {
@ -31,6 +33,44 @@ extension BrewCommand {
} }
return nil 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 { 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 import Foundation
class InstallAndUpgradeCommand: BrewCommand { class ModifyPhpVersionCommand: BrewCommand {
let title: String let title: String
let installing: [BrewFormula] let installing: [BrewPhpFormula]
let upgrading: [BrewFormula] let upgrading: [BrewPhpFormula]
let phpGuard: PhpGuard 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. 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. The process will be executed in two steps: first upgrades, then installations.
Upgrades come first because... well, otherwise installations may very well break. 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( public init(
title: String, title: String,
upgrading: [BrewFormula], upgrading: [BrewPhpFormula],
installing: [BrewFormula] installing: [BrewPhpFormula]
) { ) {
self.title = title self.title = title
self.installing = installing self.installing = installing
@ -33,17 +43,32 @@ class InstallAndUpgradeCommand: BrewCommand {
} }
func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws { func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
let progressTitle = "Please wait..." let progressTitle = "phpman.steps.wait".localized
onProgress(.create( onProgress(.create(
value: 0.2, value: 0.2,
title: progressTitle, title: progressTitle,
description: "PHP Monitor is preparing Homebrew..." description: "phpman.steps.preparing".localized
)) ))
// Try to run all upgrade and installation operations // Determine if a formula will become unavailable
try await self.upgradePackages(onProgress) // This is the case when `php` will be bumped to a new version
try await self.installPackages(onProgress) 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 // Re-check the installed versions
await PhpEnvironments.detectPhpVersions() await PhpEnvironments.detectPhpVersions()
@ -55,6 +80,27 @@ class InstallAndUpgradeCommand: BrewCommand {
await self.completedOperations(onProgress) 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 { private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit // If no upgrades are needed, early exit
if self.upgrading.isEmpty { if self.upgrading.isEmpty {
@ -117,35 +163,12 @@ class InstallAndUpgradeCommand: BrewCommand {
try await run(command, onProgress) 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 { private func completedOperations(_ onProgress: @escaping (BrewCommandProgress) -> Void) async {
// Reload and restart PHP versions // 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 // Check which version of PHP are now installed
await PhpEnvironments.detectPhpVersions() await PhpEnvironments.detectPhpVersions()
@ -164,9 +187,8 @@ class InstallAndUpgradeCommand: BrewCommand {
// Let the UI know that the installation has been completed // Let the UI know that the installation has been completed
onProgress(.create( onProgress(.create(
value: 1, value: 1,
title: "Operation completed!", title: "phpman.steps.completed".localized,
description: "The installation has succeeded." description: "phpman.steps.success".localized
)) ))
} }
} }

View File

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

View File

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

View File

@ -141,7 +141,7 @@ class ValetSite: ValetListable {
self.determineDriverViaComposer() self.determineDriverViaComposer()
if self.driver == nil { 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() { private func determineDriverViaComposer() {
self.driverDeterminedByComposer = true self.driverDeterminedByComposer = true
PhpFrameworks.DependencyList.reversed().forEach { (key: String, value: String) in for (key, value) in ProjectTypeDetection.SpecificDependencyList
if self.notableComposerDependencies.keys.contains(key) { where notableComposerDependencies.keys.contains(key) {
self.driver = value self.driver = value
} return
}
for (key, value) in ProjectTypeDetection.CommonDependencyList
where notableComposerDependencies.keys.contains(key) {
self.driver = value
return
} }
} }

View File

@ -283,12 +283,11 @@ extension MainMenu {
return return
} }
setBusyImage()
PhpEnvironments.shared.isBusy = true PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version) PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
updatePhpVersionInStatusBar() refreshIcon()
rebuild() rebuild()
await PhpEnvironments.switcher.performSwitch(to: version) await PhpEnvironments.switcher.performSwitch(to: version)
@ -298,13 +297,12 @@ extension MainMenu {
} }
@objc func switchToPhpVersion(_ version: String) { @objc func switchToPhpVersion(_ version: String) {
setBusyImage()
PhpEnvironments.shared.isBusy = true PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version) PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
Task(priority: .userInitiated) { [unowned self] in Task(priority: .userInitiated) { [unowned self] in
updatePhpVersionInStatusBar() refreshIcon()
rebuild() rebuild()
await PhpEnvironments.switcher.performSwitch(to: version) await PhpEnvironments.switcher.performSwitch(to: version)
@ -325,13 +323,12 @@ extension MainMenu {
*/ */
func switchToPhp(_ version: String) async { func switchToPhp(_ version: String) async {
Task { @MainActor [self] in Task { @MainActor [self] in
setBusyImage()
PhpEnvironments.shared.isBusy = true PhpEnvironments.shared.isBusy = true
PhpEnvironments.shared.delegate = self PhpEnvironments.shared.delegate = self
PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version) PhpEnvironments.shared.delegate?.switcherDidStartSwitching(to: version)
} }
updatePhpVersionInStatusBar() refreshIcon()
rebuild() rebuild()
await PhpEnvironments.switcher.performSwitch(to: version) await PhpEnvironments.switcher.performSwitch(to: version)

View File

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

View File

@ -15,7 +15,7 @@ extension MainMenu {
func startup() async { func startup() async {
// Start with the icon // Start with the icon
Task { @MainActor in Task { @MainActor in
self.setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!) self.setStatusBar(image: NSImage.statusBarIcon)
} }
if await Startup().checkEnvironment() { if await Startup().checkEnvironment() {
@ -32,19 +32,14 @@ extension MainMenu {
// Determine what the `php` formula is aliased to // Determine what the `php` formula is aliased to
await PhpEnvironments.shared.determinePhpAlias() await PhpEnvironments.shared.determinePhpAlias()
// Make sure that broken symlinks are removed ASAP
await BrewDiagnostics.checkForOutdatedPhpInstallationSymlinks()
// Initialize preferences // Initialize preferences
_ = Preferences.shared _ = Preferences.shared
// Determine install method // Put some useful diagnostics information in log
Log.info(BrewDiagnostics.customCaskInstalled BrewDiagnostics.logBootInformation()
? "[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."
)
// Attempt to find out more info about Valet // Attempt to find out more info about Valet
if Valet.shared.version != nil { if Valet.shared.version != nil {
@ -63,9 +58,6 @@ extension MainMenu {
// Check for an alias conflict // Check for an alias conflict
await BrewDiagnostics.checkForCaskConflict() await BrewDiagnostics.checkForCaskConflict()
// Update the icon
updatePhpVersionInStatusBar()
// Attempt to find out if PHP-FPM is broken // Attempt to find out if PHP-FPM is broken
PhpEnvironments.prepare() PhpEnvironments.prepare()
@ -76,7 +68,6 @@ extension MainMenu {
WarningManager.shared.evaluateWarnings() WarningManager.shared.evaluateWarnings()
// Set up the config watchers on launch (updated automatically when switching) // Set up the config watchers on launch (updated automatically when switching)
Log.info("Setting up watchers...")
App.shared.handlePhpConfigWatcher() App.shared.handlePhpConfigWatcher()
// Detect built-in and custom applications // Detect built-in and custom applications
@ -105,9 +96,33 @@ extension MainMenu {
Valet.shared.notifyAboutUnsupportedTLD() 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 // Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.") 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 { if !isRunningSwiftUIPreview {
Stats.incrementSuccessfulLaunchCount() Stats.incrementSuccessfulLaunchCount()
Stats.evaluateSponsorMessageShouldBeDisplayed() Stats.evaluateSponsorMessageShouldBeDisplayed()
@ -121,15 +136,6 @@ extension MainMenu {
await AppUpdater().checkForUpdates(userInitiated: false) 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) { nonisolated func switcherDidCompleteSwitch(to version: String) {
// Mark as no longer busy // 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 Task { // Things to do after reloading domain list data
if Valet.installed { if Valet.installed {
@ -25,7 +27,7 @@ extension MainMenu {
// Perform UI updates on main thread // Perform UI updates on main thread
Task { @MainActor [self] in Task { @MainActor [self] in
updatePhpVersionInStatusBar() refreshIcon()
rebuild() rebuild()
if Valet.installed && !PhpEnvironments.shared.validate(version) { if Valet.installed && !PhpEnvironments.shared.validate(version) {

View File

@ -37,8 +37,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
// MARK: - UI related // MARK: - UI related
/** /**
Rebuilds the menu (either asynchronously or synchronously). Rebuilds the menu on the main thread.
Defaults to rebuilding the menu asynchronously.
*/ */
func rebuild() { func rebuild() {
Task { @MainActor [self] in Task { @MainActor [self] in
@ -80,13 +79,15 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func refreshActiveInstallation() { @objc func refreshActiveInstallation() {
if !PhpEnvironments.shared.isBusy { if !PhpEnvironments.shared.isBusy {
PhpEnvironments.shared.currentInstall = ActivePhpInstallation.load() PhpEnvironments.shared.currentInstall = ActivePhpInstallation.load()
updatePhpVersionInStatusBar() refreshIcon()
rebuild()
} else { } else {
Log.perf("Skipping version refresh due to busy status!") Log.perf("Skipping version refresh due to busy status!")
} }
} }
/** Updates the icon (refresh icon) and rebuilds the menu. */ /** Updates the icon (refresh icon) and rebuilds the menu. */
@available(*, deprecated, message: "Use the busy status instead")
@objc func updatePhpVersionInStatusBar() { @objc func updatePhpVersionInStatusBar() {
refreshIcon() refreshIcon()
rebuild() rebuild()
@ -139,7 +140,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
@objc func reloadPhpMonitorMenuInBackground() { @objc func reloadPhpMonitorMenuInBackground() {
asyncExecution({ asyncExecution({
// This automatically reloads the menu // 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: [ }, behaviours: [
.setsBusyUI, .setsBusyUI,
.reloadsPhpInstallation, .reloadsPhpInstallation,
@ -150,13 +151,16 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
/** Refreshes the icon with the PHP version. */ /** Refreshes the icon with the PHP version. */
@objc func refreshIcon() { @objc func refreshIcon() {
Task { @MainActor [self] in Task { @MainActor [self] in
if PhpEnvironments.shared.isBusy { if PhpEnvironments.shared.isBusy {
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!) Log.perf("Refreshing icon: currently busy")
setStatusBar(image: NSImage.statusBarIcon)
} else { } else {
Log.perf("Refreshing icon: no longer busy")
if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false { if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false {
// Static icon has been requested // Static icon has been requested
setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIconStatic"))!) setStatusBar(image: NSImage.statusBarIconStatic)
} else { } else {
// The dynamic icon has been requested // The dynamic icon has been requested
let long = Preferences.preferences[.fullPhpVersionDynamicIcon] as! Bool 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 // MARK: - Menu Item Functionality
@objc func openAbout() { @objc func openAbout() {
@ -206,6 +203,10 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
PhpDoctorWindowController.show() PhpDoctorWindowController.show()
} }
@objc func openConfigGUI() {
PhpConfigManagerWindowController.show()
}
@objc func openDomainList() { @objc func openDomainList() {
DomainListVC.show() DomainListVC.show()
} }
@ -214,6 +215,10 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
PhpVersionManagerWindowController.show() PhpVersionManagerWindowController.show()
} }
@objc func openPhpExtensionManager() {
PhpExtensionManagerWindowController.show()
}
@objc func openDonate() { @objc func openDonate() {
NSWorkspace.shared.open(Constants.Urls.DonationPage) NSWorkspace.shared.open(Constants.Urls.DonationPage)
} }

View File

@ -12,7 +12,7 @@ import Cocoa
extension StatusMenu { extension StatusMenu {
func addPhpVersionMenuItems() { @MainActor func addPhpVersionMenuItems() {
if PhpEnvironments.phpInstall == nil { if PhpEnvironments.phpInstall == nil {
addItem(HeaderView.asMenuItem(text: "⚠️ " + "mi_no_php_linked".localized, minimumWidth: 280)) addItem(HeaderView.asMenuItem(text: "⚠️ " + "mi_no_php_linked".localized, minimumWidth: 280))
addItems([ addItems([
@ -34,7 +34,7 @@ extension StatusMenu {
)) ))
} }
func addPhpActionMenuItems() { @MainActor func addPhpActionMenuItems() {
if PhpEnvironments.shared.isBusy { if PhpEnvironments.shared.isBusy {
addItem(NSMenuItem(title: "mi_busy".localized)) addItem(NSMenuItem(title: "mi_busy".localized))
return return
@ -54,7 +54,7 @@ extension StatusMenu {
self.addItem(NSMenuItem.separator()) self.addItem(NSMenuItem.separator())
} }
func addServicesManagerMenuItem() { @MainActor func addServicesManagerMenuItem() {
if PhpEnvironments.shared.isBusy { if PhpEnvironments.shared.isBusy {
return return
} }
@ -65,7 +65,7 @@ extension StatusMenu {
]) ])
} }
func addSwitchToPhpMenuItems() { @MainActor func addSwitchToPhpMenuItems() {
var shortcutKey = 1 var shortcutKey = 1
for index in (0..<PhpEnvironments.shared.availablePhpVersions.count) { for index in (0..<PhpEnvironments.shared.availablePhpVersions.count) {
// Get the short and long version // Get the short and long version
@ -102,14 +102,14 @@ extension StatusMenu {
} }
} }
func addLiteModeMenuItem() { @MainActor func addLiteModeMenuItem() {
addItems([ addItems([
NSMenuItem.separator(), NSMenuItem.separator(),
NSMenuItem(title: "mi_lite_mode".localized, action: #selector(MainMenu.openLiteModeInfo)) NSMenuItem(title: "mi_lite_mode".localized, action: #selector(MainMenu.openLiteModeInfo))
]) ])
} }
func addPreferencesMenuItems() { @MainActor func addPreferencesMenuItems() {
addItems([ addItems([
NSMenuItem.separator(), NSMenuItem.separator(),
NSMenuItem(title: "mi_preferences".localized, NSMenuItem(title: "mi_preferences".localized,
@ -119,7 +119,7 @@ extension StatusMenu {
]) ])
} }
func addCoreMenuItems() { @MainActor func addCoreMenuItems() {
addItems([ addItems([
NSMenuItem.separator(), NSMenuItem.separator(),
NSMenuItem(title: "mi_about".localized, NSMenuItem(title: "mi_about".localized,
@ -131,7 +131,7 @@ extension StatusMenu {
// MARK: - Valet // MARK: - Valet
func addValetMenuItems() { @MainActor func addValetMenuItems() {
addItems([ addItems([
HeaderView.asMenuItem(text: "mi_valet".localized), HeaderView.asMenuItem(text: "mi_valet".localized),
NSMenuItem(title: "mi_valet_config".localized, NSMenuItem(title: "mi_valet_config".localized,
@ -146,12 +146,15 @@ extension StatusMenu {
// MARK: - PHP Configuration // MARK: - PHP Configuration
func addConfigurationMenuItems() { @MainActor func addConfigurationMenuItems() {
addItems([ addItems([
HeaderView.asMenuItem(text: "mi_configuration".localized), HeaderView.asMenuItem(text: "mi_configuration".localized),
NSMenuItem(title: "mi_php_version_manager".localized, NSMenuItem(title: "mi_php_version_manager".localized,
action: #selector(MainMenu.openPhpVersionManager), action: #selector(MainMenu.openPhpVersionManager),
keyEquivalent: "m"), keyEquivalent: "m"),
NSMenuItem(title: "mi_php_ext_manager".localized,
action: #selector(MainMenu.openPhpExtensionManager),
keyEquivalent: "e"),
NSMenuItem(title: "mi_php_config".localized, NSMenuItem(title: "mi_php_config".localized,
action: #selector(MainMenu.openActiveConfigFolder), action: #selector(MainMenu.openActiveConfigFolder),
keyEquivalent: "c"), keyEquivalent: "c"),
@ -166,7 +169,7 @@ extension StatusMenu {
// MARK: - Composer // MARK: - Composer
func addComposerMenuItems() { @MainActor func addComposerMenuItems() {
addItems([ addItems([
HeaderView.asMenuItem(text: "mi_composer".localized), HeaderView.asMenuItem(text: "mi_composer".localized),
NSMenuItem( NSMenuItem(
@ -187,7 +190,7 @@ extension StatusMenu {
// MARK: - Stats // MARK: - Stats
func addStatsMenuItem() { @MainActor func addStatsMenuItem() {
guard let install = PhpEnvironments.phpInstall else { guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing stats menu item if no PHP version is linked.") Log.info("Not showing stats menu item if no PHP version is linked.")
return return
@ -204,7 +207,7 @@ extension StatusMenu {
// MARK: - Extensions // MARK: - Extensions
func addExtensionsMenuItems() { @MainActor func addExtensionsMenuItems() {
guard let install = PhpEnvironments.phpInstall else { guard let install = PhpEnvironments.phpInstall else {
Log.info("Not showing extensions menu items if no PHP version is linked.") Log.info("Not showing extensions menu items if no PHP version is linked.")
return return
@ -225,7 +228,7 @@ extension StatusMenu {
// MARK: - Presets // MARK: - Presets
func addPresetsMenuItem() { @MainActor func addPresetsMenuItem() {
guard let presets = Preferences.custom.presets else { guard let presets = Preferences.custom.presets else {
addEmptyPresetHelp() addEmptyPresetHelp()
return return

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@ class Preferences {
PreferenceName.allowProtocolForIntegrations.rawValue: true, PreferenceName.allowProtocolForIntegrations.rawValue: true,
PreferenceName.automaticBackgroundUpdateCheck.rawValue: true, PreferenceName.automaticBackgroundUpdateCheck.rawValue: true,
PreferenceName.showPhpDoctorSuggestions.rawValue: true, PreferenceName.showPhpDoctorSuggestions.rawValue: true,
PreferenceName.languageOverride.rawValue: "",
/// Preferences: Appearance /// Preferences: Appearance
PreferenceName.shouldDisplayDynamicIcon.rawValue: true, PreferenceName.shouldDisplayDynamicIcon.rawValue: true,

View File

@ -17,7 +17,9 @@ class GeneralPreferencesVC: GenericPreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil) let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC .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.getAutoRestartServicesPV())
.addView(when: true, vc.getAutomaticComposerUpdatePV()) .addView(when: true, vc.getAutomaticComposerUpdatePV())
.addView(when: true, vc.getShortcutPV()) .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 { func getIconOptionsPV() -> NSView {
return SelectPreferenceView.make( return SelectPreferenceView.make(
sectionText: "", sectionText: "",
descriptionText: "prefs.icon_options_desc".localized, 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", localizationPrefix: "prefs.icon_options",
preference: .iconTypeToDisplay, preference: .iconTypeToDisplay,
action: { action: {

View File

@ -65,9 +65,11 @@ class PreferencesWindowController: PMWindowController {
App.shared.preferencesWindowController?.showWindow(self) App.shared.preferencesWindowController?.showWindow(self)
if justCreated { if justCreated {
App.shared.preferencesWindowController?.positionWindowInTopLeftCorner() App.shared.preferencesWindowController?.positionWindowInTopRightCorner()
} }
App.shared.preferencesWindowController?.window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }

View File

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

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-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> <dependencies>
<deployment identifier="macosx"/> <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"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<objects> <objects>
@ -23,7 +23,7 @@
<action selector="toggled:" target="c22-O7-iKe" id="c9y-JM-TdE"/> <action selector="toggled:" target="c22-O7-iKe" id="c9y-JM-TdE"/>
</connections> </connections>
</button> </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"/> <rect key="frame" x="168" y="5" width="410" height="14"/>
<textFieldCell key="cell" title="DESCRIPTION" id="9fH-up-Sob"> <textFieldCell key="cell" title="DESCRIPTION" id="9fH-up-Sob">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@ -31,7 +31,7 @@
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </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"/> <rect key="frame" x="-2" y="27" width="154" height="16"/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="150" id="euj-t0-xv4"/> <constraint firstAttribute="width" constant="150" id="euj-t0-xv4"/>

View File

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

View File

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

View File

@ -27,15 +27,15 @@ struct HelpButton: View {
.buttonStyle(BorderlessButtonStyle()) .buttonStyle(BorderlessButtonStyle())
.focusable(false) .focusable(false)
} }
}
struct HelpButton_Previews: PreviewProvider {
static var previews: some View { #Preview("Light Mode") {
Group { HelpButton(action: {})
HelpButton(action: {}).padding() .padding(100)
.previewDisplayName("Light Mode") }
HelpButton(action: {}).padding().preferredColorScheme(.dark)
.previewDisplayName("Dark Mode") #Preview("Dark Mode") {
} HelpButton(action: {})
} .padding(100)
} .preferredColorScheme(.dark)
} }

View File

@ -27,7 +27,9 @@ var isRunningSwiftUIPreview: Bool {
extension Color { extension Color {
public static var appPrimary: Color = Color("AppColor") 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 = { public static var debug: Color = {
if ProcessInfo.processInfo.environment["PAINT_PHPMON_SWIFTUI_VIEWS"] != nil { if ProcessInfo.processInfo.environment["PAINT_PHPMON_SWIFTUI_VIEWS"] != nil {

View File

@ -30,8 +30,6 @@ struct NoDomainResults: View {
} }
} }
struct NoDomainResults_Previews: PreviewProvider { #Preview {
static var previews: some View { NoDomainResults()
NoDomainResults()
}
} }

View File

@ -126,78 +126,82 @@ struct DisclaimerView: View {
} }
} }
struct VersionPopoverView_Previews: PreviewProvider { #Preview("Unknown Requirement") {
static var previews: some View { VersionPopoverView(
VersionPopoverView( site: FakeValetSite(
site: FakeValetSite( fakeWithName: "amazingwebsite",
fakeWithName: "amazingwebsite", tld: "test",
tld: "test", secure: true,
secure: true, path: "/path/to/site",
path: "/path/to/site", linked: true,
linked: true, constraint: ""
constraint: "" ),
), validPhpVersions: [],
validPhpVersions: [], parent: nil
parent: nil )
) }
.previewDisplayName("Unknown Requirement")
#Preview("Requirement Matches") {
VersionPopoverView( VersionPopoverView(
site: FakeValetSite( site: FakeValetSite(
fakeWithName: "amazingwebsite", fakeWithName: "amazingwebsite",
tld: "test", tld: "test",
secure: true, secure: true,
path: "/path/to/site", path: "/path/to/site",
linked: true, linked: true,
constraint: "^8.1" constraint: "^8.1"
), ),
validPhpVersions: [], validPhpVersions: [],
parent: nil parent: nil
) )
.previewDisplayName("Requirement Matches") }
VersionPopoverView(
site: FakeValetSite( #Preview("Isolated") {
fakeWithName: "anothersite", VersionPopoverView(
tld: "test", site: FakeValetSite(
secure: true, fakeWithName: "anothersite",
path: "/path/to/site", tld: "test",
linked: true, secure: true,
constraint: "^8.0", path: "/path/to/site",
isolated: "8.0" linked: true,
), constraint: "^8.0",
validPhpVersions: [], isolated: "8.0"
parent: nil ),
) validPhpVersions: [],
.previewDisplayName("Isolated") parent: nil
VersionPopoverView( )
site: FakeValetSite( }
fakeWithName: "anothersite",
tld: "test", #Preview("Isolated Mismatch") {
secure: true, VersionPopoverView(
path: "/path/to/site", site: FakeValetSite(
linked: true, fakeWithName: "anothersite",
constraint: "^8.0", tld: "test",
isolated: "7.4" secure: true,
), path: "/path/to/site",
validPhpVersions: [], linked: true,
parent: nil constraint: "^8.0",
) isolated: "7.4"
.previewDisplayName("Isolated Mismatch") ),
VersionPopoverView( validPhpVersions: [],
site: FakeValetSite( parent: nil
fakeWithName: "anothersite", )
tld: "test", }
secure: true,
path: "/path/to/site", #Preview("Recommend Alternatives") {
linked: true, VersionPopoverView(
constraint: "^8.0" site: FakeValetSite(
), fakeWithName: "anothersite",
validPhpVersions: [ tld: "test",
VersionNumber(major: 8, minor: 0, patch: 0), secure: true,
VersionNumber(major: 8, minor: 1, patch: 0) path: "/path/to/site",
], linked: true,
parent: nil constraint: "^8.0"
) ),
.previewDisplayName("Recommend Alternatives") validPhpVersions: [
} VersionNumber(major: 8, minor: 0, patch: 0),
VersionNumber(major: 8, minor: 1, patch: 0)
],
parent: nil
)
} }

View File

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

View File

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

View File

@ -172,23 +172,21 @@ struct ServiceView: View {
} }
} }
struct ServicesView_Previews: PreviewProvider { #Preview("Active 1") {
static var previews: some View { ServicesView(manager: FakeServicesManager(
ServicesView(manager: FakeServicesManager( formulae: ["php", "nginx", "dnsmasq"],
formulae: ["php", "nginx", "dnsmasq"], status: .active
status: .active ), perRow: 4)
), perRow: 4) .frame(width: 330.0)
.frame(width: 330.0) }
.previewDisplayName("Active 1")
#Preview("Active 2") {
ServicesView(manager: FakeServicesManager( ServicesView(manager: FakeServicesManager(
formulae: [ formulae: [
"php", "nginx", "dnsmasq", "thing1", "php", "nginx", "dnsmasq", "thing1",
"thing2", "thing3", "thing4", "thing5" "thing2", "thing3", "thing4", "thing5"
], ],
status: .inactive status: .inactive
), perRow: 4) ), perRow: 4)
.frame(width: 330.0) .frame(width: 330.0)
.previewDisplayName("Active 2")
}
} }

View File

@ -60,38 +60,49 @@ struct StatsView: View {
.padding(.leading, 30) .padding(.leading, 30)
.padding(.trailing, 30) .padding(.trailing, 30)
} else { } else {
HStack(alignment: .firstTextBaseline, spacing: 30) { HStack(alignment: .center, spacing: 10) {
VStack(alignment: .center, spacing: 3) { VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_memory_limit".localized.uppercased()) SectionHeaderView(text: "mi_memory_limit".localized.uppercased())
Text(memoryLimit) Text(memoryLimit)
.fontWeight(.medium) .fontWeight(.medium)
.font(.system(size: 16)) .font(.system(size: 16))
} }
Divider()
VStack(alignment: .center, spacing: 3) { VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_post_max_size".localized.uppercased()) SectionHeaderView(text: "mi_post_max_size".localized.uppercased())
Text(maxPostSize) Text(maxPostSize)
.fontWeight(.medium) .fontWeight(.medium)
.font(.system(size: 16)) .font(.system(size: 16))
} }
Divider()
VStack(alignment: .center, spacing: 3) { VStack(alignment: .center, spacing: 3) {
SectionHeaderView(text: "mi_upload_max_filesize".localized.uppercased()) SectionHeaderView(text: "mi_upload_max_filesize".localized.uppercased())
Text(maxUploadSize) Text(maxUploadSize)
.fontWeight(.medium) .fontWeight(.medium)
.font(.system(size: 16)) .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(10) .padding(5)
.background(Color.debug) .background(Color.debug)
} }
} }
} }
struct StatsView_Previews: PreviewProvider { #Preview {
static var previews: some View { StatsView(
StatsView( memoryLimit: "1024 MB",
memoryLimit: "1024 MB", maxPostSize: "1024 MB",
maxPostSize: "1024 MB", maxUploadSize: "1024 MB"
maxUploadSize: "1024 MB" ).frame(height: 100)
)
}
} }

View File

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

View File

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

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

View File

@ -17,13 +17,13 @@ extension App {
onChange: { Task { await self.onHomebrewPhpModification() } } onChange: { Task { await self.onHomebrewPhpModification() } }
) )
App.shared.watchers[.homebrewBinaries] = notifier App.shared.watchers["homebrewBinaries"] = notifier
} }
public func destroyHomebrewWatchers() { public func destroyHomebrewWatchers() {
// Removing requires termination and then removing reference // Removing requires termination and then removing reference
self.watchers[.homebrewBinaries]?.terminate() self.watchers["homebrewBinaries"]?.terminate()
self.watchers[.homebrewBinaries] = nil self.watchers["homebrewBinaries"] = nil
} }
public func onHomebrewPhpModification() async { public func onHomebrewPhpModification() async {
@ -31,10 +31,13 @@ extension App {
Log.info("Something changed in the Homebrew binary directory...") Log.info("Something changed in the Homebrew binary directory...")
await PhpEnvironments.detectPhpVersions() await PhpEnvironments.detectPhpVersions()
await MainMenu.shared.refreshActiveInstallation() await MainMenu.shared.refreshActiveInstallation()
// let new = PhpEnvironments.shared.currentInstall?.version.text
// TODO: //
// Check if the new and previous version are different // TODO: PHP Guard 2.0
// if so, we can show a notification if needed // 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 { extension App {
func startWatcher(_ url: URL) { func startWatchManager(_ url: URL) {
Log.perf("No watcher currently active...") Log.perf("Starting config watch manager...")
self.watcher = PhpConfigWatcher(for: url) self.watchManager = ConfigWatchManager(for: url)
self.watcher.didChange = { url in self.watchManager.didChange = { url in
Log.perf("Something has changed in: \(url)") Log.perf("Something has changed in: \(url)")
// Check if the watcher has last updated the menu less than 0.75s ago // 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 { if distance == nil || distance != nil && distance! > 0.75 {
Log.perf("Refreshing menu...") Log.perf("Refreshing menu...")
Task { @MainActor in MainMenu.shared.reloadPhpMonitorMenuInBackground() } Task { @MainActor in MainMenu.shared.reloadPhpMonitorMenuInBackground() }
self.watcher.lastUpdate = Date().timeIntervalSince1970 self.watchManager.lastUpdate = Date().timeIntervalSince1970
} }
} }
} }
func handlePhpConfigWatcher(forceReload: Bool = false) { func handlePhpConfigWatcher(forceReload: Bool = false) {
if ActiveFileSystem.shared is TestableFileSystem { 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 return
} }
guard let install = PhpEnvironments.phpInstall else { guard let install = PhpEnvironments.phpInstall else {
Log.info("It appears as if no PHP installation is currently active.") 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 return
} }
let url = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(install.version.short)") 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 // 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 Task { @MainActor in
// Watcher needs to be created // Watcher needs to be created
if self.watcher == nil { if self.watchManager == nil {
self.startWatcher(url) self.startWatchManager(url)
} }
// Watcher needs to be updated // Watcher needs to be updated
if self.watcher.url != url || forceReload { if self.watchManager.url != url || forceReload {
self.watcher.disable() self.watchManager.disable()
self.watcher = nil self.watchManager = nil
Log.perf("Watcher has stopped watching files. Starting new one...") 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)")
}
}

View File

@ -6,12 +6,9 @@
// Copyright © 2023 Nico Verbruggen. All rights reserved. // Copyright © 2023 Nico Verbruggen. All rights reserved.
// //
import Cocoa import Foundation
class FSNotifier { class FSNotifier {
enum Kind {
case homebrewLocks, homebrewBinaries
}
public static var shared: FSNotifier! = nil public static var shared: FSNotifier! = nil
@ -66,4 +63,5 @@ class FSNotifier {
deinit { deinit {
Log.perf("FSNotifier for \(self.url) will be deinitialized.") Log.perf("FSNotifier for \(self.url) will be deinitialized.")
} }
} }

View File

@ -1,154 +0,0 @@
//
// FolderWatcher.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 30/03/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Foundation
class PhpConfigWatcher {
let folderMonitorQueue = DispatchQueue(label: "FolderMonitorQueue", attributes: .concurrent)
let url: URL
var didChange: ((URL) -> Void)?
var lastUpdate: TimeInterval?
var watchers: [FSWatcher] = []
init(for url: URL) {
if FileSystem is TestableFileSystem {
fatalError("""
PhpConfigWatcher is not compatible with testable FS! "
You are not allowed to instantiate these while using a testable FS.
""")
}
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 enumerator = FileManager.default.enumerator(atPath: self.url.appendingPathComponent("conf.d").path)
let filePaths = enumerator?.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: FSWatcherBehaviour = .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 = FSWatcher(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)")
}
}
enum FSWatcherBehaviour {
case reloadsMenu
case reloadsWatchers
}
class FSWatcher {
private var parent: PhpConfigWatcher!
private var monitoredFolderFileDescriptor: CInt = -1
private var folderMonitorSource: DispatchSourceFileSystemObject?
let url: URL
init(
for url: URL,
eventMask: DispatchSource.FileSystemEvent,
parent: PhpConfigWatcher,
behaviour: FSWatcherBehaviour = .reloadsMenu
) {
self.url = url
self.parent = parent
self.startMonitoring(eventMask, behaviour: behaviour)
}
func startMonitoring(
_ eventMask: DispatchSource.FileSystemEvent,
behaviour: FSWatcherBehaviour
) {
guard folderMonitorSource == nil && monitoredFolderFileDescriptor == -1 else {
return
}
// Open the file or folder referenced by URL for monitoring only.
monitoredFolderFileDescriptor = open(url.path, O_EVTONLY)
folderMonitorSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: monitoredFolderFileDescriptor,
eventMask: eventMask,
queue: parent.folderMonitorQueue
)
// Define the block to call when a file change is detected.
folderMonitorSource?.setEventHandler { [weak self] in
// The default behaviour is to reload the menu
switch behaviour {
case .reloadsMenu:
// Default behaviour: reload the menu items
self?.parent.didChange?(self!.url)
case .reloadsWatchers:
// Alternative behaviour: reload all watchers
App.shared.handlePhpConfigWatcher(forceReload: true)
}
}
// Define a cancel handler to ensure the directory is closed when the source is cancelled.
folderMonitorSource?.setCancelHandler { [weak self] in
guard let self = self else { return }
close(self.monitoredFolderFileDescriptor)
self.monitoredFolderFileDescriptor = -1
self.folderMonitorSource = nil
}
// Start monitoring the directory via the source.
folderMonitorSource?.resume()
}
func stopMonitoring() {
folderMonitorSource?.cancel()
self.parent = nil
}
}

View File

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

View File

@ -34,14 +34,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
if site.isolatedPhpVersion != nil { if site.isolatedPhpVersion != nil {
imageViewPhpVersionOK.isHidden = false imageViewPhpVersionOK.isHidden = false
imageViewPhpVersionOK.image = NSImage(named: "Isolated") imageViewPhpVersionOK.image = NSImage.isolated
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.isolated".localized(site.servingPhpVersion) imageViewPhpVersionOK.toolTip = "domain_list.tooltips.isolated".localized(site.servingPhpVersion)
} else { } else {
imageViewPhpVersionOK.isHidden = (site.preferredPhpVersion == "???" imageViewPhpVersionOK.isHidden = (site.preferredPhpVersion == "???"
|| !site.isCompatibleWithPreferredPhpVersion) || !site.isCompatibleWithPreferredPhpVersion)
imageViewPhpVersionOK.image = NSImage(named: "Checkmark") imageViewPhpVersionOK.image = NSImage.checkmark
imageViewPhpVersionOK.toolTip = "domain_list.tooltips.checkmark".localized(site.preferredPhpVersion) imageViewPhpVersionOK.toolTip = "domain_list.tooltips.checkmark".localized(site.preferredPhpVersion)
} }
} }

View File

@ -110,6 +110,22 @@ extension DomainListVC {
} }
} }
@objc func toggleExtension(sender: ExtensionMenuItem) {
Task {
self.setUIBusy()
await sender.phpExtension?.toggle()
if Preferences.isEnabled(.autoServiceRestartAfterExtensionToggle) {
await Actions.restartPhpFpm()
}
reloadContextMenu()
self.setUINotBusy()
}
}
@objc func isolateSite(sender: PhpMenuItem) { @objc func isolateSite(sender: PhpMenuItem) {
guard let site = selectedSite else { guard let site = selectedSite else {
return return

View File

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

View File

@ -85,6 +85,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
App.shared.domainListWindowController!.showWindow(self) App.shared.domainListWindowController!.showWindow(self)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
App.shared.domainListWindowController?.window?.orderFrontRegardless()
} }
// MARK: - Lifecycle // MARK: - Lifecycle

View File

@ -31,7 +31,7 @@ struct OnboardingTextItem: View {
.opacity(self.unavailable ? 0.5 : 1) .opacity(self.unavailable ? 0.5 : 1)
Text(description.localizedForSwiftUI) Text(description.localizedForSwiftUI)
.foregroundColor(Color.secondary) .foregroundColor(Color.secondary)
.font(.system(size: 13)) .font(.system(size: 12))
.lineLimit(4) .lineLimit(4)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(minWidth: 0, maxWidth: 800, alignment: .leading) .frame(minWidth: 0, maxWidth: 800, alignment: .leading)
@ -51,7 +51,7 @@ struct OnboardingView: View {
HStack { HStack {
Image(nsImage: NSApp.applicationIconImage) Image(nsImage: NSApp.applicationIconImage)
.resizable() .resizable()
.frame(width: 80, height: 80) .frame(width: 100, height: 100)
.padding(.bottom, 5) .padding(.bottom, 5)
.padding(.trailing, 25) .padding(.trailing, 25)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@ -126,20 +126,19 @@ struct OnboardingView: View {
Button("onboarding.tour.close".localized) { Button("onboarding.tour.close".localized) {
App.shared.onboardingWindowController?.close() App.shared.onboardingWindowController?.close()
} }
.padding(.bottom, 5) .padding(.bottom, 15)
.padding(.top, 10) .padding(.top, 10)
} }
} }
.padding(.leading) .padding(.leading)
.padding(.trailing) .padding(.trailing)
.padding(.bottom, 0)
} }
.frame(width: 600)
.fixedSize(horizontal: true, vertical: false)
} }
} }
struct OnboardingView_Previews: PreviewProvider { #Preview {
static var previews: some View { OnboardingView()
Group {
OnboardingView()
}
}
} }

View File

@ -27,7 +27,7 @@ class OnboardingWindowController: PMWindowController {
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.delegate = delegate ?? windowController window.delegate = delegate ?? windowController
window.contentView = NSHostingView(rootView: OnboardingView()) window.contentView = NSHostingView(rootView: OnboardingView())
window.setContentSize(NSSize(width: 600, height: 600)) window.setContentSize(window.contentView!.fittingSize)
App.shared.onboardingWindowController = windowController App.shared.onboardingWindowController = windowController
} }

View File

@ -52,7 +52,20 @@ class BytePhpPreference: PhpPreference {
// MARK: Save Value // MARK: Save Value
private func updatedFieldValue() { private func updatedFieldValue() {
internalValue = "\(value)\(unit.rawValue)" if value == -1 {
// In case we're dealing with unlimited value, we don't need a unit
internalValue = "-1"
} else {
// We need to append the unit otherwise
internalValue = "\(value)\(unit.rawValue)"
}
do {
try PhpPreference.persistToIniFile(key: self.key, value: self.internalValue)
Log.info("The preference \(key) was updated to: \(value)")
} catch {
Log.info("The preference \(key) could not be updated")
}
} }
public static func readFrom(internalValue: String) -> (UnitOption, Int)? { public static func readFrom(internalValue: String) -> (UnitOption, Int)? {

View File

@ -15,6 +15,14 @@ class PhpPreference {
init(key: String) { init(key: String) {
self.key = key self.key = key
} }
internal static func persistToIniFile(key: String, value: String) throws {
if let file = PhpEnvironments.shared.getConfigFile(forKey: key) {
return try file.replace(key: key, value: value)
}
throw PhpConfigurationFile.ReplacementErrors.missingFile
}
} }
class BoolPhpPreference: PhpPreference { class BoolPhpPreference: PhpPreference {

View File

@ -35,12 +35,12 @@ struct PreferenceContainer<ControlView: View>: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
controlView controlView
Text(self.description.localizedForSwiftUI) Text(self.description.localizedForSwiftUI)
.lineLimit(nil)
.font(.subheadline) .font(.subheadline)
.foregroundColor(Color.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
} }
.padding(5) .padding(5)
@ -51,6 +51,7 @@ struct ByteLimitView: View {
@State private var unit: BytePhpPreference.UnitOption @State private var unit: BytePhpPreference.UnitOption
@State private var numberText: String @State private var numberText: String
@State private var unlimited: Bool @State private var unlimited: Bool
@State private var timer: Timer?
private var preference: BytePhpPreference private var preference: BytePhpPreference
@ -65,9 +66,11 @@ struct ByteLimitView: View {
if !unlimited { if !unlimited {
HStack { HStack {
TextField("", text: $numberText) TextField("", text: $numberText)
.onChange(of: numberText) { newText in .onChange(of: numberText) { [weak preference] newText in
self.preference.value = Int(newText) ?? 256 timer?.invalidate()
print(self.preference.internalValue) timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { _ in
preference?.value = Int(newText) ?? 256
}
} }
Picker("Limit Name", selection: $unit) { Picker("Limit Name", selection: $unit) {
ForEach(BytePhpPreference.UnitOption.allCases, id: \.self) { ForEach(BytePhpPreference.UnitOption.allCases, id: \.self) {
@ -79,25 +82,35 @@ struct ByteLimitView: View {
.pickerStyle(.menu) .pickerStyle(.menu)
.onChange(of: unit) { newValue in .onChange(of: unit) { newValue in
self.preference.unit = newValue self.preference.unit = newValue
print(self.preference.internalValue)
} }
} }
} }
Toggle(isOn: $unlimited) { Toggle(isOn: $unlimited) {
Label("Allow unlimited usage", systemImage: "heart").labelStyle(.titleOnly) Text("confman.byte_limit.unlimited".localizedForSwiftUI)
} }.onChange(of: unlimited, perform: { [weak preference] unlimited in
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false) { _ in
preference?.value = unlimited ? -1 : 512
preference?.unit = .megabyte
}
})
} }
} }
struct ByteLimitView_Previews: PreviewProvider { #Preview("Byte Limit View") {
static var previews: some View { PreferenceContainer(
PreferenceContainer(name: "Max Size", description: "Some maximum size") { name: "Max Size",
ByteLimitView(preference: BytePhpPreference(key: "max_memory")) description:
} "Here's an extensive description that is obviously way too long but it should wrap." +
"The point of the wrapping text is that is allows us to see what's going on with the layout here."
ConfigManagerView() ) {
.frame(width: 600, height: .infinity) ByteLimitView(preference: BytePhpPreference(key: "max_memory"))
.previewDisplayName("Config Manager") }.frame(width: 600, height: 200)
} }
#Preview("Config Manager") {
ConfigManagerView()
.frame(width: 600, height: .infinity)
.previewDisplayName("Config Manager")
} }

View File

@ -13,14 +13,14 @@ struct ConfigManagerView: View {
var preferences: [PhpPreference] = [ var preferences: [PhpPreference] = [
BytePhpPreference(key: "memory_limit"), BytePhpPreference(key: "memory_limit"),
BytePhpPreference(key: "post_max_size"), BytePhpPreference(key: "post_max_size"),
BoolPhpPreference(key: "file_uploads"), // BoolPhpPreference(key: "file_uploads"),
BytePhpPreference(key: "upload_max_filesize") BytePhpPreference(key: "upload_max_filesize")
] ]
var body: some View { var body: some View {
VStack { VStack {
HStack(alignment: .center, spacing: 15) { HStack(alignment: .center, spacing: 15) {
Image(systemName: "square.and.pencil.circle.fill") Image(systemName: "gearshape.fill")
.resizable() .resizable()
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
@ -51,6 +51,7 @@ struct ConfigManagerView: View {
if let preference = preference as? BytePhpPreference { if let preference = preference as? BytePhpPreference {
ByteLimitView(preference: preference) ByteLimitView(preference: preference)
} }
/*
if let preference = preference as? BoolPhpPreference { if let preference = preference as? BoolPhpPreference {
Toggle("", isOn: preference.$value) Toggle("", isOn: preference.$value)
.toggleStyle(.switch) .toggleStyle(.switch)
@ -59,6 +60,7 @@ struct ConfigManagerView: View {
if let preference = preference as? StringPhpPreference { if let preference = preference as? StringPhpPreference {
TextField("Placeholder", text: preference.$value) TextField("Placeholder", text: preference.$value)
} }
*/
}.frame(maxWidth: .infinity) }.frame(maxWidth: .infinity)
} }
}.padding(10) }.padding(10)
@ -67,7 +69,7 @@ struct ConfigManagerView: View {
VStack(alignment: .trailing) { VStack(alignment: .trailing) {
Button("Close", action: { Button("Close", action: {
App.shared.phpConfigManagerWindowController?.close()
}) })
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@ -78,14 +80,10 @@ struct ConfigManagerView: View {
alignment: .topTrailing alignment: .topTrailing
) )
} }
} }.frame(maxHeight: 485)
} }
} }
struct ConfigManagerView_Previews: PreviewProvider { #Preview {
static var previews: some View { ConfigManagerView().frame(width: 600)
ConfigManagerView()
.frame(width: 600)
.previewDisplayName("Live Preview")
}
} }

View File

@ -0,0 +1,46 @@
//
// ConfigManagerWindowController.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 12/09/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Cocoa
import SwiftUI
class PhpConfigManagerWindowController: PMWindowController {
// MARK: - Window Identifier
override var windowName: String {
return "ConfigManager"
}
public static func create(delegate: NSWindowDelegate?) {
let windowController = Self()
windowController.window = NSWindow()
guard let window = windowController.window else { return }
window.title = ""
window.styleMask = [.titled, .closable, .miniaturizable]
window.titlebarAppearsTransparent = true
window.delegate = delegate ?? windowController
window.contentView = NSHostingView(rootView: ConfigManagerView())
window.setContentSize(NSSize(width: 600, height: 480))
App.shared.phpConfigManagerWindowController = windowController
}
public static func show(delegate: NSWindowDelegate? = nil) {
if App.shared.phpConfigManagerWindowController == nil {
Self.create(delegate: delegate)
}
App.shared.phpConfigManagerWindowController?.showWindow(self)
App.shared.phpConfigManagerWindowController?.positionWindowInTopRightCorner()
NSApp.activate(ignoringOtherApps: true)
App.shared.phpConfigManagerWindowController?.window?.orderFrontRegardless()
}
}

View File

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

View File

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

View File

@ -89,19 +89,19 @@ struct PhpDoctorView: View {
} }
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listStyle(.plain) .listStyle(.plain)
.frame(maxHeight: .infinity, alignment: .top) .frame(minHeight: 350, maxHeight: .infinity, alignment: .top)
} }
.frame(width: 600)
.fixedSize(horizontal: true, vertical: false)
} }
} }
struct WarningListView_Previews: PreviewProvider { #Preview("Empty List") {
static var previews: some View { PhpDoctorView(empty: true, fake: true, manager: WarningManager())
PhpDoctorView(empty: true, fake: true, manager: WarningManager()) .frame(width: 600, height: 480)
.frame(width: 600, height: 480) }
.previewDisplayName("Empty List")
#Preview("List With All Warnings") {
PhpDoctorView(empty: false, fake: true, manager: WarningManager()) PhpDoctorView(empty: false, fake: true, manager: WarningManager())
.frame(width: 600, height: 480) .frame(width: 600, height: 480)
.previewDisplayName("List With All Warnings")
}
} }

View File

@ -27,7 +27,7 @@ class PhpDoctorWindowController: PMWindowController {
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.delegate = delegate ?? windowController window.delegate = delegate ?? windowController
window.contentView = NSHostingView(rootView: PhpDoctorView()) window.contentView = NSHostingView(rootView: PhpDoctorView())
window.setContentSize(NSSize(width: 600, height: 480)) window.setContentSize(window.contentView!.fittingSize)
App.shared.phpDoctorWindowController = windowController App.shared.phpDoctorWindowController = windowController
} }
@ -41,5 +41,6 @@ class PhpDoctorWindowController: PMWindowController {
App.shared.phpDoctorWindowController?.window?.setCenterPosition(offsetY: 70) App.shared.phpDoctorWindowController?.window?.setCenterPosition(offsetY: 70)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
App.shared.phpDoctorWindowController?.window?.orderFrontRegardless()
} }
} }

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