Compare commits
159 Commits
Author | SHA1 | Date | |
---|---|---|---|
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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1400"
|
LastUpgradeVersion = "1400"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
@@ -27,14 +27,42 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<TestPlans>
|
||||||
|
<TestPlanReference
|
||||||
|
reference = "container:PHP Monitor.xcodeproj/PHP Monitor.xctestplan"
|
||||||
|
default = "YES">
|
||||||
|
</TestPlanReference>
|
||||||
|
</TestPlans>
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
|
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
|
||||||
BuildableName = "phpmon-tests.xctest"
|
BuildableName = "Unit Tests.xctest"
|
||||||
BlueprintName = "phpmon-tests"
|
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">
|
ReferencedContainer = "container:PHP Monitor.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
@@ -73,6 +101,11 @@
|
|||||||
value = ""
|
value = ""
|
||||||
isEnabled = "NO">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "SLOW_SHELL_MODE"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "NO">
|
||||||
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
|
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
|
||||||
value = ""
|
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>
|
57
README.md
@@ -79,20 +79,65 @@ If you're still having issues, here's a few common questions & answers, as well
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>Which versions of PHP are supported?</strong></summary>
|
<summary><strong>Which versions of PHP are supported?</strong></summary>
|
||||||
|
|
||||||
|
The following versions of PHP are officially supported:
|
||||||
|
|
||||||
<ul>
|
<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 7.4</li>
|
||||||
<li>PHP 8.0</li>
|
<li>PHP 8.0</li>
|
||||||
<li>PHP 8.1</li>
|
<li>PHP 8.1</li>
|
||||||
<li>PHP 8.2 (experimental)</li>
|
<li>PHP 8.2</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>
|
</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.
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
@@ -6,9 +6,7 @@ Generally speaking, only the latest version of **PHP Monitor** is supported, exc
|
|||||||
|
|
||||||
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
|
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Recommended Valet Version |
|
||||||
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
|
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
|
||||||
| 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 |
|
||||||
|
|
||||||
_(*) macOS Ventura (13.0) is not officially supported until it officially releases._
|
|
||||||
|
|
||||||
## Legacy versions
|
## Legacy versions
|
||||||
|
|
||||||
@@ -16,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 |
|
| 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.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 |
|
| 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 |
|
| 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
|
import Cocoa
|
||||||
|
|
||||||
public class Command {
|
public class RealCommand: CommandProtocol {
|
||||||
|
|
||||||
/**
|
public func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
|
||||||
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 {
|
|
||||||
let task = Process()
|
let task = Process()
|
||||||
task.launchPath = path
|
task.launchPath = path
|
||||||
task.arguments = arguments
|
task.arguments = arguments
|
@@ -12,36 +12,37 @@ class Actions {
|
|||||||
|
|
||||||
// MARK: - Services
|
// MARK: - Services
|
||||||
|
|
||||||
public static func restartPhpFpm() {
|
public static func restartPhpFpm() async {
|
||||||
brew("services restart \(PhpEnv.phpInstall.formula)", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.php.name)", sudo: Homebrew.Formulae.php.elevated)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func restartNginx() {
|
public static func restartNginx() async {
|
||||||
brew("services restart nginx", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.nginx.name)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func restartDnsMasq() {
|
public static func restartDnsMasq() async {
|
||||||
brew("services restart dnsmasq", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.dnsmasq.name)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func stopValetServices() {
|
public static func stopValetServices() async {
|
||||||
brew("services stop \(PhpEnv.phpInstall.formula)", sudo: true)
|
await brew("services stop \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||||
brew("services stop nginx", sudo: true)
|
await brew("services stop \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||||
brew("services stop dnsmasq", sudo: true)
|
await brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func fixHomebrewPermissions() throws {
|
public static func fixHomebrewPermissions() throws {
|
||||||
var servicesCommands = [
|
var servicesCommands = [
|
||||||
"\(Paths.brew) services stop nginx",
|
"\(Paths.brew) services stop \(Homebrew.Formulae.nginx)",
|
||||||
"\(Paths.brew) services stop dnsmasq"
|
"\(Paths.brew) services stop \(Homebrew.Formulae.dnsmasq)"
|
||||||
]
|
]
|
||||||
|
|
||||||
var cellarCommands = [
|
var cellarCommands = [
|
||||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/nginx",
|
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.nginx)",
|
||||||
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/dnsmasq"
|
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.dnsmasq)"
|
||||||
]
|
]
|
||||||
|
|
||||||
PhpEnv.shared.availablePhpVersions.forEach { version in
|
PhpEnv.shared.availablePhpVersions.forEach { version in
|
||||||
let formula = version == PhpEnv.brewPhpVersion
|
let formula = version == PhpEnv.brewPhpAlias
|
||||||
? "php"
|
? "php"
|
||||||
: "php@\(version)"
|
: "php@\(version)"
|
||||||
servicesCommands.append("\(Paths.brew) services stop \(formula)")
|
servicesCommands.append("\(Paths.brew) services stop \(formula)")
|
||||||
@@ -64,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
|
// MARK: - Finding Config Files
|
||||||
|
|
||||||
public static func openGenericPhpConfigFolder() {
|
public static func openGenericPhpConfigFolder() {
|
||||||
@@ -94,37 +72,33 @@ class Actions {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
|
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) {
|
public static func openPhpConfigFolder(version: String) {
|
||||||
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php/\(version)/php.ini")]
|
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php/\(version)/php.ini")]
|
||||||
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
|
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() {
|
public static func openValetConfigFolder() {
|
||||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
let file = URL(string: "file://~/.config/valet".replacingTildeWithHomeDirectory)!
|
||||||
.appendingPathComponent(".config/valet")
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func openPhpMonitorConfigFile() {
|
public static func openPhpMonitorConfigFile() {
|
||||||
let file = FileManager.default.homeDirectoryForCurrentUser
|
let file = URL(string: "file://~/.config/phpmon".replacingTildeWithHomeDirectory)!
|
||||||
.appendingPathComponent(".config/phpmon")
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Other Actions
|
// MARK: - Other Actions
|
||||||
|
|
||||||
public static func createTempPhpInfoFile() -> URL {
|
public static func createTempPhpInfoFile() async -> URL {
|
||||||
// Write a file called `phpmon_phpinfo.php` to /tmp
|
try! FileSystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
|
||||||
try! "<?php phpinfo();".write(toFile: "/tmp/phpmon_phpinfo.php", atomically: true, encoding: .utf8)
|
|
||||||
|
|
||||||
// Tell php-cgi to run the PHP and output as an .html file
|
// 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")!
|
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
|
||||||
}
|
}
|
||||||
@@ -143,12 +117,10 @@ class Actions {
|
|||||||
If this does not solve the issue, the user may need to install additional
|
If this does not solve the issue, the user may need to install additional
|
||||||
extensions and/or run `composer global update`.
|
extensions and/or run `composer global update`.
|
||||||
*/
|
*/
|
||||||
public static func fixMyValet(completed: @escaping () -> Void) {
|
public static func fixMyValet() async {
|
||||||
InternalSwitcher().performSwitch(to: PhpEnv.brewPhpVersion, completion: {
|
await InternalSwitcher().performSwitch(to: PhpEnv.brewPhpAlias)
|
||||||
brew("services restart dnsmasq", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
|
||||||
brew("services restart php", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
|
||||||
brew("services restart nginx", sudo: true)
|
await brew("services restart \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
|
||||||
completed()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,46 +9,36 @@ import Cocoa
|
|||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest PHP version that is considered to be stable at the time of release.
|
|
||||||
* This version number is currently not used (only as a default fallback).
|
|
||||||
*/
|
|
||||||
static let LatestStablePhpVersion = "8.1"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The minimum version of Valet that is recommended.
|
The minimum version of Valet that is recommended.
|
||||||
If the installed version is older, a notification will be shown
|
If the installed version is older, a notification will be shown
|
||||||
every time the app launches (with a recommendation to upgrade).
|
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/v3.1.10
|
||||||
See also: https://github.com/laravel/valet/releases/tag/v2.16.2
|
|
||||||
*/
|
*/
|
||||||
static let MinimumRecommendedValetVersion = "2.16.2"
|
static let MinimumRecommendedValetVersion = "3.1.10"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PHP versions supported by this application.
|
* The PHP versions supported by this application.
|
||||||
* Versions that do not appear in this array are omitted from the list.
|
* Depends on what version of Valet is installed.
|
||||||
*/
|
*/
|
||||||
static let SupportedPhpVersions = [
|
static let ValetSupportedPhpVersionMatrix = [
|
||||||
// ====================
|
2: // Valet v2 has the broadest legacy support
|
||||||
// STABLE RELEASES
|
[
|
||||||
// ====================
|
"5.6",
|
||||||
// Versions of PHP that are stable and are supported.
|
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||||
"5.6", // only supported when Valet 2.x is active
|
"8.0", "8.1", "8.2"
|
||||||
"7.0",
|
],
|
||||||
"7.1",
|
3: // Valet v3 dropped support for v5.6
|
||||||
"7.2",
|
[
|
||||||
"7.3",
|
"7.0", "7.1", "7.2", "7.3", "7.4",
|
||||||
"7.4",
|
"8.0", "8.1", "8.2", "8.3"
|
||||||
"8.0",
|
],
|
||||||
"8.1",
|
4: // Valet v4 dropped support for <v7.4
|
||||||
|
[
|
||||||
// ====================
|
"7.4",
|
||||||
// EXPERIMENTAL SUPPORT
|
"8.0", "8.1", "8.2", "8.3"
|
||||||
// ====================
|
]
|
||||||
// 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.2"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
struct Urls {
|
struct Urls {
|
||||||
|
@@ -11,41 +11,49 @@
|
|||||||
/**
|
/**
|
||||||
Runs a `valet` command. Defaults to running as superuser.
|
Runs a `valet` command. Defaults to running as superuser.
|
||||||
*/
|
*/
|
||||||
func valet(_ command: String, sudo: Bool = true) -> String {
|
func valet(_ command: String, sudo: Bool = true) async -> String {
|
||||||
return Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)", requiresPath: true)
|
return await Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)").out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Runs a `brew` command. Can run as superuser.
|
Runs a `brew` command. Can run as superuser.
|
||||||
*/
|
*/
|
||||||
func brew(_ command: String, sudo: Bool = false) {
|
func brew(_ command: String, sudo: Bool = false) async {
|
||||||
Shell.run("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
|
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.
|
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)
|
// Escape slashes (or `sed` won't work)
|
||||||
let e_original = original.replacingOccurrences(of: "/", with: "\\/")
|
let e_original = original.replacingOccurrences(of: "/", with: "\\/")
|
||||||
let e_replacement = replacement.replacingOccurrences(of: "/", with: "\\/")
|
let e_replacement = replacement.replacingOccurrences(of: "/", with: "\\/")
|
||||||
|
|
||||||
// Check if gsed exists; it is able to follow symlinks,
|
// Check if gsed exists; it is able to follow symlinks,
|
||||||
// which we want to do to toggle the extension
|
// which we want to do to toggle the extension
|
||||||
if Filesystem.fileExists("\(Paths.binPath)/gsed") {
|
if FileSystem.fileExists("\(Paths.binPath)/gsed") {
|
||||||
Shell.run("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
await Shell.quiet("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||||
} else {
|
} 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.
|
Uses `grep` to determine whether a particular query string can be found in a particular file.
|
||||||
*/
|
*/
|
||||||
func grepContains(file: String, query: String) -> Bool {
|
func grepContains(file: String, query: String) async -> Bool {
|
||||||
return Shell.pipe("""
|
return await Shell.pipe("""
|
||||||
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
|
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
|
||||||
""")
|
""").out
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.contains("YES")
|
.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))
|
||||||
|
}
|
||||||
|
56
phpmon/Common/Core/Homebrew.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// Homebrew.swift
|
||||||
|
// PHP Monitor
|
||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 21/11/2022.
|
||||||
|
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Homebrew {
|
||||||
|
static var fake: Bool = false
|
||||||
|
|
||||||
|
struct Formulae {
|
||||||
|
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: HomebrewFormula {
|
||||||
|
return HomebrewDiagnostics.usesNginxFullFormula
|
||||||
|
? HomebrewFormula("nginx-full", elevated: true)
|
||||||
|
: HomebrewFormula("nginx", elevated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
internal var baseDir: Paths.HomebrewDir
|
||||||
|
|
||||||
private var userName: String
|
private var userName: String! = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
baseDir = App.architecture != "x86_64" ? .opt : .usr
|
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() {
|
public func detectBinaryPaths() {
|
||||||
@@ -58,7 +62,16 @@ public class Paths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static var homePath: String {
|
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 {
|
public static var cellarPath: String {
|
||||||
@@ -82,9 +95,9 @@ public class Paths {
|
|||||||
// (PHP Monitor will not use the user's own PATH)
|
// (PHP Monitor will not use the user's own PATH)
|
||||||
|
|
||||||
private func detectComposerBinary() {
|
private func detectComposerBinary() {
|
||||||
if Filesystem.fileExists("/usr/local/bin/composer") {
|
if FileSystem.fileExists("/usr/local/bin/composer") {
|
||||||
Paths.composer = "/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"
|
Paths.composer = "/opt/homebrew/bin/composer"
|
||||||
} else {
|
} else {
|
||||||
Paths.composer = nil
|
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 Foundation
|
||||||
import SwiftUI
|
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 {
|
extension String {
|
||||||
var localized: String {
|
var localized: String {
|
||||||
if #available(macOS 13, *) {
|
if #available(macOS 13, *) {
|
||||||
return NSLocalizedString(
|
return NSLocalizedString(
|
||||||
self, tableName: nil, bundle: Bundle.main, value: "", comment: ""
|
self, tableName: nil, bundle: Localization.bundle, value: "", comment: ""
|
||||||
).replacingOccurrences(of: "Preferences", with: "Settings")
|
).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 {
|
var localizedForSwiftUI: LocalizedStringKey {
|
||||||
@@ -42,6 +65,16 @@ extension String {
|
|||||||
return count
|
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 {
|
subscript(r: Range<String.Index>) -> String {
|
||||||
let start = r.lowerBound
|
let start = r.lowerBound
|
||||||
let end = r.upperBound
|
let end = r.upperBound
|
||||||
@@ -98,5 +131,4 @@ extension String {
|
|||||||
return ""
|
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,
|
onWindow window: NSWindow,
|
||||||
messageText: String,
|
messageText: String,
|
||||||
informativeText: String,
|
informativeText: String,
|
||||||
buttonTitle: String = "OK",
|
buttonTitle: String = "generic.ok".localized,
|
||||||
secondButtonTitle: String = "Cancel",
|
secondButtonTitle: String = "generic.cancel".localized,
|
||||||
style: NSAlert.Style = .warning,
|
style: NSAlert.Style = .warning,
|
||||||
onFirstButtonPressed: @escaping (() -> Void)
|
onFirstButtonPressed: @escaping (() -> Void)
|
||||||
) {
|
) {
|
||||||
|
@@ -33,31 +33,46 @@ class Application {
|
|||||||
Attempt to open a specific directory in the app of choice.
|
Attempt to open a specific directory in the app of choice.
|
||||||
(This will open the app if it isn't open yet.)
|
(This will open the app if it isn't open yet.)
|
||||||
*/
|
*/
|
||||||
@objc public func openDirectory(file: String) {
|
@objc public func openDirectory(file: String) async {
|
||||||
return Shell.run("/usr/bin/open -a \"\(name)\" \"\(file)\"")
|
return await Shell.quiet("/usr/bin/open -a \"\(name)\" \"\(file)\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks if the app is installed. */
|
/** Checks if the app is installed. */
|
||||||
func isInstalled() -> Bool {
|
func isInstalled() async -> Bool {
|
||||||
// If this script does not complain, the app exists!
|
|
||||||
return Shell.user.executeSynchronously(
|
let (process, output) = try! await Shell.attach(
|
||||||
"/usr/bin/open -Ra \"\(name)\"",
|
"/usr/bin/open -Ra \"\(name)\"",
|
||||||
requiresPath: false
|
didReceiveOutput: { _, _ in },
|
||||||
).task.terminationStatus == 0
|
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.
|
Detect which apps are available to open a specific directory.
|
||||||
*/
|
*/
|
||||||
static public func detectPresetApplications() -> [Application] {
|
static public func detectPresetApplications() async -> [Application] {
|
||||||
return [
|
var detected: [Application] = []
|
||||||
|
|
||||||
|
let detectable = [
|
||||||
Application("PhpStorm", .editor),
|
Application("PhpStorm", .editor),
|
||||||
Application("Visual Studio Code", .editor),
|
Application("Visual Studio Code", .editor),
|
||||||
Application("Sublime Text", .editor),
|
Application("Sublime Text", .editor),
|
||||||
Application("Sublime Merge", .git_gui),
|
Application("Sublime Merge", .git_gui),
|
||||||
Application("iTerm", .terminal)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -30,7 +30,7 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Log.perf("Window controller '\(windowName)' was deinitialized")
|
Log.perf("deinit: \(String(describing: self)).\(#function)")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
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.
|
Using `version.short` is advisable if you want to interact with Homebrew.
|
||||||
*/
|
*/
|
||||||
class ActivePhpInstallation {
|
class ActivePhpInstallation {
|
||||||
|
var version: VersionNumber!
|
||||||
var version: Version!
|
|
||||||
var limits: Limits!
|
var limits: Limits!
|
||||||
var iniFiles: [PhpConfigurationFile] = []
|
var iniFiles: [PhpConfigurationFile] = []
|
||||||
|
|
||||||
|
var hasErrorState: Bool = false
|
||||||
|
|
||||||
var extensions: [PhpExtension] {
|
var extensions: [PhpExtension] {
|
||||||
return iniFiles.flatMap { initFile in
|
return iniFiles.flatMap { initFile in
|
||||||
return initFile.extensions
|
return initFile.extensions
|
||||||
@@ -31,20 +32,25 @@ class ActivePhpInstallation {
|
|||||||
// MARK: - Computed
|
// MARK: - Computed
|
||||||
|
|
||||||
var formula: String {
|
var formula: String {
|
||||||
return (version.short == PhpEnv.brewPhpVersion) ? "php" : "php@\(version.short)"
|
return (version.short == PhpEnv.brewPhpAlias) ? "php" : "php@\(version.short)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initializer
|
// MARK: - Initializer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Show information about the current version
|
// 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
|
// Initialize the list of ini files that are loaded
|
||||||
iniFiles = []
|
iniFiles = []
|
||||||
|
|
||||||
// If an error occurred, exit early
|
// If an error occurred, exit early
|
||||||
if version.error {
|
if self.hasErrorState {
|
||||||
limits = Limits()
|
limits = Limits()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,10 +70,14 @@ class ActivePhpInstallation {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Return a list of .ini files parsed after php.ini
|
// Return a list of .ini files parsed after php.ini
|
||||||
let paths = Command.execute(path: Paths.php, arguments: ["-r", "echo php_ini_scanned_files();"])
|
let paths = Command.execute(
|
||||||
.replacingOccurrences(of: "\n", with: "")
|
path: Paths.php,
|
||||||
.split(separator: ",")
|
arguments: ["-r", "echo php_ini_scanned_files();"],
|
||||||
.map { String($0) }
|
trimNewlines: false
|
||||||
|
)
|
||||||
|
.replacingOccurrences(of: "\n", with: "")
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { String($0) }
|
||||||
|
|
||||||
// See if any extensions are present in said .ini files
|
// See if any extensions are present in said .ini files
|
||||||
paths.forEach { (iniFilePath) in
|
paths.forEach { (iniFilePath) in
|
||||||
@@ -81,26 +91,12 @@ class ActivePhpInstallation {
|
|||||||
When the app tries to retrieve the version, the installation is considered broken if the output is nothing,
|
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.
|
_or_ if the output contains the word "Warning" or "Error". In normal situations this should not be the case.
|
||||||
*/
|
*/
|
||||||
private func getVersion() {
|
private func determineVersion() throws {
|
||||||
self.version = Version()
|
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 = try? VersionNumber.parse(output)
|
||||||
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: ".")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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`.
|
- 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 {
|
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
|
// Check if the value is unlimited
|
||||||
if value == "-1" {
|
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,
|
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.
|
that means that Valet won't work properly.
|
||||||
*/
|
*/
|
||||||
func checkPhpFpmStatus() -> Bool {
|
func checkPhpFpmStatus() async -> Bool {
|
||||||
if self.version.short == "5.6" {
|
if self.version.short == "5.6" {
|
||||||
// The main PHP config file should contain `valet.sock` and then we're probably fine?
|
// 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"
|
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 :)
|
// 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
|
// 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.
|
Struct containing information about the limits of the current PHP installation.
|
||||||
Includes: memory limit, max upload size and max post size.
|
Includes: memory limit, max upload size and max post size.
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HomebrewService: Decodable, Equatable {
|
final class HomebrewService: Sendable, Decodable {
|
||||||
let name: String
|
let name: String
|
||||||
let service_name: String
|
let service_name: String
|
||||||
let running: Bool
|
let running: Bool
|
||||||
@@ -19,10 +19,32 @@ struct HomebrewService: Decodable, Equatable {
|
|||||||
let log_path: String?
|
let log_path: String?
|
||||||
let error_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.
|
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(
|
return HomebrewService(
|
||||||
name: service,
|
name: service,
|
||||||
service_name: service,
|
service_name: service,
|
||||||
|
@@ -14,15 +14,17 @@ class PhpEnv {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.currentInstall = ActivePhpInstallation()
|
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(
|
self.homebrewPackage = try! JSONDecoder().decode(
|
||||||
[HomebrewPackage].self,
|
[HomebrewPackage].self,
|
||||||
from: brewPhpAlias.data(using: .utf8)!
|
from: brewPhpAlias.data(using: .utf8)!
|
||||||
).first!
|
).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
|
// MARK: - Properties
|
||||||
@@ -43,7 +45,7 @@ class PhpEnv {
|
|||||||
var cachedPhpInstallations: [String: PhpInstallation] = [:]
|
var cachedPhpInstallations: [String: PhpInstallation] = [:]
|
||||||
|
|
||||||
/** Information about the currently linked PHP installation. */
|
/** Information about the currently linked PHP installation. */
|
||||||
var currentInstall: ActivePhpInstallation
|
var currentInstall: ActivePhpInstallation!
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The version that the `php` formula via Brew is aliased to on the current system.
|
The version that the `php` formula via Brew is aliased to on the current system.
|
||||||
@@ -54,7 +56,9 @@ class PhpEnv {
|
|||||||
|
|
||||||
As such, we take that information from Homebrew.
|
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
|
return Self.shared.homebrewPackage.version
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +80,20 @@ class PhpEnv {
|
|||||||
return InternalSwitcher()
|
return InternalSwitcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func detectPhpVersions() {
|
public static func detectPhpVersions() async {
|
||||||
_ = Self.shared.detectPhpVersions()
|
_ = await Self.shared.detectPhpVersions()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Detects which versions of PHP are installed.
|
Detects which versions of PHP are installed.
|
||||||
*/
|
*/
|
||||||
public func detectPhpVersions() -> [String] {
|
public func detectPhpVersions() async -> [String] {
|
||||||
let files = Shell.pipe("ls \(Paths.optPath) | grep php@")
|
let files = await Shell.pipe("ls \(Paths.optPath) | grep php@").out
|
||||||
|
|
||||||
var versionsOnly = extractPhpVersions(from: files.components(separatedBy: "\n"))
|
var versionsOnly = await extractPhpVersions(
|
||||||
|
from: files.components(separatedBy: "\n"),
|
||||||
|
supported: Constants.ValetSupportedPhpVersionMatrix[Valet.shared.version.major] ?? []
|
||||||
|
)
|
||||||
|
|
||||||
// Make sure the aliased version is detected
|
// Make sure the aliased version is detected
|
||||||
// The user may have `php` installed, but not e.g. `php@8.0`
|
// The user may have `php` installed, but not e.g. `php@8.0`
|
||||||
@@ -94,7 +101,7 @@ class PhpEnv {
|
|||||||
let phpAlias = homebrewPackage.version
|
let phpAlias = homebrewPackage.version
|
||||||
|
|
||||||
// Avoid inserting a duplicate
|
// Avoid inserting a duplicate
|
||||||
if !versionsOnly.contains(phpAlias) && Filesystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
if !versionsOnly.contains(phpAlias) && FileSystem.fileExists("\(Paths.optPath)/php/bin/php") {
|
||||||
versionsOnly.append(phpAlias)
|
versionsOnly.append(phpAlias)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,17 +129,11 @@ class PhpEnv {
|
|||||||
*/
|
*/
|
||||||
public func extractPhpVersions(
|
public func extractPhpVersions(
|
||||||
from versions: [String],
|
from versions: [String],
|
||||||
|
supported: [String],
|
||||||
checkBinaries: Bool = true,
|
checkBinaries: Bool = true,
|
||||||
generateHelpers: Bool = true
|
generateHelpers: Bool = true
|
||||||
) -> [String] {
|
) async -> [String] {
|
||||||
var output: [String] = []
|
var output: [String] = []
|
||||||
|
|
||||||
var supported = Constants.SupportedPhpVersions
|
|
||||||
|
|
||||||
if !Valet.enabled(feature: .supportForPhp56) {
|
|
||||||
supported.removeAll { $0 == "5.6" }
|
|
||||||
}
|
|
||||||
|
|
||||||
versions.filter { (version) -> Bool in
|
versions.filter { (version) -> Bool in
|
||||||
// Omit everything that doesn't start with php@
|
// Omit everything that doesn't start with php@
|
||||||
// (e.g. something-php@8.0 won't be detected)
|
// (e.g. something-php@8.0 won't be detected)
|
||||||
@@ -143,19 +144,21 @@ class PhpEnv {
|
|||||||
// is supported and where the binary exists (avoids broken installs)
|
// is supported and where the binary exists (avoids broken installs)
|
||||||
if !output.contains(version)
|
if !output.contains(version)
|
||||||
&& supported.contains(version)
|
&& supported.contains(version)
|
||||||
&& (checkBinaries ? Filesystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
&& (checkBinaries ? FileSystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
|
||||||
output.append(version)
|
output.append(version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if generateHelpers {
|
if generateHelpers {
|
||||||
output.forEach { PhpHelper.generate(for: $0) }
|
for item in output {
|
||||||
|
await PhpHelper.generate(for: item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validVersions(for constraint: String) -> [PhpVersionNumber] {
|
public func validVersions(for constraint: String) -> [VersionNumber] {
|
||||||
constraint.split(separator: "|").flatMap {
|
constraint.split(separator: "|").flatMap {
|
||||||
return PhpVersionNumberCollection
|
return PhpVersionNumberCollection
|
||||||
.make(from: self.availablePhpVersions)
|
.make(from: self.availablePhpVersions)
|
||||||
@@ -169,6 +172,9 @@ class PhpEnv {
|
|||||||
public func validate(_ version: String) -> Bool {
|
public func validate(_ version: String) -> Bool {
|
||||||
if self.currentInstall.version.short == version {
|
if self.currentInstall.version.short == version {
|
||||||
Log.info("Switching to version \(version) seems to have succeeded. Validation passed.")
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -12,7 +12,7 @@ class PhpHelper {
|
|||||||
|
|
||||||
static let keyPhrase = "This file was automatically generated by PHP Monitor."
|
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
|
// Take the PHP version (e.g. "7.2") and generate a dotless version
|
||||||
let dotless = version.replacingOccurrences(of: ".", with: "")
|
let dotless = version.replacingOccurrences(of: ".", with: "")
|
||||||
|
|
||||||
@@ -20,79 +20,82 @@ class PhpHelper {
|
|||||||
let destination = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
let destination = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||||
|
|
||||||
// Check if the ~/.config/phpmon/bin directory is in the PATH
|
// 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)
|
// 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 {
|
Task { // Create the appropriate folders and check if the files exist
|
||||||
Shell.run("mkdir -p ~/.config/phpmon/bin")
|
do {
|
||||||
|
if !FileSystem.directoryExists("~/.config/phpmon/bin") {
|
||||||
if FileManager.default.fileExists(atPath: destination) {
|
try FileSystem.createDirectory(
|
||||||
let contents = try String(contentsOfFile: destination)
|
"~/.config/phpmon/bin",
|
||||||
if !contents.contains(keyPhrase) {
|
withIntermediateDirectories: true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the symlink
|
if FileSystem.fileExists(destination) {
|
||||||
self.createSymlink(dotless)
|
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 source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
|
||||||
let destination = "/usr/local/bin/pm\(dotless)"
|
let destination = "/usr/local/bin/pm\(dotless)"
|
||||||
|
|
||||||
if !Filesystem.fileExists(destination) {
|
if !FileSystem.fileExists(destination) {
|
||||||
Log.info("Creating new symlink: \(destination)")
|
Log.info("Creating new symlink: \(destination)")
|
||||||
Shell.run("ln -s \(source) \(destination)")
|
await Shell.quiet("ln -s \(source) \(destination)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !Filesystem.fileIsSymlink(destination) {
|
if !FileSystem.isSymlink(destination) {
|
||||||
Log.info("Overwriting existing file with new symlink: \(destination)")
|
Log.info("Overwriting existing file with new symlink: \(destination)")
|
||||||
Shell.run("ln -fs \(source) \(destination)")
|
await Shell.quiet("ln -fs \(source) \(destination)")
|
||||||
return
|
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)
|
let path = filePath.replacingOccurrences(of: "~", with: Paths.homePath)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fileContents = try String(contentsOfFile: path)
|
let fileContents = try FileSystem.getStringFromFile(path)
|
||||||
return Self.init(path: path, contents: fileContents)
|
return Self.init(path: path, contents: fileContents)
|
||||||
} catch {
|
} catch {
|
||||||
Log.warn("Could not read the PHP configuration file at: `\(filePath)`")
|
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.
|
This simply toggles the extension in the .ini file.
|
||||||
You may need to restart the other services in order for this change to apply.
|
You may need to restart the other services in order for this change to apply.
|
||||||
*/
|
*/
|
||||||
func toggle() {
|
func toggle() async {
|
||||||
let newLine = enabled
|
let newLine = enabled
|
||||||
// DISABLED: Commented out line
|
// DISABLED: Commented out line
|
||||||
? "; \(line)"
|
? "; \(line)"
|
||||||
// ENABLED: Line where the comment delimiter (;) is removed
|
// ENABLED: Line where the comment delimiter (;) is removed
|
||||||
: line.replacingOccurrences(of: "; ", with: "")
|
: line.replacingOccurrences(of: "; ", with: "")
|
||||||
|
|
||||||
sed(file: file, original: line, replacement: newLine)
|
await sed(file: file, original: line, replacement: newLine)
|
||||||
|
|
||||||
enabled.toggle()
|
enabled.toggle()
|
||||||
|
|
||||||
|
if !isRunningTests {
|
||||||
|
Task { @MainActor in
|
||||||
|
MainMenu.shared.rebuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Static Methods
|
// MARK: - Static Methods
|
||||||
|
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
|
|
||||||
class PhpInstallation {
|
class PhpInstallation {
|
||||||
|
|
||||||
var versionNumber: PhpVersionNumber
|
var versionNumber: VersionNumber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
In order to determine details about a PHP installation, we’ll simply run `php-config --version`
|
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) {
|
init(_ version: String) {
|
||||||
|
|
||||||
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
|
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(
|
let longVersionString = Command.execute(
|
||||||
path: phpConfigExecutablePath,
|
path: phpConfigExecutablePath,
|
||||||
arguments: ["--version"]
|
arguments: ["--version"],
|
||||||
|
trimNewlines: false
|
||||||
).trimmingCharacters(in: .whitespacesAndNewlines)
|
).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
// The parser should always work, or the string has to be very unusual.
|
// The parser should always work, or the string has to be very unusual.
|
||||||
// If so, the app SHOULD crash, so that the users report what's up.
|
// If so, the app SHOULD crash, so that the users report what's up.
|
||||||
self.versionNumber = try! PhpVersionNumber.parse(longVersionString)
|
self.versionNumber = try! VersionNumber.parse(longVersionString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -20,44 +20,45 @@ class InternalSwitcher: PhpSwitcher {
|
|||||||
the version that is switched to may or may not be identical to `php`
|
the version that is switched to may or may not be identical to `php`
|
||||||
(without @version).
|
(without @version).
|
||||||
*/
|
*/
|
||||||
func performSwitch(to version: String, completion: @escaping () -> Void) {
|
func performSwitch(to version: String) async {
|
||||||
Log.info("Switching to \(version), unlinking all versions...")
|
Log.info("Switching to \(version), unlinking all versions...")
|
||||||
|
|
||||||
let versions = getVersionsToBeHandled(version)
|
let versions = getVersionsToBeHandled(version)
|
||||||
|
|
||||||
let group = DispatchGroup()
|
await withTaskGroup(of: String.self, body: { group in
|
||||||
|
for available in PhpEnv.shared.availablePhpVersions {
|
||||||
PhpEnv.shared.availablePhpVersions.forEach { (available) in
|
group.addTask {
|
||||||
group.enter()
|
await self.disableDefaultPhpFpmPool(available)
|
||||||
|
await self.stopPhpVersion(available)
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
return available
|
||||||
self.disableDefaultPhpFpmPool(available)
|
}
|
||||||
self.stopPhpVersion(available)
|
|
||||||
group.leave()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
group.notify(queue: .global(qos: .userInitiated)) {
|
var unlinked: [String] = []
|
||||||
Log.info("All versions have been unlinked!")
|
for await version in group {
|
||||||
Log.info("Linking the new version!")
|
unlinked.append(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.info("These versions have been unlinked: \(unlinked)")
|
||||||
|
Log.info("Linking the new version \(version)!")
|
||||||
|
|
||||||
for formula in versions {
|
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!")
|
Log.info("Restarting nginx, just to be sure!")
|
||||||
brew("services restart nginx", sudo: true)
|
await brew("services restart nginx", sudo: true)
|
||||||
|
|
||||||
Log.info("The new version(s) have been linked!")
|
Log.info("The new version(s) have been linked!")
|
||||||
completion()
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getVersionsToBeHandled(_ primary: String) -> Set<String> {
|
func getVersionsToBeHandled(_ primary: String) -> Set<String> {
|
||||||
let isolated = Valet.shared.sites.filter { site in
|
let isolated = Valet.shared.sites.filter { site in
|
||||||
site.isolatedPhpVersion != nil
|
site.isolatedPhpVersion != nil
|
||||||
}.map { site in
|
}.map { site in
|
||||||
return site.isolatedPhpVersion!.versionNumber.homebrewVersion
|
return site.isolatedPhpVersion!.versionNumber.short
|
||||||
}
|
}
|
||||||
|
|
||||||
var versions: Set<String> = [primary]
|
var versions: Set<String> = [primary]
|
||||||
@@ -71,22 +72,22 @@ class InternalSwitcher: PhpSwitcher {
|
|||||||
|
|
||||||
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
|
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
|
||||||
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
|
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"
|
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).")
|
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 existing = "\(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 new = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon"
|
||||||
do {
|
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), "
|
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.")
|
+ "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).")
|
Log.info("Success: A default `www.conf` file was disabled for PHP \(version).")
|
||||||
} catch {
|
} catch {
|
||||||
Log.err(error)
|
Log.err(error)
|
||||||
@@ -94,28 +95,28 @@ class InternalSwitcher: PhpSwitcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopPhpVersion(_ version: String) {
|
func stopPhpVersion(_ version: String) async {
|
||||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||||
brew("unlink \(formula)")
|
await brew("unlink \(formula)")
|
||||||
brew("services stop \(formula)", sudo: true)
|
await brew("services stop \(formula)", sudo: true)
|
||||||
Log.info("Unlinked and stopped services for \(formula)")
|
Log.info("Unlinked and stopped services for \(formula)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPhpVersion(_ version: String, primary: Bool) {
|
func startPhpVersion(_ version: String, primary: Bool) async {
|
||||||
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
|
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
|
||||||
|
|
||||||
if primary {
|
if primary {
|
||||||
Log.info("\(formula) is the primary formula, linking and starting services...")
|
Log.info("\(formula) is the primary formula, linking and starting services...")
|
||||||
brew("link \(formula) --overwrite --force")
|
await brew("link \(formula) --overwrite --force")
|
||||||
} else {
|
} else {
|
||||||
Log.info("\(formula) is an isolated PHP version, starting services only...")
|
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 {
|
if Valet.enabled(feature: .isolatedSites) && primary {
|
||||||
let socketVersion = version.replacingOccurrences(of: ".", with: "")
|
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).")
|
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -18,6 +18,6 @@ protocol PhpSwitcherDelegate: AnyObject {
|
|||||||
|
|
||||||
protocol PhpSwitcher {
|
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>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>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>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>
|
<br>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -31,10 +31,18 @@ class App {
|
|||||||
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
|
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 {
|
static var architecture: String {
|
||||||
|
if fakeArchitecture != nil { return fakeArchitecture! }
|
||||||
|
|
||||||
var systeminfo = utsname()
|
var systeminfo = utsname()
|
||||||
uname(&systeminfo)
|
uname(&systeminfo)
|
||||||
let machine = withUnsafeBytes(of: &systeminfo.machine) {bufPtr->String in
|
let machine = withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
|
||||||
let data = Data(bufPtr)
|
let data = Data(bufPtr)
|
||||||
if let lastIndex = data.lastIndex(where: {$0 != 0}) {
|
if let lastIndex = data.lastIndex(where: {$0 != 0}) {
|
||||||
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
|
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
|
||||||
@@ -45,8 +53,18 @@ class App {
|
|||||||
return machine
|
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
|
// MARK: Variables
|
||||||
|
|
||||||
|
/** Technical information about the current environment. */
|
||||||
|
var environment = EnvironmentManager()
|
||||||
|
|
||||||
/** The list of preferences that are currently active. */
|
/** The list of preferences that are currently active. */
|
||||||
var preferences: [PreferenceName: Bool]!
|
var preferences: [PreferenceName: Bool]!
|
||||||
|
|
||||||
@@ -65,9 +83,6 @@ class App {
|
|||||||
/** List of detected (installed) applications that PHP Monitor can work with. */
|
/** List of detected (installed) applications that PHP Monitor can work with. */
|
||||||
var detectedApplications: [Application] = []
|
var detectedApplications: [Application] = []
|
||||||
|
|
||||||
/** The services manager, responsible for figuring out what services are active/inactive. */
|
|
||||||
var services = ServicesManager.shared
|
|
||||||
|
|
||||||
/** The warning manager, responsible for keeping track of warnings. */
|
/** The warning manager, responsible for keeping track of warnings. */
|
||||||
var warnings = WarningManager.shared
|
var warnings = WarningManager.shared
|
||||||
|
|
||||||
|
@@ -20,8 +20,7 @@ extension AppDelegate {
|
|||||||
|
|
||||||
Please note that PHP Monitor needs to be running in the background for this to work.
|
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) {
|
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
|
||||||
Log.info("Acting on commands via phpmon:// has been disabled.")
|
Log.info("Acting on commands via phpmon:// has been disabled.")
|
||||||
return
|
return
|
||||||
|
@@ -33,15 +33,17 @@ extension AppDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func reloadDomainListPressed(_ sender: Any) {
|
@IBAction func reloadDomainListPressed(_ sender: Any) {
|
||||||
let vc = App.shared.domainListWindowController?
|
Task { // Reload domains
|
||||||
.window?.contentViewController as? DomainListVC
|
let vc = App.shared.domainListWindowController?
|
||||||
|
.window?.contentViewController as? DomainListVC
|
||||||
|
|
||||||
if vc != nil {
|
if vc != nil {
|
||||||
// If the view exists, directly reload the list of sites
|
// If the view exists, directly reload the list of sites.
|
||||||
vc!.reloadDomains()
|
await vc!.reloadDomains()
|
||||||
} else {
|
} else {
|
||||||
// If the view does not exist, reload the cached data that was populated when the app initially launched.
|
// If the view does not exist, reload the cached data that was populated when the app launched.
|
||||||
Valet.shared.reloadSites()
|
await Valet.shared.reloadSites()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,13 +13,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
|
|
||||||
// MARK: - Variables
|
// 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 App singleton contains information about the state of
|
||||||
the application and global variables.
|
the application and global variables.
|
||||||
@@ -64,18 +57,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
*/
|
*/
|
||||||
override init() {
|
override init() {
|
||||||
logger.verbosity = .info
|
logger.verbosity = .info
|
||||||
|
|
||||||
#if DEBUG
|
#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
|
#endif
|
||||||
|
|
||||||
if CommandLine.arguments.contains("--v") {
|
if CommandLine.arguments.contains("--v") {
|
||||||
logger.verbosity = .performance
|
logger.verbosity = .performance
|
||||||
Log.info("Extra verbose mode has been activated.")
|
Log.info("Extra verbose mode has been activated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.separator(as: .info)
|
Log.separator(as: .info)
|
||||||
Log.info("PHP MONITOR by Nico Verbruggen")
|
Log.info("PHP MONITOR by Nico Verbruggen")
|
||||||
Log.info("Version \(App.version)")
|
Log.info("Version \(App.version)")
|
||||||
Log.separator(as: .info)
|
Log.separator(as: .info)
|
||||||
self.sharedShell = Shell.user
|
|
||||||
self.state = App.shared
|
self.state = App.shared
|
||||||
self.menu = MainMenu.shared
|
self.menu = MainMenu.shared
|
||||||
self.paths = Paths.shared
|
self.paths = Paths.shared
|
||||||
@@ -87,6 +86,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
self.phpEnvironment = PhpEnv.shared
|
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
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,8 +101,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
// Make sure notifications will work
|
// Make sure notifications will work
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
// Make sure the menu performs its initial checks
|
Task { // Make sure the menu performs its initial checks
|
||||||
Task { await menu.startup() }
|
await paths.loadUser()
|
||||||
|
await menu.startup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -21,7 +21,7 @@ class AppUpdateChecker {
|
|||||||
|
|
||||||
public static func retrieveVersionFromCask(
|
public static func retrieveVersionFromCask(
|
||||||
_ initiatedFromBackground: Bool = true
|
_ initiatedFromBackground: Bool = true
|
||||||
) -> String {
|
) async -> String {
|
||||||
let caskFile = App.version.contains("-dev")
|
let caskFile = App.version.contains("-dev")
|
||||||
? Constants.Urls.DevBuildCaskFile.absoluteString
|
? Constants.Urls.DevBuildCaskFile.absoluteString
|
||||||
: Constants.Urls.StableBuildCaskFile.absoluteString
|
: Constants.Urls.StableBuildCaskFile.absoluteString
|
||||||
@@ -32,14 +32,14 @@ class AppUpdateChecker {
|
|||||||
command = "curl -s --max-time 5"
|
command = "curl -s --max-time 5"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Shell.pipe(
|
return await Shell.pipe(
|
||||||
"\(command) '\(caskFile)' | grep version"
|
"\(command) '\(caskFile)' | grep version"
|
||||||
)
|
).out
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func checkIfNewerVersionIsAvailable(
|
public static func checkIfNewerVersionIsAvailable(
|
||||||
initiatedFromBackground: Bool = true
|
initiatedFromBackground: Bool = true
|
||||||
) {
|
) async {
|
||||||
if initiatedFromBackground {
|
if initiatedFromBackground {
|
||||||
if !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
if !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
||||||
Log.info("Automatic updates are disabled. No check will be performed.")
|
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.")
|
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 {
|
guard let onlineVersion = AppVersion.from(versionString) else {
|
||||||
Log.err("We couldn't check for updates!")
|
Log.err("We couldn't check for updates!")
|
||||||
@@ -119,13 +119,13 @@ class AppUpdateChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func notifyVersionDoesNotNeedUpgrade() {
|
private static func notifyVersionDoesNotNeedUpgrade() {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
BetterAlert().withInformation(
|
BetterAlert().withInformation(
|
||||||
title: "updater.alerts.is_latest_version.title".localized,
|
title: "updater.alerts.is_latest_version.title".localized,
|
||||||
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
|
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
|
||||||
description: ""
|
description: ""
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: "generic.ok".localized)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ class AppUpdateChecker {
|
|||||||
let devSuffix = isDev ? "-dev" : ""
|
let devSuffix = isDev ? "-dev" : ""
|
||||||
let command = isDev ? "brew upgrade phpmon-dev" : "brew upgrade phpmon"
|
let command = isDev ? "brew upgrade phpmon-dev" : "brew upgrade phpmon"
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
BetterAlert().withInformation(
|
BetterAlert().withInformation(
|
||||||
title: "updater.alerts.newer_version_available.title".localized(version.humanReadable),
|
title: "updater.alerts.newer_version_available.title".localized(version.humanReadable),
|
||||||
subtitle: "updater.alerts.newer_version_available.subtitle".localized,
|
subtitle: "updater.alerts.newer_version_available.subtitle".localized,
|
||||||
@@ -160,7 +160,7 @@ class AppUpdateChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func notifyAboutConnectionIssue() {
|
private static func notifyAboutConnectionIssue() {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
BetterAlert().withInformation(
|
BetterAlert().withInformation(
|
||||||
title: "updater.alerts.cannot_check_for_update.title".localized,
|
title: "updater.alerts.cannot_check_for_update.title".localized,
|
||||||
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
|
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
|
||||||
@@ -174,7 +174,7 @@ class AppUpdateChecker {
|
|||||||
NSWorkspace.shared.open(Constants.Urls.GitHubReleases)
|
NSWorkspace.shared.open(Constants.Urls.GitHubReleases)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: "generic.ok".localized)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="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>
|
<dependencies>
|
||||||
<deployment identifier="macosx"/>
|
<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="Image references" minToolsVersion="12.0"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
|
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
|
||||||
@@ -1112,17 +1112,17 @@ Gw
|
|||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<view key="view" id="U5U-QR-YXS">
|
<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"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="kkd-UV-SnA">
|
<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">
|
<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"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
|
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
|
||||||
<rect key="frame" x="20" y="196" width="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">
|
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
|
||||||
<font key="font" metaFont="system"/>
|
<font key="font" metaFont="system"/>
|
||||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||||
@@ -1149,7 +1149,7 @@ Gw
|
|||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</textField>
|
||||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
|
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
|
||||||
<rect key="frame" x="20" y="147" width="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">
|
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
|
||||||
<font key="font" metaFont="system"/>
|
<font key="font" metaFont="system"/>
|
||||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||||
@@ -1176,7 +1176,7 @@ Gw
|
|||||||
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
|
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||||
</box>
|
</box>
|
||||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="4Vi-cN-ude">
|
<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">
|
<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"/>
|
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||||
<font key="font" metaFont="system"/>
|
<font key="font" metaFont="system"/>
|
||||||
@@ -1205,7 +1205,10 @@ Gw
|
|||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
|
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
|
||||||
<rect key="frame" x="18" y="128" width="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">
|
<textFieldCell key="cell" title="[i18n] Preview text here" id="ISE-9R-ncQ">
|
||||||
<font key="font" metaFont="smallSystem"/>
|
<font key="font" metaFont="smallSystem"/>
|
||||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||||
@@ -1223,7 +1226,7 @@ Gw
|
|||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
|
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
|
||||||
<rect key="frame" x="18" y="60" width="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">
|
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges.
You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
|
||||||
<font key="font" metaFont="smallSystem"/>
|
<font key="font" metaFont="smallSystem"/>
|
||||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||||
@@ -1239,7 +1242,7 @@ Gw
|
|||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</textField>
|
||||||
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
|
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
|
||||||
<rect key="frame" x="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">
|
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
|
||||||
<font key="font" metaFont="smallSystem"/>
|
<font key="font" metaFont="smallSystem"/>
|
||||||
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
|
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
|
||||||
@@ -1293,7 +1296,7 @@ Gw
|
|||||||
</viewController>
|
</viewController>
|
||||||
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="210" y="1524"/>
|
<point key="canvasLocation" x="220" y="1522"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--Window Controller-->
|
<!--Window Controller-->
|
||||||
<scene sceneID="5Gf-7O-tdA">
|
<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
|
let action: (String) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCommands() -> [InterApp.Action] { return [
|
@MainActor static func getCommands() -> [InterApp.Action] { return [
|
||||||
InterApp.Action(command: "list", action: { _ in
|
InterApp.Action(command: "list", action: { _ in
|
||||||
DomainListVC.show()
|
DomainListVC.show()
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "services/stop", action: { _ in
|
InterApp.Action(command: "services/stop", action: { _ in
|
||||||
MainMenu.shared.stopValetServices()
|
Task { MainMenu.shared.stopValetServices() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "services/restart/all", action: { _ in
|
InterApp.Action(command: "services/restart/all", action: { _ in
|
||||||
MainMenu.shared.restartValetServices()
|
Task { MainMenu.shared.restartValetServices() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "services/restart/nginx", action: { _ in
|
InterApp.Action(command: "services/restart/nginx", action: { _ in
|
||||||
MainMenu.shared.restartNginx()
|
Task { MainMenu.shared.restartNginx() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "services/restart/php", action: { _ in
|
InterApp.Action(command: "services/restart/php", action: { _ in
|
||||||
MainMenu.shared.restartPhpFpm()
|
Task { MainMenu.shared.restartPhpFpm() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "services/restart/dnsmasq", action: { _ in
|
InterApp.Action(command: "services/restart/dnsmasq", action: { _ in
|
||||||
MainMenu.shared.restartDnsMasq()
|
Task { MainMenu.shared.restartDnsMasq() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "locate/config", action: { _ in
|
InterApp.Action(command: "locate/config", action: { _ in
|
||||||
MainMenu.shared.openActiveConfigFolder()
|
Task { MainMenu.shared.openActiveConfigFolder() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "locate/composer", action: { _ in
|
InterApp.Action(command: "locate/composer", action: { _ in
|
||||||
MainMenu.shared.openGlobalComposerFolder()
|
Task { MainMenu.shared.openGlobalComposerFolder() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "locate/valet", action: { _ in
|
InterApp.Action(command: "locate/valet", action: { _ in
|
||||||
MainMenu.shared.openValetConfigFolder()
|
Task { MainMenu.shared.openValetConfigFolder() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "phpinfo", action: { _ in
|
InterApp.Action(command: "phpinfo", action: { _ in
|
||||||
MainMenu.shared.openPhpInfo()
|
Task { MainMenu.shared.openPhpInfo() }
|
||||||
}),
|
}),
|
||||||
InterApp.Action(command: "switch/php/", action: { version in
|
InterApp.Action(command: "switch/php/", action: { version in
|
||||||
if PhpEnv.shared.availablePhpVersions.contains(version) {
|
Task { MainMenu.shared.switchToAnyPhpVersion(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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
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,80 +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 = [
|
|
||||||
PhpEnv.phpInstall.formula,
|
|
||||||
"nginx",
|
|
||||||
"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) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let userServiceNames = Preferences.custom.services else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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...
|
// If we get here, something's gone wrong and the check has failed...
|
||||||
Log.info("[FAIL] \(check.name)")
|
Log.info("[FAIL] \(check.name)")
|
||||||
showAlert(for: check)
|
await showAlert(for: check)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,29 +45,27 @@ class Startup {
|
|||||||
- ones that require an app restart, which prompt the user to exit the app
|
- 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
|
- ones that allow the app to continue, which allow the user to retry
|
||||||
*/
|
*/
|
||||||
private func showAlert(for check: EnvironmentCheck) {
|
@MainActor private func showAlert(for check: EnvironmentCheck) {
|
||||||
DispatchQueue.main.async {
|
if check.requiresAppRestart {
|
||||||
if check.requiresAppRestart {
|
|
||||||
BetterAlert()
|
|
||||||
.withInformation(
|
|
||||||
title: check.titleText,
|
|
||||||
subtitle: check.subtitleText,
|
|
||||||
description: check.descriptionText
|
|
||||||
)
|
|
||||||
.withPrimary(text: check.buttonText, action: { _ in
|
|
||||||
exit(1)
|
|
||||||
}).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
BetterAlert()
|
BetterAlert()
|
||||||
.withInformation(
|
.withInformation(
|
||||||
title: check.titleText,
|
title: check.titleText,
|
||||||
subtitle: check.subtitleText,
|
subtitle: check.subtitleText,
|
||||||
description: check.descriptionText
|
description: check.descriptionText
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: check.buttonText, action: { _ in
|
||||||
.show()
|
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.
|
initialized when it is done working. The switcher must be initialized on the main thread.
|
||||||
*/
|
*/
|
||||||
private func initializeSwitcher() {
|
private func initializeSwitcher() {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
let appDelegate = NSApplication.shared.delegate as! AppDelegate
|
let appDelegate = NSApplication.shared.delegate as! AppDelegate
|
||||||
appDelegate.initializeSwitcher()
|
appDelegate.initializeSwitcher()
|
||||||
}
|
}
|
||||||
@@ -88,7 +86,7 @@ class Startup {
|
|||||||
// The Homebrew binary must exist.
|
// The Homebrew binary must exist.
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: { return !FileManager.default.fileExists(atPath: Paths.brew) },
|
command: { return !FileSystem.fileExists(Paths.brew) },
|
||||||
name: "`\(Paths.brew)` exists",
|
name: "`\(Paths.brew)` exists",
|
||||||
titleText: "alert.homebrew_missing.title".localized,
|
titleText: "alert.homebrew_missing.title".localized,
|
||||||
subtitleText: "alert.homebrew_missing.subtitle".localized,
|
subtitleText: "alert.homebrew_missing.subtitle".localized,
|
||||||
@@ -105,7 +103,7 @@ class Startup {
|
|||||||
// The PHP binary must exist.
|
// The PHP binary must exist.
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: { return !Filesystem.fileExists(Paths.php) },
|
command: { return !FileSystem.fileExists(Paths.php) },
|
||||||
name: "`\(Paths.php)` exists",
|
name: "`\(Paths.php)` exists",
|
||||||
titleText: "startup.errors.php_binary.title".localized,
|
titleText: "startup.errors.php_binary.title".localized,
|
||||||
subtitleText: "startup.errors.php_binary.subtitle".localized,
|
subtitleText: "startup.errors.php_binary.subtitle".localized,
|
||||||
@@ -115,7 +113,9 @@ class Startup {
|
|||||||
// Make sure we can detect one or more PHP installations.
|
// Make sure we can detect one or more PHP installations.
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
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",
|
name: "`ls \(Paths.optPath) | grep php` returned php result",
|
||||||
titleText: "startup.errors.php_opt.title".localized,
|
titleText: "startup.errors.php_opt.title".localized,
|
||||||
subtitleText: "startup.errors.php_opt.subtitle".localized(
|
subtitleText: "startup.errors.php_opt.subtitle".localized(
|
||||||
@@ -128,7 +128,7 @@ class Startup {
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: {
|
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",
|
name: "`valet` binary exists",
|
||||||
titleText: "startup.errors.valet_executable.title".localized,
|
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`.
|
// functioning correctly. Let the user know that they need to run `valet trust`.
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
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",
|
name: "`/private/etc/sudoers.d/brew` contains brew",
|
||||||
titleText: "startup.errors.sudoers_brew.title".localized,
|
titleText: "startup.errors.sudoers_brew.title".localized,
|
||||||
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
|
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
|
||||||
descriptionText: "startup.errors.sudoers_brew.desc".localized
|
descriptionText: "startup.errors.sudoers_brew.desc".localized
|
||||||
),
|
),
|
||||||
EnvironmentCheck(
|
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",
|
name: "`/private/etc/sudoers.d/valet` contains valet",
|
||||||
titleText: "startup.errors.sudoers_valet.title".localized,
|
titleText: "startup.errors.sudoers_valet.title".localized,
|
||||||
subtitleText: "startup.errors.sudoers_valet.subtitle".localized,
|
subtitleText: "startup.errors.sudoers_valet.subtitle".localized,
|
||||||
@@ -160,7 +160,10 @@ class Startup {
|
|||||||
// Verify if the Homebrew services are running (as root).
|
// Verify if the Homebrew services are running (as root).
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: { return HomebrewDiagnostics.cannotLoadService() },
|
command: {
|
||||||
|
await HomebrewDiagnostics.loadInstalledTaps()
|
||||||
|
return await HomebrewDiagnostics.cannotLoadService("dnsmasq")
|
||||||
|
},
|
||||||
name: "`sudo \(Paths.brew) services info` JSON loaded",
|
name: "`sudo \(Paths.brew) services info` JSON loaded",
|
||||||
titleText: "startup.errors.services_json_error.title".localized,
|
titleText: "startup.errors.services_json_error.title".localized,
|
||||||
subtitleText: "startup.errors.services_json_error.subtitle".localized,
|
subtitleText: "startup.errors.services_json_error.subtitle".localized,
|
||||||
@@ -171,7 +174,7 @@ class Startup {
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: {
|
command: {
|
||||||
return !Filesystem.directoryExists("~/.config/valet")
|
return !FileSystem.directoryExists("~/.config/valet")
|
||||||
},
|
},
|
||||||
name: "`.config/valet` not empty (Valet installed)",
|
name: "`.config/valet` not empty (Valet installed)",
|
||||||
titleText: "startup.errors.valet_not_installed.title".localized,
|
titleText: "startup.errors.valet_not_installed.title".localized,
|
||||||
@@ -200,10 +203,10 @@ class Startup {
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: {
|
command: {
|
||||||
|
let nodePath = await Shell.pipe("which node").out
|
||||||
return App.architecture == "x86_64"
|
return App.architecture == "x86_64"
|
||||||
&& FileManager.default.fileExists(atPath: "/usr/local/bin/which")
|
&& FileSystem.fileExists("/usr/local/bin/which")
|
||||||
&& Shell.pipe("which node", requiresPath: false)
|
&& nodePath.contains("env: node: No such file or directory")
|
||||||
.contains("env: node: No such file or directory")
|
|
||||||
},
|
},
|
||||||
name: "`env: node` issue does not apply",
|
name: "`env: node` issue does not apply",
|
||||||
titleText: "startup.errors.which_alias_issue.title".localized,
|
titleText: "startup.errors.which_alias_issue.title".localized,
|
||||||
@@ -215,7 +218,7 @@ class Startup {
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: {
|
command: {
|
||||||
return valet("--version", sudo: false)
|
return await Shell.pipe("valet --version").out
|
||||||
.contains("Composer detected issues in your platform")
|
.contains("Composer detected issues in your platform")
|
||||||
},
|
},
|
||||||
name: "`no global composer issues",
|
name: "`no global composer issues",
|
||||||
@@ -228,7 +231,7 @@ class Startup {
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
EnvironmentCheck(
|
EnvironmentCheck(
|
||||||
command: {
|
command: {
|
||||||
let output = valet("--version", sudo: false)
|
let output = await Shell.pipe("valet --version").out
|
||||||
// Failure condition #1: does not contain Laravel Valet
|
// Failure condition #1: does not contain Laravel Valet
|
||||||
if !output.contains("Laravel Valet") {
|
if !output.contains("Laravel Valet") {
|
||||||
return true
|
return true
|
||||||
@@ -239,7 +242,7 @@ class Startup {
|
|||||||
.components(separatedBy: "Laravel Valet")[1]
|
.components(separatedBy: "Laravel Valet")[1]
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
// Extract the version number
|
// Extract the version number
|
||||||
Valet.shared.version = VersionExtractor.from(output)
|
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
|
||||||
// Get the actual version
|
// Get the actual version
|
||||||
return Valet.shared.version == nil
|
return Valet.shared.version == nil
|
||||||
},
|
},
|
||||||
@@ -247,6 +250,19 @@ class Startup {
|
|||||||
titleText: "startup.errors.valet_version_unknown.title".localized,
|
titleText: "startup.errors.valet_version_unknown.title".localized,
|
||||||
subtitleText: "startup.errors.valet_version_unknown.subtitle".localized,
|
subtitleText: "startup.errors.valet_version_unknown.subtitle".localized,
|
||||||
descriptionText: "startup.errors.valet_version_unknown.desc".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(Valet.shared.version.text),
|
||||||
|
descriptionText: "startup.errors.valet_version_not_supported.desc".localized
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@@ -65,17 +65,20 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
|
|||||||
@IBAction func pressedCreateProxy(_ sender: Any) {
|
@IBAction func pressedCreateProxy(_ sender: Any) {
|
||||||
let domain = self.inputDomainName.stringValue
|
let domain = self.inputDomainName.stringValue
|
||||||
let proxyName = self.inputProxySubject.stringValue
|
let proxyName = self.inputProxySubject.stringValue
|
||||||
let secure = self.buttonSecure.state == .on ? " --secure" : ""
|
let secure = (self.buttonSecure.state == .on)
|
||||||
|
|
||||||
dismissView(outcome: .OK)
|
dismissView(outcome: .OK)
|
||||||
|
|
||||||
App.shared.domainListWindowController?.contentVC.setUIBusy()
|
App.shared.domainListWindowController?.contentVC.setUIBusy()
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
Task { // Ensure we proxy the site asynchronously and reload UI on main thread again
|
||||||
Shell.run("\(Paths.valet) proxy \(domain) \(proxyName)\(secure)", requiresPath: true)
|
try! await ValetInteractor.shared.proxy(
|
||||||
Actions.restartNginx()
|
domain: domain,
|
||||||
|
proxy: proxyName,
|
||||||
|
secure: secure
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
App.shared.domainListWindowController?.contentVC.setUINotBusy()
|
App.shared.domainListWindowController?.contentVC.setUINotBusy()
|
||||||
App.shared.domainListWindowController?.pressedReload(nil)
|
App.shared.domainListWindowController?.pressedReload(nil)
|
||||||
}
|
}
|
||||||
@@ -157,8 +160,14 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
previewText.stringValue = "domain_list.add.proxy_available"
|
var translationKey = "domain_list.add.proxy_available"
|
||||||
.localized(
|
|
||||||
|
if inputProxySubject.stringValue.starts(with: "https://") {
|
||||||
|
translationKey = "domain_list.add.proxy_https_warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
previewText.stringValue =
|
||||||
|
translationKey.localized(
|
||||||
inputProxySubject.stringValue,
|
inputProxySubject.stringValue,
|
||||||
buttonSecure.state == .on ? "https" : "http",
|
buttonSecure.state == .on ? "https" : "http",
|
||||||
inputDomainName.stringValue,
|
inputDomainName.stringValue,
|
||||||
|
@@ -51,11 +51,11 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
|
|||||||
|
|
||||||
// MARK: - Outlet Interactions
|
// MARK: - Outlet Interactions
|
||||||
|
|
||||||
@IBAction func pressedCreateLink(_ sender: Any) {
|
func createLink() async {
|
||||||
let path = pathControl.url!.path
|
let path = pathControl.url!.path
|
||||||
let name = inputDomainName.stringValue
|
let name = inputDomainName.stringValue
|
||||||
|
|
||||||
if !Filesystem.exists(path) {
|
if !FileSystem.anyExists(path) {
|
||||||
Alert.confirm(
|
Alert.confirm(
|
||||||
onWindow: view.window!,
|
onWindow: view.window!,
|
||||||
messageText: "domain_list.alert.folder_missing.title".localized,
|
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
|
// 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
|
Task {
|
||||||
Shell.run("cd '\(path)' && \(Paths.valet) link '\(name)' && valet links", requiresPath: true)
|
try! await ValetInteractor.shared.link(path: path, domain: name)
|
||||||
|
|
||||||
dismissView(outcome: .OK)
|
dismissView(outcome: .OK)
|
||||||
|
|
||||||
// Reset search
|
// Reset search
|
||||||
App.shared.domainListWindowController?
|
App.shared.domainListWindowController?
|
||||||
.searchToolbarItem
|
.searchToolbarItem
|
||||||
.searchField.stringValue = ""
|
.searchField.stringValue = ""
|
||||||
|
|
||||||
// Add the new item and scrolls to it
|
// Add the new item and scrolls to it
|
||||||
App.shared.domainListWindowController?
|
await App.shared.domainListWindowController?
|
||||||
.contentVC
|
.contentVC
|
||||||
.addedNewSite(
|
.addedNewSite(
|
||||||
name: name,
|
name: name,
|
||||||
secure: buttonSecure.state == .on
|
secureAfterLinking: buttonSecure.state == .on
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func pressedCreateLink(_ sender: Any) {
|
||||||
|
Task { await createLink() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func pressedCancel(_ sender: Any) {
|
@IBAction func pressedCancel(_ sender: Any) {
|
||||||
|
@@ -53,13 +53,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
|
|||||||
@IBAction func pressedPhpVersion(_ sender: Any) {
|
@IBAction func pressedPhpVersion(_ sender: Any) {
|
||||||
guard let site = self.site else { return }
|
guard let site = self.site else { return }
|
||||||
|
|
||||||
var validPhpSuggestions: [PhpVersionNumber] {
|
var validPhpSuggestions: [VersionNumber] {
|
||||||
if site.isolatedPhpVersion != nil {
|
if site.isolatedPhpVersion != nil {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return PhpEnv.shared.validVersions(for: site.composerPhp).filter({ version in
|
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 {
|
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 = "cd '\(selectedSite.absolutePath)' && sudo \(Paths.valet) \(action) && 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() {
|
@objc func openInBrowser() {
|
||||||
guard let selected = self.selected else {
|
guard let selected = self.selected else {
|
||||||
return
|
return
|
||||||
@@ -91,7 +22,7 @@ extension DomainListVC {
|
|||||||
title: "domain_list.alert.invalid_folder_name".localized,
|
title: "domain_list.alert.invalid_folder_name".localized,
|
||||||
subtitle: "domain_list.alert.invalid_folder_name_desc".localized
|
subtitle: "domain_list.alert.invalid_folder_name_desc".localized
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: "generic.ok".localized)
|
||||||
.show()
|
.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,43 +30,121 @@ extension DomainListVC {
|
|||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func openInFinder() {
|
@objc func openInFinder() async {
|
||||||
Shell.run("open '\(selectedSite!.absolutePath)'")
|
await Shell.quiet("open '\(selectedSite!.absolutePath)'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func openInTerminal() {
|
@objc func openInTerminal() async {
|
||||||
Shell.run("open -b com.apple.terminal '\(selectedSite!.absolutePath)'")
|
await Shell.quiet("open -b com.apple.terminal '\(selectedSite!.absolutePath)'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func openWithEditor(sender: EditorMenuItem) {
|
@objc func openWithEditor(sender: EditorMenuItem) async {
|
||||||
guard let editor = sender.editor else { return }
|
guard let editor = sender.editor else { return }
|
||||||
editor.openDirectory(file: selectedSite!.absolutePath)
|
await 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) {
|
@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) {
|
waitAndExecute {
|
||||||
self.selectedSite!.determineIsolated()
|
do {
|
||||||
self.selectedSite!.determineComposerPhpVersion()
|
// Instruct Valet to isolate a given PHP version
|
||||||
|
try await site.isolate(version: sender.version)
|
||||||
if self.selectedSite!.isolatedPhpVersion == nil {
|
// Reload the UI
|
||||||
BetterAlert()
|
self.reloadSelectedRow()
|
||||||
.withInformation(
|
} catch {
|
||||||
title: "domain_list.alerts_isolation_failed.title".localized,
|
// Notify the user about a failed command
|
||||||
subtitle: "domain_list.alerts_isolation_failed.subtitle".localized,
|
let error = error as! ValetInteractionError
|
||||||
description: "domain_list.alerts_isolation_failed.desc".localized(command)
|
self.notifyAboutFailedSiteIsolation(command: error.command)
|
||||||
)
|
|
||||||
.withPrimary(text: "OK")
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeIsolatedSite() {
|
@objc func removeIsolatedSite() {
|
||||||
self.performAction(command: "sudo \(Paths.valet) unisolate --site '\(self.selectedSite!.name)' && exit;") {
|
guard let site = selectedSite else {
|
||||||
self.selectedSite!.isolatedPhpVersion = nil
|
return
|
||||||
self.selectedSite!.determineComposerPhpVersion()
|
}
|
||||||
|
|
||||||
|
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,
|
style: .critical,
|
||||||
onFirstButtonPressed: {
|
onFirstButtonPressed: {
|
||||||
self.waitAndExecute {
|
self.waitAndExecute {
|
||||||
Shell.run("valet unlink '\(site.name)'", requiresPath: true)
|
await site.unlink()
|
||||||
} completion: {
|
await self.reloadDomainsWithoutUI()
|
||||||
self.reloadDomains()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -179,25 +187,75 @@ extension DomainListVC {
|
|||||||
style: .critical,
|
style: .critical,
|
||||||
onFirstButtonPressed: {
|
onFirstButtonPressed: {
|
||||||
self.waitAndExecute {
|
self.waitAndExecute {
|
||||||
Shell.run("valet unproxy '\(proxy.domain)'", requiresPath: true)
|
await proxy.remove()
|
||||||
} completion: {
|
await self.reloadDomainsWithoutUI()
|
||||||
self.reloadDomains()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performAction(command: String, beforeCellReload: @escaping () -> Void) {
|
@objc func useInTerminal() {
|
||||||
let rowToReload = tableView.selectedRow
|
guard let site = selectedSite else {
|
||||||
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(HeaderView.asMenuItem(text: "domain_list.site_isolation".localized))
|
||||||
menu.addItem(NSMenuItem(title: "domain_list.isolate".localized, submenu: items))
|
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())
|
menu.addItem(NSMenuItem.separator())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -19,7 +19,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
/// List of sites that will be displayed in this view. Originates from the `Valet` object.
|
/// 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.
|
/// Array that contains various apps that might open a particular site directory.
|
||||||
var applications: [Application] {
|
var applications: [Application] {
|
||||||
@@ -48,7 +48,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
return domains[tableView.selectedRow] as? ValetProxy
|
return domains[tableView.selectedRow] as? ValetProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected: DomainListable? {
|
var selected: ValetListable? {
|
||||||
if tableView.selectedRow == -1 {
|
if tableView.selectedRow == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
domains = Valet.getDomainListable()
|
domains = Valet.getDomainListable()
|
||||||
searchedFor(text: lastSearchedFor)
|
searchedFor(text: lastSearchedFor)
|
||||||
} else {
|
} else {
|
||||||
reloadDomains()
|
Task { await reloadDomains() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +107,12 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
Disables the UI so the user cannot interact with it.
|
Disables the UI so the user cannot interact with it.
|
||||||
Also shows a spinner to indicate that we're busy.
|
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
|
// 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
|
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
|
tableView.alphaValue = 0.3
|
||||||
@@ -121,7 +123,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
/**
|
/**
|
||||||
Re-enables the UI so the user can interact with it.
|
Re-enables the UI so the user can interact with it.
|
||||||
*/
|
*/
|
||||||
public func setUINotBusy() {
|
@MainActor public func setUINotBusy() {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
progressIndicator.stopAnimation(nil)
|
progressIndicator.stopAnimation(nil)
|
||||||
tableView.alphaValue = 1.0
|
tableView.alphaValue = 1.0
|
||||||
@@ -136,13 +138,13 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
- Parameter execute: Callback of the work that needs to happen.
|
- Parameter execute: Callback of the work that needs to happen.
|
||||||
- Parameter completion: Callback that is fired when the work is done.
|
- Parameter completion: Callback that is fired when the work is done.
|
||||||
*/
|
*/
|
||||||
internal func waitAndExecute(_ execute: @escaping () -> Void, completion: @escaping () -> Void = {}) {
|
internal func waitAndExecute(_ execute: @escaping () async -> Void, completion: @escaping () -> Void = {}) {
|
||||||
setUIBusy()
|
Task { // Legacy `waitAndExecute` with UI
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
|
setUIBusy()
|
||||||
execute()
|
await execute()
|
||||||
|
|
||||||
// For a smoother animation, expect at least a 0.2 second delay
|
Task { @MainActor in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in
|
await delay(seconds: 0.2)
|
||||||
completion()
|
completion()
|
||||||
setUINotBusy()
|
setUINotBusy()
|
||||||
}
|
}
|
||||||
@@ -151,15 +153,21 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
|
|
||||||
// MARK: - Site Data Loading
|
// MARK: - Site Data Loading
|
||||||
|
|
||||||
func reloadDomains() {
|
func reloadDomains() async {
|
||||||
waitAndExecute {
|
waitAndExecute {
|
||||||
Valet.shared.reloadSites()
|
await Valet.shared.reloadSites()
|
||||||
} completion: { [self] in
|
} completion: { [self] in
|
||||||
domains = Valet.shared.sites
|
domains = Valet.shared.sites
|
||||||
searchedFor(text: lastSearchedFor)
|
searchedFor(text: lastSearchedFor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reloadDomainsWithoutUI() async {
|
||||||
|
await Valet.shared.reloadSites()
|
||||||
|
domains = Valet.shared.sites
|
||||||
|
searchedFor(text: lastSearchedFor)
|
||||||
|
}
|
||||||
|
|
||||||
func applySortDescriptor(_ descriptor: NSSortDescriptor) {
|
func applySortDescriptor(_ descriptor: NSSortDescriptor) {
|
||||||
sortDescriptor = descriptor
|
sortDescriptor = descriptor
|
||||||
|
|
||||||
@@ -177,22 +185,22 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
self.domains = descriptor.ascending ? sorted.reversed() : sorted
|
self.domains = descriptor.ascending ? sorted.reversed() : sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
func addedNewSite(name: String, secure: Bool) {
|
func addedNewSite(name: String, secureAfterLinking: Bool) async {
|
||||||
waitAndExecute {
|
waitAndExecute {
|
||||||
Valet.shared.reloadSites()
|
await Valet.shared.reloadSites()
|
||||||
} completion: { [self] in
|
} 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()
|
domains = Valet.getDomainListable()
|
||||||
searchedFor(text: "")
|
searchedFor(text: "")
|
||||||
if let site = domains.enumerated().first(where: { $0.element.getListableName() == name }) {
|
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.selectRowIndexes([site.offset], byExtendingSelection: false)
|
||||||
self.tableView.scrollRowToVisible(site.offset)
|
self.tableView.scrollRowToVisible(site.offset)
|
||||||
if secure && !site.element.getListableSecured() {
|
if shouldSecure && !site.element.getListableSecured() {
|
||||||
self.toggleSecure()
|
self.toggleSecure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +266,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
self.applySortDescriptor(sortDescriptor)
|
self.applySortDescriptor(sortDescriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
self.tableView.reloadData()
|
self.tableView.reloadData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +300,6 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
|
|||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Log.perf("DomainListVC deallocated")
|
Log.perf("deinit: \(String(describing: self)).\(#function)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -51,7 +51,7 @@ class DomainListWindowController: PMWindowController, NSSearchFieldDelegate, NST
|
|||||||
// MARK: - Reload functionality
|
// MARK: - Reload functionality
|
||||||
|
|
||||||
@IBAction func pressedReload(_ sender: Any?) {
|
@IBAction func pressedReload(_ sender: Any?) {
|
||||||
contentVC.reloadDomains()
|
Task { await contentVC.reloadDomains() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func pressedAddLink(_ sender: Any?) {
|
@IBAction func pressedAddLink(_ sender: Any?) {
|
||||||
|
@@ -8,9 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class ComposerWindow {
|
@MainActor class ComposerWindow {
|
||||||
|
|
||||||
private var menu: MainMenu?
|
|
||||||
private var shouldNotify: Bool! = nil
|
private var shouldNotify: Bool! = nil
|
||||||
private var completion: ((Bool) -> Void)! = nil
|
private var completion: ((Bool) -> Void)! = nil
|
||||||
private var window: TerminalProgressWindowController?
|
private var window: TerminalProgressWindowController?
|
||||||
@@ -19,21 +17,19 @@ class ComposerWindow {
|
|||||||
Updates the global dependencies and runs the completion callback when done.
|
Updates the global dependencies and runs the completion callback when done.
|
||||||
*/
|
*/
|
||||||
func updateGlobalDependencies(notify: Bool, completion: @escaping (Bool) -> Void) {
|
func updateGlobalDependencies(notify: Bool, completion: @escaping (Bool) -> Void) {
|
||||||
self.menu = MainMenu.shared
|
|
||||||
self.shouldNotify = notify
|
self.shouldNotify = notify
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
|
|
||||||
Paths.shared.detectBinaryPaths()
|
Paths.shared.detectBinaryPaths()
|
||||||
|
|
||||||
if Paths.composer == nil {
|
if Paths.composer == nil {
|
||||||
DispatchQueue.main.async {
|
self.presentMissingAlert()
|
||||||
self.presentMissingAlert()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
PhpEnv.shared.isBusy = true
|
PhpEnv.shared.isBusy = true
|
||||||
menu?.setBusyImage()
|
MainMenu.shared.setBusyImage()
|
||||||
menu?.rebuild()
|
MainMenu.shared.rebuild()
|
||||||
|
|
||||||
window = TerminalProgressWindowController.display(
|
window = TerminalProgressWindowController.display(
|
||||||
title: "alert.composer_progress.title".localized,
|
title: "alert.composer_progress.title".localized,
|
||||||
@@ -42,45 +38,44 @@ class ComposerWindow {
|
|||||||
|
|
||||||
window?.setType(info: true)
|
window?.setType(info: true)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
Task { // Start the Composer global update as a separate task
|
||||||
let task = Shell.user.createTask(
|
await performComposerUpdate()
|
||||||
for: "\(Paths.composer!) global update", requiresPath: true
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
private func performComposerUpdate() async {
|
||||||
self.window?.addToConsole("\(Paths.composer!) global update\n")
|
do {
|
||||||
}
|
try await runComposerUpdateShellCommand()
|
||||||
|
} catch {
|
||||||
|
composerUpdateFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
task.listen(
|
private func runComposerUpdateShellCommand() async throws {
|
||||||
didReceiveStandardOutputData: { string in
|
let command = "\(Paths.composer!) global update"
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.window?.addToConsole(string)
|
|
||||||
}
|
|
||||||
// Log.perf("\(string.trimmingCharacters(in: .newlines))")
|
|
||||||
},
|
|
||||||
didReceiveStandardErrorData: { string in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.window?.addToConsole(string)
|
|
||||||
}
|
|
||||||
// Log.perf("\(string.trimmingCharacters(in: .newlines))")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task.launch()
|
self.window?.addToConsole("\(command)\n")
|
||||||
task.waitUntilExit()
|
|
||||||
task.haltListening()
|
|
||||||
|
|
||||||
if task.terminationStatus <= 0 {
|
let (process, _) = try await Shell.attach(
|
||||||
composerUpdateSucceeded()
|
command,
|
||||||
} else {
|
didReceiveOutput: { [weak self] (incoming, _) in
|
||||||
composerUpdateFailed()
|
guard let window = self?.window else { return }
|
||||||
}
|
window.addToConsole(incoming)
|
||||||
|
},
|
||||||
|
withTimeout: .minutes(5)
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.terminationStatus <= 0 {
|
||||||
|
composerUpdateSucceeded()
|
||||||
|
} else {
|
||||||
|
composerUpdateFailed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func composerUpdateSucceeded() {
|
private func composerUpdateSucceeded() {
|
||||||
// Closing the window should happen after a slight delay
|
// 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()
|
window?.close()
|
||||||
if shouldNotify {
|
if shouldNotify {
|
||||||
LocalNotification.send(
|
LocalNotification.send(
|
||||||
@@ -97,7 +92,7 @@ class ComposerWindow {
|
|||||||
|
|
||||||
private func composerUpdateFailed() {
|
private func composerUpdateFailed() {
|
||||||
// Showing that something failed should be shown immediately
|
// Showing that something failed should be shown immediately
|
||||||
DispatchQueue.main.async { [self] in
|
Task { @MainActor [self] in
|
||||||
window?.setType(info: false)
|
window?.setType(info: false)
|
||||||
window?.progressView?.labelTitle.stringValue = "alert.composer_failure.title".localized
|
window?.progressView?.labelTitle.stringValue = "alert.composer_failure.title".localized
|
||||||
window?.progressView?.labelDescription.stringValue = "alert.composer_failure.info".localized
|
window?.progressView?.labelDescription.stringValue = "alert.composer_failure.info".localized
|
||||||
@@ -111,8 +106,8 @@ class ComposerWindow {
|
|||||||
|
|
||||||
private func removeBusyStatus() {
|
private func removeBusyStatus() {
|
||||||
PhpEnv.shared.isBusy = false
|
PhpEnv.shared.isBusy = false
|
||||||
DispatchQueue.main.async { [self] in
|
Task { @MainActor in
|
||||||
menu?.updatePhpVersionInStatusBar()
|
MainMenu.shared.updatePhpVersionInStatusBar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +120,11 @@ class ComposerWindow {
|
|||||||
subtitle: "alert.composer_missing.subtitle".localized,
|
subtitle: "alert.composer_missing.subtitle".localized,
|
||||||
description: "alert.composer_missing.desc".localized
|
description: "alert.composer_missing.desc".localized
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: "generic.ok".localized)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
Log.perf("deinit: \(String(describing: self)).\(#function)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -71,7 +71,7 @@ struct PhpFrameworks {
|
|||||||
public static func detectFallbackDependency(_ basePath: String) -> String? {
|
public static func detectFallbackDependency(_ basePath: String) -> String? {
|
||||||
for entry in Self.FileMapping {
|
for entry in Self.FileMapping {
|
||||||
let found = entry.value
|
let found = entry.value
|
||||||
.map { path in return Filesystem.exists(basePath + path) }
|
.map { path in return FileSystem.anyExists(basePath + path) }
|
||||||
.contains(true)
|
.contains(true)
|
||||||
|
|
||||||
if found {
|
if found {
|
||||||
|
@@ -9,18 +9,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class HomebrewDiagnostics {
|
class HomebrewDiagnostics {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Determines the Homebrew taps the user has installed.
|
Determines the Homebrew taps the user has installed.
|
||||||
*/
|
*/
|
||||||
public static var installedTaps: [String] = {
|
public static var installedTaps: [String] = []
|
||||||
return Shell
|
|
||||||
|
/**
|
||||||
|
Load which taps are installed.
|
||||||
|
*/
|
||||||
|
public static func loadInstalledTaps() async {
|
||||||
|
installedTaps = await Shell
|
||||||
.pipe("\(Paths.brew) tap")
|
.pipe("\(Paths.brew) tap")
|
||||||
|
.out
|
||||||
.split(separator: "\n")
|
.split(separator: "\n")
|
||||||
.map { string in
|
.map { string in
|
||||||
return String(string)
|
return String(string)
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Determines whether the PHP Monitor Cask is installed.
|
Determines whether the PHP Monitor Cask is installed.
|
||||||
@@ -29,6 +34,17 @@ class HomebrewDiagnostics {
|
|||||||
return installedTaps.contains("nicoverbruggen/cask")
|
return installedTaps.contains("nicoverbruggen/cask")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/**
|
||||||
|
Determines whether to use the regular `nginx` or `nginx-full` formula.
|
||||||
|
*/
|
||||||
|
public static var usesNginxFullFormula: Bool = {
|
||||||
|
guard let destination = try? FileManager.default
|
||||||
|
.destinationOfSymbolicLink(atPath: "\(Paths.binPath)/nginx") else { return false }
|
||||||
|
|
||||||
|
// Verify that the `nginx` binary is symlinked to a directory that includes `nginx-full`.
|
||||||
|
return destination.contains("/nginx-full/")
|
||||||
|
}()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
It is possible to have the `shivammathur/php` tap installed, and for the core homebrew information to be outdated.
|
It is possible to have the `shivammathur/php` tap installed, and for the core homebrew information to be outdated.
|
||||||
This will then result in two different aliases claiming to point to the same formula (`php`).
|
This will then result in two different aliases claiming to point to the same formula (`php`).
|
||||||
@@ -36,8 +52,8 @@ class HomebrewDiagnostics {
|
|||||||
|
|
||||||
This check only needs to be performed if the `shivammathur/php` tap is active.
|
This check only needs to be performed if the `shivammathur/php` tap is active.
|
||||||
*/
|
*/
|
||||||
public static func checkForCaskConflict() {
|
public static func checkForCaskConflict() async {
|
||||||
if hasAliasConflict() {
|
if await hasAliasConflict() {
|
||||||
presentAlertAboutConflict()
|
presentAlertAboutConflict()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,19 +81,21 @@ class HomebrewDiagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versions.forEach { version in
|
versions.forEach { version in
|
||||||
switcher.disableDefaultPhpFpmPool(version)
|
Task { // Fix each pool concurrently (but perform the tasks sequentially)
|
||||||
switcher.stopPhpVersion(version)
|
await switcher.disableDefaultPhpFpmPool(version)
|
||||||
switcher.startPhpVersion(version, primary: version == primary)
|
await switcher.stopPhpVersion(version)
|
||||||
|
await switcher.startPhpVersion(version, primary: version == primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Check if the alias conflict as documented in `checkForCaskConflict` actually occurred.
|
Check if the alias conflict as documented in `checkForCaskConflict` actually occurred.
|
||||||
*/
|
*/
|
||||||
private static func hasAliasConflict() -> Bool {
|
private static func hasAliasConflict() async -> Bool {
|
||||||
let tapAlias = Shell.pipe("\(Paths.brew) info shivammathur/php/php --json")
|
let tapAlias = await Shell.pipe("brew info shivammathur/php/php --json").out
|
||||||
|
|
||||||
if tapAlias.contains("brew tap shivammathur/php") || tapAlias.contains("Error") {
|
if tapAlias.contains("brew tap shivammathur/php") || tapAlias.contains("Error") || tapAlias.isEmpty {
|
||||||
Log.info("The user does not appear to have tapped: shivammathur/php")
|
Log.info("The user does not appear to have tapped: shivammathur/php")
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
@@ -89,13 +107,13 @@ class HomebrewDiagnostics {
|
|||||||
from: tapAlias.data(using: .utf8)!
|
from: tapAlias.data(using: .utf8)!
|
||||||
).first!
|
).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. "
|
Log.warn("The `php` formula alias seems to be the different between the tap and core. "
|
||||||
+ "This could be a problem!")
|
+ "This could be a problem!")
|
||||||
Log.info("Determining whether both of these versions are installed...")
|
Log.info("Determining whether both of these versions are installed...")
|
||||||
|
|
||||||
let bothInstalled = PhpEnv.shared.availablePhpVersions.contains(tapPhp.version)
|
let bothInstalled = PhpEnv.shared.availablePhpVersions.contains(tapPhp.version)
|
||||||
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpVersion)
|
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpAlias)
|
||||||
|
|
||||||
if bothInstalled {
|
if bothInstalled {
|
||||||
Log.warn("Both conflicting aliases seem to be installed, warning the user!")
|
Log.warn("Both conflicting aliases seem to be installed, warning the user!")
|
||||||
@@ -116,13 +134,13 @@ class HomebrewDiagnostics {
|
|||||||
Show this alert in case the tapped Cask does cause issues because of the conflict.
|
Show this alert in case the tapped Cask does cause issues because of the conflict.
|
||||||
*/
|
*/
|
||||||
private static func presentAlertAboutConflict() {
|
private static func presentAlertAboutConflict() {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
BetterAlert()
|
BetterAlert()
|
||||||
.withInformation(
|
.withInformation(
|
||||||
title: "alert.php_alias_conflict.title".localized,
|
title: "alert.php_alias_conflict.title".localized,
|
||||||
subtitle: "alert.php_alias_conflict.info".localized
|
subtitle: "alert.php_alias_conflict.info".localized
|
||||||
)
|
)
|
||||||
.withPrimary(text: "OK")
|
.withPrimary(text: "generic.ok".localized)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,13 +149,14 @@ class HomebrewDiagnostics {
|
|||||||
In order to see if we support the --json syntax, we'll query nginx.
|
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.
|
If the JSON response cannot be parsed, Homebrew is probably out of date.
|
||||||
*/
|
*/
|
||||||
public static func cannotLoadService(_ name: String = "nginx") -> 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(
|
let serviceInfo = try? JSONDecoder().decode(
|
||||||
[HomebrewService].self,
|
[HomebrewService].self,
|
||||||
from: Shell.pipe(
|
from: nginxJson.data(using: .utf8)!
|
||||||
"sudo \(Paths.brew) services info \(name) --json",
|
|
||||||
requiresPath: true
|
|
||||||
).data(using: .utf8)!
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return serviceInfo == nil
|
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
|
// PHP Monitor
|
||||||
//
|
//
|
||||||
// Created by Nico Verbruggen on 12/04/2022.
|
// Created by Nico Verbruggen on 12/04/2022.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol DomainListable {
|
protocol ValetListable {
|
||||||
|
|
||||||
func getListableName() -> String
|
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
|
import Foundation
|
||||||
|
|
||||||
class ValetProxy: DomainListable {
|
class ValetProxy: ValetListable {
|
||||||
var domain: String
|
var domain: String
|
||||||
var tld: String
|
var tld: String
|
||||||
var target: String
|
var target: String
|
||||||
var secured: Bool = false
|
var secured: Bool = false
|
||||||
|
|
||||||
init(_ configuration: NginxConfigurationFile) {
|
init(domain: String, target: String, secure: Bool, tld: String) {
|
||||||
self.domain = configuration.domain
|
self.domain = domain
|
||||||
self.tld = configuration.tld
|
self.tld = tld
|
||||||
self.target = configuration.proxy!
|
self.target = target
|
||||||
self.secured = Filesystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
|
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 {
|
func getListableName() -> String {
|
||||||
return self.domain
|
return self.domain
|
||||||
@@ -50,4 +60,18 @@ class ValetProxy: DomainListable {
|
|||||||
func getListableUrl() -> URL? {
|
func getListableUrl() -> URL? {
|
||||||
return URL(string: "\(self.secured ? "https://" : "http://")\(self.domain).\(self.tld)")
|
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
|
// 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.
|
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol SiteScanner {
|
protocol DomainScanner {
|
||||||
|
|
||||||
|
// MARK: - Sites
|
||||||
|
|
||||||
func resolveSiteCount(paths: [String]) -> Int
|
func resolveSiteCount(paths: [String]) -> Int
|
||||||
|
|
||||||
func resolveSitesFrom(paths: [String]) -> [ValetSite]
|
func resolveSitesFrom(paths: [String]) -> [ValetSite]
|
||||||
|
|
||||||
func resolveSite(path: String) -> ValetSite?
|
func resolveSite(path: String) -> ValetSite?
|
||||||
|
|
||||||
|
// MARK: - Proxies
|
||||||
|
|
||||||
|
func resolveProxies(directoryPath: String) -> [ValetProxy]
|
||||||
|
|
||||||
}
|
}
|
@@ -1,41 +1,54 @@
|
|||||||
//
|
//
|
||||||
// FakeSiteScanner.swift
|
// FakeDomainScanner.swift
|
||||||
// PHP Monitor
|
// PHP Monitor
|
||||||
//
|
//
|
||||||
// Created by Nico Verbruggen on 02/04/2022.
|
// Created by Nico Verbruggen on 02/04/2022.
|
||||||
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
// Copyright © 2022 Nico Verbruggen. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
class FakeSiteScanner: SiteScanner {
|
class FakeDomainScanner: DomainScanner {
|
||||||
let fakes = [
|
|
||||||
ValetSite(fakeWithName: "laravel", tld: "test", secure: true,
|
var sites: [ValetSite] = [
|
||||||
|
FakeValetSite(fakeWithName: "laravel", tld: "test", secure: true,
|
||||||
path: "~/Code/laravel/framework", linked: 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"),
|
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),
|
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"),
|
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"),
|
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")
|
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 {
|
func resolveSiteCount(paths: [String]) -> Int {
|
||||||
return fakes.count
|
return sites.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveSitesFrom(paths: [String]) -> [ValetSite] {
|
func resolveSitesFrom(paths: [String]) -> [ValetSite] {
|
||||||
return fakes
|
return sites
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveSite(path: String) -> ValetSite? {
|
func resolveSite(path: String) -> ValetSite? {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Proxies
|
||||||
|
|
||||||
|
func resolveProxies(directoryPath: String) -> [ValetProxy] {
|
||||||
|
return proxies
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// ValetSiteScanner.swift
|
// ValetDomainScanner.swift
|
||||||
// PHP Monitor
|
// PHP Monitor
|
||||||
//
|
//
|
||||||
// Created by Nico Verbruggen on 02/04/2022.
|
// Created by Nico Verbruggen on 02/04/2022.
|
||||||
@@ -8,12 +8,15 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class ValetSiteScanner: SiteScanner {
|
class ValetDomainScanner: DomainScanner {
|
||||||
|
|
||||||
|
// MARK: - Sites
|
||||||
|
|
||||||
func resolveSiteCount(paths: [String]) -> Int {
|
func resolveSiteCount(paths: [String]) -> Int {
|
||||||
return paths.map { path in
|
return paths.map { path in
|
||||||
|
|
||||||
let entries = try! FileManager.default
|
let entries = try! FileSystem
|
||||||
.contentsOfDirectory(atPath: path)
|
.getShallowContentsOfDirectory(path)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
.map { self.isSite($0, forPath: path) }
|
.map { self.isSite($0, forPath: path) }
|
||||||
@@ -27,8 +30,8 @@ class ValetSiteScanner: SiteScanner {
|
|||||||
var sites: [ValetSite] = []
|
var sites: [ValetSite] = []
|
||||||
|
|
||||||
paths.forEach { path in
|
paths.forEach { path in
|
||||||
let entries = try! FileManager.default
|
let entries = try! FileSystem
|
||||||
.contentsOfDirectory(atPath: path)
|
.getShallowContentsOfDirectory(path)
|
||||||
|
|
||||||
return entries.forEach {
|
return entries.forEach {
|
||||||
if let site = self.resolveSite(path: "\(path)/\($0)") {
|
if let site = self.resolveSite(path: "\(path)/\($0)") {
|
||||||
@@ -48,24 +51,19 @@ class ValetSiteScanner: SiteScanner {
|
|||||||
// Get the TLD from the global Valet object
|
// Get the TLD from the global Valet object
|
||||||
let tld = Valet.shared.config.tld
|
let tld = Valet.shared.config.tld
|
||||||
|
|
||||||
// See if the file is a symlink, if so, resolve it
|
if !FileSystem.anyExists(path) {
|
||||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
|
|
||||||
Log.warn("Could not parse the site: \(path), skipping!")
|
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
|
// We should also check that we can interpret the path correctly
|
||||||
if URL(fileURLWithPath: path).lastPathComponent == "" {
|
if URL(fileURLWithPath: path).lastPathComponent == "" {
|
||||||
Log.warn("Could not parse the site: \(path), skipping!")
|
Log.warn("Could not parse the site: \(path), skipping!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if type == FileAttributeType.typeSymbolicLink {
|
if FileSystem.isSymlink(path) {
|
||||||
return ValetSite(aliasPath: path, tld: tld)
|
return ValetSite(aliasPath: path, tld: tld)
|
||||||
} else if type == FileAttributeType.typeDirectory {
|
} else if FileSystem.isDirectory(path) {
|
||||||
return ValetSite(absolutePath: path, tld: tld)
|
return ValetSite(absolutePath: path, tld: tld)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +77,26 @@ class ValetSiteScanner: SiteScanner {
|
|||||||
private func isSite(_ entry: String, forPath path: String) -> Bool {
|
private func isSite(_ entry: String, forPath path: String) -> Bool {
|
||||||
let siteDir = path + "/" + entry
|
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 {
|
func resolveProxies(directoryPath: String) -> [ValetProxy] {
|
||||||
return true
|
return try! FileManager
|
||||||
}
|
.default
|
||||||
|
.contentsOfDirectory(atPath: directoryPath)
|
||||||
return false
|
.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|