Compare commits
167 Commits
Author | SHA1 | Date | |
---|---|---|---|
9a175e7291 | |||
d53e92ce94 | |||
3344fdb1dc | |||
020a0260f1 | |||
3ce7e8f48b | |||
ae7e13de9b | |||
c82ea1fac1 | |||
d6258f54a9 | |||
e6d2c873a5 | |||
300b10c5d8 | |||
6eea08cd4f | |||
3c946a53e8 | |||
d8738b685f | |||
55fc90bcf5 | |||
6a2f4d248c | |||
a50eb04f3c | |||
4cbfbeb4e5 | |||
18dd597d38 | |||
e5c80ab52f | |||
0b3a83c1e4 | |||
6e7c0d827c | |||
d05f39efe7 | |||
27894e4884 | |||
f153fee05c | |||
71e1ed1b93 | |||
61ecefb6e7 | |||
422a7738bd | |||
0beda388eb | |||
684a53fc4a | |||
456948ffd9 | |||
a090cbc20b | |||
7b07520440 | |||
6d52992c9d | |||
291d20b2b3 | |||
3d505aebde | |||
d854dee2a1 | |||
67e8589834 | |||
a36c9b1563 | |||
df1b1c5856 | |||
5af7214320 | |||
e20d3ffd22 | |||
be70559d4c | |||
61988a141b | |||
3bcf52bf0a | |||
296bc486c4 | |||
59f60b5013 | |||
de2c1aca5d | |||
44c1ea7de4 | |||
923f0237e8 | |||
ff7c68ddfb | |||
bf2c0c259f | |||
0fdd1264ed | |||
c85a8e3818 | |||
15fe5e4716 | |||
de6dea066e | |||
ee230f3086 | |||
bc96b50630 | |||
d6554ceea9 | |||
4b04f70638 | |||
d49e74fab1 | |||
e34dadcb9b | |||
a696a9a386 | |||
837392d606 | |||
912d9e7423 | |||
cb98d40bef | |||
1f165058b2 | |||
04c78eba35 | |||
4bfde7b062 | |||
5f39cd757a | |||
073b7cf943 | |||
31a0bb986f | |||
81183acea8 | |||
3f8739dc30 | |||
c3e55df9fb | |||
c6aa06842c | |||
a801174f0a | |||
fa5c843619 | |||
bc739e1982 | |||
a21d928a6c | |||
76d96b3507 | |||
4de7179d1c | |||
f2d5b94831 | |||
ff5fdd82b1 | |||
4f6bae87d4 | |||
ce44166b48 | |||
5caca85d7a | |||
fa2d2105c5 | |||
8417d637fe | |||
e8c85f93f9 | |||
24659d4385 | |||
786b59aa92 | |||
771f8dc757 | |||
e18db4eadd | |||
1ece5c34bf | |||
04ed29bc9f | |||
ac2184ba97 | |||
8310cf2729 | |||
696f9bf351 | |||
17b1329d71 | |||
4bfa98fc20 | |||
658cec27c1 | |||
1c0d9f6826 | |||
507d7d5b23 | |||
4b8b46a822 | |||
ea5dd3bc46 | |||
83657fee6f | |||
4c752b6a15 | |||
5e3e0c087b | |||
273070ef27 | |||
cb3208c008 | |||
d401fe997d | |||
eaf6ef658f | |||
d91e16d674 | |||
008603b8c3 | |||
728274aaca | |||
daaece3cfa | |||
eaa74b7141 | |||
8a6656d3e2 | |||
ad46f51d73 | |||
12a4efc775 | |||
ec4c4df5fd | |||
b0f27dcfa5 | |||
f5d2ec2b7e | |||
6db5cdec25 | |||
03cf4ef3e4 | |||
6feb8118d9 | |||
45a82b2c9e | |||
ed3622cc4e | |||
e2ab7f08ed | |||
ad41e3891e | |||
108ae05c1d | |||
fb1ca60240 | |||
3267dc8add | |||
1fd7db15a7 | |||
0b33116eb0 | |||
2c25bcbdb5 | |||
8a6139d5e7 | |||
953ccb3792 | |||
c26c491340 | |||
c9a5cd3a9f | |||
86eb295489 | |||
572330eaa1 | |||
5ebafdb4e3 | |||
ffffcad84b | |||
4c11fae541 | |||
99da328921 | |||
bbac2632a2 | |||
513a86ec39 | |||
a59efb7fce | |||
3483569410 | |||
5399bddfeb | |||
a682d0cfb0 | |||
e871a00490 | |||
1d396202db | |||
39769d815f | |||
90a69338f7 | |||
3f25759d4f | |||
4494a0555f | |||
0d86f3ded6 | |||
fc27131cca | |||
bb124bd0ee | |||
c35e7781f4 | |||
61528cea46 | |||
883f5a1a5d | |||
95729c5315 | |||
f02e45486e | |||
6ddddc744a |
@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
|
||||
BuildableName = "PHP Monitor.app"
|
||||
BlueprintName = "PHP Monitor"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug.Dev"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
|
||||
BuildableName = "Unit Tests.xctest"
|
||||
BlueprintName = "Unit Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C471E7BB28F9B90F0021E251"
|
||||
BuildableName = "UI Tests.xctest"
|
||||
BlueprintName = "UI Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C471E7AC28F9B4940021E251"
|
||||
BuildableName = "Feature Tests.xctest"
|
||||
BlueprintName = "Feature Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug.Dev"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
|
||||
BuildableName = "PHP Monitor.app"
|
||||
BlueprintName = "PHP Monitor"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "--v"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--configuration:~/.phpmon_fconf_working.json"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--configuration:~/.phpmon_fconf_broken.json"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "EXTREME_DOCTOR_MODE"
|
||||
value = ""
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
|
||||
value = ""
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release.Dev"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
|
||||
BuildableName = "PHP Monitor.app"
|
||||
BlueprintName = "PHP Monitor"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug.Dev">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release.Dev"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.3">
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
@ -27,14 +27,42 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:PHP Monitor.xcodeproj/PHP Monitor.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
|
||||
BuildableName = "phpmon-tests.xctest"
|
||||
BlueprintName = "phpmon-tests"
|
||||
BuildableName = "Unit Tests.xctest"
|
||||
BlueprintName = "Unit Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C471E7AC28F9B4940021E251"
|
||||
BuildableName = "Feature Tests.xctest"
|
||||
BlueprintName = "Feature Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C471E7BB28F9B90F0021E251"
|
||||
BuildableName = "UI Tests.xctest"
|
||||
BlueprintName = "UI Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
@ -73,6 +101,11 @@
|
||||
value = ""
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "SLOW_SHELL_MODE"
|
||||
value = ""
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
|
||||
value = ""
|
||||
|
@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
|
||||
BuildableName = "Unit Tests.xctest"
|
||||
BlueprintName = "Unit Tests"
|
||||
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
60
README.md
@ -79,29 +79,71 @@ If you're still having issues, here's a few common questions & answers, as well
|
||||
<details>
|
||||
<summary><strong>Which versions of PHP are supported?</strong></summary>
|
||||
|
||||
The following versions of PHP are officially supported:
|
||||
|
||||
<ul>
|
||||
<li>PHP 5.6 (only if you are running Valet 2)</li>
|
||||
<li>PHP 7.0</li>
|
||||
<li>PHP 7.1</li>
|
||||
<li>PHP 7.2</li>
|
||||
<li>PHP 7.3</li>
|
||||
<li>PHP 7.4</li>
|
||||
<li>PHP 8.0</li>
|
||||
<li>PHP 8.1</li>
|
||||
<li>PHP 8.2</li>
|
||||
<li>PHP 8.3 (experimental)</li>
|
||||
</ul>
|
||||
|
||||
The following versions have some support via backport and/or dev version:
|
||||
|
||||
<ul>
|
||||
<li>PHP 5.6 (Valet 2 only)</li>
|
||||
<li>PHP 7.0 (Valet 2 and 3 only)</li>
|
||||
<li>PHP 7.1 (Valet 2 and 3 only)</li>
|
||||
<li>PHP 7.2 (Valet 2 and 3 only)</li>
|
||||
<li>PHP 7.3 (Valet 2 and 3 only)</li>
|
||||
</ul>
|
||||
|
||||
Additionally, the following dev version is also available:
|
||||
|
||||
<ul>
|
||||
<li>PHP 8.3-dev (experimental)</li>
|
||||
</ul>
|
||||
|
||||
For more details, consult the [constants file](https://github.com/nicoverbruggen/phpmon/blob/main/phpmon/Common/Core/Constants.swift#L16) file to see which versions are supported.
|
||||
|
||||
Backports are available via [this tap](https://github.com/shivammathur/homebrew-php). For more information about those backports, please see the next FAQ entry.
|
||||
|
||||
For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How do I install additional versions of PHP, including legacy versions?</strong></summary>
|
||||
|
||||
Assuming you have installed the `php` formula, the latest stable version of PHP is installed. At the time of writing, this is PHP 8.2.
|
||||
|
||||
You can install other supported versions of PHP out of the box, so `php@8.0` and `php@8.1` at the time of writing.
|
||||
|
||||
If you wish to install older (officially unsupported) versions of PHP for local use, you can do so by using [Shivam Mathur's tap](https://github.com/shivammathur/homebrew-php):
|
||||
|
||||
```sh
|
||||
brew tap shivammathur/php
|
||||
```
|
||||
|
||||
You may find that this tap is already in use: if you've used Valet before, it automatically uses this tap for legacy versions of PHP.
|
||||
|
||||
You can then install those older versions:
|
||||
|
||||
```sh
|
||||
brew install php@7.0
|
||||
brew install php@7.1
|
||||
...
|
||||
```
|
||||
|
||||
**Always make sure to restart PHP Monitor after installing or upgrading PHP versions!**
|
||||
|
||||
> *Note*: Using this tap may cause [temporary alias conflicts](https://github.com/nicoverbruggen/phpmon/issues/54#issuecomment-979789724) while the core tap alias and the tap's alias refer to a different version of PHP, but this is generally speaking a minor inconvenience, since this normally only applies when a new PHP version releases.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>I want PHP Monitor to start up when I boot my Mac!</strong></summary>
|
||||
|
||||
On macOS Ventura, you can accomplish this by going to **System Settings > General > Login Items** and adding PHP Monitor.app to the list **Open at Login**. You can do this with any application, by the way.
|
||||
|
||||
On older versions of macOS, you can do this by dragging *PHP Monitor.app* into the **Login Items** section in **System Preferences > Users & Groups** for your account.
|
||||
You can do this by dragging *PHP Monitor.app* into the **Login Items** section in **System Preferences > Users & Groups** for your account.
|
||||
|
||||
Super convenient!
|
||||
</details>
|
||||
|
@ -6,7 +6,7 @@ Generally speaking, only the latest version of **PHP Monitor** is supported, exc
|
||||
|
||||
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
|
||||
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
|
||||
| 5.x | ✅ Universal binary | ✅ Yes | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0) | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x) | 3.0 recommended<br/> 2.16.2 minimum |
|
||||
| 6.x | ✅ Universal binary | ✅ Yes | 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.4-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
|
||||
|
||||
## Legacy versions
|
||||
|
||||
@ -14,6 +14,7 @@ These versions of PHP Monitor are no longer supported, but if you’re using an
|
||||
|
||||
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version |
|
||||
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
|
||||
| 5.6 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0)* | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x) | 3.0 recommended<br/> 2.16.2 minimum |
|
||||
| 4.1 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 11+ | PHP 5.6—PHP 8.2 | 2.16.2 |
|
||||
| 4.0 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 10.14+ | PHP 5.6—PHP 8.2 | 2.13 |
|
||||
| 3.5 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 10.14+ | PHP 5.6—PHP 8.2 | 2.13 |
|
||||
|
BIN
assets/affinity/icon_se.afdesign
Normal file
@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
@ -1,332 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name":"php",
|
||||
"full_name":"php",
|
||||
"tap":"homebrew/core",
|
||||
"oldname":null,
|
||||
"aliases":[
|
||||
"php@8.0"
|
||||
],
|
||||
"versioned_formulae":[
|
||||
"php@7.4",
|
||||
"php@7.3",
|
||||
"php@7.2"
|
||||
],
|
||||
"desc":"General-purpose scripting language",
|
||||
"license":"PHP-3.01",
|
||||
"homepage":"https://www.php.net/",
|
||||
"versions":{
|
||||
"stable":"8.0.2",
|
||||
"head":"HEAD",
|
||||
"bottle":true
|
||||
},
|
||||
"urls":{
|
||||
"stable":{
|
||||
"url":"https://www.php.net/distributions/php-8.0.2.tar.xz",
|
||||
"tag":null,
|
||||
"revision":null
|
||||
}
|
||||
},
|
||||
"revision":0,
|
||||
"version_scheme":0,
|
||||
"bottle":{
|
||||
"stable":{
|
||||
"rebuild":0,
|
||||
"cellar":"/opt/homebrew/Cellar",
|
||||
"prefix":"/opt/homebrew",
|
||||
"root_url":"https://homebrew.bintray.com/bottles",
|
||||
"files":{
|
||||
"arm64_big_sur":{
|
||||
"url":"https://homebrew.bintray.com/bottles/php-8.0.2.arm64_big_sur.bottle.tar.gz",
|
||||
"sha256":"cbefa1db73d08b9af4593a44512b8d727e43033ee8517736bae5f16315501b12"
|
||||
},
|
||||
"big_sur":{
|
||||
"url":"https://homebrew.bintray.com/bottles/php-8.0.2.big_sur.bottle.tar.gz",
|
||||
"sha256":"6857142e12254b15da4e74c2986dd24faca57dac8d467b04621db349e277dd63"
|
||||
},
|
||||
"catalina":{
|
||||
"url":"https://homebrew.bintray.com/bottles/php-8.0.2.catalina.bottle.tar.gz",
|
||||
"sha256":"b651611134c18f93fdf121a4277b51b197a896a19ccb8020289b4e19e0638349"
|
||||
},
|
||||
"mojave":{
|
||||
"url":"https://homebrew.bintray.com/bottles/php-8.0.2.mojave.bottle.tar.gz",
|
||||
"sha256":"9583a51fcc6f804aadbb14e18f770d4fb4973deaed6ddc4770342e62974ffbca"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keg_only":false,
|
||||
"bottle_disabled":false,
|
||||
"options":[
|
||||
|
||||
],
|
||||
"build_dependencies":[
|
||||
"httpd",
|
||||
"pkg-config"
|
||||
],
|
||||
"dependencies":[
|
||||
"apr",
|
||||
"apr-util",
|
||||
"argon2",
|
||||
"aspell",
|
||||
"autoconf",
|
||||
"curl",
|
||||
"freetds",
|
||||
"gd",
|
||||
"gettext",
|
||||
"glib",
|
||||
"gmp",
|
||||
"icu4c",
|
||||
"krb5",
|
||||
"libffi",
|
||||
"libpq",
|
||||
"libsodium",
|
||||
"libzip",
|
||||
"oniguruma",
|
||||
"openldap",
|
||||
"openssl@1.1",
|
||||
"pcre2",
|
||||
"sqlite",
|
||||
"tidy-html5",
|
||||
"unixodbc"
|
||||
],
|
||||
"recommended_dependencies":[
|
||||
|
||||
],
|
||||
"optional_dependencies":[
|
||||
|
||||
],
|
||||
"uses_from_macos":[
|
||||
{
|
||||
"xz":"build"
|
||||
},
|
||||
"bzip2",
|
||||
"libedit",
|
||||
"libxml2",
|
||||
"libxslt",
|
||||
"zlib"
|
||||
],
|
||||
"requirements":[
|
||||
|
||||
],
|
||||
"conflicts_with":[
|
||||
|
||||
],
|
||||
"caveats":"To enable PHP in Apache add the following to httpd.conf and restart Apache:\n LoadModule php_module $(brew --prefix)/opt/php/lib/httpd/modules/libphp.so\n\n <FilesMatch \\.php$>\n SetHandler application/x-httpd-php\n </FilesMatch>\n\nFinally, check DirectoryIndex includes index.php\n DirectoryIndex index.php index.html\n\nThe php.ini and php-fpm.ini file can be found in:\n $(brew --prefix)/etc/php/8.0/\n",
|
||||
"installed":[
|
||||
{
|
||||
"version":"8.0.2",
|
||||
"used_options":[
|
||||
|
||||
],
|
||||
"built_as_bottle":true,
|
||||
"poured_from_bottle":true,
|
||||
"runtime_dependencies":[
|
||||
{
|
||||
"full_name":"apr",
|
||||
"version":"1.7.0"
|
||||
},
|
||||
{
|
||||
"full_name":"openssl@1.1",
|
||||
"version":"1.1.1i"
|
||||
},
|
||||
{
|
||||
"full_name":"apr-util",
|
||||
"version":"1.6.1"
|
||||
},
|
||||
{
|
||||
"full_name":"argon2",
|
||||
"version":"20190702"
|
||||
},
|
||||
{
|
||||
"full_name":"aspell",
|
||||
"version":"0.60.8"
|
||||
},
|
||||
{
|
||||
"full_name":"autoconf",
|
||||
"version":"2.69"
|
||||
},
|
||||
{
|
||||
"full_name":"brotli",
|
||||
"version":"1.0.9"
|
||||
},
|
||||
{
|
||||
"full_name":"gettext",
|
||||
"version":"0.21"
|
||||
},
|
||||
{
|
||||
"full_name":"libunistring",
|
||||
"version":"0.9.10"
|
||||
},
|
||||
{
|
||||
"full_name":"libidn2",
|
||||
"version":"2.3.0"
|
||||
},
|
||||
{
|
||||
"full_name":"libmetalink",
|
||||
"version":"0.1.3"
|
||||
},
|
||||
{
|
||||
"full_name":"libssh2",
|
||||
"version":"1.9.0"
|
||||
},
|
||||
{
|
||||
"full_name":"c-ares",
|
||||
"version":"1.17.1"
|
||||
},
|
||||
{
|
||||
"full_name":"jemalloc",
|
||||
"version":"5.2.1"
|
||||
},
|
||||
{
|
||||
"full_name":"libev",
|
||||
"version":"4.33"
|
||||
},
|
||||
{
|
||||
"full_name":"nghttp2",
|
||||
"version":"1.43.0"
|
||||
},
|
||||
{
|
||||
"full_name":"openldap",
|
||||
"version":"2.4.57"
|
||||
},
|
||||
{
|
||||
"full_name":"rtmpdump",
|
||||
"version":"2.4+20151223"
|
||||
},
|
||||
{
|
||||
"full_name":"zstd",
|
||||
"version":"1.4.8"
|
||||
},
|
||||
{
|
||||
"full_name":"curl",
|
||||
"version":"7.75.0"
|
||||
},
|
||||
{
|
||||
"full_name":"libtool",
|
||||
"version":"2.4.6"
|
||||
},
|
||||
{
|
||||
"full_name":"unixodbc",
|
||||
"version":"2.3.9"
|
||||
},
|
||||
{
|
||||
"full_name":"freetds",
|
||||
"version":"1.2.18"
|
||||
},
|
||||
{
|
||||
"full_name":"libpng",
|
||||
"version":"1.6.37"
|
||||
},
|
||||
{
|
||||
"full_name":"freetype",
|
||||
"version":"2.10.4"
|
||||
},
|
||||
{
|
||||
"full_name":"fontconfig",
|
||||
"version":"2.13.1"
|
||||
},
|
||||
{
|
||||
"full_name":"jpeg",
|
||||
"version":"9d"
|
||||
},
|
||||
{
|
||||
"full_name":"libtiff",
|
||||
"version":"4.2.0"
|
||||
},
|
||||
{
|
||||
"full_name":"webp",
|
||||
"version":"1.2.0"
|
||||
},
|
||||
{
|
||||
"full_name":"gd",
|
||||
"version":"2.3.1"
|
||||
},
|
||||
{
|
||||
"full_name":"libffi",
|
||||
"version":"3.3"
|
||||
},
|
||||
{
|
||||
"full_name":"pcre",
|
||||
"version":"8.44"
|
||||
},
|
||||
{
|
||||
"full_name":"gdbm",
|
||||
"version":"1.18.1"
|
||||
},
|
||||
{
|
||||
"full_name":"readline",
|
||||
"version":"8.1"
|
||||
},
|
||||
{
|
||||
"full_name":"sqlite",
|
||||
"version":"3.34.0"
|
||||
},
|
||||
{
|
||||
"full_name":"tcl-tk",
|
||||
"version":"8.6.11"
|
||||
},
|
||||
{
|
||||
"full_name":"xz",
|
||||
"version":"5.2.5"
|
||||
},
|
||||
{
|
||||
"full_name":"python@3.9",
|
||||
"version":"3.9.1"
|
||||
},
|
||||
{
|
||||
"full_name":"glib",
|
||||
"version":"2.66.6"
|
||||
},
|
||||
{
|
||||
"full_name":"gmp",
|
||||
"version":"6.2.1"
|
||||
},
|
||||
{
|
||||
"full_name":"icu4c",
|
||||
"version":"67.1"
|
||||
},
|
||||
{
|
||||
"full_name":"krb5",
|
||||
"version":"1.19"
|
||||
},
|
||||
{
|
||||
"full_name":"libpq",
|
||||
"version":"13.1"
|
||||
},
|
||||
{
|
||||
"full_name":"libsodium",
|
||||
"version":"1.0.18"
|
||||
},
|
||||
{
|
||||
"full_name":"libzip",
|
||||
"version":"1.7.3"
|
||||
},
|
||||
{
|
||||
"full_name":"oniguruma",
|
||||
"version":"6.9.6"
|
||||
},
|
||||
{
|
||||
"full_name":"pcre2",
|
||||
"version":"10.36"
|
||||
},
|
||||
{
|
||||
"full_name":"tidy-html5",
|
||||
"version":"5.6.0"
|
||||
}
|
||||
],
|
||||
"installed_as_dependency":false,
|
||||
"installed_on_request":true
|
||||
}
|
||||
],
|
||||
"linked_keg":"8.0.2",
|
||||
"pinned":false,
|
||||
"outdated":false,
|
||||
"deprecated":false,
|
||||
"deprecation_date":null,
|
||||
"deprecation_reason":null,
|
||||
"disabled":false,
|
||||
"disable_date":null,
|
||||
"disable_reason":null
|
||||
}
|
||||
]
|
@ -1,29 +0,0 @@
|
||||
//
|
||||
// PhpVersionDetectionTest.swift
|
||||
// phpmon-tests
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/04/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
class PhpVersionDetectionTest: XCTestCase {
|
||||
|
||||
func testCanDetectValidPhpVersions() throws {
|
||||
let outcome = PhpEnv.shared.extractPhpVersions(from: [
|
||||
"", // empty lines should be omitted
|
||||
"php@8.0",
|
||||
"php@8.0", // should only be detected once
|
||||
"meta-php@8.0", // should be omitted, invalid
|
||||
"php@8.0-coolio", // should be omitted, invalid
|
||||
"php@7.0",
|
||||
"",
|
||||
"unrelatedphp@1.0", // should be omitted, invalid
|
||||
"php@5.6",
|
||||
"php@5.4" // should be omitted, not supported
|
||||
], checkBinaries: false, generateHelpers: false)
|
||||
|
||||
XCTAssertEqual(outcome, ["8.0", "7.0"])
|
||||
}
|
||||
}
|
68
phpmon/Assets.xcassets/AppIconEA.appiconset/Contents.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_128x128.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_128x128@2x.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_16x16.png
Normal file
After Width: | Height: | Size: 575 B |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_16x16@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_256x256.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_256x256@2x.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_32x32@2x.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_512x512.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
phpmon/Assets.xcassets/AppIconEA.appiconset/icon_512x512@2x.png
Normal file
After Width: | Height: | Size: 41 KiB |
25
phpmon/Common/Command/ActiveCommand.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// ActiveCommand.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var Command: CommandProtocol {
|
||||
return ActiveCommand.shared
|
||||
}
|
||||
|
||||
class ActiveCommand {
|
||||
static var shared: CommandProtocol = RealCommand()
|
||||
|
||||
public static func useTestable(_ output: [String: String]) {
|
||||
Self.shared = TestableCommand(commands: output)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealCommand()
|
||||
}
|
||||
}
|
22
phpmon/Common/Command/CommandProtocol.swift
Normal file
@ -0,0 +1,22 @@
|
||||
//
|
||||
// CommandProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol CommandProtocol {
|
||||
|
||||
/**
|
||||
Immediately executes a command.
|
||||
|
||||
- Parameter path: The path of the command or program to invoke.
|
||||
- Parameter arguments: A list of arguments that are passed on.
|
||||
- Parameter trimNewlines: Removes empty new line output.
|
||||
*/
|
||||
func execute(path: String, arguments: [String], trimNewlines: Bool) -> String
|
||||
|
||||
}
|
@ -7,16 +7,9 @@
|
||||
|
||||
import Cocoa
|
||||
|
||||
public class Command {
|
||||
public class RealCommand: CommandProtocol {
|
||||
|
||||
/**
|
||||
Immediately executes a command.
|
||||
|
||||
- Parameter path: The path of the command or program to invoke.
|
||||
- Parameter arguments: A list of arguments that are passed on.
|
||||
- Parameter trimNewlines: Removes empty new line output.
|
||||
*/
|
||||
public static func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
|
||||
let task = Process()
|
||||
task.launchPath = path
|
||||
task.arguments = arguments
|
@ -12,22 +12,22 @@ class Actions {
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
public static func restartPhpFpm() {
|
||||
brew("services restart \(Homebrew.Formulae.php)", sudo: true)
|
||||
public static func restartPhpFpm() async {
|
||||
await brew("services restart \(Homebrew.Formulae.php.name)", sudo: Homebrew.Formulae.php.elevated)
|
||||
}
|
||||
|
||||
public static func restartNginx() {
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
public static func restartNginx() async {
|
||||
await brew("services restart \(Homebrew.Formulae.nginx.name)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
}
|
||||
|
||||
public static func restartDnsMasq() {
|
||||
brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
public static func restartDnsMasq() async {
|
||||
await brew("services restart \(Homebrew.Formulae.dnsmasq.name)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
}
|
||||
|
||||
public static func stopValetServices() {
|
||||
brew("services stop \(Homebrew.Formulae.php)", sudo: true)
|
||||
brew("services stop \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
public static func stopValetServices() async {
|
||||
await brew("services stop \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||
await brew("services stop \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
await brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
}
|
||||
|
||||
public static func fixHomebrewPermissions() throws {
|
||||
@ -35,13 +35,16 @@ class Actions {
|
||||
"\(Paths.brew) services stop \(Homebrew.Formulae.nginx)",
|
||||
"\(Paths.brew) services stop \(Homebrew.Formulae.dnsmasq)"
|
||||
]
|
||||
|
||||
var cellarCommands = [
|
||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.nginx)",
|
||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.dnsmasq)"
|
||||
]
|
||||
|
||||
PhpEnv.shared.availablePhpVersions.forEach { version in
|
||||
let formula = version == PhpEnv.brewPhpVersion ? "php" : "php@\(version)"
|
||||
let formula = version == PhpEnv.brewPhpAlias
|
||||
? "php"
|
||||
: "php@\(version)"
|
||||
servicesCommands.append("\(Paths.brew) services stop \(formula)")
|
||||
cellarCommands.append("chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(formula)")
|
||||
}
|
||||
@ -62,29 +65,6 @@ class Actions {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Third Party Services
|
||||
public static func stopService(name: String, completion: @escaping () -> Void) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
brew("services stop \(name)", sudo: ServicesManager.shared.rootServices.contains { $0.value.name == name })
|
||||
ServicesManager.loadHomebrewServices(completed: {
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public static func startService(name: String, completion: @escaping () -> Void) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
brew("services start \(name)", sudo: ServicesManager.shared.rootServices.contains { $0.value.name == name })
|
||||
ServicesManager.loadHomebrewServices(completed: {
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finding Config Files
|
||||
|
||||
public static func openGenericPhpConfigFolder() {
|
||||
@ -92,37 +72,33 @@ class Actions {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
|
||||
}
|
||||
|
||||
public static func openGlobalComposerFolder() {
|
||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".composer/composer.json")
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
public static func openPhpConfigFolder(version: String) {
|
||||
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php/\(version)/php.ini")]
|
||||
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
|
||||
}
|
||||
|
||||
public static func openGlobalComposerFolder() {
|
||||
let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
public static func openValetConfigFolder() {
|
||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/valet")
|
||||
let file = URL(string: "file://~/.config/valet".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
public static func openPhpMonitorConfigFile() {
|
||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/phpmon")
|
||||
let file = URL(string: "file://~/.config/phpmon".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
// MARK: - Other Actions
|
||||
|
||||
public static func createTempPhpInfoFile() -> URL {
|
||||
// Write a file called `phpmon_phpinfo.php` to /tmp
|
||||
try! "<?php phpinfo();".write(toFile: "/tmp/phpmon_phpinfo.php", atomically: true, encoding: .utf8)
|
||||
public static func createTempPhpInfoFile() async -> URL {
|
||||
try! FileSystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
|
||||
|
||||
// Tell php-cgi to run the PHP and output as an .html file
|
||||
Shell.run("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
await Shell.quiet("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
|
||||
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
|
||||
}
|
||||
@ -141,12 +117,10 @@ class Actions {
|
||||
If this does not solve the issue, the user may need to install additional
|
||||
extensions and/or run `composer global update`.
|
||||
*/
|
||||
public static func fixMyValet(completed: @escaping () -> Void) {
|
||||
InternalSwitcher().performSwitch(to: PhpEnv.brewPhpVersion, completion: {
|
||||
brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: true)
|
||||
brew("services restart \(Homebrew.Formulae.php)", sudo: true)
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
completed()
|
||||
})
|
||||
public static func fixMyValet() async {
|
||||
await InternalSwitcher().performSwitch(to: PhpEnv.brewPhpAlias)
|
||||
await brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||
await brew("services restart \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||
await brew("services restart \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||
}
|
||||
}
|
||||
|
@ -13,37 +13,43 @@ struct Constants {
|
||||
The minimum version of Valet that is recommended.
|
||||
If the installed version is older, a notification will be shown
|
||||
every time the app launches (with a recommendation to upgrade).
|
||||
|
||||
The minimum requirement is currently synced to PHP 8.1 compatibility.
|
||||
See also: https://github.com/laravel/valet/releases/tag/v2.16.2
|
||||
|
||||
See also: https://github.com/laravel/valet/releases/tag/v3.1.10
|
||||
*/
|
||||
static let MinimumRecommendedValetVersion = "2.16.2"
|
||||
static let MinimumRecommendedValetVersion = "3.1.10"
|
||||
|
||||
/**
|
||||
* The PHP versions supported by this application.
|
||||
* Versions that do not appear in this array are omitted from the list.
|
||||
* Any other PHP versions are considered invalid.
|
||||
*/
|
||||
static let SupportedPhpVersions = [
|
||||
// ====================
|
||||
// STABLE RELEASES
|
||||
// ====================
|
||||
// Versions of PHP that are stable and are supported.
|
||||
"5.6", // only supported when Valet 2.x is active
|
||||
"7.0",
|
||||
"7.1",
|
||||
"7.2",
|
||||
"7.3",
|
||||
"7.4",
|
||||
"8.0",
|
||||
"8.1",
|
||||
"8.2",
|
||||
static let DetectedPhpVersions: Set = [
|
||||
"5.6",
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2", "8.3"
|
||||
]
|
||||
|
||||
// ====================
|
||||
// EXPERIMENTAL SUPPORT
|
||||
// ====================
|
||||
// Every release that supports the next release will always support the next
|
||||
// dev release. In this case, that means that the version below is detected.
|
||||
"8.3"
|
||||
/**
|
||||
The PHP versions supported by each version of Valet.
|
||||
*/
|
||||
static let ValetSupportedPhpVersionMatrix: [Int: Set] = [
|
||||
2: // Valet v2 has the broadest legacy support
|
||||
[
|
||||
"5.6",
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2"
|
||||
],
|
||||
3: // Valet v3 dropped support for v5.6
|
||||
[
|
||||
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||
"8.0", "8.1", "8.2",
|
||||
"8.3" // dev
|
||||
],
|
||||
4: // Valet v4 dropped support for v7.0-v7.3
|
||||
[
|
||||
"7.4",
|
||||
"8.0", "8.1", "8.2",
|
||||
"8.3" // dev
|
||||
]
|
||||
]
|
||||
|
||||
struct Urls {
|
||||
|
@ -11,41 +11,49 @@
|
||||
/**
|
||||
Runs a `valet` command. Defaults to running as superuser.
|
||||
*/
|
||||
func valet(_ command: String, sudo: Bool = true) -> String {
|
||||
return Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)", requiresPath: true)
|
||||
func valet(_ command: String, sudo: Bool = true) async -> String {
|
||||
return await Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)").out
|
||||
}
|
||||
|
||||
/**
|
||||
Runs a `brew` command. Can run as superuser.
|
||||
*/
|
||||
func brew(_ command: String, sudo: Bool = false) {
|
||||
Shell.run("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
|
||||
func brew(_ command: String, sudo: Bool = false) async {
|
||||
await Shell.quiet("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
|
||||
}
|
||||
|
||||
/**
|
||||
Runs `sed` in order to replace all occurrences of a string in a specific file with another.
|
||||
*/
|
||||
func sed(file: String, original: String, replacement: String) {
|
||||
func sed(file: String, original: String, replacement: String) async {
|
||||
// Escape slashes (or `sed` won't work)
|
||||
let e_original = original.replacingOccurrences(of: "/", with: "\\/")
|
||||
let e_replacement = replacement.replacingOccurrences(of: "/", with: "\\/")
|
||||
|
||||
// Check if gsed exists; it is able to follow symlinks,
|
||||
// which we want to do to toggle the extension
|
||||
if Filesystem.fileExists("\(Paths.binPath)/gsed") {
|
||||
Shell.run("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
if FileSystem.fileExists("\(Paths.binPath)/gsed") {
|
||||
await Shell.quiet("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
} else {
|
||||
Shell.run("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
await Shell.quiet("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Uses `grep` to determine whether a particular query string can be found in a particular file.
|
||||
*/
|
||||
func grepContains(file: String, query: String) -> Bool {
|
||||
return Shell.pipe("""
|
||||
func grepContains(file: String, query: String) async -> Bool {
|
||||
return await Shell.pipe("""
|
||||
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
|
||||
""")
|
||||
""").out
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.contains("YES")
|
||||
}
|
||||
|
||||
/**
|
||||
Attempts to introduce sleep for a particular duration. Use with caution.
|
||||
Only intended for testing purposes.
|
||||
*/
|
||||
func delay(seconds: Double) async {
|
||||
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
}
|
||||
|
@ -9,17 +9,48 @@
|
||||
import Foundation
|
||||
|
||||
class Homebrew {
|
||||
static var fake: Bool = false
|
||||
|
||||
struct Formulae {
|
||||
static var php: String {
|
||||
return PhpEnv.phpInstall.formula
|
||||
static var php: HomebrewFormula {
|
||||
if Homebrew.fake {
|
||||
return HomebrewFormula("php", elevated: true)
|
||||
}
|
||||
|
||||
if PhpEnv.shared.homebrewPackage == nil {
|
||||
fatalError("You must either load the HomebrewPackage object or call `fake` on the Homebrew class.")
|
||||
}
|
||||
|
||||
return HomebrewFormula(PhpEnv.phpInstall.formula, elevated: true)
|
||||
}
|
||||
|
||||
static var nginx: String {
|
||||
return HomebrewDiagnostics.usesNginxFullFormula ? "nginx-full" : "nginx"
|
||||
static var nginx: HomebrewFormula {
|
||||
return HomebrewDiagnostics.usesNginxFullFormula
|
||||
? HomebrewFormula("nginx-full", elevated: true)
|
||||
: HomebrewFormula("nginx", elevated: true)
|
||||
}
|
||||
|
||||
static var dnsmasq: String {
|
||||
return "dnsmasq"
|
||||
static var dnsmasq: HomebrewFormula {
|
||||
return HomebrewFormula("dnsmasq", elevated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HomebrewFormula: Equatable, Hashable {
|
||||
let name: String
|
||||
let elevated: Bool
|
||||
|
||||
init(_ name: String, elevated: Bool = true) {
|
||||
self.name = name
|
||||
self.elevated = elevated
|
||||
}
|
||||
|
||||
static func == (lhs: HomebrewFormula, rhs: HomebrewFormula) -> Bool {
|
||||
return lhs.elevated == rhs.elevated && lhs.name == rhs.name
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(name)
|
||||
hasher.combine(elevated)
|
||||
}
|
||||
}
|
||||
|
@ -17,11 +17,15 @@ public class Paths {
|
||||
|
||||
internal var baseDir: Paths.HomebrewDir
|
||||
|
||||
private var userName: String
|
||||
private var userName: String! = nil
|
||||
|
||||
init() {
|
||||
baseDir = App.architecture != "x86_64" ? .opt : .usr
|
||||
userName = String(Shell.pipe("id -un").split(separator: "\n")[0])
|
||||
}
|
||||
|
||||
public func loadUser() async {
|
||||
let output = await Shell.pipe("id -un").out
|
||||
userName = String(output.split(separator: "\n")[0])
|
||||
}
|
||||
|
||||
public func detectBinaryPaths() {
|
||||
@ -58,7 +62,16 @@ public class Paths {
|
||||
}
|
||||
|
||||
public static var homePath: String {
|
||||
return NSHomeDirectory()
|
||||
if FileSystem is RealFileSystem {
|
||||
return NSHomeDirectory()
|
||||
}
|
||||
|
||||
if FileSystem is TestableFileSystem {
|
||||
let fs = FileSystem as! TestableFileSystem
|
||||
return fs.homeDirectory
|
||||
}
|
||||
|
||||
fatalError("A valid FileSystem must be allowed to return the home path")
|
||||
}
|
||||
|
||||
public static var cellarPath: String {
|
||||
@ -82,9 +95,9 @@ public class Paths {
|
||||
// (PHP Monitor will not use the user's own PATH)
|
||||
|
||||
private func detectComposerBinary() {
|
||||
if Filesystem.fileExists("/usr/local/bin/composer") {
|
||||
if FileSystem.fileExists("/usr/local/bin/composer") {
|
||||
Paths.composer = "/usr/local/bin/composer"
|
||||
} else if Filesystem.fileExists("/opt/homebrew/bin/composer") {
|
||||
} else if FileSystem.fileExists("/opt/homebrew/bin/composer") {
|
||||
Paths.composer = "/opt/homebrew/bin/composer"
|
||||
} else {
|
||||
Paths.composer = nil
|
||||
|
@ -1,33 +0,0 @@
|
||||
//
|
||||
// Shell+PATH.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 15/08/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Shell {
|
||||
|
||||
var PATH: String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/zsh"
|
||||
|
||||
let command = Filesystem.fileExists("~/.zshrc")
|
||||
// source the user's .zshrc file if it exists to complete $PATH
|
||||
? ". ~/.zshrc && echo $PATH"
|
||||
// otherwise, non-interactive mode is sufficient
|
||||
: "echo $PATH"
|
||||
|
||||
task.arguments = ["--login", "-lc", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
return String(data: data, encoding: String.Encoding.utf8) ?? ""
|
||||
}
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
//
|
||||
// Shell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
public class Shell {
|
||||
|
||||
// MARK: - Invoke static functions
|
||||
|
||||
public static func run(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) {
|
||||
Shell.user.run(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
public static func pipe(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> String {
|
||||
return Shell.user.pipe(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
/**
|
||||
We now require macOS 11, so no need to detect which terminal to use.
|
||||
*/
|
||||
public var shell: String = "/bin/sh"
|
||||
|
||||
/** Additional exports that are sent if `requiresPath` is set to true. */
|
||||
public var exports: String = ""
|
||||
|
||||
/**
|
||||
Singleton to access a user shell (with --login)
|
||||
*/
|
||||
public static let user = Shell()
|
||||
|
||||
/**
|
||||
Runs a shell command without using the output.
|
||||
Uses the default shell.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
*/
|
||||
private func run(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) {
|
||||
// Equivalent of piping to /dev/null; don't do anything with the string
|
||||
_ = Shell.pipe(command, requiresPath: requiresPath)
|
||||
}
|
||||
|
||||
/**
|
||||
Runs a shell command and returns the output.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
*/
|
||||
private func pipe(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> String {
|
||||
let shellOutput = self.executeSynchronously(command, requiresPath: requiresPath)
|
||||
let hasError = (
|
||||
shellOutput.standardOutput == ""
|
||||
&& shellOutput.errorOutput.lengthOfBytes(using: .utf8) > 0
|
||||
)
|
||||
return !hasError ? shellOutput.standardOutput : shellOutput.errorOutput
|
||||
}
|
||||
|
||||
/**
|
||||
Runs the command and returns a `ShellOutput` object, which contains info about the process.
|
||||
|
||||
- Parameter command: The command to run
|
||||
- Parameter requiresPath: By default, the PATH is not resolved but some binaries might require this
|
||||
- Parameter waitUntilExit: Waits for the command to complete before returning the `ShellOutput`
|
||||
*/
|
||||
public func executeSynchronously(
|
||||
_ command: String,
|
||||
requiresPath: Bool = false
|
||||
) -> Shell.Output {
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
|
||||
let task = self.createTask(for: command, requiresPath: requiresPath)
|
||||
task.standardOutput = outputPipe
|
||||
task.standardError = errorPipe
|
||||
task.launch()
|
||||
task.waitUntilExit()
|
||||
|
||||
let output = Shell.Output(
|
||||
standardOutput: String(
|
||||
data: outputPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
)!,
|
||||
errorOutput: String(
|
||||
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
)!,
|
||||
task: task
|
||||
)
|
||||
|
||||
if CommandLine.arguments.contains("--v") {
|
||||
log(task: task, output: output)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a new process with the correct PATH and shell.
|
||||
*/
|
||||
public func createTask(for command: String, requiresPath: Bool) -> Process {
|
||||
var completeCommand = ""
|
||||
|
||||
if requiresPath {
|
||||
// Basic export (PATH)
|
||||
completeCommand += "export PATH=\(Paths.binPath):$PATH && "
|
||||
|
||||
// Put additional exports in between
|
||||
if !self.exports.isEmpty {
|
||||
completeCommand += "\(self.exports) && "
|
||||
}
|
||||
}
|
||||
|
||||
completeCommand += command
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = self.shell
|
||||
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
Verbose logging for PHP Monitor's synchronous shell output.
|
||||
*/
|
||||
private func log(task: Process, output: Output) {
|
||||
Log.info("")
|
||||
Log.info("==== COMMAND ====")
|
||||
Log.info("")
|
||||
Log.info("\(self.shell) \(task.arguments?.joined(separator: " ") ?? "")")
|
||||
Log.info("")
|
||||
Log.info("==== OUTPUT ====")
|
||||
Log.info("")
|
||||
dump(output)
|
||||
Log.info("")
|
||||
Log.info("==== END OUTPUT ====")
|
||||
Log.info("")
|
||||
}
|
||||
|
||||
public class Output {
|
||||
public let standardOutput: String
|
||||
public let errorOutput: String
|
||||
public let task: Process
|
||||
|
||||
init(standardOutput: String,
|
||||
errorOutput: String,
|
||||
task: Process) {
|
||||
self.standardOutput = standardOutput
|
||||
self.errorOutput = errorOutput
|
||||
self.task = task
|
||||
}
|
||||
}
|
||||
}
|
21
phpmon/Common/Extensions/DataExtension.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// DataExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
var prettyPrintedJSONString: NSString? {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: self, options: []),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||
let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return prettyPrintedString
|
||||
}
|
||||
}
|
17
phpmon/Common/Extensions/DictionaryExtension.swift
Normal file
@ -0,0 +1,17 @@
|
||||
//
|
||||
// DictionaryExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Dictionary {
|
||||
mutating func renameKey(fromKey: Key, toKey: Key) {
|
||||
if let entry = removeValue(forKey: fromKey) {
|
||||
self[toKey] = entry
|
||||
}
|
||||
}
|
||||
}
|
@ -7,15 +7,38 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Localization {
|
||||
static var bundle: Bundle = {
|
||||
if !isRunningTests {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
let foundBundle = Bundle(identifier: "com.nicoverbruggen.phpmon.dev")
|
||||
?? Bundle(identifier: "com.nicoverbruggen.phpmon")
|
||||
?? Bundle(identifier: "com.nicoverbruggen.phpmon.ui-tests")
|
||||
|
||||
if foundBundle == nil {
|
||||
let bundles = Bundle.allBundles
|
||||
.map { $0.bundleIdentifier }
|
||||
.filter { $0 != nil }
|
||||
.map { $0! }
|
||||
|
||||
fatalError("The following bundles were found: \(bundles)")
|
||||
}
|
||||
|
||||
return foundBundle!
|
||||
}()
|
||||
}
|
||||
|
||||
extension String {
|
||||
var localized: String {
|
||||
if #available(macOS 13, *) {
|
||||
return NSLocalizedString(
|
||||
self, tableName: nil, bundle: Bundle.main, value: "", comment: ""
|
||||
self, tableName: nil, bundle: Localization.bundle, value: "", comment: ""
|
||||
).replacingOccurrences(of: "Preferences", with: "Settings")
|
||||
}
|
||||
|
||||
return NSLocalizedString(self, tableName: nil, bundle: Bundle.main, value: "", comment: "")
|
||||
return NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "")
|
||||
}
|
||||
|
||||
var localizedForSwiftUI: LocalizedStringKey {
|
||||
@ -42,6 +65,16 @@ extension String {
|
||||
return count
|
||||
}
|
||||
|
||||
func matches(pattern: String) -> Bool {
|
||||
let pred = NSPredicate(format: "self LIKE %@", pattern)
|
||||
return !NSArray(object: self).filtered(using: pred).isEmpty
|
||||
}
|
||||
|
||||
static func random(_ length: Int) -> String {
|
||||
let characters = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
return String((0..<length).map { _ in characters.randomElement()! })
|
||||
}
|
||||
|
||||
subscript(r: Range<String.Index>) -> String {
|
||||
let start = r.lowerBound
|
||||
let end = r.upperBound
|
||||
@ -98,5 +131,4 @@ extension String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
15
phpmon/Common/Extensions/TimeIntervalExtension.swift
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// TimeExtension.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 29/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
public static func minutes(_ amount: Int) -> TimeInterval {
|
||||
return Double(amount * 60)
|
||||
}
|
||||
}
|
26
phpmon/Common/Filesystem/ActiveFileSystem.swift
Normal file
@ -0,0 +1,26 @@
|
||||
//
|
||||
// FS.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var FileSystem: FileSystemProtocol {
|
||||
return ActiveFileSystem.shared
|
||||
}
|
||||
|
||||
class ActiveFileSystem {
|
||||
static var shared: FileSystemProtocol = RealFileSystem()
|
||||
|
||||
/** Note: Intermediate directories are not automatically inferred and have to be manually declared. */
|
||||
public static func useTestable(_ files: [String: FakeFile]) {
|
||||
Self.shared = TestableFileSystem(files: files)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealFileSystem()
|
||||
}
|
||||
}
|
50
phpmon/Common/Filesystem/FileSystemProtocol.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// FileSystemProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FileSystemProtocol {
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) throws
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String]
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws
|
||||
|
||||
func remove(_ path: String) throws
|
||||
|
||||
// MARK: — Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool
|
||||
|
||||
func anyExists(_ path: String) -> Bool
|
||||
|
||||
func fileExists(_ path: String) -> Bool
|
||||
|
||||
func directoryExists(_ path: String) -> Bool
|
||||
|
||||
func isSymlink(_ path: String) -> Bool
|
||||
|
||||
func isDirectory(_ path: String) -> Bool
|
||||
}
|
126
phpmon/Common/Filesystem/RealFileSystem.swift
Normal file
@ -0,0 +1,126 @@
|
||||
//
|
||||
// RealFileSystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingTildeWithHomeDirectory: String {
|
||||
return self.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
}
|
||||
}
|
||||
|
||||
class RealFileSystem: FileSystemProtocol {
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) {
|
||||
try! FileManager.default.createDirectory(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
withIntermediateDirectories: withIntermediateDirectories
|
||||
)
|
||||
}
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws {
|
||||
try content.write(
|
||||
to: URL(fileURLWithPath: path.replacingTildeWithHomeDirectory),
|
||||
atomically: true,
|
||||
encoding: String.Encoding.utf8
|
||||
)
|
||||
}
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String {
|
||||
return try String(
|
||||
contentsOf: URL(fileURLWithPath: path.replacingTildeWithHomeDirectory),
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String] {
|
||||
return try FileManager.default.contentsOfDirectory(atPath: path)
|
||||
}
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String {
|
||||
return try FileManager.default.destinationOfSymbolicLink(atPath: path)
|
||||
}
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws {
|
||||
try FileManager.default.moveItem(atPath: path, toPath: newPath)
|
||||
}
|
||||
|
||||
func remove(_ path: String) throws {
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
|
||||
// MARK: — FS Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws {
|
||||
_ = system("chmod +x \(path.replacingTildeWithHomeDirectory)")
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool {
|
||||
return FileManager.default.isExecutableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
) && FileManager.default.isReadableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool {
|
||||
return FileManager.default.isWritableFile(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func anyExists(_ path: String) -> Bool {
|
||||
return FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && !isDirectory.boolValue
|
||||
}
|
||||
|
||||
func directoryExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingTildeWithHomeDirectory,
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
|
||||
func isSymlink(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeSymbolicLink
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDirectory(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeDirectory
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
@ -13,8 +13,8 @@ class Alert {
|
||||
onWindow window: NSWindow,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitle: String = "OK",
|
||||
secondButtonTitle: String = "Cancel",
|
||||
buttonTitle: String = "generic.ok".localized,
|
||||
secondButtonTitle: String = "generic.cancel".localized,
|
||||
style: NSAlert.Style = .warning,
|
||||
onFirstButtonPressed: @escaping (() -> Void)
|
||||
) {
|
||||
|
@ -34,30 +34,45 @@ class Application {
|
||||
(This will open the app if it isn't open yet.)
|
||||
*/
|
||||
@objc public func openDirectory(file: String) {
|
||||
return Shell.run("/usr/bin/open -a \"\(name)\" \"\(file)\"")
|
||||
Task { await Shell.quiet("/usr/bin/open -a \"\(name)\" \"\(file)\"") }
|
||||
}
|
||||
|
||||
/** Checks if the app is installed. */
|
||||
func isInstalled() -> Bool {
|
||||
// If this script does not complain, the app exists!
|
||||
return Shell.user.executeSynchronously(
|
||||
func isInstalled() async -> Bool {
|
||||
|
||||
let (process, output) = try! await Shell.attach(
|
||||
"/usr/bin/open -Ra \"\(name)\"",
|
||||
requiresPath: false
|
||||
).task.terminationStatus == 0
|
||||
didReceiveOutput: { _, _ in },
|
||||
withTimeout: 2.0
|
||||
)
|
||||
|
||||
if Shell is TestableShell {
|
||||
// When testing, check the error output (must not be empty)
|
||||
return !output.hasError
|
||||
} else {
|
||||
// If this script does not complain, the app exists!
|
||||
return process.terminationStatus == 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Detect which apps are available to open a specific directory.
|
||||
*/
|
||||
static public func detectPresetApplications() -> [Application] {
|
||||
return [
|
||||
static public func detectPresetApplications() async -> [Application] {
|
||||
var detected: [Application] = []
|
||||
|
||||
let detectable = [
|
||||
Application("PhpStorm", .editor),
|
||||
Application("Visual Studio Code", .editor),
|
||||
Application("Sublime Text", .editor),
|
||||
Application("Sublime Merge", .git_gui),
|
||||
Application("iTerm", .terminal)
|
||||
].filter {
|
||||
return $0.isInstalled()
|
||||
]
|
||||
|
||||
for app in detectable where await app.isInstalled() {
|
||||
detected.append(app)
|
||||
}
|
||||
|
||||
return detected
|
||||
}
|
||||
}
|
||||
|
@ -1,61 +0,0 @@
|
||||
//
|
||||
// Filesystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 07/12/2021.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class Filesystem {
|
||||
|
||||
/**
|
||||
Checks if a file or directory exists at the provided path.
|
||||
*/
|
||||
public static func exists(_ path: String) -> Bool {
|
||||
return FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a file exists at the provided path.
|
||||
*/
|
||||
public static func fileExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath),
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && !isDirectory.boolValue
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a directory exists at the provided path.
|
||||
*/
|
||||
public static func directoryExists(_ path: String) -> Bool {
|
||||
var isDirectory: ObjCBool = true
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: path.replacingOccurrences(of: "~", with: Paths.homePath),
|
||||
isDirectory: &isDirectory
|
||||
)
|
||||
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if a given file is a symbolic link.
|
||||
*/
|
||||
public static func fileIsSymlink(_ path: String) -> Bool {
|
||||
do {
|
||||
let attribs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attribs[.type] as! FileAttributeType == FileAttributeType.typeSymbolicLink
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
28
phpmon/Common/Helpers/System.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// System.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Run a simple blocking Shell command on the user's own system.
|
||||
Avoid using this method in favor of the fakeable Shell class unless needed for express system operations.
|
||||
*/
|
||||
public func system(_ command: String) -> String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/sh"
|
||||
task.arguments = ["-c", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output: String = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String
|
||||
|
||||
return output
|
||||
}
|
17
phpmon/Common/Helpers/WIP.swift
Normal file
@ -0,0 +1,17 @@
|
||||
//
|
||||
// WIP.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
func todo(_ context: String = "") {
|
||||
if !context.isEmpty {
|
||||
fatalError("To be implemented: \(context)")
|
||||
}
|
||||
|
||||
fatalError("To be implemented")
|
||||
}
|
@ -17,11 +17,12 @@ import Foundation
|
||||
Using `version.short` is advisable if you want to interact with Homebrew.
|
||||
*/
|
||||
class ActivePhpInstallation {
|
||||
|
||||
var version: Version!
|
||||
var version: VersionNumber!
|
||||
var limits: Limits!
|
||||
var iniFiles: [PhpConfigurationFile] = []
|
||||
|
||||
var hasErrorState: Bool = false
|
||||
|
||||
var extensions: [PhpExtension] {
|
||||
return iniFiles.flatMap { initFile in
|
||||
return initFile.extensions
|
||||
@ -31,20 +32,25 @@ class ActivePhpInstallation {
|
||||
// MARK: - Computed
|
||||
|
||||
var formula: String {
|
||||
return (version.short == PhpEnv.brewPhpVersion) ? "php" : "php@\(version.short)"
|
||||
return (version.short == PhpEnv.brewPhpAlias) ? "php" : "php@\(version.short)"
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init() {
|
||||
// Show information about the current version
|
||||
getVersion()
|
||||
do {
|
||||
try determineVersion()
|
||||
} catch {
|
||||
// TODO: In future versions of PHP Monitor, this should not crash
|
||||
fatalError("Could not determine or parse PHP version; aborting")
|
||||
}
|
||||
|
||||
// Initialize the list of ini files that are loaded
|
||||
iniFiles = []
|
||||
|
||||
// If an error occurred, exit early
|
||||
if version.error {
|
||||
if self.hasErrorState {
|
||||
limits = Limits()
|
||||
return
|
||||
}
|
||||
@ -64,10 +70,14 @@ class ActivePhpInstallation {
|
||||
)
|
||||
|
||||
// Return a list of .ini files parsed after php.ini
|
||||
let paths = Command.execute(path: Paths.php, arguments: ["-r", "echo php_ini_scanned_files();"])
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.split(separator: ",")
|
||||
.map { String($0) }
|
||||
let paths = Command.execute(
|
||||
path: Paths.php,
|
||||
arguments: ["-r", "echo php_ini_scanned_files();"],
|
||||
trimNewlines: false
|
||||
)
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.split(separator: ",")
|
||||
.map { String($0) }
|
||||
|
||||
// See if any extensions are present in said .ini files
|
||||
paths.forEach { (iniFilePath) in
|
||||
@ -81,26 +91,12 @@ class ActivePhpInstallation {
|
||||
When the app tries to retrieve the version, the installation is considered broken if the output is nothing,
|
||||
_or_ if the output contains the word "Warning" or "Error". In normal situations this should not be the case.
|
||||
*/
|
||||
private func getVersion() {
|
||||
self.version = Version()
|
||||
private func determineVersion() throws {
|
||||
let output = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
|
||||
|
||||
let version = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
|
||||
self.hasErrorState = (output == "" || output.contains("Warning") || output.contains("Error"))
|
||||
|
||||
if version == "" || version.contains("Warning") || version.contains("Error") {
|
||||
self.version.short = "💩 BROKEN"
|
||||
self.version.long = ""
|
||||
self.version.error = true
|
||||
return
|
||||
}
|
||||
|
||||
// That's the long version
|
||||
self.version.long = version
|
||||
|
||||
// Next up, let's strip away the minor version number
|
||||
let segments = self.version.long.components(separatedBy: ".")
|
||||
|
||||
// Get the first two elements
|
||||
self.version.short = segments[0...1].joined(separator: ".")
|
||||
self.version = try? VersionNumber.parse(output)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,7 +115,7 @@ class ActivePhpInstallation {
|
||||
- Parameter key: The key of the `ini` value that needs to be retrieved. For example, you can use `memory_limit`.
|
||||
*/
|
||||
private func getByteCount(key: String) -> String {
|
||||
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"])
|
||||
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"], trimNewlines: false)
|
||||
|
||||
// Check if the value is unlimited
|
||||
if value == "-1" {
|
||||
@ -139,31 +135,20 @@ class ActivePhpInstallation {
|
||||
versions of PHP, we can just check for the existence of the `valet-fpm.conf` file. If the check here fails,
|
||||
that means that Valet won't work properly.
|
||||
*/
|
||||
func checkPhpFpmStatus() -> Bool {
|
||||
func checkPhpFpmStatus() async -> Bool {
|
||||
if self.version.short == "5.6" {
|
||||
// The main PHP config file should contain `valet.sock` and then we're probably fine?
|
||||
let fileName = "\(Paths.etcPath)/php/5.6/php-fpm.conf"
|
||||
return Shell.pipe("cat \(fileName)").contains("valet.sock")
|
||||
return await Shell.pipe("cat \(fileName)").out
|
||||
.contains("valet.sock")
|
||||
}
|
||||
|
||||
// Make sure to check if valet-fpm.conf exists. If it does, we should be fine :)
|
||||
return Filesystem.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
|
||||
return FileSystem.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/**
|
||||
Struct containing information about the version number of the current PHP installation.
|
||||
Also includes information about whether the install is considered "broken" or not.
|
||||
If an error was found in the terminal output, `error` is set to `true` and the installation
|
||||
can be considered broken. (The app will display this as well.)
|
||||
*/
|
||||
struct Version {
|
||||
var short = "???"
|
||||
var long = "???"
|
||||
var error = false
|
||||
}
|
||||
|
||||
/**
|
||||
Struct containing information about the limits of the current PHP installation.
|
||||
Includes: memory limit, max upload size and max post size.
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct HomebrewService: Decodable, Equatable {
|
||||
final class HomebrewService: Sendable, Decodable {
|
||||
let name: String
|
||||
let service_name: String
|
||||
let running: Bool
|
||||
@ -19,10 +19,32 @@ struct HomebrewService: Decodable, Equatable {
|
||||
let log_path: String?
|
||||
let error_log_path: String?
|
||||
|
||||
init(
|
||||
name: String,
|
||||
service_name: String,
|
||||
running: Bool,
|
||||
loaded: Bool,
|
||||
pid: Int? = nil,
|
||||
user: String? = nil,
|
||||
status: String? = nil,
|
||||
log_path: String? = nil,
|
||||
error_log_path: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.service_name = service_name
|
||||
self.running = running
|
||||
self.loaded = loaded
|
||||
self.pid = pid
|
||||
self.user = user
|
||||
self.status = status
|
||||
self.log_path = log_path
|
||||
self.error_log_path = error_log_path
|
||||
}
|
||||
|
||||
/**
|
||||
Dummy data for preview purposes.
|
||||
*/
|
||||
public static func dummy(named service: String, enabled: Bool) -> Self {
|
||||
public static func dummy(named service: String, enabled: Bool) -> HomebrewService {
|
||||
return HomebrewService(
|
||||
name: service,
|
||||
service_name: service,
|
||||
|
@ -14,15 +14,17 @@ class PhpEnv {
|
||||
|
||||
init() {
|
||||
self.currentInstall = ActivePhpInstallation()
|
||||
}
|
||||
|
||||
let brewPhpAlias = Shell.pipe("\(Paths.brew) info php --json")
|
||||
func determinePhpAlias() async {
|
||||
let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
|
||||
|
||||
self.homebrewPackage = try! JSONDecoder().decode(
|
||||
[HomebrewPackage].self,
|
||||
from: brewPhpAlias.data(using: .utf8)!
|
||||
).first!
|
||||
|
||||
Log.info("When on your system, the `php` formula means version \(homebrewPackage.version)!")
|
||||
Log.info("[BREW] On your system, the `php` formula means version \(homebrewPackage.version)!")
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
@ -36,14 +38,17 @@ class PhpEnv {
|
||||
/** Whether the switcher is busy performing any actions. */
|
||||
var isBusy: Bool = false
|
||||
|
||||
/** All available versions of PHP. */
|
||||
/** All versions of PHP that are currently supported. */
|
||||
var availablePhpVersions: [String] = []
|
||||
|
||||
/** All versions of PHP that are currently installed but not compatible. */
|
||||
var incompatiblePhpVersions: [String] = []
|
||||
|
||||
/** Cached information about the PHP installations. */
|
||||
var cachedPhpInstallations: [String: PhpInstallation] = [:]
|
||||
|
||||
/** Information about the currently linked PHP installation. */
|
||||
var currentInstall: ActivePhpInstallation
|
||||
var currentInstall: ActivePhpInstallation!
|
||||
|
||||
/**
|
||||
The version that the `php` formula via Brew is aliased to on the current system.
|
||||
@ -54,7 +59,9 @@ class PhpEnv {
|
||||
|
||||
As such, we take that information from Homebrew.
|
||||
*/
|
||||
static var brewPhpVersion: String {
|
||||
static var brewPhpAlias: String {
|
||||
if Homebrew.fake { return "8.2" }
|
||||
|
||||
return Self.shared.homebrewPackage.version
|
||||
}
|
||||
|
||||
@ -76,17 +83,21 @@ class PhpEnv {
|
||||
return InternalSwitcher()
|
||||
}
|
||||
|
||||
public static func detectPhpVersions() {
|
||||
_ = Self.shared.detectPhpVersions()
|
||||
public static func detectPhpVersions() async {
|
||||
_ = await Self.shared.detectPhpVersions()
|
||||
}
|
||||
|
||||
/**
|
||||
Detects which versions of PHP are installed.
|
||||
*/
|
||||
public func detectPhpVersions() -> [String] {
|
||||
let files = Shell.pipe("ls \(Paths.optPath) | grep php@")
|
||||
public func detectPhpVersions() async -> Set<String> {
|
||||
let files = await Shell.pipe("ls \(Paths.optPath) | grep php@").out
|
||||
|
||||
var versionsOnly = extractPhpVersions(from: files.components(separatedBy: "\n"))
|
||||
let versions = await extractPhpVersions(from: files.components(separatedBy: "\n"))
|
||||
|
||||
let supportedByValet = Constants.ValetSupportedPhpVersionMatrix[Valet.shared.version.major] ?? []
|
||||
|
||||
var supportedVersions = versions.intersection(supportedByValet)
|
||||
|
||||
// Make sure the aliased version is detected
|
||||
// The user may have `php` installed, but not e.g. `php@8.0`
|
||||
@ -94,13 +105,18 @@ class PhpEnv {
|
||||
let phpAlias = homebrewPackage.version
|
||||
|
||||
// Avoid inserting a duplicate
|
||||
if !versionsOnly.contains(phpAlias) && Filesystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
||||
versionsOnly.append(phpAlias)
|
||||
if !supportedVersions.contains(phpAlias) && FileSystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
||||
supportedVersions.insert(phpAlias)
|
||||
}
|
||||
|
||||
Log.info("The PHP versions that were detected are: \(versionsOnly)")
|
||||
availablePhpVersions = Array(supportedVersions)
|
||||
.sorted(by: { $0.versionCompare($1) == .orderedDescending })
|
||||
|
||||
availablePhpVersions = versionsOnly
|
||||
incompatiblePhpVersions = Array(versions.subtracting(supportedByValet))
|
||||
.sorted(by: { $0.versionCompare($1) == .orderedDescending })
|
||||
|
||||
Log.info("The PHP versions that were detected are: \(availablePhpVersions)")
|
||||
Log.info("The PHP versions that were unsupported are: \(incompatiblePhpVersions)")
|
||||
|
||||
var mappedVersions: [String: PhpInstallation] = [:]
|
||||
|
||||
@ -110,7 +126,7 @@ class PhpEnv {
|
||||
|
||||
cachedPhpInstallations = mappedVersions
|
||||
|
||||
return versionsOnly
|
||||
return supportedVersions
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,15 +140,9 @@ class PhpEnv {
|
||||
from versions: [String],
|
||||
checkBinaries: Bool = true,
|
||||
generateHelpers: Bool = true
|
||||
) -> [String] {
|
||||
var output: [String] = []
|
||||
|
||||
var supported = Constants.SupportedPhpVersions
|
||||
|
||||
if !Valet.enabled(feature: .supportForPhp56) {
|
||||
supported.removeAll { $0 == "5.6" }
|
||||
}
|
||||
|
||||
) async -> Set<String> {
|
||||
let supported = Constants.DetectedPhpVersions
|
||||
var output: Set<String> = []
|
||||
versions.filter { (version) -> Bool in
|
||||
// Omit everything that doesn't start with php@
|
||||
// (e.g. something-php@8.0 won't be detected)
|
||||
@ -143,19 +153,21 @@ class PhpEnv {
|
||||
// is supported and where the binary exists (avoids broken installs)
|
||||
if !output.contains(version)
|
||||
&& supported.contains(version)
|
||||
&& (checkBinaries ? Filesystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
||||
output.append(version)
|
||||
&& (checkBinaries ? FileSystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
||||
output.insert(version)
|
||||
}
|
||||
}
|
||||
|
||||
if generateHelpers {
|
||||
output.forEach { PhpHelper.generate(for: $0) }
|
||||
for item in output {
|
||||
await PhpHelper.generate(for: item)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
public func validVersions(for constraint: String) -> [PhpVersionNumber] {
|
||||
public func validVersions(for constraint: String) -> [VersionNumber] {
|
||||
constraint.split(separator: "|").flatMap {
|
||||
return PhpVersionNumberCollection
|
||||
.make(from: self.availablePhpVersions)
|
||||
@ -169,6 +181,9 @@ class PhpEnv {
|
||||
public func validate(_ version: String) -> Bool {
|
||||
if self.currentInstall.version.short == version {
|
||||
Log.info("Switching to version \(version) seems to have succeeded. Validation passed.")
|
||||
Log.info("Keeping track that this is the new version!")
|
||||
Stats.persistCurrentGlobalPhpVersion(version: version)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ class PhpHelper {
|
||||
|
||||
static let keyPhrase = "This file was automatically generated by PHP Monitor."
|
||||
|
||||
public static func generate(for version: String) {
|
||||
public static func generate(for version: String) async {
|
||||
// Take the PHP version (e.g. "7.2") and generate a dotless version
|
||||
let dotless = version.replacingOccurrences(of: ".", with: "")
|
||||
|
||||
@ -20,79 +20,82 @@ class PhpHelper {
|
||||
let destination = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||
|
||||
// Check if the ~/.config/phpmon/bin directory is in the PATH
|
||||
let inPath = Shell.user.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
|
||||
let inPath = Shell.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
|
||||
|
||||
// Check if we can create symlinks (`/usr/local/bin` must be writable)
|
||||
let canWriteSymlinks = FileManager.default.isWritableFile(atPath: "/usr/local/bin/")
|
||||
let canWriteSymlinks = FileSystem.isWriteableFile("/usr/local/bin/")
|
||||
|
||||
do {
|
||||
Shell.run("mkdir -p ~/.config/phpmon/bin")
|
||||
|
||||
if FileManager.default.fileExists(atPath: destination) {
|
||||
let contents = try String(contentsOfFile: destination)
|
||||
if !contents.contains(keyPhrase) {
|
||||
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
|
||||
+ "(or is unreadable). Not updating this file.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Let's follow the symlink to the PHP binary folder
|
||||
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
|
||||
.resolvingSymlinksInPath().path
|
||||
|
||||
// The contents of the script!
|
||||
let script = """
|
||||
#!/bin/zsh
|
||||
# \(keyPhrase)
|
||||
# It reflects the location of PHP \(version)'s binaries on your system.
|
||||
# Usage: . pm\(dotless)
|
||||
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
|
||||
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|
||||
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
|
||||
export PATH=\(path):$PATH
|
||||
"""
|
||||
|
||||
// Write to the destination
|
||||
try script.write(
|
||||
to: URL(fileURLWithPath: destination),
|
||||
atomically: true,
|
||||
encoding: String.Encoding.utf8
|
||||
)
|
||||
|
||||
// Make sure the file is executable
|
||||
Shell.run("chmod +x \(destination)")
|
||||
|
||||
// Create a symlink if the folder is not in the PATH
|
||||
if !inPath {
|
||||
// First, check if we can create symlinks at all
|
||||
if !canWriteSymlinks {
|
||||
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
|
||||
return
|
||||
Task { // Create the appropriate folders and check if the files exist
|
||||
do {
|
||||
if !FileSystem.directoryExists("~/.config/phpmon/bin") {
|
||||
try FileSystem.createDirectory(
|
||||
"~/.config/phpmon/bin",
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
// Write the symlink
|
||||
self.createSymlink(dotless)
|
||||
if FileSystem.fileExists(destination) {
|
||||
let contents = try String(contentsOfFile: destination)
|
||||
if !contents.contains(keyPhrase) {
|
||||
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
|
||||
+ "(or is unreadable). Not updating this file.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Let's follow the symlink to the PHP binary folder
|
||||
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
|
||||
.resolvingSymlinksInPath().path
|
||||
|
||||
// The contents of the script!
|
||||
let script = """
|
||||
#!/bin/zsh
|
||||
# \(keyPhrase)
|
||||
# It reflects the location of PHP \(version)'s binaries on your system.
|
||||
# Usage: . pm\(dotless)
|
||||
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
|
||||
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|
||||
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
|
||||
export PATH=\(path):$PATH
|
||||
"""
|
||||
|
||||
try FileSystem.writeAtomicallyToFile(destination, content: script)
|
||||
|
||||
if !FileSystem.isExecutableFile(destination) {
|
||||
try FileSystem.makeExecutable(destination)
|
||||
}
|
||||
|
||||
// Create a symlink if the folder is not in the PATH
|
||||
if !inPath {
|
||||
// First, check if we can create symlinks at all
|
||||
if !canWriteSymlinks {
|
||||
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
|
||||
return
|
||||
}
|
||||
|
||||
// Write the symlink
|
||||
await self.createSymlink(dotless)
|
||||
}
|
||||
} catch {
|
||||
Log.err(error)
|
||||
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func createSymlink(_ dotless: String) {
|
||||
private static func createSymlink(_ dotless: String) async {
|
||||
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||
let destination = "/usr/local/bin/pm\(dotless)"
|
||||
|
||||
if !Filesystem.fileExists(destination) {
|
||||
if !FileSystem.fileExists(destination) {
|
||||
Log.info("Creating new symlink: \(destination)")
|
||||
Shell.run("ln -s \(source) \(destination)")
|
||||
await Shell.quiet("ln -s \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
if !Filesystem.fileIsSymlink(destination) {
|
||||
if !FileSystem.isSymlink(destination) {
|
||||
Log.info("Overwriting existing file with new symlink: \(destination)")
|
||||
Shell.run("ln -fs \(source) \(destination)")
|
||||
await Shell.quiet("ln -fs \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1,208 +0,0 @@
|
||||
//
|
||||
// PhpVersionNumber.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PhpVersionNumberCollection: Equatable {
|
||||
let versions: [PhpVersionNumber]
|
||||
|
||||
public static func make(from versions: [String]) -> Self {
|
||||
return PhpVersionNumberCollection(
|
||||
versions: versions.map { try! PhpVersionNumber.parse($0) }
|
||||
)
|
||||
}
|
||||
|
||||
public var first: PhpVersionNumber? {
|
||||
return self.versions.first
|
||||
}
|
||||
|
||||
public var all: [PhpVersionNumber] {
|
||||
return self.versions
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if any versions of PHP are valid for the constraint provided.
|
||||
Due to the complexity of evaluating these, a important test is maintained.
|
||||
More information on these constraints can be found here:
|
||||
https://getcomposer.org/doc/articles/versions.md#writing-version-constraints
|
||||
|
||||
- Parameter constraint: The full constraint as a string (e.g. "^7.0")
|
||||
- Parameter strict: Whether the patch version check is strict. See more below.
|
||||
|
||||
The strict mode does not matter if a patch version is provided for all versions in the collection.
|
||||
|
||||
Strict mode assumes that any PHP version lacking precise patch information, e.g. inferred
|
||||
from Homebrew corresponds to the .0 patch version of that version. The default, which is imprecise,
|
||||
assumes that the patch version is .999, which means that in all cases the patch version check is
|
||||
always going to pass.
|
||||
|
||||
**STRICT MODE (= patch precision on)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in strict mode only 8.1.? will
|
||||
be considered valid (8.0 translates to 8.0.0 and as such is older than 8.0.1, 8.1.0 is OK).
|
||||
When checking against actual PHP versions installed by the user (with patch precision), use
|
||||
strict mode.
|
||||
|
||||
**NON-STRICT MODE (= patch precision off)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in non-strict mode version 8.0
|
||||
is assumed to be equal to version 8.0.999, which is actually fine if 8.0.1 is the required version.
|
||||
In non-strict mode, the patch version is ignored for regular version checks (no caret / tilde).
|
||||
If checking compatibility with general Homebrew versions of PHP, do NOT use strict mode, since
|
||||
the patch version there is not used. (The formula php@8.0 suffices for ^8.0.1.)
|
||||
*/
|
||||
public func matching(constraint: String, strict: Bool = false) -> [PhpVersionNumber] {
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .versionOnly) {
|
||||
// Strict constraint (e.g. "7.0") -> returns specific version
|
||||
return self.versions.filter { $0.isSameAs(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .caretVersionRange) {
|
||||
// Caret range means that the major version is never higher but minor version can be higher
|
||||
// ^7.2 will be compatible with all versions between 7.2 and 8.0
|
||||
return self.versions.filter { $0.hasNewerMinorVersionOrPatch(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .tildeVersionRange) {
|
||||
// Tilde range means that most specific digit is used as the basis.
|
||||
return self.versions.filter {
|
||||
version.patch != nil
|
||||
// If a patch is provided then the minor version cannot be bumped.
|
||||
? $0.hasSameMajorAndMinorButNewerOrSamePatch(version, strict)
|
||||
// If a patch is not provided then the major version cannot be bumped.
|
||||
: $0.hasSameMajorButNewerOrSameMinor(version, strict)
|
||||
}
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .greaterThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .greaterThan) {
|
||||
return self.versions.filter { $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .smallerThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
if let version = PhpVersionNumber.make(from: constraint, type: .smallerThan) {
|
||||
return self.versions.filter { $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public struct PhpVersionNumber: Equatable, Hashable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int?
|
||||
|
||||
public func toString() -> String {
|
||||
return self.patch == nil
|
||||
? "\(major).\(minor)"
|
||||
: "\(major).\(minor).\(patch!)"
|
||||
}
|
||||
|
||||
public func patch(_ strictFallback: Bool = true, _ constraint: PhpVersionNumber? = nil) -> Int {
|
||||
return patch ?? (strictFallback ? 0 : constraint?.patch ?? 999)
|
||||
}
|
||||
|
||||
public var homebrewVersion: String {
|
||||
return "\(major).\(minor)"
|
||||
}
|
||||
|
||||
public enum MatchType: String {
|
||||
case versionOnly = #"^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case caretVersionRange = #"^\^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case tildeVersionRange = #"^~(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThanOrEqual = #"^>=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThan = #"^>(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThanOrEqual = #"^<=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThan = #"^<(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
}
|
||||
|
||||
public static func parse(_ text: String) throws -> Self {
|
||||
guard let versionText = VersionExtractor.from(text) else {
|
||||
throw VersionParseError()
|
||||
}
|
||||
|
||||
return Self.make(from: versionText)!
|
||||
}
|
||||
|
||||
public static func make(from versionString: String, type: MatchType = .versionOnly) -> Self? {
|
||||
let regex = try! NSRegularExpression(pattern: type.rawValue, options: [])
|
||||
|
||||
let match = regex.matches(
|
||||
in: versionString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: versionString.count)
|
||||
).first
|
||||
|
||||
if match != nil {
|
||||
let major = Int(
|
||||
versionString[Range(match!.range(withName: "major"), in: versionString)!]
|
||||
)!
|
||||
let minor = Int(
|
||||
versionString[Range(match!.range(withName: "minor"), in: versionString)!]
|
||||
)!
|
||||
var patch: Int?
|
||||
if let minorRange = Range(match!.range(withName: "patch"), in: versionString) {
|
||||
patch = Int(versionString[minorRange])
|
||||
}
|
||||
return Self(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Comparison Logic
|
||||
|
||||
internal func isSameAs(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor == version.minor
|
||||
&& (strict ? self.patch(strict, version) == version.patch(strict) : true)
|
||||
}
|
||||
|
||||
internal func isNewerThan(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major > version.major ||
|
||||
self.major == version.major && self.minor > version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) > version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func isOlderThan(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major < version.major ||
|
||||
self.major == version.major && self.minor < version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) < version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasNewerMinorVersionOrPatch(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major &&
|
||||
(
|
||||
(self.minor == version.minor && self.patch(strict) >= version.patch(strict, self))
|
||||
|| self.minor > version.minor
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasSameMajorAndMinorButNewerOrSamePatch(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict, version) >= version.patch(strict)
|
||||
}
|
||||
|
||||
internal func hasSameMajorButNewerOrSameMinor(_ version: PhpVersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor >= version.minor
|
||||
}
|
||||
}
|
100
phpmon/Common/PHP/PHP Version/PhpVersionNumberCollection.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// PhpVersionNumberCollection.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 06/01/2023.
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PhpVersionNumberCollection: Equatable {
|
||||
let versions: [VersionNumber]
|
||||
|
||||
public static func make(from versions: [String]) -> Self {
|
||||
return PhpVersionNumberCollection(
|
||||
versions: versions.map { try! VersionNumber.parse($0) }
|
||||
)
|
||||
}
|
||||
|
||||
public var first: VersionNumber? {
|
||||
return self.versions.first
|
||||
}
|
||||
|
||||
public var all: [VersionNumber] {
|
||||
return self.versions
|
||||
}
|
||||
|
||||
/**
|
||||
Checks if any versions of PHP are valid for the constraint provided.
|
||||
Due to the complexity of evaluating these, a important test is maintained.
|
||||
More information on these constraints can be found here:
|
||||
https://getcomposer.org/doc/articles/versions.md#writing-version-constraints
|
||||
|
||||
- Parameter constraint: The full constraint as a string (e.g. "^7.0")
|
||||
- Parameter strict: Whether the patch version check is strict. See more below.
|
||||
|
||||
The strict mode does not matter if a patch version is provided for all versions in the collection.
|
||||
|
||||
Strict mode assumes that any PHP version lacking precise patch information, e.g. inferred
|
||||
from Homebrew corresponds to the .0 patch version of that version. The default, which is imprecise,
|
||||
assumes that the patch version is .999, which means that in all cases the patch version check is
|
||||
always going to pass.
|
||||
|
||||
**STRICT MODE (= patch precision on)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in strict mode only 8.1.? will
|
||||
be considered valid (8.0 translates to 8.0.0 and as such is older than 8.0.1, 8.1.0 is OK).
|
||||
When checking against actual PHP versions installed by the user (with patch precision), use
|
||||
strict mode.
|
||||
|
||||
**NON-STRICT MODE (= patch precision off)**
|
||||
|
||||
Given versions 8.0.? and 8.1.?, but the requirement is ^8.0.1, in non-strict mode version 8.0
|
||||
is assumed to be equal to version 8.0.999, which is actually fine if 8.0.1 is the required version.
|
||||
In non-strict mode, the patch version is ignored for regular version checks (no caret / tilde).
|
||||
If checking compatibility with general Homebrew versions of PHP, do NOT use strict mode, since
|
||||
the patch version there is not used. (The formula php@8.0 suffices for ^8.0.1.)
|
||||
*/
|
||||
public func matching(constraint: String, strict: Bool = false) -> [VersionNumber] {
|
||||
if let version = VersionNumber.make(from: constraint, type: .versionOnly) {
|
||||
// Strict constraint (e.g. "7.0") -> returns specific version
|
||||
return self.versions.filter { $0.isSameAs(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .caretVersionRange) {
|
||||
// Caret range means that the major version is never higher but minor version can be higher
|
||||
// ^7.2 will be compatible with all versions between 7.2 and 8.0
|
||||
return self.versions.filter { $0.hasNewerMinorVersionOrPatch(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .tildeVersionRange) {
|
||||
// Tilde range means that most specific digit is used as the basis.
|
||||
return self.versions.filter {
|
||||
version.patch != nil
|
||||
// If a patch is provided then the minor version cannot be bumped.
|
||||
? $0.hasSameMajorAndMinorButNewerOrSamePatch(version, strict)
|
||||
// If a patch is not provided then the major version cannot be bumped.
|
||||
: $0.hasSameMajorButNewerOrSameMinor(version, strict)
|
||||
}
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .greaterThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .greaterThan) {
|
||||
return self.versions.filter { $0.isNewerThan(version, strict) }
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .smallerThanOrEqual) {
|
||||
return self.versions.filter { $0.isSameAs(version, strict) || $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
if let version = VersionNumber.make(from: constraint, type: .smallerThan) {
|
||||
return self.versions.filter { $0.isOlderThan(version, strict)}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
131
phpmon/Common/PHP/PHP Version/VersionNumber.swift
Normal file
@ -0,0 +1,131 @@
|
||||
//
|
||||
// PhpVersionNumber.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/01/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A version number that is (mostly) compatible with the semantic versioning standard.
|
||||
For more information about semantic versioning, see: https://semver.org/
|
||||
|
||||
- Note: If you want to check version constraints for PHP versions, please see `PhpVersionNumberCollection`.
|
||||
*/
|
||||
public struct VersionNumber: Equatable, Hashable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int?
|
||||
|
||||
var text: String {
|
||||
return self.patch == nil
|
||||
? "\(major).\(minor)"
|
||||
: "\(major).\(minor).\(patch!)"
|
||||
}
|
||||
|
||||
public func patch(_ strictFallback: Bool = true, _ constraint: VersionNumber? = nil) -> Int {
|
||||
return patch ?? (strictFallback ? 0 : constraint?.patch ?? 999)
|
||||
}
|
||||
|
||||
public var long: String {
|
||||
return "\(major).\(minor).\(patch ?? 0)"
|
||||
}
|
||||
|
||||
public var short: String {
|
||||
return "\(major).\(minor)"
|
||||
}
|
||||
|
||||
public enum MatchType: String {
|
||||
case versionOnly = #"^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case caretVersionRange = #"^\^(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case tildeVersionRange = #"^~(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThanOrEqual = #"^>=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case greaterThan = #"^>(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThanOrEqual = #"^<=(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
case smallerThan = #"^<(?<major>\d+).(?<minor>\d+).?(?<patch>\d+)?\z"#
|
||||
}
|
||||
|
||||
public static func parse(_ text: String) throws -> Self {
|
||||
guard let versionText = VersionExtractor.from(text) else {
|
||||
throw VersionParseError()
|
||||
}
|
||||
|
||||
return Self.make(from: versionText)!
|
||||
}
|
||||
|
||||
public static func make(from versionString: String, type: MatchType = .versionOnly) -> Self? {
|
||||
let regex = try! NSRegularExpression(pattern: type.rawValue, options: [])
|
||||
|
||||
let match = regex.matches(
|
||||
in: versionString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: versionString.count)
|
||||
).first
|
||||
|
||||
if match != nil {
|
||||
let major = Int(
|
||||
versionString[Range(match!.range(withName: "major"), in: versionString)!]
|
||||
)!
|
||||
let minor = Int(
|
||||
versionString[Range(match!.range(withName: "minor"), in: versionString)!]
|
||||
)!
|
||||
var patch: Int?
|
||||
if let minorRange = Range(match!.range(withName: "patch"), in: versionString) {
|
||||
patch = Int(versionString[minorRange])
|
||||
}
|
||||
return Self(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Comparison Logic
|
||||
|
||||
internal func isSameMajorVersionAs(_ version: VersionNumber) -> Bool {
|
||||
return self.major == version.major
|
||||
}
|
||||
|
||||
internal func isSameAs(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor == version.minor
|
||||
&& (strict ? self.patch(strict, version) == version.patch(strict) : true)
|
||||
}
|
||||
|
||||
internal func isNewerThan(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major > version.major ||
|
||||
self.major == version.major && self.minor > version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) > version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func isOlderThan(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return (
|
||||
self.major < version.major ||
|
||||
self.major == version.major && self.minor < version.minor ||
|
||||
self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict) < version.patch(strict)
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasNewerMinorVersionOrPatch(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major &&
|
||||
(
|
||||
(self.minor == version.minor && self.patch(strict) >= version.patch(strict, self))
|
||||
|| self.minor > version.minor
|
||||
)
|
||||
}
|
||||
|
||||
internal func hasSameMajorAndMinorButNewerOrSamePatch(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major && self.minor == version.minor
|
||||
&& self.patch(strict, version) >= version.patch(strict)
|
||||
}
|
||||
|
||||
internal func hasSameMajorButNewerOrSameMinor(_ version: VersionNumber, _ strict: Bool) -> Bool {
|
||||
return self.major == version.major
|
||||
&& self.minor >= version.minor
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ class PhpConfigurationFile: CreatedFromFile {
|
||||
let path = filePath.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||
|
||||
do {
|
||||
let fileContents = try String(contentsOfFile: path)
|
||||
let fileContents = try FileSystem.getStringFromFile(path)
|
||||
return Self.init(path: path, contents: fileContents)
|
||||
} catch {
|
||||
Log.warn("Could not read the PHP configuration file at: `\(filePath)`")
|
||||
|
@ -75,16 +75,23 @@ class PhpExtension {
|
||||
This simply toggles the extension in the .ini file.
|
||||
You may need to restart the other services in order for this change to apply.
|
||||
*/
|
||||
func toggle() {
|
||||
func toggle() async {
|
||||
let newLine = enabled
|
||||
// DISABLED: Commented out line
|
||||
? "; \(line)"
|
||||
// ENABLED: Line where the comment delimiter (;) is removed
|
||||
: line.replacingOccurrences(of: "; ", with: "")
|
||||
|
||||
sed(file: file, original: line, replacement: newLine)
|
||||
await sed(file: file, original: line, replacement: newLine)
|
||||
|
||||
enabled.toggle()
|
||||
|
||||
if !isRunningTests {
|
||||
Task { @MainActor in
|
||||
MainMenu.shared.rebuild()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Static Methods
|
||||
|
@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
class PhpInstallation {
|
||||
|
||||
var versionNumber: PhpVersionNumber
|
||||
var versionNumber: VersionNumber
|
||||
|
||||
/**
|
||||
In order to determine details about a PHP installation, we’ll simply run `php-config --version`
|
||||
@ -19,17 +19,18 @@ class PhpInstallation {
|
||||
init(_ version: String) {
|
||||
|
||||
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
|
||||
self.versionNumber = PhpVersionNumber.make(from: version)!
|
||||
self.versionNumber = VersionNumber.make(from: version)!
|
||||
|
||||
if Filesystem.fileExists(phpConfigExecutablePath) {
|
||||
if FileSystem.fileExists(phpConfigExecutablePath) {
|
||||
let longVersionString = Command.execute(
|
||||
path: phpConfigExecutablePath,
|
||||
arguments: ["--version"]
|
||||
arguments: ["--version"],
|
||||
trimNewlines: false
|
||||
).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// The parser should always work, or the string has to be very unusual.
|
||||
// If so, the app SHOULD crash, so that the users report what's up.
|
||||
self.versionNumber = try! PhpVersionNumber.parse(longVersionString)
|
||||
self.versionNumber = try! VersionNumber.parse(longVersionString)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,49 +15,50 @@ class InternalSwitcher: PhpSwitcher {
|
||||
- unlinking the current version
|
||||
- stopping the active services
|
||||
- linking the new desired version
|
||||
|
||||
|
||||
Please note that depending on which version is installed,
|
||||
the version that is switched to may or may not be identical to `php`
|
||||
(without @version).
|
||||
*/
|
||||
func performSwitch(to version: String, completion: @escaping () -> Void) {
|
||||
func performSwitch(to version: String) async {
|
||||
Log.info("Switching to \(version), unlinking all versions...")
|
||||
|
||||
let versions = getVersionsToBeHandled(version)
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
PhpEnv.shared.availablePhpVersions.forEach { (available) in
|
||||
group.enter()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.disableDefaultPhpFpmPool(available)
|
||||
self.stopPhpVersion(available)
|
||||
group.leave()
|
||||
await withTaskGroup(of: String.self, body: { group in
|
||||
for available in PhpEnv.shared.availablePhpVersions {
|
||||
group.addTask {
|
||||
await self.disableDefaultPhpFpmPool(available)
|
||||
await self.stopPhpVersion(available)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .global(qos: .userInitiated)) {
|
||||
Log.info("All versions have been unlinked!")
|
||||
Log.info("Linking the new version!")
|
||||
var unlinked: [String] = []
|
||||
for await version in group {
|
||||
unlinked.append(version)
|
||||
}
|
||||
|
||||
Log.info("These versions have been unlinked: \(unlinked)")
|
||||
Log.info("Linking the new version \(version)!")
|
||||
|
||||
for formula in versions {
|
||||
self.startPhpVersion(formula, primary: (version == formula))
|
||||
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
|
||||
await self.startPhpVersion(formula, primary: (version == formula))
|
||||
}
|
||||
|
||||
Log.info("Restarting nginx, just to be sure!")
|
||||
brew("services restart \(Homebrew.Formulae.nginx)", sudo: true)
|
||||
await brew("services restart nginx", sudo: true)
|
||||
|
||||
Log.info("The new version(s) have been linked!")
|
||||
completion()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getVersionsToBeHandled(_ primary: String) -> Set<String> {
|
||||
let isolated = Valet.shared.sites.filter { site in
|
||||
site.isolatedPhpVersion != nil
|
||||
}.map { site in
|
||||
return site.isolatedPhpVersion!.versionNumber.homebrewVersion
|
||||
return site.isolatedPhpVersion!.versionNumber.short
|
||||
}
|
||||
|
||||
var versions: Set<String> = [primary]
|
||||
@ -71,22 +72,22 @@ class InternalSwitcher: PhpSwitcher {
|
||||
|
||||
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
|
||||
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
return FileManager.default.fileExists(atPath: pool)
|
||||
return FileSystem.fileExists(pool)
|
||||
}
|
||||
|
||||
func disableDefaultPhpFpmPool(_ version: String) {
|
||||
func disableDefaultPhpFpmPool(_ version: String) async {
|
||||
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
if FileManager.default.fileExists(atPath: pool) {
|
||||
if FileSystem.fileExists(pool) {
|
||||
Log.info("A default `www.conf` file was found in the php-fpm.d directory for PHP \(version).")
|
||||
let existing = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf")!
|
||||
let new = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon")!
|
||||
let existing = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
||||
let new = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon"
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: new.path) {
|
||||
if FileSystem.fileExists(new) {
|
||||
Log.info("A moved `www.conf.disabled-by-phpmon` file was found for PHP \(version), "
|
||||
+ "cleaning up so the newer `www.conf` can be moved again.")
|
||||
try FileManager.default.removeItem(at: new)
|
||||
try FileSystem.remove(new)
|
||||
}
|
||||
try FileManager.default.moveItem(at: existing, to: new)
|
||||
try FileSystem.move(from: existing, to: new)
|
||||
Log.info("Success: A default `www.conf` file was disabled for PHP \(version).")
|
||||
} catch {
|
||||
Log.err(error)
|
||||
@ -94,28 +95,28 @@ class InternalSwitcher: PhpSwitcher {
|
||||
}
|
||||
}
|
||||
|
||||
func stopPhpVersion(_ version: String) {
|
||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
||||
brew("unlink \(formula)")
|
||||
brew("services stop \(formula)", sudo: true)
|
||||
func stopPhpVersion(_ version: String) async {
|
||||
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||
await brew("unlink \(formula)")
|
||||
await brew("services stop \(formula)", sudo: true)
|
||||
Log.info("Unlinked and stopped services for \(formula)")
|
||||
}
|
||||
|
||||
func startPhpVersion(_ version: String, primary: Bool) {
|
||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
||||
func startPhpVersion(_ version: String, primary: Bool) async {
|
||||
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||
|
||||
if primary {
|
||||
Log.info("\(formula) is the primary formula, linking and starting services...")
|
||||
brew("link \(formula) --overwrite --force")
|
||||
await brew("link \(formula) --overwrite --force")
|
||||
} else {
|
||||
Log.info("\(formula) is an isolated PHP version, starting services only...")
|
||||
}
|
||||
|
||||
brew("services start \(formula)", sudo: true)
|
||||
await brew("services start \(formula)", sudo: true)
|
||||
|
||||
if Valet.enabled(feature: .isolatedSites) && primary {
|
||||
let socketVersion = version.replacingOccurrences(of: ".", with: "")
|
||||
Shell.run("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
await Shell.quiet("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,6 @@ protocol PhpSwitcherDelegate: AnyObject {
|
||||
|
||||
protocol PhpSwitcher {
|
||||
|
||||
func performSwitch(to version: String, completion: @escaping () -> Void)
|
||||
func performSwitch(to version: String) async
|
||||
|
||||
}
|
||||
|
25
phpmon/Common/Shell/ActiveShell.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// Shell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 20/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var Shell: ShellProtocol {
|
||||
return ActiveShell.shared
|
||||
}
|
||||
|
||||
class ActiveShell {
|
||||
static var shared: ShellProtocol = RealShell()
|
||||
|
||||
public static func useTestable(_ expectations: [String: BatchFakeShellOutput]) {
|
||||
Self.shared = TestableShell(expectations: expectations)
|
||||
}
|
||||
|
||||
public static func useSystem() {
|
||||
Self.shared = RealShell()
|
||||
}
|
||||
}
|
164
phpmon/Common/Shell/RealShell.swift
Normal file
@ -0,0 +1,164 @@
|
||||
//
|
||||
// RealShell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Process: @unchecked Sendable {}
|
||||
extension Timer: @unchecked Sendable {}
|
||||
|
||||
class RealShell: ShellProtocol {
|
||||
/**
|
||||
The launch path of the terminal in question that is used.
|
||||
On macOS, we use /bin/sh since it's pretty fast.
|
||||
*/
|
||||
private(set) var launchPath: String = "/bin/sh"
|
||||
|
||||
/**
|
||||
For some commands, we need to know what's in the user's PATH.
|
||||
The entire PATH is retrieved here, so we can set the PATH in our own terminal as necessary.
|
||||
*/
|
||||
private(set) var PATH: String = { return RealShell.getPath() }()
|
||||
|
||||
/**
|
||||
Exports are additional environment variables set by the user via the custom configuration.
|
||||
These are populated when the configuration file is being loaded.
|
||||
*/
|
||||
var exports: String = ""
|
||||
|
||||
/** Retrieves the user's PATH by opening an interactive shell and echoing $PATH. */
|
||||
private static func getPath() -> String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/zsh"
|
||||
|
||||
// We need an interactive shell so the user's PATH is loaded in correctly
|
||||
task.arguments = ["--login", "-ilc", "echo $PATH"]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.launch()
|
||||
|
||||
return String(
|
||||
data: pipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: String.Encoding.utf8
|
||||
) ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
Create a process that will run the required shell with the appropriate arguments.
|
||||
This process still needs to be started, or one can attach output handlers.
|
||||
*/
|
||||
private func getShellProcess(for command: String) -> Process {
|
||||
var completeCommand = ""
|
||||
|
||||
// Basic export (PATH)
|
||||
completeCommand += "export PATH=\(Paths.binPath):$PATH && "
|
||||
|
||||
// Put additional exports (as defined by the user) in between
|
||||
if !self.exports.isEmpty {
|
||||
completeCommand += "\(self.exports) && "
|
||||
}
|
||||
|
||||
completeCommand += command
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = self.launchPath
|
||||
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
|
||||
return task
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/**
|
||||
Set custom environment variables.
|
||||
These will be exported when a command is executed.
|
||||
*/
|
||||
public func setCustomEnvironmentVariables(_ variables: [String: String]) {
|
||||
self.exports = variables.map { (key, value) in
|
||||
return "export \(key)=\(value)"
|
||||
}.joined(separator: "&&")
|
||||
}
|
||||
|
||||
// MARK: - Shellable Protocol
|
||||
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
let task = getShellProcess(for: command)
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
|
||||
// Seriously slow down how long it takes for the shell to return output
|
||||
// (in order to debug or identify async issues)
|
||||
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
|
||||
Log.info("[SLOW SHELL] \(command)")
|
||||
await delay(seconds: 3.0)
|
||||
}
|
||||
|
||||
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
|
||||
)!
|
||||
|
||||
return .out(stdOut, stdErr)
|
||||
}
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = await self.pipe(command)
|
||||
}
|
||||
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval = 5.0
|
||||
) async throws -> (Process, ShellOutput) {
|
||||
let process = getShellProcess(for: command)
|
||||
|
||||
let output = ShellOutput.empty()
|
||||
|
||||
process.listen { incoming in
|
||||
output.out += incoming; didReceiveOutput(incoming, .stdOut)
|
||||
} didReceiveStandardErrorData: { incoming in
|
||||
output.err += incoming; didReceiveOutput(incoming, .stdErr)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in
|
||||
// Only terminate if the process is still running
|
||||
if process.isRunning {
|
||||
process.terminationHandler = nil
|
||||
process.terminate()
|
||||
return continuation.resume(throwing: ShellError.timedOut)
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { [timer, output] process in
|
||||
timer.invalidate()
|
||||
|
||||
process.haltListening()
|
||||
|
||||
if !output.err.isEmpty {
|
||||
return continuation.resume(returning: (process, .err(output.err)))
|
||||
}
|
||||
|
||||
return continuation.resume(returning: (process, .out(output.out)))
|
||||
}
|
||||
|
||||
process.launch()
|
||||
process.waitUntilExit()
|
||||
})
|
||||
}
|
||||
}
|
83
phpmon/Common/Shell/ShellProtocol.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// ShellProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ShellProtocol {
|
||||
/**
|
||||
The PATH for the current shell.
|
||||
*/
|
||||
var PATH: String { get }
|
||||
|
||||
/**
|
||||
Run a command asynchronously.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
|
||||
Common usage:
|
||||
```
|
||||
let output = await Shell.pipe("php -v")
|
||||
```
|
||||
*/
|
||||
func pipe(_ command: String) async -> ShellOutput
|
||||
|
||||
/**
|
||||
Run a command asynchronously, without returning the output of the command.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
*/
|
||||
func quiet(_ command: String) async
|
||||
|
||||
/**
|
||||
Runs a command asynchronously, and fires closure with `stdout` or `stderr` data as it comes in.
|
||||
|
||||
You can specify how long this task should run.
|
||||
The process will always be terminated after the specified time interval.
|
||||
(Whether it is complete or not.)
|
||||
|
||||
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism.
|
||||
The end result is still the most relevant output (where error output is preferred if it exists).
|
||||
*/
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> (Process, ShellOutput)
|
||||
}
|
||||
|
||||
enum ShellStream: Codable {
|
||||
case stdOut, stdErr, stdIn
|
||||
}
|
||||
|
||||
class ShellOutput {
|
||||
var out: String
|
||||
var err: String
|
||||
|
||||
var hasError: Bool {
|
||||
return err.lengthOfBytes(using: .utf8) > 0
|
||||
}
|
||||
|
||||
init(out: String, err: String) {
|
||||
self.out = out
|
||||
self.err = err
|
||||
}
|
||||
|
||||
static func empty() -> ShellOutput {
|
||||
return ShellOutput(out: "", err: "")
|
||||
}
|
||||
|
||||
static func out(_ out: String?, _ err: String? = nil) -> ShellOutput {
|
||||
return ShellOutput(out: out ?? "", err: err ?? "")
|
||||
}
|
||||
|
||||
static func err(_ err: String?) -> ShellOutput {
|
||||
return ShellOutput(out: "", err: err ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
enum ShellError: Error {
|
||||
case timedOut
|
||||
}
|
27
phpmon/Common/Testables/TestableCommand.swift
Normal file
@ -0,0 +1,27 @@
|
||||
//
|
||||
// TestableCommand.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TestableCommand: CommandProtocol {
|
||||
init(commands: [String: String]) {
|
||||
self.commands = commands
|
||||
}
|
||||
|
||||
var commands: [String: String]
|
||||
|
||||
func execute(path: String, arguments: [String]) -> String {
|
||||
self.execute(path: path, arguments: arguments, trimNewlines: false)
|
||||
}
|
||||
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool) -> String {
|
||||
let concatenatedCommand = "\(path) \(arguments.joined(separator: " "))"
|
||||
assert(commands.keys.contains(concatenatedCommand), "Command `\(concatenatedCommand)` not found")
|
||||
return self.commands[concatenatedCommand]!
|
||||
}
|
||||
}
|
67
phpmon/Common/Testables/TestableConfiguration.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// TestableConfiguration.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TestableConfiguration: Codable {
|
||||
var architecture: String
|
||||
var filesystem: [String: FakeFile]
|
||||
var shellOutput: [String: BatchFakeShellOutput]
|
||||
var commandOutput: [String: String]
|
||||
|
||||
func apply() {
|
||||
Log.separator()
|
||||
Log.info("USING TESTABLE CONFIGURATION...")
|
||||
Homebrew.fake = true
|
||||
Log.separator()
|
||||
Log.info("Applying fake shell...")
|
||||
ActiveShell.useTestable(shellOutput)
|
||||
Log.info("Applying fake filesystem...")
|
||||
ActiveFileSystem.useTestable(filesystem)
|
||||
Log.info("Applying fake commands...")
|
||||
ActiveCommand.useTestable(commandOutput)
|
||||
Log.info("Applying fake scanner...")
|
||||
ValetScanner.useFake()
|
||||
Log.info("Applying fake services manager...")
|
||||
ServicesManager.useFake()
|
||||
Log.info("Applying fake Valet domain interactor...")
|
||||
ValetInteractor.useFake()
|
||||
}
|
||||
|
||||
func toJson(pretty: Bool = false) -> String {
|
||||
let data = try! JSONEncoder().encode(self)
|
||||
|
||||
if pretty {
|
||||
return data.prettyPrintedJSONString! as String
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)!
|
||||
}
|
||||
|
||||
static func loadFrom(path: String) -> TestableConfiguration {
|
||||
let url = URL(fileURLWithPath: path.replacingTildeWithHomeDirectory)
|
||||
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
/*
|
||||
You will need to run the `TestableConfigurationTest` test,
|
||||
which will generate two configuration files you can use.
|
||||
*/
|
||||
fatalError("Error: the expected configuration file at \(url.path) is missing!")
|
||||
}
|
||||
|
||||
/*
|
||||
If the decoder below fails to decode the configuration file,
|
||||
the configuration may have been updated.
|
||||
In that case, you will need to run the test (see above) again.
|
||||
*/
|
||||
return try! JSONDecoder().decode(
|
||||
TestableConfiguration.self,
|
||||
from: try! String(contentsOf: url, encoding: .utf8).data(using: .utf8)!
|
||||
)
|
||||
}
|
||||
}
|
288
phpmon/Common/Testables/TestableFileSystem.swift
Normal file
@ -0,0 +1,288 @@
|
||||
//
|
||||
// TestableFileSystem.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 04/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TestableFileSystem: FileSystemProtocol {
|
||||
|
||||
/**
|
||||
Initialize a fake filesystem with a bunch of files.
|
||||
You do not need to specify directories (unless symlinks), those will be created automatically.
|
||||
*/
|
||||
init(files: [String: FakeFile]) {
|
||||
self.files = files
|
||||
|
||||
// Ensure that each of the ~ characters are replaced with the home directory path
|
||||
for key in self.files.keys where key.contains("~") {
|
||||
self.files.renameKey(
|
||||
fromKey: key,
|
||||
toKey: key.replacingOccurrences(of: "~", with: self.homeDirectory)
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure that intermediate directories are created
|
||||
for file in self.files {
|
||||
self.createIntermediateDirectories(file.key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Internal file handling of the fake filesystem.
|
||||
You can easily dump what's in here by using:
|
||||
```
|
||||
let fs = FileSystem as! TestableFileSystem
|
||||
fs.printContents()
|
||||
```
|
||||
*/
|
||||
private(set) var files: [String: FakeFile]
|
||||
|
||||
/**
|
||||
The home directory for the fake filesystem.
|
||||
*/
|
||||
private(set) var homeDirectory = "/Users/fake"
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
func createDirectory(_ path: String, withIntermediateDirectories: Bool) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
if files[path] != nil {
|
||||
throw TestableFileSystemError.alreadyExists
|
||||
}
|
||||
|
||||
self.createIntermediateDirectories(path)
|
||||
|
||||
self.files[path] = .fake(.directory)
|
||||
}
|
||||
|
||||
func writeAtomicallyToFile(_ path: String, content: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
if files[path] != nil {
|
||||
throw TestableFileSystemError.alreadyExists
|
||||
}
|
||||
|
||||
self.files[path] = .fake(.text, content)
|
||||
}
|
||||
|
||||
func getStringFromFile(_ path: String) throws -> String {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
return file.content ?? ""
|
||||
}
|
||||
|
||||
func getShallowContentsOfDirectory(_ path: String) throws -> [String] {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
var seek = path
|
||||
if !seek.hasSuffix("/") {
|
||||
seek = "\(seek)/"
|
||||
}
|
||||
|
||||
return self.files.keys
|
||||
.filter { $0.hasPrefix(seek) }
|
||||
.map { $0.replacingOccurrences(of: seek, with: "") }
|
||||
.filter { !$0.contains("/") }
|
||||
}
|
||||
|
||||
func getDestinationOfSymlink(_ path: String) throws -> String {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
if file.type != .symlink {
|
||||
throw TestableFileSystemError.notSymlink
|
||||
}
|
||||
|
||||
guard let pathToSymlink = file.content else {
|
||||
throw TestableFileSystemError.invalidSymlink
|
||||
}
|
||||
|
||||
if !files.keys.contains(pathToSymlink) {
|
||||
throw TestableFileSystemError.invalidSymlink
|
||||
}
|
||||
|
||||
return pathToSymlink
|
||||
}
|
||||
|
||||
// MARK: - Move & Delete Files
|
||||
|
||||
func move(from path: String, to newPath: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
let newPath = newPath.replacingTildeWithHomeDirectory
|
||||
|
||||
self.files.keys.forEach { key in
|
||||
if key.hasPrefix(path) {
|
||||
self.files.renameKey(
|
||||
fromKey: key,
|
||||
toKey: key.replacingOccurrences(of: path, with: newPath)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
self.files.renameKey(fromKey: path, toKey: newPath)
|
||||
}
|
||||
|
||||
func remove(_ path: String) throws {
|
||||
// Remove recursively
|
||||
self.files.keys.forEach { key in
|
||||
if key.hasPrefix(path) {
|
||||
self.files.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
self.files.removeValue(forKey: path)
|
||||
}
|
||||
|
||||
// MARK: — Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
throw TestableFileSystemError.fileMissing
|
||||
}
|
||||
|
||||
file.type = .binary
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path.replacingTildeWithHomeDirectory] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .binary
|
||||
}
|
||||
|
||||
func isWriteableFile(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path.replacingTildeWithHomeDirectory] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !file.readOnly
|
||||
}
|
||||
|
||||
func anyExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
return files.keys.contains(path)
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return [.binary, .symlink, .text].contains(file.type)
|
||||
}
|
||||
|
||||
func directoryExists(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return [.directory].contains(file.type)
|
||||
}
|
||||
|
||||
func isSymlink(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .symlink
|
||||
}
|
||||
|
||||
func isDirectory(_ path: String) -> Bool {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
guard let file = files[path] else {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.type == .directory
|
||||
}
|
||||
|
||||
public func printContents() {
|
||||
for key in self.files.keys.sorted() {
|
||||
print("\(key) -> \(self.files[key]!.type)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createIntermediateDirectories(_ path: String) {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
let items = path.components(separatedBy: "/")
|
||||
|
||||
var preceding = ""
|
||||
|
||||
for item in items {
|
||||
let key = preceding == "/"
|
||||
? "/\(item)"
|
||||
: "\(preceding)/\(item)"
|
||||
|
||||
if !self.files.keys.contains(key) {
|
||||
self.files[key] = .fake(.directory)
|
||||
}
|
||||
|
||||
preceding = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FakeFileType: Codable {
|
||||
case binary, text, directory, symlink
|
||||
}
|
||||
|
||||
class FakeFile: Codable {
|
||||
var type: FakeFileType
|
||||
var content: String?
|
||||
var readOnly: Bool = false
|
||||
|
||||
init(type: FakeFileType, content: String?, readOnly: Bool = false) {
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.readOnly = readOnly
|
||||
}
|
||||
|
||||
public static func fake(
|
||||
_ type: FakeFileType,
|
||||
_ content: String? = nil,
|
||||
readOnly: Bool = false
|
||||
) -> FakeFile {
|
||||
return FakeFile(
|
||||
type: type,
|
||||
content: content,
|
||||
readOnly: readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum TestableFileSystemError: Error {
|
||||
case fileMissing
|
||||
case alreadyExists
|
||||
case notSymlink
|
||||
case invalidSymlink
|
||||
}
|
123
phpmon/Common/Testables/TestableShell.swift
Normal file
@ -0,0 +1,123 @@
|
||||
//
|
||||
// TestableShell.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class TestableShell: ShellProtocol {
|
||||
var PATH: String {
|
||||
return "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin"
|
||||
}
|
||||
|
||||
init(expectations: [String: BatchFakeShellOutput]) {
|
||||
self.expectations = expectations
|
||||
}
|
||||
|
||||
var expectations: [String: BatchFakeShellOutput] = [:]
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
}
|
||||
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
return output
|
||||
}
|
||||
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> (Process, ShellOutput) {
|
||||
|
||||
// Seriously slow down the shell's return rate in order to debug or identify async issues
|
||||
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
|
||||
Log.info("[SLOW SHELL] \(command)")
|
||||
await delay(seconds: 3.0)
|
||||
}
|
||||
|
||||
// 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 (Process(), .err("No Expected Output"))
|
||||
}
|
||||
|
||||
let output = await expectation.output(didReceiveOutput: { output, type in
|
||||
didReceiveOutput(output, type)
|
||||
}, ignoreDelay: isRunningTests)
|
||||
|
||||
return (Process(), output)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeShellOutput: Codable {
|
||||
let delay: TimeInterval
|
||||
let output: String
|
||||
let stream: ShellStream
|
||||
|
||||
static func instant(_ output: String, _ stream: ShellStream = .stdOut) -> FakeShellOutput {
|
||||
return FakeShellOutput(delay: 0, output: output, stream: stream)
|
||||
}
|
||||
|
||||
static func delayed(_ delay: TimeInterval, _ output: String, _ stream: ShellStream = .stdOut) -> FakeShellOutput {
|
||||
return FakeShellOutput(delay: delay, output: output, stream: stream)
|
||||
}
|
||||
}
|
||||
|
||||
struct BatchFakeShellOutput: Codable {
|
||||
var items: [FakeShellOutput]
|
||||
|
||||
static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: items)
|
||||
}
|
||||
|
||||
static func instant(_ output: String, _ stream: ShellStream = .stdOut) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: [.instant(output, stream)])
|
||||
}
|
||||
|
||||
static func delayed(
|
||||
_ delay: TimeInterval,
|
||||
_ output: String,
|
||||
_ stream: ShellStream = .stdOut
|
||||
) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: [.delayed(delay, output, stream)])
|
||||
}
|
||||
|
||||
/**
|
||||
Outputs the fake shell output as expected.
|
||||
*/
|
||||
public func output(
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
ignoreDelay: Bool = false
|
||||
) async -> ShellOutput {
|
||||
let output = ShellOutput.empty()
|
||||
|
||||
for item in items {
|
||||
if !ignoreDelay {
|
||||
await delay(seconds: 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.
|
||||
*/
|
||||
public func outputInstantaneously(
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void = { _, _ in }
|
||||
) async -> ShellOutput {
|
||||
return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true)
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
<p><b>Do you enjoy using the app?</b> Leave a <a href="https://phpmon.app/github">star on GitHub</a>!</p>
|
||||
<p><b>Having issues?</b> Consult the <a href="https://phpmon.app/faq">FAQ</a> section, I did my best to ensure everything is documented.</p>
|
||||
<p><b>Want to support further development of PHP Monitor?</b> You can <a href="https://phpmon.app/sponsor">financially support</a> the continued development of this app.</p>
|
||||
<p><b>Get the latest on Twitter</b> Give me a <a href="https://twitter.com/nicoverbruggen">follow on Twitter</a> to learn about the latest and greatest updates of this app.</p>
|
||||
<p><b>Get the latest on Mastodon.</b> Give me a <a href="https://phpc.social/@nicoverbruggen">follow on Mastodon</a> to learn about what's brewing and when new updates drop.</p>
|
||||
<br>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -31,10 +31,18 @@ class App {
|
||||
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
|
||||
}
|
||||
|
||||
/** Just the bundle name. */
|
||||
static var identifier: String {
|
||||
Bundle.main.infoDictionary?["CFBundleIdentifier"] as! String
|
||||
}
|
||||
|
||||
/** The system architecture. Paths differ based on this value. */
|
||||
static var architecture: String {
|
||||
if fakeArchitecture != nil { return fakeArchitecture! }
|
||||
|
||||
var systeminfo = utsname()
|
||||
uname(&systeminfo)
|
||||
let machine = withUnsafeBytes(of: &systeminfo.machine) {bufPtr->String in
|
||||
let machine = withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
|
||||
let data = Data(bufPtr)
|
||||
if let lastIndex = data.lastIndex(where: {$0 != 0}) {
|
||||
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
|
||||
@ -45,8 +53,18 @@ class App {
|
||||
return machine
|
||||
}
|
||||
|
||||
/**
|
||||
A fake architecture.
|
||||
When set, the real machine's system architecture is not used,
|
||||
but this fixed value is used instead.
|
||||
*/
|
||||
static var fakeArchitecture: String?
|
||||
|
||||
// MARK: Variables
|
||||
|
||||
/** Technical information about the current environment. */
|
||||
var environment = EnvironmentManager()
|
||||
|
||||
/** The list of preferences that are currently active. */
|
||||
var preferences: [PreferenceName: Bool]!
|
||||
|
||||
@ -65,9 +83,6 @@ class App {
|
||||
/** List of detected (installed) applications that PHP Monitor can work with. */
|
||||
var detectedApplications: [Application] = []
|
||||
|
||||
/** The services manager, responsible for figuring out what services are active/inactive. */
|
||||
var services = ServicesManager.shared
|
||||
|
||||
/** The warning manager, responsible for keeping track of warnings. */
|
||||
var warnings = WarningManager.shared
|
||||
|
||||
|
@ -20,8 +20,7 @@ extension AppDelegate {
|
||||
|
||||
Please note that PHP Monitor needs to be running in the background for this to work.
|
||||
*/
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
|
||||
@MainActor func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
|
||||
Log.info("Acting on commands via phpmon:// has been disabled.")
|
||||
return
|
||||
|
@ -33,15 +33,17 @@ extension AppDelegate {
|
||||
}
|
||||
|
||||
@IBAction func reloadDomainListPressed(_ sender: Any) {
|
||||
let vc = App.shared.domainListWindowController?
|
||||
.window?.contentViewController as? DomainListVC
|
||||
Task { // Reload domains
|
||||
let vc = App.shared.domainListWindowController?
|
||||
.window?.contentViewController as? DomainListVC
|
||||
|
||||
if vc != nil {
|
||||
// If the view exists, directly reload the list of sites
|
||||
vc!.reloadDomains()
|
||||
} else {
|
||||
// If the view does not exist, reload the cached data that was populated when the app initially launched.
|
||||
Valet.shared.reloadSites()
|
||||
if vc != nil {
|
||||
// If the view exists, directly reload the list of sites.
|
||||
await vc!.reloadDomains()
|
||||
} else {
|
||||
// If the view does not exist, reload the cached data that was populated when the app launched.
|
||||
await Valet.shared.reloadSites()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,13 +13,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
/**
|
||||
The Shell singleton that keeps track of the history of all
|
||||
(invoked by PHP Monitor) shell commands. It is used to
|
||||
invoke all commands in this application.
|
||||
*/
|
||||
let sharedShell: Shell
|
||||
|
||||
/**
|
||||
The App singleton contains information about the state of
|
||||
the application and global variables.
|
||||
@ -64,18 +57,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
*/
|
||||
override init() {
|
||||
logger.verbosity = .info
|
||||
|
||||
#if DEBUG
|
||||
logger.verbosity = .performance
|
||||
logger.verbosity = .performance
|
||||
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
|
||||
Self.initializeTestingProfile(profile.replacingOccurrences(of: "--configuration:", with: ""))
|
||||
}
|
||||
#endif
|
||||
|
||||
if CommandLine.arguments.contains("--v") {
|
||||
logger.verbosity = .performance
|
||||
Log.info("Extra verbose mode has been activated.")
|
||||
}
|
||||
|
||||
Log.separator(as: .info)
|
||||
Log.info("PHP MONITOR by Nico Verbruggen")
|
||||
Log.info("Version \(App.version)")
|
||||
Log.separator(as: .info)
|
||||
self.sharedShell = Shell.user
|
||||
|
||||
self.state = App.shared
|
||||
self.menu = MainMenu.shared
|
||||
self.paths = Paths.shared
|
||||
@ -87,6 +86,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
self.phpEnvironment = PhpEnv.shared
|
||||
}
|
||||
|
||||
static func initializeTestingProfile(_ path: String) {
|
||||
Log.info("The configuration with path `\(path)` is being requested...")
|
||||
TestableConfiguration.loadFrom(path: path).apply()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/**
|
||||
@ -97,8 +101,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Make sure notifications will work
|
||||
setupNotifications()
|
||||
// Make sure the menu performs its initial checks
|
||||
Task { await menu.startup() }
|
||||
Task { // Make sure the menu performs its initial checks
|
||||
await paths.loadUser()
|
||||
await menu.startup()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ class AppUpdateChecker {
|
||||
|
||||
public static func retrieveVersionFromCask(
|
||||
_ initiatedFromBackground: Bool = true
|
||||
) -> String {
|
||||
) async -> String {
|
||||
let caskFile = App.version.contains("-dev")
|
||||
? Constants.Urls.DevBuildCaskFile.absoluteString
|
||||
: Constants.Urls.StableBuildCaskFile.absoluteString
|
||||
@ -32,14 +32,14 @@ class AppUpdateChecker {
|
||||
command = "curl -s --max-time 5"
|
||||
}
|
||||
|
||||
return Shell.pipe(
|
||||
return await Shell.pipe(
|
||||
"\(command) '\(caskFile)' | grep version"
|
||||
)
|
||||
).out
|
||||
}
|
||||
|
||||
public static func checkIfNewerVersionIsAvailable(
|
||||
initiatedFromBackground: Bool = true
|
||||
) {
|
||||
) async {
|
||||
if initiatedFromBackground {
|
||||
if !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
||||
Log.info("Automatic updates are disabled. No check will be performed.")
|
||||
@ -49,7 +49,7 @@ class AppUpdateChecker {
|
||||
Log.info("Automatic updates are enabled, a check will be performed.")
|
||||
}
|
||||
|
||||
let versionString = retrieveVersionFromCask(initiatedFromBackground)
|
||||
let versionString = await retrieveVersionFromCask(initiatedFromBackground)
|
||||
|
||||
guard let onlineVersion = AppVersion.from(versionString) else {
|
||||
Log.err("We couldn't check for updates!")
|
||||
@ -119,13 +119,13 @@ class AppUpdateChecker {
|
||||
}
|
||||
|
||||
private static func notifyVersionDoesNotNeedUpgrade() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.is_latest_version.title".localized,
|
||||
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
|
||||
description: ""
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@ -134,7 +134,7 @@ class AppUpdateChecker {
|
||||
let devSuffix = isDev ? "-dev" : ""
|
||||
let command = isDev ? "brew upgrade phpmon-dev" : "brew upgrade phpmon"
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.newer_version_available.title".localized(version.humanReadable),
|
||||
subtitle: "updater.alerts.newer_version_available.subtitle".localized,
|
||||
@ -160,7 +160,7 @@ class AppUpdateChecker {
|
||||
}
|
||||
|
||||
private static func notifyAboutConnectionIssue() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert().withInformation(
|
||||
title: "updater.alerts.cannot_check_for_update.title".localized,
|
||||
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
|
||||
@ -174,7 +174,7 @@ class AppUpdateChecker {
|
||||
NSWorkspace.shared.open(Constants.Urls.GitHubReleases)
|
||||
}
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="20037"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
|
||||
<capability name="Image references" minToolsVersion="12.0"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
|
||||
@ -1112,17 +1112,17 @@ Gw
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" id="U5U-QR-YXS">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="kkd-UV-SnA">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
|
||||
<view key="contentView" id="IXW-35-8NJ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
|
||||
<rect key="frame" x="20" y="196" width="440" 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">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
@ -1149,7 +1149,7 @@ Gw
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
|
||||
<rect key="frame" x="20" y="147" width="440" 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">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
@ -1176,7 +1176,7 @@ Gw
|
||||
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</box>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="4Vi-cN-ude">
|
||||
<rect key="frame" x="317" y="13" width="150" height="32"/>
|
||||
<rect key="frame" x="377" y="13" width="150" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Create Proxy" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="H2Z-c5-5Vk">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
@ -1205,7 +1205,10 @@ Gw
|
||||
</connections>
|
||||
</button>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
|
||||
<rect key="frame" x="18" y="128" width="444" height="14"/>
|
||||
<rect key="frame" x="18" y="128" width="504" height="14"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
|
||||
</constraints>
|
||||
<textFieldCell key="cell" title="[i18n] Preview text here" id="ISE-9R-ncQ">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
@ -1223,7 +1226,7 @@ Gw
|
||||
</connections>
|
||||
</button>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
|
||||
<rect key="frame" x="18" y="60" width="444" 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">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
@ -1239,7 +1242,7 @@ Gw
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
|
||||
<rect key="frame" x="131" 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">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
|
||||
@ -1293,7 +1296,7 @@ Gw
|
||||
</viewController>
|
||||
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="210" y="1524"/>
|
||||
<point key="canvasLocation" x="220" y="1522"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="5Gf-7O-tdA">
|
||||
|
36
phpmon/Domain/App/EnvironmentManager.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// EnvironmentManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 14/09/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class EnvironmentManager {
|
||||
var values: [EnvironmentProperty: Bool] = [:]
|
||||
|
||||
public func process() async {
|
||||
self.values[.hasValetInstalled] = await !{
|
||||
let output = await Shell.pipe("valet --version").out
|
||||
|
||||
// Failure condition #1: does not contain Laravel Valet
|
||||
if !output.contains("Laravel Valet") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Extract the version number
|
||||
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
|
||||
|
||||
// Get the actual version
|
||||
return Valet.shared.version == nil
|
||||
|
||||
}() // returns true if none of the failure conditions are met
|
||||
}
|
||||
}
|
||||
|
||||
public enum EnvironmentProperty {
|
||||
case hasHomebrewInstalled
|
||||
case hasValetInstalled
|
||||
}
|
@ -21,51 +21,39 @@ class InterApp {
|
||||
let action: (String) -> Void
|
||||
}
|
||||
|
||||
static func getCommands() -> [InterApp.Action] { return [
|
||||
@MainActor static func getCommands() -> [InterApp.Action] { return [
|
||||
InterApp.Action(command: "list", action: { _ in
|
||||
DomainListVC.show()
|
||||
}),
|
||||
InterApp.Action(command: "services/stop", action: { _ in
|
||||
MainMenu.shared.stopValetServices()
|
||||
Task { MainMenu.shared.stopValetServices() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/all", action: { _ in
|
||||
MainMenu.shared.restartValetServices()
|
||||
Task { MainMenu.shared.restartValetServices() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/nginx", action: { _ in
|
||||
MainMenu.shared.restartNginx()
|
||||
Task { MainMenu.shared.restartNginx() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/php", action: { _ in
|
||||
MainMenu.shared.restartPhpFpm()
|
||||
Task { MainMenu.shared.restartPhpFpm() }
|
||||
}),
|
||||
InterApp.Action(command: "services/restart/dnsmasq", action: { _ in
|
||||
MainMenu.shared.restartDnsMasq()
|
||||
Task { MainMenu.shared.restartDnsMasq() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/config", action: { _ in
|
||||
MainMenu.shared.openActiveConfigFolder()
|
||||
Task { MainMenu.shared.openActiveConfigFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/composer", action: { _ in
|
||||
MainMenu.shared.openGlobalComposerFolder()
|
||||
Task { MainMenu.shared.openGlobalComposerFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "locate/valet", action: { _ in
|
||||
MainMenu.shared.openValetConfigFolder()
|
||||
Task { MainMenu.shared.openValetConfigFolder() }
|
||||
}),
|
||||
InterApp.Action(command: "phpinfo", action: { _ in
|
||||
MainMenu.shared.openPhpInfo()
|
||||
Task { MainMenu.shared.openPhpInfo() }
|
||||
}),
|
||||
InterApp.Action(command: "switch/php/", action: { version in
|
||||
if PhpEnv.shared.availablePhpVersions.contains(version) {
|
||||
MainMenu.shared.switchToPhpVersion(version)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
BetterAlert().withInformation(
|
||||
title: "alert.php_switch_unavailable.title".localized,
|
||||
subtitle: "alert.php_switch_unavailable.subtitle".localized(version)
|
||||
).withPrimary(
|
||||
text: "alert.php_switch_unavailable.ok".localized
|
||||
).show()
|
||||
}
|
||||
}
|
||||
Task { MainMenu.shared.switchToAnyPhpVersion(version) }
|
||||
})
|
||||
]}
|
||||
|
||||
}
|
||||
|
89
phpmon/Domain/App/Services/FakeServicesManager.swift
Normal file
@ -0,0 +1,89 @@
|
||||
//
|
||||
// FakeServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FakeServicesManager: ServicesManager {
|
||||
var fixedFormulae: [String] = []
|
||||
var fixedStatus: Service.Status = .active
|
||||
|
||||
override init() {}
|
||||
|
||||
init(
|
||||
formulae: [String] = ["php", "nginx", "dnsmasq"],
|
||||
status: Service.Status = .active,
|
||||
loading: Bool = false
|
||||
) {
|
||||
super.init()
|
||||
|
||||
Log.warn("A fake services manager is being used, so Homebrew formula resolver is set to act in fake mode.")
|
||||
Log.warn("If you do not want this behaviour, do not make use of a `FakeServicesManager`!")
|
||||
|
||||
self.fixedFormulae = formulae
|
||||
self.fixedStatus = status
|
||||
|
||||
self.services = []
|
||||
self.reapplyServices()
|
||||
|
||||
if loading {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.firstRunComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
private func reapplyServices() {
|
||||
let services = self.formulae.map {
|
||||
let wrapper = Service(
|
||||
formula: $0,
|
||||
service: HomebrewService.dummy(named: $0.name, enabled: self.fixedStatus == .active)
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.services = services
|
||||
}
|
||||
}
|
||||
|
||||
override var formulae: [HomebrewFormula] {
|
||||
return fixedFormulae.map { formula in
|
||||
return HomebrewFormula.init(formula, elevated: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadServicesStatus() async {
|
||||
await delay(seconds: 0.3)
|
||||
|
||||
self.reapplyServices()
|
||||
}
|
||||
|
||||
override func toggleService(named: String) async {
|
||||
await delay(seconds: 0.3)
|
||||
|
||||
let services = services.map({ service in
|
||||
let newServiceEnabled = service.name == named
|
||||
? service.status != .active // inverse (i.e. if active -> becomes inactive)
|
||||
: service.status == .active // service remains unmodified if it's not the named one we change
|
||||
|
||||
return Service(
|
||||
formula: service.formula,
|
||||
service: HomebrewService.dummy(
|
||||
named: service.name,
|
||||
enabled: newServiceEnabled
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
Task { @MainActor in
|
||||
self.services = services
|
||||
}
|
||||
}
|
||||
}
|
55
phpmon/Domain/App/Services/Service.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// ServiceWrapper.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/** Service linked to a Homebrew formula and whether it is currently (in)active or missing. */
|
||||
public struct Service: Hashable {
|
||||
var formula: HomebrewFormula
|
||||
var status: Status = .missing
|
||||
|
||||
public var name: String {
|
||||
return formula.name
|
||||
}
|
||||
|
||||
init(formula: HomebrewFormula, service: HomebrewService? = nil) {
|
||||
self.formula = formula
|
||||
|
||||
guard let service else { return }
|
||||
|
||||
self.status = service.running ? .active : .inactive
|
||||
|
||||
if service.status == "error" {
|
||||
self.status = .error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocols
|
||||
|
||||
public static func == (lhs: Service, rhs: Service) -> Bool {
|
||||
return lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(formula)
|
||||
hasher.combine(status)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
public enum Status: String {
|
||||
case active
|
||||
case inactive
|
||||
case error
|
||||
case missing
|
||||
|
||||
var asBool: Bool {
|
||||
return self == .active
|
||||
}
|
||||
}
|
||||
}
|
131
phpmon/Domain/App/Services/ServicesManager.swift
Normal file
@ -0,0 +1,131 @@
|
||||
//
|
||||
// ServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/06/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ServicesManager: ObservableObject {
|
||||
|
||||
@ObservedObject static var shared: ServicesManager = ValetServicesManager()
|
||||
|
||||
@Published var services = [Service]()
|
||||
|
||||
@Published var firstRunComplete: Bool = false
|
||||
|
||||
public static func useFake() {
|
||||
ServicesManager.shared = FakeServicesManager.init(
|
||||
formulae: ["php", "nginx", "dnsmasq", "mysql"],
|
||||
status: .active
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
The order of services is important, so easy access is accomplished
|
||||
without much fanfare through subscripting.
|
||||
*/
|
||||
subscript(name: String) -> Service? {
|
||||
return self.services.first { wrapper in
|
||||
wrapper.name == name
|
||||
}
|
||||
}
|
||||
|
||||
public var hasError: Bool {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return false
|
||||
}
|
||||
|
||||
return self.services[0...2]
|
||||
.map { $0.status }
|
||||
.contains(.error)
|
||||
}
|
||||
|
||||
public var statusMessage: String {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
let statuses = self.services[0...2].map { $0.status }
|
||||
|
||||
if statuses.contains(.missing) {
|
||||
return "A key service is not installed."
|
||||
}
|
||||
if statuses.contains(.error) {
|
||||
return "A key service is reporting an error state."
|
||||
}
|
||||
if statuses.contains(.inactive) {
|
||||
return "A key service is not running."
|
||||
}
|
||||
|
||||
return "All Valet services are OK."
|
||||
}
|
||||
|
||||
public var statusColor: Color {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
return .yellow
|
||||
}
|
||||
|
||||
let statuses = self.services[0...2].map { $0.status }
|
||||
|
||||
if statuses.contains(.missing)
|
||||
|| statuses.contains(.inactive)
|
||||
|| statuses.contains(.error) {
|
||||
return .red
|
||||
}
|
||||
|
||||
return .green
|
||||
}
|
||||
|
||||
/**
|
||||
This method is called when the system configuration has changed
|
||||
and all the status of one or more services may need to be determined.
|
||||
*/
|
||||
public func reloadServicesStatus() async {
|
||||
fatalError("This method `\(#function)` has not been implemented")
|
||||
}
|
||||
|
||||
/**
|
||||
This method is called when a service needs to be toggled (on/off).
|
||||
*/
|
||||
public func toggleService(named: String) async {
|
||||
fatalError("This method `\(#function)` has not been implemented")
|
||||
}
|
||||
|
||||
/**
|
||||
This method will notify all publishers that subscribe to notifiable objects.
|
||||
The notified objects include this very ServicesManager as well as any individual service instances.
|
||||
*/
|
||||
public func broadcastServicesUpdated() {
|
||||
Task { @MainActor in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
var formulae: [HomebrewFormula] {
|
||||
var formulae = [
|
||||
Homebrew.Formulae.php,
|
||||
Homebrew.Formulae.nginx,
|
||||
Homebrew.Formulae.dnsmasq
|
||||
]
|
||||
|
||||
let additionalFormulae = (Preferences.custom.services ?? []).map({ item in
|
||||
return HomebrewFormula(item, elevated: false)
|
||||
})
|
||||
|
||||
formulae.append(contentsOf: additionalFormulae)
|
||||
|
||||
return formulae
|
||||
}
|
||||
|
||||
init() {
|
||||
Log.info("The services manager will determine which Valet services exist on this system.")
|
||||
|
||||
services = formulae.map {
|
||||
Service(formula: $0)
|
||||
}
|
||||
}
|
||||
}
|
157
phpmon/Domain/App/Services/ValetServicesManager.swift
Normal file
@ -0,0 +1,157 @@
|
||||
//
|
||||
// ValetServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 23/12/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class ValetServicesManager: ServicesManager {
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
// Load the initial services state
|
||||
Task {
|
||||
await self.reloadServicesStatus()
|
||||
|
||||
Task { @MainActor in
|
||||
firstRunComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
The last known state of all Homebrew services.
|
||||
*/
|
||||
var homebrewServices: [HomebrewService] = []
|
||||
|
||||
/**
|
||||
This method allows us to reload the Homebrew services, but we run this command
|
||||
twice (once for user services, and once for root services). Please note that
|
||||
these two commands are executed concurrently.
|
||||
*/
|
||||
override func reloadServicesStatus() async {
|
||||
await withTaskGroup(of: [HomebrewService].self, body: { group in
|
||||
// First, retrieve the status of the formulae that run as root
|
||||
group.addTask {
|
||||
let rootServiceNames = self.formulae
|
||||
.filter { $0.elevated }
|
||||
.map { $0.name }
|
||||
|
||||
let rootJson = await Shell
|
||||
.pipe("sudo \(Paths.brew) services info --all --json")
|
||||
.out.data(using: .utf8)!
|
||||
|
||||
return try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: rootJson)
|
||||
.filter({ return rootServiceNames.contains($0.name) })
|
||||
}
|
||||
|
||||
// At the same time, retrieve the status of the formulae that run as user
|
||||
group.addTask {
|
||||
let userServiceNames = self.formulae
|
||||
.filter { !$0.elevated }
|
||||
.map { $0.name }
|
||||
|
||||
let normalJson = await Shell
|
||||
.pipe("\(Paths.brew) services info --all --json")
|
||||
.out.data(using: .utf8)!
|
||||
|
||||
return try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: normalJson)
|
||||
.filter({ return userServiceNames.contains($0.name) })
|
||||
}
|
||||
|
||||
// Ensure that Homebrew services' output is stored
|
||||
self.homebrewServices = []
|
||||
for await services in group {
|
||||
homebrewServices.append(contentsOf: services)
|
||||
}
|
||||
|
||||
// Dispatch the update of the new service wrappers
|
||||
Task { @MainActor in
|
||||
// Ensure both commands complete (but run concurrently)
|
||||
services = formulae.map { formula in
|
||||
Service(
|
||||
formula: formula,
|
||||
service: homebrewServices.first(where: { service in
|
||||
service.name == formula.name
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Broadcast that all services have been updated
|
||||
self.broadcastServicesUpdated()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func toggleService(named: String) async {
|
||||
guard let wrapper = self[named] else {
|
||||
return Log.err("The wrapper for '\(named)' is missing.")
|
||||
}
|
||||
|
||||
// Normally, we allow starting and stopping
|
||||
var action = wrapper.status == .active ? "stop" : "start"
|
||||
|
||||
// However, if we've encountered an error, attempt to restart
|
||||
if wrapper.status == .error {
|
||||
action = "restart"
|
||||
}
|
||||
|
||||
// Run the command
|
||||
await brew(
|
||||
"services \(action) \(wrapper.formula.name)",
|
||||
sudo: wrapper.formula.elevated
|
||||
)
|
||||
|
||||
// Reload the services status to confirm this worked
|
||||
await ServicesManager.shared.reloadServicesStatus()
|
||||
|
||||
Task {
|
||||
await presentTroubleshootingForService(named: named)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func presentTroubleshootingForService(named: String) {
|
||||
let after = self.homebrewServices.first { service in
|
||||
return service.name == named
|
||||
}
|
||||
|
||||
guard let after else { return }
|
||||
|
||||
if after.status == "error" {
|
||||
Log.err("The service '\(named)' is now reporting an error.")
|
||||
|
||||
guard let errorLogPath = after.error_log_path else {
|
||||
return BetterAlert().withInformation(
|
||||
title: "alert.service_error.title".localized(named),
|
||||
subtitle: "alert.service_error.subtitle.no_error_log".localized(named),
|
||||
description: "alert.service_error.extra".localized
|
||||
)
|
||||
.withPrimary(text: "alert.service_error.button.close".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
BetterAlert().withInformation(
|
||||
title: "alert.service_error.title".localized(named),
|
||||
subtitle: "alert.service_error.subtitle.error_log".localized(named),
|
||||
description: "alert.service_error.extra".localized
|
||||
)
|
||||
.withPrimary(text: "alert.service_error.button.close".localized)
|
||||
.withTertiary(text: "alert.service_error.button.show_log".localized, action: { alert in
|
||||
let url = URL(fileURLWithPath: errorLogPath)
|
||||
if errorLogPath.hasSuffix(".log") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
alert.close(with: .OK)
|
||||
})
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
//
|
||||
// ServicesManager.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/06/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ServicesManager: ObservableObject {
|
||||
|
||||
static var shared = ServicesManager()
|
||||
|
||||
@Published var rootServices: [String: HomebrewService] = [:]
|
||||
@Published var userServices: [String: HomebrewService] = [:]
|
||||
|
||||
public static func loadHomebrewServices(completed: (() -> Void)? = nil) {
|
||||
let rootServiceNames = [
|
||||
Homebrew.Formulae.php,
|
||||
Homebrew.Formulae.nginx,
|
||||
Homebrew.Formulae.dnsmasq
|
||||
]
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let data = Shell
|
||||
.pipe("sudo \(Paths.brew) services info --all --json", requiresPath: true)
|
||||
.data(using: .utf8)!
|
||||
|
||||
let services = try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: data)
|
||||
.filter({ return rootServiceNames.contains($0.name) })
|
||||
|
||||
DispatchQueue.main.async {
|
||||
ServicesManager.shared.rootServices = Dictionary(
|
||||
uniqueKeysWithValues: services.map { ($0.name, $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let userServiceNames = Preferences.custom.services ?? []
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let data = Shell
|
||||
.pipe("\(Paths.brew) services info --all --json", requiresPath: true)
|
||||
.data(using: .utf8)!
|
||||
|
||||
let services = try! JSONDecoder()
|
||||
.decode([HomebrewService].self, from: data)
|
||||
.filter({ return userServiceNames.contains($0.name) })
|
||||
|
||||
DispatchQueue.main.async {
|
||||
ServicesManager.shared.userServices = Dictionary(
|
||||
uniqueKeysWithValues: services.map { ($0.name, $0) }
|
||||
)
|
||||
completed?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadData() {
|
||||
Self.loadHomebrewServices()
|
||||
}
|
||||
|
||||
/**
|
||||
Dummy data for preview purposes.
|
||||
*/
|
||||
func withDummyServices(_ services: [String: Bool]) -> Self {
|
||||
for (service, enabled) in services {
|
||||
let item = HomebrewService.dummy(named: service, enabled: enabled)
|
||||
self.rootServices[service] = item
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
@ -29,7 +29,7 @@ class Startup {
|
||||
|
||||
// If we get here, something's gone wrong and the check has failed...
|
||||
Log.info("[FAIL] \(check.name)")
|
||||
showAlert(for: check)
|
||||
await showAlert(for: check)
|
||||
return false
|
||||
}
|
||||
|
||||
@ -45,29 +45,27 @@ class Startup {
|
||||
- ones that require an app restart, which prompt the user to exit the app
|
||||
- ones that allow the app to continue, which allow the user to retry
|
||||
*/
|
||||
private func showAlert(for check: EnvironmentCheck) {
|
||||
DispatchQueue.main.async {
|
||||
if check.requiresAppRestart {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: check.buttonText, action: { _ in
|
||||
exit(1)
|
||||
}).show()
|
||||
}
|
||||
|
||||
@MainActor private func showAlert(for check: EnvironmentCheck) {
|
||||
if check.requiresAppRestart {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.show()
|
||||
.withPrimary(text: check.buttonText, action: { _ in
|
||||
exit(1)
|
||||
}).show()
|
||||
}
|
||||
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -75,7 +73,7 @@ class Startup {
|
||||
initialized when it is done working. The switcher must be initialized on the main thread.
|
||||
*/
|
||||
private func initializeSwitcher() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
let appDelegate = NSApplication.shared.delegate as! AppDelegate
|
||||
appDelegate.initializeSwitcher()
|
||||
}
|
||||
@ -88,7 +86,7 @@ class Startup {
|
||||
// The Homebrew binary must exist.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !FileManager.default.fileExists(atPath: Paths.brew) },
|
||||
command: { return !FileSystem.fileExists(Paths.brew) },
|
||||
name: "`\(Paths.brew)` exists",
|
||||
titleText: "alert.homebrew_missing.title".localized,
|
||||
subtitleText: "alert.homebrew_missing.subtitle".localized,
|
||||
@ -105,7 +103,7 @@ class Startup {
|
||||
// The PHP binary must exist.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Filesystem.fileExists(Paths.php) },
|
||||
command: { return !FileSystem.fileExists(Paths.php) },
|
||||
name: "`\(Paths.php)` exists",
|
||||
titleText: "startup.errors.php_binary.title".localized,
|
||||
subtitleText: "startup.errors.php_binary.subtitle".localized,
|
||||
@ -115,7 +113,9 @@ class Startup {
|
||||
// Make sure we can detect one or more PHP installations.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("ls \(Paths.optPath) | grep php").contains("php") },
|
||||
command: {
|
||||
return await !Shell.pipe("ls \(Paths.optPath) | grep php").out.contains("php")
|
||||
},
|
||||
name: "`ls \(Paths.optPath) | grep php` returned php result",
|
||||
titleText: "startup.errors.php_opt.title".localized,
|
||||
subtitleText: "startup.errors.php_opt.subtitle".localized(
|
||||
@ -128,7 +128,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return !(Filesystem.fileExists(Paths.valet) || Filesystem.fileExists("~/.composer/vendor/bin/valet"))
|
||||
return !(FileSystem.fileExists(Paths.valet) || FileSystem.fileExists("~/.composer/vendor/bin/valet"))
|
||||
},
|
||||
name: "`valet` binary exists",
|
||||
titleText: "startup.errors.valet_executable.title".localized,
|
||||
@ -143,14 +143,14 @@ class Startup {
|
||||
// functioning correctly. Let the user know that they need to run `valet trust`.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("cat /private/etc/sudoers.d/brew").contains(Paths.brew) },
|
||||
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/brew").out.contains(Paths.brew) },
|
||||
name: "`/private/etc/sudoers.d/brew` contains brew",
|
||||
titleText: "startup.errors.sudoers_brew.title".localized,
|
||||
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
|
||||
descriptionText: "startup.errors.sudoers_brew.desc".localized
|
||||
),
|
||||
EnvironmentCheck(
|
||||
command: { return !Shell.pipe("cat /private/etc/sudoers.d/valet").contains(Paths.valet) },
|
||||
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/valet").out.contains(Paths.valet) },
|
||||
name: "`/private/etc/sudoers.d/valet` contains valet",
|
||||
titleText: "startup.errors.sudoers_valet.title".localized,
|
||||
subtitleText: "startup.errors.sudoers_valet.subtitle".localized,
|
||||
@ -160,7 +160,10 @@ class Startup {
|
||||
// Verify if the Homebrew services are running (as root).
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { return HomebrewDiagnostics.cannotLoadService("dnsmasq") },
|
||||
command: {
|
||||
await HomebrewDiagnostics.loadInstalledTaps()
|
||||
return await HomebrewDiagnostics.cannotLoadService("dnsmasq")
|
||||
},
|
||||
name: "`sudo \(Paths.brew) services info` JSON loaded",
|
||||
titleText: "startup.errors.services_json_error.title".localized,
|
||||
subtitleText: "startup.errors.services_json_error.subtitle".localized,
|
||||
@ -171,7 +174,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return !Filesystem.directoryExists("~/.config/valet")
|
||||
return !FileSystem.directoryExists("~/.config/valet")
|
||||
},
|
||||
name: "`.config/valet` not empty (Valet installed)",
|
||||
titleText: "startup.errors.valet_not_installed.title".localized,
|
||||
@ -200,10 +203,10 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
let nodePath = await Shell.pipe("which node").out
|
||||
return App.architecture == "x86_64"
|
||||
&& FileManager.default.fileExists(atPath: "/usr/local/bin/which")
|
||||
&& Shell.pipe("which node", requiresPath: false)
|
||||
.contains("env: node: No such file or directory")
|
||||
&& FileSystem.fileExists("/usr/local/bin/which")
|
||||
&& nodePath.contains("env: node: No such file or directory")
|
||||
},
|
||||
name: "`env: node` issue does not apply",
|
||||
titleText: "startup.errors.which_alias_issue.title".localized,
|
||||
@ -215,7 +218,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
return valet("--version", sudo: false)
|
||||
return await Shell.pipe("valet --version").out
|
||||
.contains("Composer detected issues in your platform")
|
||||
},
|
||||
name: "`no global composer issues",
|
||||
@ -228,7 +231,7 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
let output = valet("--version", sudo: false)
|
||||
let output = await Shell.pipe("valet --version").out
|
||||
// Failure condition #1: does not contain Laravel Valet
|
||||
if !output.contains("Laravel Valet") {
|
||||
return true
|
||||
@ -239,7 +242,7 @@ class Startup {
|
||||
.components(separatedBy: "Laravel Valet")[1]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
// Extract the version number
|
||||
Valet.shared.version = VersionExtractor.from(output)
|
||||
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
|
||||
// Get the actual version
|
||||
return Valet.shared.version == nil
|
||||
},
|
||||
@ -247,6 +250,19 @@ class Startup {
|
||||
titleText: "startup.errors.valet_version_unknown.title".localized,
|
||||
subtitleText: "startup.errors.valet_version_unknown.subtitle".localized,
|
||||
descriptionText: "startup.errors.valet_version_unknown.desc".localized
|
||||
),
|
||||
// =================================================================================
|
||||
// Ensure the Valet version is supported.
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: {
|
||||
// We currently support Valet 2, 3 or 4. Any other version should get an alert.
|
||||
return ![2, 3, 4].contains(Valet.shared.version.major)
|
||||
},
|
||||
name: "valet version is supported",
|
||||
titleText: "startup.errors.valet_version_not_supported.title".localized,
|
||||
subtitleText: "startup.errors.valet_version_not_supported.subtitle".localized,
|
||||
descriptionText: "startup.errors.valet_version_not_supported.desc".localized
|
||||
)
|
||||
]
|
||||
}
|
||||
|
@ -65,17 +65,20 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
|
||||
@IBAction func pressedCreateProxy(_ sender: Any) {
|
||||
let domain = self.inputDomainName.stringValue
|
||||
let proxyName = self.inputProxySubject.stringValue
|
||||
let secure = self.buttonSecure.state == .on ? " --secure" : ""
|
||||
let secure = (self.buttonSecure.state == .on)
|
||||
|
||||
dismissView(outcome: .OK)
|
||||
|
||||
App.shared.domainListWindowController?.contentVC.setUIBusy()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
Shell.run("\(Paths.valet) proxy \(domain) \(proxyName)\(secure)", requiresPath: true)
|
||||
Actions.restartNginx()
|
||||
Task { // Ensure we proxy the site asynchronously and reload UI on main thread again
|
||||
try! await ValetInteractor.shared.proxy(
|
||||
domain: domain,
|
||||
proxy: proxyName,
|
||||
secure: secure
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
App.shared.domainListWindowController?.contentVC.setUINotBusy()
|
||||
App.shared.domainListWindowController?.pressedReload(nil)
|
||||
}
|
||||
@ -157,8 +160,14 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
previewText.stringValue = "domain_list.add.proxy_available"
|
||||
.localized(
|
||||
var translationKey = "domain_list.add.proxy_available"
|
||||
|
||||
if inputProxySubject.stringValue.starts(with: "https://") {
|
||||
translationKey = "domain_list.add.proxy_https_warning"
|
||||
}
|
||||
|
||||
previewText.stringValue =
|
||||
translationKey.localized(
|
||||
inputProxySubject.stringValue,
|
||||
buttonSecure.state == .on ? "https" : "http",
|
||||
inputDomainName.stringValue,
|
||||
|
@ -51,11 +51,11 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
|
||||
|
||||
// MARK: - Outlet Interactions
|
||||
|
||||
@IBAction func pressedCreateLink(_ sender: Any) {
|
||||
func createLink() async {
|
||||
let path = pathControl.url!.path
|
||||
let name = inputDomainName.stringValue
|
||||
|
||||
if !Filesystem.exists(path) {
|
||||
if !FileSystem.anyExists(path) {
|
||||
Alert.confirm(
|
||||
onWindow: view.window!,
|
||||
messageText: "domain_list.alert.folder_missing.title".localized,
|
||||
@ -70,23 +70,28 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
|
||||
}
|
||||
|
||||
// Adding `valet links` is a workaround for Valet malforming the config.json file
|
||||
// TODO: I will have to investigate and report this behaviour if possible
|
||||
Shell.run("cd '\(path)' && \(Paths.valet) link '\(name)' && valet links", requiresPath: true)
|
||||
Task {
|
||||
try! await ValetInteractor.shared.link(path: path, domain: name)
|
||||
|
||||
dismissView(outcome: .OK)
|
||||
dismissView(outcome: .OK)
|
||||
|
||||
// Reset search
|
||||
App.shared.domainListWindowController?
|
||||
.searchToolbarItem
|
||||
.searchField.stringValue = ""
|
||||
// Reset search
|
||||
App.shared.domainListWindowController?
|
||||
.searchToolbarItem
|
||||
.searchField.stringValue = ""
|
||||
|
||||
// Add the new item and scrolls to it
|
||||
App.shared.domainListWindowController?
|
||||
.contentVC
|
||||
.addedNewSite(
|
||||
name: name,
|
||||
secure: buttonSecure.state == .on
|
||||
)
|
||||
// Add the new item and scrolls to it
|
||||
await App.shared.domainListWindowController?
|
||||
.contentVC
|
||||
.addedNewSite(
|
||||
name: name,
|
||||
secureAfterLinking: buttonSecure.state == .on
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func pressedCreateLink(_ sender: Any) {
|
||||
Task { await createLink() }
|
||||
}
|
||||
|
||||
@IBAction func pressedCancel(_ sender: Any) {
|
||||
|
@ -53,13 +53,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
|
||||
@IBAction func pressedPhpVersion(_ sender: Any) {
|
||||
guard let site = self.site else { return }
|
||||
|
||||
var validPhpSuggestions: [PhpVersionNumber] {
|
||||
var validPhpSuggestions: [VersionNumber] {
|
||||
if site.isolatedPhpVersion != nil {
|
||||
return []
|
||||
}
|
||||
|
||||
return PhpEnv.shared.validVersions(for: site.composerPhp).filter({ version in
|
||||
version.homebrewVersion != PhpEnv.phpInstall.version.short
|
||||
version.short != PhpEnv.phpInstall.version.short
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -11,75 +11,6 @@ import Cocoa
|
||||
|
||||
extension DomainListVC {
|
||||
|
||||
@objc func toggleSecure() {
|
||||
if selected is ValetSite {
|
||||
toggleSecureForSite()
|
||||
} else {
|
||||
toggleSecureForProxy()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecureForProxy() {
|
||||
let originalSecureStatus = selectedProxy!.secured
|
||||
let selectedProxy = selectedProxy!
|
||||
|
||||
self.waitAndExecute {
|
||||
// 1. Remove the original proxy
|
||||
Shell.run("\(Paths.valet) unproxy \(selectedProxy.domain)", requiresPath: true)
|
||||
|
||||
// 2. Add a new proxy, which is either secured/unsecured
|
||||
let secure = originalSecureStatus ? "" : " --secure"
|
||||
Shell.run("\(Paths.valet) proxy \(selectedProxy.domain) \(selectedProxy.target)\(secure)",
|
||||
requiresPath: true)
|
||||
|
||||
// 3. Restart nginx
|
||||
Actions.restartNginx()
|
||||
|
||||
// 4. Reload site list
|
||||
DispatchQueue.main.async {
|
||||
App.shared.domainListWindowController?.pressedReload(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecureForSite() {
|
||||
let rowToReload = tableView.selectedRow
|
||||
let originalSecureStatus = selectedSite!.secured
|
||||
let action = selectedSite!.secured ? "unsecure" : "secure"
|
||||
let selectedSite = selectedSite!
|
||||
let command = "sudo \(Paths.valet) \(action) '\(selectedSite.name)' && exit;"
|
||||
|
||||
waitAndExecute {
|
||||
Shell.run(command, requiresPath: true)
|
||||
} completion: { [self] in
|
||||
selectedSite.determineSecured()
|
||||
if selectedSite.secured == originalSecureStatus {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "domain_list.alerts_status_not_changed.title".localized,
|
||||
subtitle: "domain_list.alerts_status_not_changed.desc".localized(command)
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.show()
|
||||
} else {
|
||||
let newState = selectedSite.secured ? "secured" : "unsecured"
|
||||
LocalNotification.send(
|
||||
title: "domain_list.alerts_status_changed.title".localized,
|
||||
subtitle: "domain_list.alerts_status_changed.desc"
|
||||
.localized(
|
||||
"\(selectedSite.name).\(Valet.shared.config.tld)",
|
||||
newState
|
||||
),
|
||||
preference: .notifyAboutSecureToggle
|
||||
)
|
||||
}
|
||||
|
||||
tableView.reloadData(forRowIndexes: [rowToReload], columnIndexes: [0, 1, 2, 3, 4])
|
||||
tableView.deselectRow(rowToReload)
|
||||
tableView.selectRowIndexes([rowToReload], byExtendingSelection: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openInBrowser() {
|
||||
guard let selected = self.selected else {
|
||||
return
|
||||
@ -91,7 +22,7 @@ extension DomainListVC {
|
||||
title: "domain_list.alert.invalid_folder_name".localized,
|
||||
subtitle: "domain_list.alert.invalid_folder_name_desc".localized
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
@ -100,11 +31,11 @@ extension DomainListVC {
|
||||
}
|
||||
|
||||
@objc func openInFinder() {
|
||||
Shell.run("open '\(selectedSite!.absolutePath)'")
|
||||
Task { return await Shell.quiet("open '\(selectedSite!.absolutePath)'") }
|
||||
}
|
||||
|
||||
@objc func openInTerminal() {
|
||||
Shell.run("open -b com.apple.terminal '\(selectedSite!.absolutePath)'")
|
||||
Task { await Shell.quiet("open -b com.apple.terminal '\(selectedSite!.absolutePath)'") }
|
||||
}
|
||||
|
||||
@objc func openWithEditor(sender: EditorMenuItem) {
|
||||
@ -112,30 +43,108 @@ extension DomainListVC {
|
||||
editor.openDirectory(file: selectedSite!.absolutePath)
|
||||
}
|
||||
|
||||
// MARK: - UI interaction
|
||||
|
||||
private func performAction(command: String, beforeCellReload: @escaping () -> Void) {
|
||||
let rowToReload = tableView.selectedRow
|
||||
|
||||
waitAndExecute {
|
||||
await Shell.quiet(command)
|
||||
} completion: { [self] in
|
||||
beforeCellReload()
|
||||
tableView.reloadData(forRowIndexes: [rowToReload], columnIndexes: [0, 1, 2, 3, 4])
|
||||
tableView.deselectRow(rowToReload)
|
||||
tableView.selectRowIndexes([rowToReload], byExtendingSelection: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadSelectedRow() {
|
||||
tableView.reloadData(forRowIndexes: [tableView.selectedRow], columnIndexes: [0, 1, 2, 3, 4])
|
||||
tableView.deselectRow(tableView.selectedRow)
|
||||
tableView.selectRowIndexes([tableView.selectedRow], byExtendingSelection: true)
|
||||
}
|
||||
|
||||
// MARK: - Interactions with `valet` or terminal
|
||||
|
||||
@objc func toggleSecure() {
|
||||
if selected is ValetSite {
|
||||
Task { await toggleSecure(site: selected as! ValetSite) }
|
||||
}
|
||||
|
||||
if selected is ValetProxy {
|
||||
Task { await toggleSecure(proxy: selected as! ValetProxy) }
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecure(proxy: ValetProxy) async {
|
||||
waitAndExecute {
|
||||
do {
|
||||
// Recreate proxy as secure or unsecured proxy
|
||||
try await proxy.toggleSecure()
|
||||
// Send a notification about the new status (if applicable)
|
||||
self.notifyAboutModifiedSecureStatus(domain: proxy.domain, secured: proxy.secured)
|
||||
// Reload the UI (do this last so we don't invalidate the proxy)
|
||||
self.reloadSelectedRow()
|
||||
} catch {
|
||||
// Notify the user about a failed command
|
||||
let error = error as! ValetInteractionError
|
||||
self.notifyAboutFailedSecureStatus(command: error.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecure(site: ValetSite) async {
|
||||
waitAndExecute {
|
||||
do {
|
||||
// Instruct Valet to secure or unsecure a site
|
||||
try await site.toggleSecure()
|
||||
// Send a notification about the new status (if applicable)
|
||||
self.notifyAboutModifiedSecureStatus(domain: site.name, secured: site.secured)
|
||||
// Reload the UI (do this last so we don't invalidate the site)
|
||||
self.reloadSelectedRow()
|
||||
} catch {
|
||||
// Notify the user about a failed command
|
||||
let error = error as! ValetInteractionError
|
||||
self.notifyAboutFailedSecureStatus(command: error.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func isolateSite(sender: PhpMenuItem) {
|
||||
let command = "sudo \(Paths.valet) isolate php@\(sender.version) --site '\(self.selectedSite!.name)' && exit;"
|
||||
guard let site = selectedSite else {
|
||||
return
|
||||
}
|
||||
|
||||
self.performAction(command: command) {
|
||||
self.selectedSite!.determineIsolated()
|
||||
self.selectedSite!.determineComposerPhpVersion()
|
||||
|
||||
if self.selectedSite!.isolatedPhpVersion == nil {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "domain_list.alerts_isolation_failed.title".localized,
|
||||
subtitle: "domain_list.alerts_isolation_failed.subtitle".localized,
|
||||
description: "domain_list.alerts_isolation_failed.desc".localized(command)
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.show()
|
||||
waitAndExecute {
|
||||
do {
|
||||
// Instruct Valet to isolate a given PHP version
|
||||
try await site.isolate(version: sender.version)
|
||||
// Reload the UI
|
||||
self.reloadSelectedRow()
|
||||
} catch {
|
||||
// Notify the user about a failed command
|
||||
let error = error as! ValetInteractionError
|
||||
self.notifyAboutFailedSiteIsolation(command: error.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func removeIsolatedSite() {
|
||||
self.performAction(command: "sudo \(Paths.valet) unisolate --site '\(self.selectedSite!.name)' && exit;") {
|
||||
self.selectedSite!.isolatedPhpVersion = nil
|
||||
self.selectedSite!.determineComposerPhpVersion()
|
||||
guard let site = selectedSite else {
|
||||
return
|
||||
}
|
||||
|
||||
waitAndExecute {
|
||||
do {
|
||||
// Instruct Valet to remove isolation
|
||||
try await site.unisolate()
|
||||
// Reload the UI
|
||||
self.reloadSelectedRow()
|
||||
} catch {
|
||||
// Notify the user about a failed command
|
||||
let error = error as! ValetInteractionError
|
||||
self.notifyAboutFailedSiteIsolation(command: error.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,9 +166,8 @@ extension DomainListVC {
|
||||
style: .critical,
|
||||
onFirstButtonPressed: {
|
||||
self.waitAndExecute {
|
||||
Shell.run("valet unlink '\(site.name)'", requiresPath: true)
|
||||
} completion: {
|
||||
self.reloadDomains()
|
||||
await site.unlink()
|
||||
await self.reloadDomainsWithoutUI()
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -179,25 +187,75 @@ extension DomainListVC {
|
||||
style: .critical,
|
||||
onFirstButtonPressed: {
|
||||
self.waitAndExecute {
|
||||
Shell.run("valet unproxy '\(proxy.domain)'", requiresPath: true)
|
||||
} completion: {
|
||||
self.reloadDomains()
|
||||
await proxy.remove()
|
||||
await self.reloadDomainsWithoutUI()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func performAction(command: String, beforeCellReload: @escaping () -> Void) {
|
||||
let rowToReload = tableView.selectedRow
|
||||
|
||||
waitAndExecute {
|
||||
Shell.run(command, requiresPath: true)
|
||||
} completion: { [self] in
|
||||
beforeCellReload()
|
||||
tableView.reloadData(forRowIndexes: [rowToReload], columnIndexes: [0, 1, 2, 3, 4])
|
||||
tableView.deselectRow(rowToReload)
|
||||
tableView.selectRowIndexes([rowToReload], byExtendingSelection: true)
|
||||
@objc func useInTerminal() {
|
||||
guard let site = selectedSite else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let version = site.isolatedPhpVersion?.versionNumber else {
|
||||
return
|
||||
}
|
||||
|
||||
self.notifyAboutUsingIsolatedPhpVersionInTerminal(version: version)
|
||||
}
|
||||
|
||||
// MARK: - Alerts & Modals
|
||||
|
||||
private func notifyAboutModifiedSecureStatus(domain: String, secured: Bool) {
|
||||
LocalNotification.send(
|
||||
title: "domain_list.alerts_status_changed.title".localized,
|
||||
subtitle: "domain_list.alerts_status_changed.desc"
|
||||
.localized(
|
||||
// 1. The domain that was secured is listed
|
||||
"\(domain).\(Valet.shared.config.tld)",
|
||||
// 2. What the domain is is listed (secure / unsecure)
|
||||
secured
|
||||
? "domain_list.alerts_status_secure".localized
|
||||
: "domain_list.alerts_status_unsecure".localized
|
||||
),
|
||||
preference: .notifyAboutSecureToggle
|
||||
)
|
||||
}
|
||||
|
||||
private func notifyAboutUsingIsolatedPhpVersionInTerminal(version: VersionNumber) {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "domain_list.alerts_isolated_php_terminal.title".localized(version.short),
|
||||
subtitle: "domain_list.alerts_isolated_php_terminal.subtitle".localized(
|
||||
"\(version.major)\(version.minor)",
|
||||
version.short
|
||||
),
|
||||
description: "domain_list.alerts_isolated_php_terminal.desc".localized
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
private func notifyAboutFailedSecureStatus(command: String) {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "domain_list.alerts_status_not_changed.title".localized,
|
||||
subtitle: "domain_list.alerts_status_not_changed.desc".localized(command)
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
private func notifyAboutFailedSiteIsolation(command: String) {
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "domain_list.alerts_isolation_failed.title".localized,
|
||||
subtitle: "domain_list.alerts_isolation_failed.subtitle".localized,
|
||||
description: "domain_list.alerts_isolation_failed.desc".localized(command)
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +130,13 @@ extension DomainListVC {
|
||||
|
||||
menu.addItem(HeaderView.asMenuItem(text: "domain_list.site_isolation".localized))
|
||||
menu.addItem(NSMenuItem(title: "domain_list.isolate".localized, submenu: items))
|
||||
|
||||
if site.isolatedPhpVersion != nil {
|
||||
menu.addItem(NSMenuItem(
|
||||
title: "domain_list.use_in_terminal".localized(site.servingPhpVersion),
|
||||
action: #selector(self.useInTerminal)
|
||||
))
|
||||
}
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
// MARK: - Variables
|
||||
|
||||
/// List of sites that will be displayed in this view. Originates from the `Valet` object.
|
||||
var domains: [DomainListable] = []
|
||||
var domains: [ValetListable] = []
|
||||
|
||||
/// Array that contains various apps that might open a particular site directory.
|
||||
var applications: [Application] {
|
||||
@ -48,7 +48,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
return domains[tableView.selectedRow] as? ValetProxy
|
||||
}
|
||||
|
||||
var selected: DomainListable? {
|
||||
var selected: ValetListable? {
|
||||
if tableView.selectedRow == -1 {
|
||||
return nil
|
||||
}
|
||||
@ -97,7 +97,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
domains = Valet.getDomainListable()
|
||||
searchedFor(text: lastSearchedFor)
|
||||
} else {
|
||||
reloadDomains()
|
||||
Task { await reloadDomains() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,10 +107,12 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
Disables the UI so the user cannot interact with it.
|
||||
Also shows a spinner to indicate that we're busy.
|
||||
*/
|
||||
public func setUIBusy() {
|
||||
@MainActor public func setUIBusy() {
|
||||
// If it takes more than 0.5s to set the UI to not busy, show a spinner
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { _ in
|
||||
self.progressIndicator.startAnimation(true)
|
||||
Task {
|
||||
@MainActor in self.progressIndicator.startAnimation(true)
|
||||
}
|
||||
})
|
||||
|
||||
tableView.alphaValue = 0.3
|
||||
@ -121,7 +123,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
/**
|
||||
Re-enables the UI so the user can interact with it.
|
||||
*/
|
||||
public func setUINotBusy() {
|
||||
@MainActor public func setUINotBusy() {
|
||||
timer?.invalidate()
|
||||
progressIndicator.stopAnimation(nil)
|
||||
tableView.alphaValue = 1.0
|
||||
@ -136,13 +138,13 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
- Parameter execute: Callback of the work that needs to happen.
|
||||
- Parameter completion: Callback that is fired when the work is done.
|
||||
*/
|
||||
internal func waitAndExecute(_ execute: @escaping () -> Void, completion: @escaping () -> Void = {}) {
|
||||
setUIBusy()
|
||||
DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
|
||||
execute()
|
||||
internal func waitAndExecute(_ execute: @escaping () async -> Void, completion: @escaping () -> Void = {}) {
|
||||
Task { // Legacy `waitAndExecute` with UI
|
||||
setUIBusy()
|
||||
await execute()
|
||||
|
||||
// For a smoother animation, expect at least a 0.2 second delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in
|
||||
Task { @MainActor in
|
||||
await delay(seconds: 0.2)
|
||||
completion()
|
||||
setUINotBusy()
|
||||
}
|
||||
@ -151,15 +153,21 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
|
||||
// MARK: - Site Data Loading
|
||||
|
||||
func reloadDomains() {
|
||||
func reloadDomains() async {
|
||||
waitAndExecute {
|
||||
Valet.shared.reloadSites()
|
||||
await Valet.shared.reloadSites()
|
||||
} completion: { [self] in
|
||||
domains = Valet.shared.sites
|
||||
searchedFor(text: lastSearchedFor)
|
||||
}
|
||||
}
|
||||
|
||||
func reloadDomainsWithoutUI() async {
|
||||
await Valet.shared.reloadSites()
|
||||
domains = Valet.shared.sites
|
||||
searchedFor(text: lastSearchedFor)
|
||||
}
|
||||
|
||||
func applySortDescriptor(_ descriptor: NSSortDescriptor) {
|
||||
sortDescriptor = descriptor
|
||||
|
||||
@ -177,22 +185,22 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
self.domains = descriptor.ascending ? sorted.reversed() : sorted
|
||||
}
|
||||
|
||||
func addedNewSite(name: String, secure: Bool) {
|
||||
func addedNewSite(name: String, secureAfterLinking: Bool) async {
|
||||
waitAndExecute {
|
||||
Valet.shared.reloadSites()
|
||||
await Valet.shared.reloadSites()
|
||||
} completion: { [self] in
|
||||
find(name, secure)
|
||||
find(name, secureAfterLinking)
|
||||
}
|
||||
}
|
||||
|
||||
private func find(_ name: String, _ secure: Bool = false) {
|
||||
private func find(_ name: String, _ shouldSecure: Bool = false) {
|
||||
domains = Valet.getDomainListable()
|
||||
searchedFor(text: "")
|
||||
if let site = domains.enumerated().first(where: { $0.element.getListableName() == name }) {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
self.tableView.selectRowIndexes([site.offset], byExtendingSelection: false)
|
||||
self.tableView.scrollRowToVisible(site.offset)
|
||||
if secure && !site.element.getListableSecured() {
|
||||
if shouldSecure && !site.element.getListableSecured() {
|
||||
self.toggleSecure()
|
||||
}
|
||||
}
|
||||
@ -258,7 +266,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
||||
self.applySortDescriptor(sortDescriptor)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ class DomainListWindowController: PMWindowController, NSSearchFieldDelegate, NST
|
||||
// MARK: - Reload functionality
|
||||
|
||||
@IBAction func pressedReload(_ sender: Any?) {
|
||||
contentVC.reloadDomains()
|
||||
Task { await contentVC.reloadDomains() }
|
||||
}
|
||||
|
||||
@IBAction func pressedAddLink(_ sender: Any?) {
|
||||
|
@ -8,9 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class ComposerWindow {
|
||||
|
||||
private var menu: MainMenu?
|
||||
@MainActor class ComposerWindow {
|
||||
private var shouldNotify: Bool! = nil
|
||||
private var completion: ((Bool) -> Void)! = nil
|
||||
private var window: TerminalProgressWindowController?
|
||||
@ -19,21 +17,19 @@ class ComposerWindow {
|
||||
Updates the global dependencies and runs the completion callback when done.
|
||||
*/
|
||||
func updateGlobalDependencies(notify: Bool, completion: @escaping (Bool) -> Void) {
|
||||
self.menu = MainMenu.shared
|
||||
self.shouldNotify = notify
|
||||
self.completion = completion
|
||||
|
||||
Paths.shared.detectBinaryPaths()
|
||||
|
||||
if Paths.composer == nil {
|
||||
DispatchQueue.main.async {
|
||||
self.presentMissingAlert()
|
||||
}
|
||||
self.presentMissingAlert()
|
||||
return
|
||||
}
|
||||
|
||||
PhpEnv.shared.isBusy = true
|
||||
menu?.setBusyImage()
|
||||
menu?.rebuild()
|
||||
MainMenu.shared.setBusyImage()
|
||||
MainMenu.shared.rebuild()
|
||||
|
||||
window = TerminalProgressWindowController.display(
|
||||
title: "alert.composer_progress.title".localized,
|
||||
@ -42,45 +38,44 @@ class ComposerWindow {
|
||||
|
||||
window?.setType(info: true)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
let task = Shell.user.createTask(
|
||||
for: "\(Paths.composer!) global update", requiresPath: true
|
||||
)
|
||||
Task { // Start the Composer global update as a separate task
|
||||
await performComposerUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.window?.addToConsole("\(Paths.composer!) global update\n")
|
||||
}
|
||||
private func performComposerUpdate() async {
|
||||
do {
|
||||
try await runComposerUpdateShellCommand()
|
||||
} catch {
|
||||
composerUpdateFailed()
|
||||
}
|
||||
}
|
||||
|
||||
task.listen(
|
||||
didReceiveStandardOutputData: { [weak self] string in
|
||||
DispatchQueue.main.async {
|
||||
self?.window?.addToConsole(string)
|
||||
}
|
||||
// Log.perf("\(string.trimmingCharacters(in: .newlines))")
|
||||
},
|
||||
didReceiveStandardErrorData: { [weak self] string in
|
||||
DispatchQueue.main.async {
|
||||
self?.window?.addToConsole(string)
|
||||
}
|
||||
// Log.perf("\(string.trimmingCharacters(in: .newlines))")
|
||||
}
|
||||
)
|
||||
private func runComposerUpdateShellCommand() async throws {
|
||||
let command = "\(Paths.composer!) global update"
|
||||
|
||||
task.launch()
|
||||
task.waitUntilExit()
|
||||
task.haltListening()
|
||||
self.window?.addToConsole("\(command)\n")
|
||||
|
||||
if task.terminationStatus <= 0 {
|
||||
composerUpdateSucceeded()
|
||||
} else {
|
||||
composerUpdateFailed()
|
||||
}
|
||||
let (process, _) = try await Shell.attach(
|
||||
command,
|
||||
didReceiveOutput: { [weak self] (incoming, _) in
|
||||
guard let window = self?.window else { return }
|
||||
window.addToConsole(incoming)
|
||||
},
|
||||
withTimeout: .minutes(5)
|
||||
)
|
||||
|
||||
if process.terminationStatus <= 0 {
|
||||
composerUpdateSucceeded()
|
||||
} else {
|
||||
composerUpdateFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private func composerUpdateSucceeded() {
|
||||
// Closing the window should happen after a slight delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [self] in
|
||||
Task { @MainActor in
|
||||
await delay(seconds: 1.0)
|
||||
window?.close()
|
||||
if shouldNotify {
|
||||
LocalNotification.send(
|
||||
@ -91,20 +86,18 @@ class ComposerWindow {
|
||||
}
|
||||
window = nil
|
||||
removeBusyStatus()
|
||||
menu = nil
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func composerUpdateFailed() {
|
||||
// Showing that something failed should be shown immediately
|
||||
DispatchQueue.main.async { [self] in
|
||||
Task { @MainActor [self] in
|
||||
window?.setType(info: false)
|
||||
window?.progressView?.labelTitle.stringValue = "alert.composer_failure.title".localized
|
||||
window?.progressView?.labelDescription.stringValue = "alert.composer_failure.info".localized
|
||||
window = nil
|
||||
removeBusyStatus()
|
||||
menu = nil
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
@ -113,7 +106,9 @@ class ComposerWindow {
|
||||
|
||||
private func removeBusyStatus() {
|
||||
PhpEnv.shared.isBusy = false
|
||||
menu?.updatePhpVersionInStatusBar()
|
||||
Task { @MainActor in
|
||||
MainMenu.shared.updatePhpVersionInStatusBar()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Alert
|
||||
@ -125,7 +120,7 @@ class ComposerWindow {
|
||||
subtitle: "alert.composer_missing.subtitle".localized,
|
||||
description: "alert.composer_missing.desc".localized
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
@ -71,7 +71,7 @@ struct PhpFrameworks {
|
||||
public static func detectFallbackDependency(_ basePath: String) -> String? {
|
||||
for entry in Self.FileMapping {
|
||||
let found = entry.value
|
||||
.map { path in return Filesystem.exists(basePath + path) }
|
||||
.map { path in return FileSystem.anyExists(basePath + path) }
|
||||
.contains(true)
|
||||
|
||||
if found {
|
||||
|
@ -9,18 +9,23 @@
|
||||
import Foundation
|
||||
|
||||
class HomebrewDiagnostics {
|
||||
|
||||
/**
|
||||
Determines the Homebrew taps the user has installed.
|
||||
*/
|
||||
public static var installedTaps: [String] = {
|
||||
return Shell
|
||||
public static var installedTaps: [String] = []
|
||||
|
||||
/**
|
||||
Load which taps are installed.
|
||||
*/
|
||||
public static func loadInstalledTaps() async {
|
||||
installedTaps = await Shell
|
||||
.pipe("\(Paths.brew) tap")
|
||||
.out
|
||||
.split(separator: "\n")
|
||||
.map { string in
|
||||
return String(string)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
/**
|
||||
Determines whether the PHP Monitor Cask is installed.
|
||||
@ -36,12 +41,8 @@ class HomebrewDiagnostics {
|
||||
guard let destination = try? FileManager.default
|
||||
.destinationOfSymbolicLink(atPath: "\(Paths.binPath)/nginx") else { return false }
|
||||
|
||||
return
|
||||
// Verify that the `nginx` binary is symlinked to a directory that includes `nginx-full`.
|
||||
destination.contains("/nginx-full/")
|
||||
// Verify that the formula exists.
|
||||
&& !Shell.pipe("\(Paths.brew) info nginx-full --json")
|
||||
.contains("Error: No available formula")
|
||||
// Verify that the `nginx` binary is symlinked to a directory that includes `nginx-full`.
|
||||
return destination.contains("/nginx-full/")
|
||||
}()
|
||||
|
||||
/**
|
||||
@ -51,8 +52,8 @@ class HomebrewDiagnostics {
|
||||
|
||||
This check only needs to be performed if the `shivammathur/php` tap is active.
|
||||
*/
|
||||
public static func checkForCaskConflict() {
|
||||
if hasAliasConflict() {
|
||||
public static func checkForCaskConflict() async {
|
||||
if await hasAliasConflict() {
|
||||
presentAlertAboutConflict()
|
||||
}
|
||||
}
|
||||
@ -80,17 +81,19 @@ class HomebrewDiagnostics {
|
||||
}
|
||||
|
||||
versions.forEach { version in
|
||||
switcher.disableDefaultPhpFpmPool(version)
|
||||
switcher.stopPhpVersion(version)
|
||||
switcher.startPhpVersion(version, primary: version == primary)
|
||||
Task { // Fix each pool concurrently (but perform the tasks sequentially)
|
||||
await switcher.disableDefaultPhpFpmPool(version)
|
||||
await switcher.stopPhpVersion(version)
|
||||
await switcher.startPhpVersion(version, primary: version == primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Check if the alias conflict as documented in `checkForCaskConflict` actually occurred.
|
||||
*/
|
||||
private static func hasAliasConflict() -> Bool {
|
||||
let tapAlias = Shell.pipe("\(Paths.brew) info shivammathur/php/php --json")
|
||||
private static func hasAliasConflict() async -> Bool {
|
||||
let tapAlias = await Shell.pipe("brew info shivammathur/php/php --json").out
|
||||
|
||||
if tapAlias.contains("brew tap shivammathur/php") || tapAlias.contains("Error") || tapAlias.isEmpty {
|
||||
Log.info("The user does not appear to have tapped: shivammathur/php")
|
||||
@ -104,13 +107,13 @@ class HomebrewDiagnostics {
|
||||
from: tapAlias.data(using: .utf8)!
|
||||
).first!
|
||||
|
||||
if tapPhp.version != PhpEnv.brewPhpVersion {
|
||||
if tapPhp.version != PhpEnv.brewPhpAlias {
|
||||
Log.warn("The `php` formula alias seems to be the different between the tap and core. "
|
||||
+ "This could be a problem!")
|
||||
Log.info("Determining whether both of these versions are installed...")
|
||||
|
||||
let bothInstalled = PhpEnv.shared.availablePhpVersions.contains(tapPhp.version)
|
||||
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpVersion)
|
||||
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpAlias)
|
||||
|
||||
if bothInstalled {
|
||||
Log.warn("Both conflicting aliases seem to be installed, warning the user!")
|
||||
@ -131,28 +134,29 @@ class HomebrewDiagnostics {
|
||||
Show this alert in case the tapped Cask does cause issues because of the conflict.
|
||||
*/
|
||||
private static func presentAlertAboutConflict() {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
BetterAlert()
|
||||
.withInformation(
|
||||
title: "alert.php_alias_conflict.title".localized,
|
||||
subtitle: "alert.php_alias_conflict.info".localized
|
||||
)
|
||||
.withPrimary(text: "OK")
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
In order to see if we support the --json syntax, we'll query dnsmasq.
|
||||
In order to see if we support the --json syntax, we'll query nginx.
|
||||
If the JSON response cannot be parsed, Homebrew is probably out of date.
|
||||
*/
|
||||
public static func cannotLoadService(_ name: String) -> Bool {
|
||||
public static func cannotLoadService(_ name: String) async -> Bool {
|
||||
let nginxJson = await Shell
|
||||
.pipe("sudo \(Paths.brew) services info \(name) --json")
|
||||
.out
|
||||
|
||||
let serviceInfo = try? JSONDecoder().decode(
|
||||
[HomebrewService].self,
|
||||
from: Shell.pipe(
|
||||
"sudo \(Paths.brew) services info \(name) --json",
|
||||
requiresPath: true
|
||||
).data(using: .utf8)!
|
||||
from: nginxJson.data(using: .utf8)!
|
||||
)
|
||||
|
||||
return serviceInfo == nil
|
||||
|
@ -0,0 +1,88 @@
|
||||
//
|
||||
// FakeValetInteractor.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 13/12/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FakeValetInteractor: ValetInteractor {
|
||||
var delayTime: TimeInterval = 1.0
|
||||
|
||||
// MARK: - Managing Domains
|
||||
|
||||
override func link(path: String, domain: String) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
if let scanner = ValetScanner.active as? FakeDomainScanner {
|
||||
scanner.sites.append(
|
||||
FakeValetSite(
|
||||
fakeWithName: domain,
|
||||
tld: Valet.shared.config.tld,
|
||||
secure: false,
|
||||
path: path,
|
||||
linked: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override func unlink(site: ValetSite) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
if let scanner = ValetScanner.active as? FakeDomainScanner {
|
||||
scanner.sites.removeAll { $0 === site }
|
||||
}
|
||||
}
|
||||
|
||||
override func proxy(domain: String, proxy: String, secure: Bool) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
if let scanner = ValetScanner.active as? FakeDomainScanner {
|
||||
scanner.proxies.append(
|
||||
FakeValetProxy(
|
||||
domain: domain,
|
||||
target: proxy,
|
||||
secure: secure,
|
||||
tld: Valet.shared.config.tld
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override func remove(proxy: ValetProxy) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
if let scanner = ValetScanner.active as? FakeDomainScanner {
|
||||
scanner.proxies.removeAll { $0.domain == proxy.domain }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifying Domains
|
||||
|
||||
override func toggleSecure(proxy: ValetProxy) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
proxy.secured = !proxy.secured
|
||||
}
|
||||
|
||||
override func toggleSecure(site: ValetSite) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
site.secured = !site.secured
|
||||
}
|
||||
|
||||
override func isolate(site: ValetSite, version: String) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
site.isolatedPhpVersion = PhpEnv.shared.cachedPhpInstallations[version]
|
||||
site.evaluateCompatibility()
|
||||
}
|
||||
|
||||
override func unisolate(site: ValetSite) async throws {
|
||||
await delay(seconds: delayTime)
|
||||
|
||||
site.isolatedPhpVersion = nil
|
||||
site.evaluateCompatibility()
|
||||
}
|
||||
}
|
128
phpmon/Domain/Integrations/Valet/Domains/ValetInteractor.swift
Normal file
@ -0,0 +1,128 @@
|
||||
//
|
||||
// ValetInteractor.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 21/10/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ValetInteractionError: Error {
|
||||
/// The command the user should try (and failed).
|
||||
var command: String
|
||||
}
|
||||
|
||||
class ValetInteractor {
|
||||
static var shared = ValetInteractor()
|
||||
|
||||
public static func useFake() {
|
||||
ValetInteractor.shared = FakeValetInteractor()
|
||||
}
|
||||
|
||||
// MARK: - Managing Domains
|
||||
|
||||
public func link(path: String, domain: String) async throws {
|
||||
await Shell.quiet("cd '\(path)' && \(Paths.valet) link '\(domain)' && valet links")
|
||||
}
|
||||
|
||||
public func unlink(site: ValetSite) async throws {
|
||||
await Shell.quiet("valet unlink '\(site.name)'")
|
||||
}
|
||||
|
||||
public func proxy(domain: String, proxy: String, secure: Bool) async throws {
|
||||
let command = secure
|
||||
? "\(Paths.valet) proxy \(domain) \(proxy) --secure"
|
||||
: "\(Paths.valet) proxy \(domain) \(proxy)"
|
||||
|
||||
await Shell.quiet(command)
|
||||
await Actions.restartNginx()
|
||||
}
|
||||
|
||||
public func remove(proxy: ValetProxy) async throws {
|
||||
await Shell.quiet("valet unproxy '\(proxy.domain)'")
|
||||
}
|
||||
|
||||
// MARK: - Modifying Domains
|
||||
|
||||
public func toggleSecure(site: ValetSite) async throws {
|
||||
// Keep track of the original status (secure or not?)
|
||||
let originalSecureStatus = site.secured
|
||||
|
||||
// Keep track of the command we wish to run
|
||||
let action = site.secured ? "unsecure" : "secure"
|
||||
let command = "cd '\(site.absolutePath)' && sudo \(Paths.valet) \(action) && exit;"
|
||||
|
||||
// Run the command
|
||||
await Shell.quiet(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineSecured()
|
||||
if site.secured == originalSecureStatus {
|
||||
throw ValetInteractionError(command: command)
|
||||
}
|
||||
}
|
||||
|
||||
public func toggleSecure(proxy: ValetProxy) async throws {
|
||||
// Keep track of the original status (secure or not?)
|
||||
let originalSecureStatus = proxy.secured
|
||||
|
||||
// Build the list of commands we will need to run
|
||||
let commands: [String] = [
|
||||
// Unproxy the given domain
|
||||
"\(Paths.valet) unproxy \(proxy.domain)",
|
||||
// Re-create the proxy (with the inverse secured status)
|
||||
originalSecureStatus
|
||||
? "\(Paths.valet) proxy \(proxy.domain) \(proxy.target)"
|
||||
: "\(Paths.valet) proxy \(proxy.domain) \(proxy.target) --secure"
|
||||
]
|
||||
|
||||
// Run the commands
|
||||
for command in commands {
|
||||
await Shell.quiet(command)
|
||||
}
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
proxy.determineSecured()
|
||||
if proxy.secured == originalSecureStatus {
|
||||
throw ValetInteractionError(
|
||||
command: commands.joined(separator: " && ")
|
||||
)
|
||||
}
|
||||
|
||||
// Restart nginx to load the new configuration
|
||||
await Actions.restartNginx()
|
||||
}
|
||||
|
||||
public func isolate(site: ValetSite, version: String) async throws {
|
||||
let command = "sudo \(Paths.valet) isolate php@\(version) --site '\(site.name)'"
|
||||
|
||||
// Run the command
|
||||
await Shell.quiet(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineIsolated()
|
||||
site.determineComposerPhpVersion()
|
||||
|
||||
// If the version is not isolated, this failed
|
||||
if site.isolatedPhpVersion == nil {
|
||||
throw ValetInteractionError(command: command)
|
||||
}
|
||||
}
|
||||
|
||||
public func unisolate(site: ValetSite) async throws {
|
||||
let command = "sudo \(Paths.valet) unisolate --site '\(site.name)'"
|
||||
|
||||
// Run the command
|
||||
await Shell.quiet(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineIsolated()
|
||||
site.determineComposerPhpVersion()
|
||||
|
||||
// If the version is somehow still isolated, this failed
|
||||
if site.isolatedPhpVersion != nil {
|
||||
throw ValetInteractionError(command: command)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
//
|
||||
// DomainListable.swift
|
||||
// ValetListable.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 12/04/2022.
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol DomainListable {
|
||||
protocol ValetListable {
|
||||
|
||||
func getListableName() -> String
|
||||
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// FakeValetProxy.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 16/12/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FakeValetProxy: ValetProxy {
|
||||
override func determineSecured() {
|
||||
return
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
//
|
||||
// ProxyScanner.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 02/04/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ProxyScanner {
|
||||
|
||||
func resolveProxies(directoryPath: String) -> [ValetProxy]
|
||||
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
//
|
||||
// ValetProxyScanner.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 11/04/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ValetProxyScanner: ProxyScanner {
|
||||
func resolveProxies(directoryPath: String) -> [ValetProxy] {
|
||||
return try! FileManager
|
||||
.default
|
||||
.contentsOfDirectory(atPath: directoryPath)
|
||||
.filter {
|
||||
return !$0.starts(with: ".")
|
||||
}
|
||||
.compactMap {
|
||||
return NginxConfigurationFile.from(filePath: "\(directoryPath)/\($0)")
|
||||
}
|
||||
.filter {
|
||||
return $0.proxy != nil
|
||||
}
|
||||
.map {
|
||||
return ValetProxy($0)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
//
|
||||
// ValetProxy+Fake.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 02/04/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ValetProxy {
|
||||
|
||||
}
|
@ -8,20 +8,30 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class ValetProxy: DomainListable {
|
||||
class ValetProxy: ValetListable {
|
||||
var domain: String
|
||||
var tld: String
|
||||
var target: String
|
||||
var secured: Bool = false
|
||||
|
||||
init(_ configuration: NginxConfigurationFile) {
|
||||
self.domain = configuration.domain
|
||||
self.tld = configuration.tld
|
||||
self.target = configuration.proxy!
|
||||
self.secured = Filesystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
|
||||
init(domain: String, target: String, secure: Bool, tld: String) {
|
||||
self.domain = domain
|
||||
self.tld = tld
|
||||
self.target = target
|
||||
self.secured = false
|
||||
}
|
||||
|
||||
// MARK: - DomainListable Protocol
|
||||
convenience init(_ configuration: NginxConfigurationFile) {
|
||||
self.init(
|
||||
domain: configuration.domain,
|
||||
target: configuration.proxy!,
|
||||
secure: false,
|
||||
tld: configuration.tld
|
||||
)
|
||||
self.determineSecured()
|
||||
}
|
||||
|
||||
// MARK: - ValetListable Protocol
|
||||
|
||||
func getListableName() -> String {
|
||||
return self.domain
|
||||
@ -50,4 +60,18 @@ class ValetProxy: DomainListable {
|
||||
func getListableUrl() -> URL? {
|
||||
return URL(string: "\(self.secured ? "https://" : "http://")\(self.domain).\(self.tld)")
|
||||
}
|
||||
|
||||
// MARK: - Interactions
|
||||
|
||||
func determineSecured() {
|
||||
self.secured = FileSystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
|
||||
}
|
||||
|
||||
func toggleSecure() async throws {
|
||||
try await ValetInteractor.shared.toggleSecure(proxy: self)
|
||||
}
|
||||
|
||||
func remove() async {
|
||||
try! await ValetInteractor.shared.remove(proxy: self)
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,25 @@
|
||||
//
|
||||
// ValetSiteScanner.swift
|
||||
// DomainScanner.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 19/03/2022.
|
||||
// Created by Nico Verbruggen on 02/04/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SiteScanner {
|
||||
protocol DomainScanner {
|
||||
|
||||
// MARK: - Sites
|
||||
|
||||
func resolveSiteCount(paths: [String]) -> Int
|
||||
|
||||
func resolveSitesFrom(paths: [String]) -> [ValetSite]
|
||||
|
||||
func resolveSite(path: String) -> ValetSite?
|
||||
|
||||
// MARK: - Proxies
|
||||
|
||||
func resolveProxies(directoryPath: String) -> [ValetProxy]
|
||||
|
||||
}
|
@ -1,41 +1,54 @@
|
||||
//
|
||||
// FakeSiteScanner.swift
|
||||
// FakeDomainScanner.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 02/04/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
class FakeSiteScanner: SiteScanner {
|
||||
let fakes = [
|
||||
ValetSite(fakeWithName: "laravel", tld: "test", secure: true,
|
||||
class FakeDomainScanner: DomainScanner {
|
||||
|
||||
var sites: [ValetSite] = [
|
||||
FakeValetSite(fakeWithName: "laravel", tld: "test", secure: true,
|
||||
path: "~/Code/laravel/framework", linked: true),
|
||||
|
||||
ValetSite(fakeWithName: "tailwind", tld: "test", secure: true,
|
||||
FakeValetSite(fakeWithName: "tailwind", tld: "test", secure: true,
|
||||
path: "~/Code/tailwind/site", linked: true, constraint: "8.0"),
|
||||
|
||||
ValetSite(fakeWithName: "forge", tld: "test", secure: true,
|
||||
FakeValetSite(fakeWithName: "forge", tld: "test", secure: true,
|
||||
path: "~/Code/laravel/forge", linked: true),
|
||||
|
||||
ValetSite(fakeWithName: "concord", tld: "test", secure: false,
|
||||
FakeValetSite(fakeWithName: "concord", tld: "test", secure: false,
|
||||
path: "~/Code/concord", linked: true, driver: "Laravel (^8.0)", constraint: "^7.4", isolated: "7.4"),
|
||||
|
||||
ValetSite(fakeWithName: "drupal", tld: "test", secure: false,
|
||||
FakeValetSite(fakeWithName: "drupal", tld: "test", secure: false,
|
||||
path: "~/Sites/drupal", linked: false, driver: "Drupal", constraint: "^7.4", isolated: "7.4"),
|
||||
|
||||
ValetSite(fakeWithName: "wordpress", tld: "test", secure: false,
|
||||
FakeValetSite(fakeWithName: "wordpress", tld: "test", secure: false,
|
||||
path: "~/Sites/wordpress", linked: false, driver: "WordPress", constraint: "^7.4", isolated: "7.4")
|
||||
]
|
||||
|
||||
var proxies: [ValetProxy] = [
|
||||
FakeValetProxy(domain: "mailgun", target: "http://127.0.0.1:9999", secure: true, tld: "test")
|
||||
]
|
||||
|
||||
// MARK: - Sites
|
||||
|
||||
func resolveSiteCount(paths: [String]) -> Int {
|
||||
return fakes.count
|
||||
return sites.count
|
||||
}
|
||||
|
||||
func resolveSitesFrom(paths: [String]) -> [ValetSite] {
|
||||
return fakes
|
||||
return sites
|
||||
}
|
||||
|
||||
func resolveSite(path: String) -> ValetSite? {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Proxies
|
||||
|
||||
func resolveProxies(directoryPath: String) -> [ValetProxy] {
|
||||
return proxies
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
//
|
||||
// ValetSiteScanner.swift
|
||||
// ValetDomainScanner.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 02/04/2022.
|
||||
@ -8,12 +8,15 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class ValetSiteScanner: SiteScanner {
|
||||
class ValetDomainScanner: DomainScanner {
|
||||
|
||||
// MARK: - Sites
|
||||
|
||||
func resolveSiteCount(paths: [String]) -> Int {
|
||||
return paths.map { path in
|
||||
|
||||
let entries = try! FileManager.default
|
||||
.contentsOfDirectory(atPath: path)
|
||||
let entries = try! FileSystem
|
||||
.getShallowContentsOfDirectory(path)
|
||||
|
||||
return entries
|
||||
.map { self.isSite($0, forPath: path) }
|
||||
@ -27,8 +30,8 @@ class ValetSiteScanner: SiteScanner {
|
||||
var sites: [ValetSite] = []
|
||||
|
||||
paths.forEach { path in
|
||||
let entries = try! FileManager.default
|
||||
.contentsOfDirectory(atPath: path)
|
||||
let entries = try! FileSystem
|
||||
.getShallowContentsOfDirectory(path)
|
||||
|
||||
return entries.forEach {
|
||||
if let site = self.resolveSite(path: "\(path)/\($0)") {
|
||||
@ -48,24 +51,19 @@ class ValetSiteScanner: SiteScanner {
|
||||
// Get the TLD from the global Valet object
|
||||
let tld = Valet.shared.config.tld
|
||||
|
||||
// See if the file is a symlink, if so, resolve it
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
|
||||
if !FileSystem.anyExists(path) {
|
||||
Log.warn("Could not parse the site: \(path), skipping!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// We can also determine whether the thing at the path is a directory, too
|
||||
let type = attrs[FileAttributeKey.type] as! FileAttributeType
|
||||
|
||||
// We should also check that we can interpret the path correctly
|
||||
if URL(fileURLWithPath: path).lastPathComponent == "" {
|
||||
Log.warn("Could not parse the site: \(path), skipping!")
|
||||
return nil
|
||||
}
|
||||
|
||||
if type == FileAttributeType.typeSymbolicLink {
|
||||
if FileSystem.isSymlink(path) {
|
||||
return ValetSite(aliasPath: path, tld: tld)
|
||||
} else if type == FileAttributeType.typeDirectory {
|
||||
} else if FileSystem.isDirectory(path) {
|
||||
return ValetSite(absolutePath: path, tld: tld)
|
||||
}
|
||||
|
||||
@ -79,14 +77,26 @@ class ValetSiteScanner: SiteScanner {
|
||||
private func isSite(_ entry: String, forPath path: String) -> Bool {
|
||||
let siteDir = path + "/" + entry
|
||||
|
||||
let attrs = try! FileManager.default.attributesOfItem(atPath: siteDir)
|
||||
return (FileSystem.isDirectory(siteDir) || FileSystem.isSymlink(siteDir))
|
||||
}
|
||||
|
||||
let type = attrs[FileAttributeKey.type] as! FileAttributeType
|
||||
// MARK: - Proxies
|
||||
|
||||
if type == FileAttributeType.typeSymbolicLink || type == FileAttributeType.typeDirectory {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
func resolveProxies(directoryPath: String) -> [ValetProxy] {
|
||||
return try! FileManager
|
||||
.default
|
||||
.contentsOfDirectory(atPath: directoryPath)
|
||||
.filter {
|
||||
return !$0.starts(with: ".")
|
||||
}
|
||||
.compactMap {
|
||||
return NginxConfigurationFile.from(filePath: "\(directoryPath)/\($0)")
|
||||
}
|
||||
.filter {
|
||||
return $0.proxy != nil
|
||||
}
|
||||
.map {
|
||||
return ValetProxy($0)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Valet+Scanners.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 01/11/2022.
|
||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ValetScanner {
|
||||
|
||||
static var active: DomainScanner = ValetDomainScanner()
|
||||
|
||||
public static func useFake() {
|
||||
ValetScanner.active = FakeDomainScanner()
|
||||
}
|
||||
|
||||
}
|
@ -8,8 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ValetSite {
|
||||
|
||||
class FakeValetSite: ValetSite {
|
||||
convenience init(
|
||||
fakeWithName name: String,
|
||||
tld: String,
|
||||
@ -36,13 +35,8 @@ extension ValetSite {
|
||||
self.isolatedPhpVersion = PhpInstallation(isolated)
|
||||
}
|
||||
|
||||
self.composerPhpCompatibleWithLinked = self.composerPhp.split(separator: "|")
|
||||
.map { string in
|
||||
let origin = self.isolatedPhpVersion?.versionNumber.homebrewVersion ?? PhpEnv.phpInstall.version.long
|
||||
return !PhpVersionNumberCollection.make(from: [origin])
|
||||
.matching(constraint: string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
.isEmpty
|
||||
}.contains(true)
|
||||
if PhpEnv.shared.currentInstall != nil {
|
||||
self.evaluateCompatibility()
|
||||
}
|
||||
}
|
||||
|
||||
}
|