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

Compare commits

...

162 Commits

Author SHA1 Message Date
ae7e13de9b 🔧 Bump build for new pre-release build 2023-01-12 17:20:58 +01:00
c82ea1fac1 🐛 Fix concurrency crashes with @objc methods 2023-01-12 17:20:15 +01:00
d6258f54a9 🐛 Fix crash when Valet.shared is nil 2023-01-11 22:23:39 +01:00
e6d2c873a5 👌 Add modal to inform about helper scripts 2023-01-11 18:03:10 +01:00
300b10c5d8 🐛 Fix crash with check for updates 2023-01-11 17:50:55 +01:00
6eea08cd4f 👌 Finalize PHP Guard functionality 2023-01-10 17:53:55 +01:00
3c946a53e8 👌 Keep track of last used global PHP version 2023-01-09 19:19:18 +01:00
d8738b685f 👌 Get rid of warnings 2023-01-09 18:10:51 +01:00
55fc90bcf5 👌 Better alerts for error state 2023-01-08 13:14:18 +01:00
6a2f4d248c 👌 Improved error handling 2023-01-08 12:48:47 +01:00
a50eb04f3c 👌 Services now report error status 2023-01-08 11:50:01 +01:00
4cbfbeb4e5 👌 Add copy about inactive services 2023-01-07 23:54:01 +01:00
18dd597d38 👌 SwiftUI fixes 2023-01-07 19:01:31 +01:00
e5c80ab52f 👌 Resolve height issues 2023-01-07 18:54:07 +01:00
0b3a83c1e4 👌 Cleanup 2023-01-07 18:10:31 +01:00
6e7c0d827c 👌 Initial loading state for services manager 2023-01-07 17:16:51 +01:00
d05f39efe7 👌 Check if Valet version is supported 2023-01-07 13:24:52 +01:00
27894e4884 👌 Handle TODOs 2023-01-07 13:03:27 +01:00
f153fee05c Fix broken tests after test config using 8.2 2023-01-07 12:57:45 +01:00
71e1ed1b93 👌 Async switcher (Swift concurrency) 2023-01-07 12:53:27 +01:00
61ecefb6e7 👌 Use filesystem abstraction 2023-01-07 12:53:07 +01:00
422a7738bd 👌 Add fake services system 2023-01-06 21:50:34 +01:00
0beda388eb 👌 Cleanup 2023-01-06 21:33:51 +01:00
684a53fc4a 🏗 WIP: Functional service toggling 2023-01-06 20:30:25 +01:00
456948ffd9 🏗 WIP: Broken services toggling
You can break it by opening the menu twice.
2023-01-06 19:14:17 +01:00
a090cbc20b 👌 Revert loading 2023-01-06 18:34:39 +01:00
7b07520440 🏗 WIP: Service toggling 2023-01-06 18:33:55 +01:00
6d52992c9d 👌 Add comments about concurrency 2023-01-06 13:07:02 +01:00
291d20b2b3 🏗 WIP: Functional services list 2023-01-06 12:59:15 +01:00
3d505aebde 🚛 VersionNumber, PhpVersionNumberCollection 2023-01-06 12:41:37 +01:00
d854dee2a1 Fix tests related to version number parsing 2023-01-05 17:58:55 +01:00
67e8589834 ♻️ Alter how PHP version support is handled 2023-01-05 17:45:23 +01:00
a36c9b1563 📝 Update documentation 2023-01-05 12:17:31 +01:00
df1b1c5856 🏗️ Investigate event propagation 2023-01-03 20:17:29 +01:00
5af7214320 🏗️ Layout changes 2023-01-03 20:15:44 +01:00
e20d3ffd22 🏗️ WIP: Various fixes and improvements
- Fixed `brewPhpAlias` (must be configurable later)
- Added TODOs for where the filesystem abstraction is required
- Set `Homebrew.fake` early on when applying testable configuration
- Evaluate `FakeValetSite` compatibility again
- Never display sponsor alert when running tests
- Upgrade TestableConfiguration.working to use PHP 8.2
2023-01-03 19:29:44 +01:00
be70559d4c 🏗 WIP: Adjust dimensions 2023-01-03 16:58:48 +01:00
61988a141b 🏗 WIP: Center buttons and checkmarks 2023-01-03 16:14:45 +01:00
3bcf52bf0a 🏗 WIP: Use objectWillChange.send() 2023-01-03 16:11:55 +01:00
296bc486c4 🏗 WIP: Fake services manager 2022-12-24 15:17:47 +01:00
59f60b5013 🏗 WIP: Services manager main status
Please note that these are computed properties.
They should be computed when a service status
is modified, and should be `Published`.
2022-12-24 15:04:25 +01:00
de2c1aca5d 🏗 More SwiftUI experiments 2022-12-24 14:51:23 +01:00
44c1ea7de4 🏗 SwiftUI experiments 2022-12-23 19:20:04 +01:00
923f0237e8 🔥 WIP: Removed broken view 2022-12-20 19:42:27 +01:00
ff7c68ddfb 🏗 WIP: Services rewrite 2022-12-19 18:09:10 +01:00
bf2c0c259f 👌 Mastodon > Twitter 2022-12-19 17:35:32 +01:00
0fdd1264ed 👌 Await the outcome of cash conflict check 2022-12-19 17:30:56 +01:00
c85a8e3818 Add test for tapping on "add domain" 2022-12-18 14:50:03 +01:00
15fe5e4716 Add tests for domain list interaction 2022-12-16 20:50:53 +01:00
de6dea066e Use ValetInteractor to add links and proxies 2022-12-16 20:20:31 +01:00
ee230f3086 👌 Unify scanners 2022-12-15 22:28:15 +01:00
bc96b50630 Add fake isolation interaction 2022-12-14 20:28:49 +01:00
d6554ceea9 Add site isolation to ValetInteractor 2022-12-14 20:18:06 +01:00
4b04f70638 👌 Fix phrasing ("site is unsecured")
A site can be either insecure or unsecured. Insecure has a rather
negative connotation and is also a human trait. Unsecured is a better 
term in this particular case.
2022-12-13 23:49:55 +01:00
d49e74fab1 🚛 Move FakeValetInteractor to separate file 2022-12-13 23:47:59 +01:00
e34dadcb9b 👌 Reinstate UI busy for secure, add fake delay 2022-12-13 23:45:19 +01:00
a696a9a386 👌 Style fix 2022-12-13 23:32:50 +01:00
837392d606 Add unlinking sites for fake links 2022-12-13 23:32:19 +01:00
912d9e7423 Add FakeValetInteractor 2022-12-13 23:21:15 +01:00
cb98d40bef 👌 Fix crash with new secure/unsecure mechanism 2022-12-13 23:02:30 +01:00
1f165058b2 ♻️ Rework interaction with Valet commands 2022-12-13 22:56:36 +01:00
04c78eba35 👌 Generate a new JSON file for current dev env 2022-12-13 20:24:48 +01:00
4bfde7b062 👌 Add warning about https proxy subjects 2022-12-13 20:21:34 +01:00
5f39cd757a 📝 Update README 2022-12-09 18:05:31 +01:00
073b7cf943 🏗 WIP: Interactions 2022-12-06 20:53:42 +01:00
31a0bb986f ♻️ Some odd refactoring 2022-12-06 19:43:45 +01:00
81183acea8 ♻️ Cleanup 2022-12-06 19:28:20 +01:00
3f8739dc30 👌 Fix timing issue 2022-11-22 00:14:46 +01:00
c3e55df9fb WIP: Add support for nginx-full, formulae tweaks 2022-11-22 00:01:31 +01:00
c6aa06842c 🔥 Remove reference to LatestStablePhpVersion 2022-11-18 19:18:36 +01:00
a801174f0a Fully cover RealFileSystem 2022-11-09 20:05:15 +01:00
fa5c843619 Add tests for RealFileSystem class 2022-11-08 20:14:10 +01:00
bc739e1982 👌 Add feature test for InternalSwitcher 2022-11-07 20:42:52 +01:00
a21d928a6c 📝 Update SECURITY.md 2022-11-02 21:22:49 +01:00
76d96b3507 Fix issue with UI tests and .localizable 2022-11-02 21:05:12 +01:00
4de7179d1c 👌 Include brew (un)link commands for tests 2022-11-02 20:08:15 +01:00
f2d5b94831 Fix tests 2022-11-02 19:44:36 +01:00
ff5fdd82b1 👌 Add default system "www.conf" file 2022-11-01 17:11:55 +01:00
4f6bae87d4 Add Composer to testable configuration 2022-11-01 17:08:01 +01:00
ce44166b48 Added more tests, added to fake & real FS 2022-11-01 17:02:26 +01:00
5caca85d7a 👌 Fake FS: ~ and intermediate directories 2022-11-01 16:47:45 +01:00
fa2d2105c5 👌 Removed remaining FileManager.default usage 2022-11-01 14:11:34 +01:00
8417d637fe 👌 FileSystem changes, rework and testing 2022-11-01 13:47:16 +01:00
e8c85f93f9 👌 Change where scanners are initialized 2022-11-01 12:10:47 +01:00
24659d4385 👌 Bump recommended Valet version 2022-10-25 23:30:49 +02:00
786b59aa92 🐛 Handle empty output for brew info 2022-10-22 17:39:43 +02:00
771f8dc757 🏗 WIP for fake site enhancements 2022-10-21 19:45:42 +02:00
e18db4eadd 👌 Mark determineVersion as throws 2022-10-21 19:31:05 +02:00
1ece5c34bf Correctly parse pre-release PHP versions 2022-10-21 19:27:31 +02:00
04ed29bc9f 👌 Fix warnings 2022-10-21 18:19:31 +02:00
ac2184ba97 👌 Add brew tap homebrew/services instruction
This now recommends the appropriate solution for #208.
2022-10-20 20:44:54 +02:00
8310cf2729 👌 Touching up correctness of process handling 2022-10-19 14:57:28 +02:00
696f9bf351 👌 Various async improvements 2022-10-19 14:27:39 +02:00
17b1329d71 👌 Cleanup 2022-10-19 13:52:11 +02:00
4bfa98fc20 ♻️ Refactor DispatchQueue to new Task API 2022-10-19 13:44:53 +02:00
658cec27c1 👌 Cleanup 2022-10-18 23:44:18 +02:00
1c0d9f6826 👌 Swift 6 compatibility 2022-10-18 23:41:46 +02:00
507d7d5b23 Fix PHP version detection requirement 2022-10-18 16:43:17 +02:00
4b8b46a822 Fix test 2022-10-18 14:12:57 +02:00
ea5dd3bc46 👌 Snake case for tests 2022-10-18 14:11:55 +02:00
83657fee6f Tests are final 2022-10-16 15:37:17 +02:00
4c752b6a15 Localization support for in test files 2022-10-16 15:13:13 +02:00
5e3e0c087b 👌 Read configuration from JSON file
This allows us to alter the configuration prior to launching the app,
which allows for additional flexibility during testing.
2022-10-16 14:35:19 +02:00
273070ef27 ️ Sped up and improved UI test 2022-10-15 16:37:33 +02:00
cb3208c008 Real UI test 2022-10-15 15:36:03 +02:00
d401fe997d Make UI test actually functional 2022-10-15 15:14:49 +02:00
eaf6ef658f 🚛 Moved tests around, added Feature, UI tests 2022-10-14 18:03:14 +02:00
d91e16d674 Add test plan, fix unit tests 2022-10-14 17:10:58 +02:00
008603b8c3 👌 Fix project structure 2022-10-14 16:55:37 +02:00
728274aaca 👌 Fix warnings 2022-10-14 16:54:11 +02:00
daaece3cfa Added fake commands 2022-10-12 23:25:06 +02:00
eaa74b7141 👌 Annotate configurations 2022-10-12 22:50:06 +02:00
8a6656d3e2 👌 Improve logging 2022-10-12 22:40:48 +02:00
ad46f51d73 👌 Use fake filesystem 2022-10-12 22:38:03 +02:00
12a4efc775 👌 Improvements to BetterAlert, apply() configs
- MainActor fixes for BetterAlert
- Added `apply` for TestableConfiguration
2022-10-12 22:19:36 +02:00
ec4c4df5fd Add preference to disable TLD alert (#206) 2022-10-12 18:52:44 +02:00
b0f27dcfa5 ♻️ Filesystem to FileSystem / ActiveFileSystem 2022-10-08 01:04:51 +02:00
f5d2ec2b7e 👌 Add delay global function 2022-10-08 00:27:29 +02:00
6db5cdec25 🐛 Introduce SLOW SHELL and fix a few issues 2022-10-07 22:55:48 +02:00
03cf4ef3e4 👌 Added various TODO items 2022-10-06 23:35:14 +02:00
6feb8118d9 🚛 Move files around 2022-10-06 23:34:31 +02:00
45a82b2c9e 🏗 Checked and fixed various Task { } blocks 2022-10-06 23:29:13 +02:00
ed3622cc4e Fix tests 2022-10-06 23:00:21 +02:00
e2ab7f08ed 🏗 Remove LegacyShell entirely 2022-10-06 22:55:05 +02:00
ad41e3891e 🏗 Mark old Shell as deprecated from now on 2022-10-05 22:39:36 +02:00
108ae05c1d 🏗 Use new shell when parsing apps 2022-10-05 22:38:54 +02:00
fb1ca60240 Fix tests, new shell in various places 2022-10-05 01:07:09 +02:00
3267dc8add ♻️ Use new Shell.pipe to replace legacy shell 2022-10-05 00:38:17 +02:00
1fd7db15a7 🏗 Testable terminal output 2022-10-04 23:42:43 +02:00
0b33116eb0 🏗 Fake shell in use 2022-10-04 19:39:34 +02:00
2c25bcbdb5 🏗 Ensure Shellable has PATH 2022-10-04 19:00:33 +02:00
8a6139d5e7 🏗 Remove synchronous terminal commands 2022-10-04 18:40:41 +02:00
953ccb3792 🏗 WIP 2022-10-04 17:57:05 +02:00
c26c491340 🏗 WIP 2022-10-03 22:27:50 +02:00
c9a5cd3a9f 🏗 WIP 2022-10-03 19:27:14 +02:00
86eb295489 👌 Add runComposerUpdateShellCommand method 2022-09-30 23:45:32 +02:00
572330eaa1 👌 Remove reference to singleton 2022-09-30 23:44:16 +02:00
5ebafdb4e3 👌 Shell tweaks, fix ComposerWindow async issue 2022-09-29 19:08:40 +02:00
ffffcad84b 🐛 Fix ComposerWindow deinit not firing 2022-09-29 19:00:59 +02:00
4c11fae541 🏗 WIP: Run shell commands in parallel 2022-09-28 22:18:16 +02:00
99da328921 🏗 WIP: Even better Shell functionality 2022-09-28 21:43:18 +02:00
bbac2632a2 🏗 WIP: Much improved Shell protocol 2022-09-28 21:28:51 +02:00
513a86ec39 🐛 Fix an issue with missing separator item 2022-09-28 18:24:34 +02:00
a59efb7fce 🏗 WIP: Shell rework 2022-09-27 22:27:33 +02:00
3483569410 🏗 WIP: Shell rework 2022-09-27 20:26:11 +02:00
5399bddfeb 🏗 WIP: Shell rework 2022-09-26 20:37:24 +02:00
a682d0cfb0 🐛 Merge fixes from 'dev/5.6' into dev/6.0 2022-09-23 16:48:47 +02:00
e871a00490 👌 Add additional commentary to new shell classes 2022-09-21 21:42:22 +02:00
1d396202db 👌 Add PATH to SystemShell 2022-09-21 21:31:00 +02:00
39769d815f 🚛 Move around files 2022-09-21 21:06:11 +02:00
90a69338f7 🏗 Add additional test 2022-09-20 20:53:15 +02:00
3f25759d4f 🏗 Fix SwiftLint, WIP shell rework 2022-09-20 20:49:29 +02:00
4494a0555f 🚧 WIP: Shell rework 2022-09-20 00:33:58 +02:00
0d86f3ded6 📝 Update README, contribution guidelines 2022-09-18 14:09:36 +02:00
fc27131cca 👌 Add support for <= and < version constraints
I am entirely unsure why one would need these, but I figured I'd get
these in the app before I start the work on PHP Monitor 6.0.

This ensures all common version constraints can now be parsed correctly.
2022-09-18 00:06:37 +02:00
bb124bd0ee 👌 Cleanup and removal of unneeded dump 2022-09-17 23:16:49 +02:00
c35e7781f4 🔥 Remove unneeded plist file 2022-09-17 23:14:39 +02:00
61528cea46 🏗 WIP 2022-09-14 19:05:14 +02:00
883f5a1a5d 🔀 Merge branch 'dev/5.6' into dev/6.0 2022-09-10 21:46:17 +02:00
95729c5315 ♻️ Single target, multiple configurations 2022-09-10 21:21:58 +02:00
f02e45486e ♻️ Refactor ActivePhpInstallation 2022-09-09 22:18:52 +02:00
6ddddc744a 🔧 Add target for PHP Monitor SE 2022-09-09 21:46:20 +02:00
168 changed files with 7585 additions and 2366 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
version = "1.3">
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@@ -27,14 +27,42 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:PHP Monitor.xcodeproj/PHP Monitor.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
BuildableName = "phpmon-tests.xctest"
BlueprintName = "phpmon-tests"
BuildableName = "Unit Tests.xctest"
BlueprintName = "Unit Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7AC28F9B4940021E251"
BuildableName = "Feature Tests.xctest"
BlueprintName = "Feature Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7BB28F9B90F0021E251"
BuildableName = "UI Tests.xctest"
BlueprintName = "UI Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
@@ -73,6 +101,11 @@
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "SLOW_SHELL_MODE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
value = ""

View File

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

View File

@@ -79,20 +79,65 @@ If you're still having issues, here's a few common questions & answers, as well
<details>
<summary><strong>Which versions of PHP are supported?</strong></summary>
The following versions of PHP are officially supported:
<ul>
<li>PHP 5.6 (only if you are running Valet 2)</li>
<li>PHP 7.0</li>
<li>PHP 7.1</li>
<li>PHP 7.2</li>
<li>PHP 7.3</li>
<li>PHP 7.4</li>
<li>PHP 8.0</li>
<li>PHP 8.1</li>
<li>PHP 8.2 (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>
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>

View File

@@ -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 |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 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 |
_(*) macOS Ventura (13.0) is not officially supported until it officially releases._
| 6.x | ✅ Universal binary | ✅ Yes | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0) | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x)<br/>PHP 7.4-PHP 8.2 (w/ Valet 4.x) | 3.0 or higher recommended<br/> 2.16.2 minimum |
## Legacy versions
@@ -16,6 +14,7 @@ These versions of PHP Monitor are no longer supported, but if youre using an
| Version | Apple Silicon | Supported | Supported macOS | Deployment Target | Detected PHP Versions | Minimum Required Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 5.6 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0)<br/>Ventura (13.0)* | macOS 11+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.2 (w/ Valet 3.x) | 3.0 recommended<br/> 2.16.2 minimum |
| 4.1 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 11+ | PHP 5.6—PHP 8.2 | 2.16.2 |
| 4.0 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 10.14+ | PHP 5.6—PHP 8.2 | 2.13 |
| 3.5 | ✅ Universal binary | ❌ | Big Sur (11.0)<br/>Monterey (12.0) | macOS 10.14+ | PHP 5.6—PHP 8.2 | 2.13 |

Binary file not shown.

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

View File

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

View File

@@ -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"])
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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()
}
}

View 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
}

View File

@@ -7,16 +7,9 @@
import Cocoa
public class Command {
public class RealCommand: CommandProtocol {
/**
Immediately executes a command.
- Parameter path: The path of the command or program to invoke.
- Parameter arguments: A list of arguments that are passed on.
- Parameter trimNewlines: Removes empty new line output.
*/
public static func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
public func execute(path: String, arguments: [String], trimNewlines: Bool = false) -> String {
let task = Process()
task.launchPath = path
task.arguments = arguments

View File

@@ -12,36 +12,37 @@ class Actions {
// MARK: - Services
public static func restartPhpFpm() {
brew("services restart \(PhpEnv.phpInstall.formula)", sudo: true)
public static func restartPhpFpm() async {
await brew("services restart \(Homebrew.Formulae.php.name)", sudo: Homebrew.Formulae.php.elevated)
}
public static func restartNginx() {
brew("services restart nginx", sudo: true)
public static func restartNginx() async {
await brew("services restart \(Homebrew.Formulae.nginx.name)", sudo: Homebrew.Formulae.nginx.elevated)
}
public static func restartDnsMasq() {
brew("services restart dnsmasq", sudo: true)
public static func restartDnsMasq() async {
await brew("services restart \(Homebrew.Formulae.dnsmasq.name)", sudo: Homebrew.Formulae.dnsmasq.elevated)
}
public static func stopValetServices() {
brew("services stop \(PhpEnv.phpInstall.formula)", sudo: true)
brew("services stop nginx", sudo: true)
brew("services stop dnsmasq", sudo: true)
public static func stopValetServices() async {
await brew("services stop \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
await brew("services stop \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
await brew("services stop \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
}
public static func fixHomebrewPermissions() throws {
var servicesCommands = [
"\(Paths.brew) services stop nginx",
"\(Paths.brew) services stop dnsmasq"
"\(Paths.brew) services stop \(Homebrew.Formulae.nginx)",
"\(Paths.brew) services stop \(Homebrew.Formulae.dnsmasq)"
]
var cellarCommands = [
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/nginx",
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/dnsmasq"
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.nginx)",
"chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(Homebrew.Formulae.dnsmasq)"
]
PhpEnv.shared.availablePhpVersions.forEach { version in
let formula = version == PhpEnv.brewPhpVersion
let formula = version == PhpEnv.brewPhpAlias
? "php"
: "php@\(version)"
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
public static func openGenericPhpConfigFolder() {
@@ -94,37 +72,33 @@ class Actions {
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
}
public static func openGlobalComposerFolder() {
let file = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".composer/composer.json")
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
public static func openPhpConfigFolder(version: String) {
let files = [NSURL(fileURLWithPath: "\(Paths.etcPath)/php/\(version)/php.ini")]
NSWorkspace.shared.activateFileViewerSelecting(files as [URL])
}
public static func openGlobalComposerFolder() {
let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
public static func openValetConfigFolder() {
let file = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/valet")
let file = URL(string: "file://~/.config/valet".replacingTildeWithHomeDirectory)!
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
public static func openPhpMonitorConfigFile() {
let file = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/phpmon")
let file = URL(string: "file://~/.config/phpmon".replacingTildeWithHomeDirectory)!
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
// MARK: - Other Actions
public static func createTempPhpInfoFile() -> URL {
// Write a file called `phpmon_phpinfo.php` to /tmp
try! "<?php phpinfo();".write(toFile: "/tmp/phpmon_phpinfo.php", atomically: true, encoding: .utf8)
public static func createTempPhpInfoFile() async -> URL {
try! FileSystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
// Tell php-cgi to run the PHP and output as an .html file
Shell.run("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
await Shell.quiet("\(Paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
}
@@ -143,12 +117,10 @@ class Actions {
If this does not solve the issue, the user may need to install additional
extensions and/or run `composer global update`.
*/
public static func fixMyValet(completed: @escaping () -> Void) {
InternalSwitcher().performSwitch(to: PhpEnv.brewPhpVersion, completion: {
brew("services restart dnsmasq", sudo: true)
brew("services restart php", sudo: true)
brew("services restart nginx", sudo: true)
completed()
})
public static func fixMyValet() async {
await InternalSwitcher().performSwitch(to: PhpEnv.brewPhpAlias)
await brew("services restart \(Homebrew.Formulae.dnsmasq)", sudo: Homebrew.Formulae.dnsmasq.elevated)
await brew("services restart \(Homebrew.Formulae.php)", sudo: Homebrew.Formulae.php.elevated)
await brew("services restart \(Homebrew.Formulae.nginx)", sudo: Homebrew.Formulae.nginx.elevated)
}
}

View File

@@ -9,46 +9,36 @@ import Cocoa
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.
If the installed version is older, a notification will be shown
every time the app launches (with a recommendation to upgrade).
The minimum requirement is currently synced to PHP 8.1 compatibility.
See also: https://github.com/laravel/valet/releases/tag/v2.16.2
See also: https://github.com/laravel/valet/releases/tag/v3.1.10
*/
static let MinimumRecommendedValetVersion = "2.16.2"
static let MinimumRecommendedValetVersion = "3.1.10"
/**
* The PHP versions supported by this application.
* Versions that do not appear in this array are omitted from the list.
* Depends on what version of Valet is installed.
*/
static let SupportedPhpVersions = [
// ====================
// STABLE RELEASES
// ====================
// Versions of PHP that are stable and are supported.
"5.6", // only supported when Valet 2.x is active
"7.0",
"7.1",
"7.2",
"7.3",
"7.4",
"8.0",
"8.1",
// ====================
// EXPERIMENTAL SUPPORT
// ====================
// Every release that supports the next release will always support the next
// dev release. In this case, that means that the version below is detected.
"8.2"
static let ValetSupportedPhpVersionMatrix = [
2: // Valet v2 has the broadest legacy support
[
"5.6",
"7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2"
],
3: // Valet v3 dropped support for v5.6
[
"7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2", "8.3"
],
4: // Valet v4 dropped support for <v7.4
[
"7.4",
"8.0", "8.1", "8.2", "8.3"
]
]
struct Urls {

View File

@@ -11,41 +11,49 @@
/**
Runs a `valet` command. Defaults to running as superuser.
*/
func valet(_ command: String, sudo: Bool = true) -> String {
return Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)", requiresPath: true)
func valet(_ command: String, sudo: Bool = true) async -> String {
return await Shell.pipe("\(sudo ? "sudo " : "")" + "\(Paths.valet) \(command)").out
}
/**
Runs a `brew` command. Can run as superuser.
*/
func brew(_ command: String, sudo: Bool = false) {
Shell.run("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
func brew(_ command: String, sudo: Bool = false) async {
await Shell.quiet("\(sudo ? "sudo " : "")" + "\(Paths.brew) \(command)")
}
/**
Runs `sed` in order to replace all occurrences of a string in a specific file with another.
*/
func sed(file: String, original: String, replacement: String) {
func sed(file: String, original: String, replacement: String) async {
// Escape slashes (or `sed` won't work)
let e_original = original.replacingOccurrences(of: "/", with: "\\/")
let e_replacement = replacement.replacingOccurrences(of: "/", with: "\\/")
// Check if gsed exists; it is able to follow symlinks,
// which we want to do to toggle the extension
if Filesystem.fileExists("\(Paths.binPath)/gsed") {
Shell.run("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
if FileSystem.fileExists("\(Paths.binPath)/gsed") {
await Shell.quiet("\(Paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
} else {
Shell.run("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
await Shell.quiet("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
}
}
/**
Uses `grep` to determine whether a particular query string can be found in a particular file.
*/
func grepContains(file: String, query: String) -> Bool {
return Shell.pipe("""
func grepContains(file: String, query: String) async -> Bool {
return await Shell.pipe("""
grep -q '\(query)' \(file); [ $? -eq 0 ] && echo "YES" || echo "NO"
""")
""").out
.trimmingCharacters(in: .whitespacesAndNewlines)
.contains("YES")
}
/**
Attempts to introduce sleep for a particular duration. Use with caution.
Only intended for testing purposes.
*/
func delay(seconds: Double) async {
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}

View 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)
}
}

View File

@@ -17,11 +17,15 @@ public class Paths {
internal var baseDir: Paths.HomebrewDir
private var userName: String
private var userName: String! = nil
init() {
baseDir = App.architecture != "x86_64" ? .opt : .usr
userName = String(Shell.pipe("id -un").split(separator: "\n")[0])
}
public func loadUser() async {
let output = await Shell.pipe("id -un").out
userName = String(output.split(separator: "\n")[0])
}
public func detectBinaryPaths() {
@@ -58,7 +62,16 @@ public class Paths {
}
public static var homePath: String {
return NSHomeDirectory()
if FileSystem is RealFileSystem {
return NSHomeDirectory()
}
if FileSystem is TestableFileSystem {
let fs = FileSystem as! TestableFileSystem
return fs.homeDirectory
}
fatalError("A valid FileSystem must be allowed to return the home path")
}
public static var cellarPath: String {
@@ -82,9 +95,9 @@ public class Paths {
// (PHP Monitor will not use the user's own PATH)
private func detectComposerBinary() {
if Filesystem.fileExists("/usr/local/bin/composer") {
if FileSystem.fileExists("/usr/local/bin/composer") {
Paths.composer = "/usr/local/bin/composer"
} else if Filesystem.fileExists("/opt/homebrew/bin/composer") {
} else if FileSystem.fileExists("/opt/homebrew/bin/composer") {
Paths.composer = "/opt/homebrew/bin/composer"
} else {
Paths.composer = nil

View File

@@ -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) ?? ""
}
}

View File

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

View 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
}
}

View 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
}
}
}

View File

@@ -7,15 +7,38 @@
import Foundation
import SwiftUI
struct Localization {
static var bundle: Bundle = {
if !isRunningTests {
return Bundle.main
}
let foundBundle = Bundle(identifier: "com.nicoverbruggen.phpmon.dev")
?? Bundle(identifier: "com.nicoverbruggen.phpmon")
?? Bundle(identifier: "com.nicoverbruggen.phpmon.ui-tests")
if foundBundle == nil {
let bundles = Bundle.allBundles
.map { $0.bundleIdentifier }
.filter { $0 != nil }
.map { $0! }
fatalError("The following bundles were found: \(bundles)")
}
return foundBundle!
}()
}
extension String {
var localized: String {
if #available(macOS 13, *) {
return NSLocalizedString(
self, tableName: nil, bundle: Bundle.main, value: "", comment: ""
self, tableName: nil, bundle: Localization.bundle, value: "", comment: ""
).replacingOccurrences(of: "Preferences", with: "Settings")
}
return NSLocalizedString(self, tableName: nil, bundle: Bundle.main, value: "", comment: "")
return NSLocalizedString(self, tableName: nil, bundle: Localization.bundle, value: "", comment: "")
}
var localizedForSwiftUI: LocalizedStringKey {
@@ -42,6 +65,16 @@ extension String {
return count
}
func matches(pattern: String) -> Bool {
let pred = NSPredicate(format: "self LIKE %@", pattern)
return !NSArray(object: self).filtered(using: pred).isEmpty
}
static func random(_ length: Int) -> String {
let characters = "0123456789abcdefghijklmnopqrstuvwxyz"
return String((0..<length).map { _ in characters.randomElement()! })
}
subscript(r: Range<String.Index>) -> String {
let start = r.lowerBound
let end = r.upperBound
@@ -98,5 +131,4 @@ extension String {
return ""
}
}
}

View 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)
}
}

View 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()
}
}

View 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
}

View 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
}
}
}

View File

@@ -13,8 +13,8 @@ class Alert {
onWindow window: NSWindow,
messageText: String,
informativeText: String,
buttonTitle: String = "OK",
secondButtonTitle: String = "Cancel",
buttonTitle: String = "generic.ok".localized,
secondButtonTitle: String = "generic.cancel".localized,
style: NSAlert.Style = .warning,
onFirstButtonPressed: @escaping (() -> Void)
) {

View File

@@ -34,30 +34,45 @@ class Application {
(This will open the app if it isn't open yet.)
*/
@objc public func openDirectory(file: String) {
return Shell.run("/usr/bin/open -a \"\(name)\" \"\(file)\"")
Task { await Shell.quiet("/usr/bin/open -a \"\(name)\" \"\(file)\"") }
}
/** Checks if the app is installed. */
func isInstalled() -> Bool {
// If this script does not complain, the app exists!
return Shell.user.executeSynchronously(
func isInstalled() async -> Bool {
let (process, output) = try! await Shell.attach(
"/usr/bin/open -Ra \"\(name)\"",
requiresPath: false
).task.terminationStatus == 0
didReceiveOutput: { _, _ in },
withTimeout: 2.0
)
if Shell is TestableShell {
// When testing, check the error output (must not be empty)
return !output.hasError
} else {
// If this script does not complain, the app exists!
return process.terminationStatus == 0
}
}
/**
Detect which apps are available to open a specific directory.
*/
static public func detectPresetApplications() -> [Application] {
return [
static public func detectPresetApplications() async -> [Application] {
var detected: [Application] = []
let detectable = [
Application("PhpStorm", .editor),
Application("Visual Studio Code", .editor),
Application("Sublime Text", .editor),
Application("Sublime Merge", .git_gui),
Application("iTerm", .terminal)
].filter {
return $0.isInstalled()
]
for app in detectable where await app.isInstalled() {
detected.append(app)
}
return detected
}
}

View File

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

View File

@@ -30,7 +30,7 @@ class PMWindowController: NSWindowController, NSWindowDelegate {
}
deinit {
Log.perf("Window controller '\(windowName)' was deinitialized")
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
}

View 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
}

View 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")
}

View File

@@ -17,11 +17,12 @@ import Foundation
Using `version.short` is advisable if you want to interact with Homebrew.
*/
class ActivePhpInstallation {
var version: Version!
var version: VersionNumber!
var limits: Limits!
var iniFiles: [PhpConfigurationFile] = []
var hasErrorState: Bool = false
var extensions: [PhpExtension] {
return iniFiles.flatMap { initFile in
return initFile.extensions
@@ -31,20 +32,25 @@ class ActivePhpInstallation {
// MARK: - Computed
var formula: String {
return (version.short == PhpEnv.brewPhpVersion) ? "php" : "php@\(version.short)"
return (version.short == PhpEnv.brewPhpAlias) ? "php" : "php@\(version.short)"
}
// MARK: - Initializer
init() {
// Show information about the current version
getVersion()
do {
try determineVersion()
} catch {
// TODO: In future versions of PHP Monitor, this should not crash
fatalError("Could not determine or parse PHP version; aborting")
}
// Initialize the list of ini files that are loaded
iniFiles = []
// If an error occurred, exit early
if version.error {
if self.hasErrorState {
limits = Limits()
return
}
@@ -64,10 +70,14 @@ class ActivePhpInstallation {
)
// Return a list of .ini files parsed after php.ini
let paths = Command.execute(path: Paths.php, arguments: ["-r", "echo php_ini_scanned_files();"])
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
let paths = Command.execute(
path: Paths.php,
arguments: ["-r", "echo php_ini_scanned_files();"],
trimNewlines: false
)
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
// See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in
@@ -81,26 +91,12 @@ class ActivePhpInstallation {
When the app tries to retrieve the version, the installation is considered broken if the output is nothing,
_or_ if the output contains the word "Warning" or "Error". In normal situations this should not be the case.
*/
private func getVersion() {
self.version = Version()
private func determineVersion() throws {
let output = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
let version = Command.execute(path: Paths.phpConfig, arguments: ["--version"], trimNewlines: true)
self.hasErrorState = (output == "" || output.contains("Warning") || output.contains("Error"))
if version == "" || version.contains("Warning") || version.contains("Error") {
self.version.short = "💩 BROKEN"
self.version.long = ""
self.version.error = true
return
}
// That's the long version
self.version.long = version
// Next up, let's strip away the minor version number
let segments = self.version.long.components(separatedBy: ".")
// Get the first two elements
self.version.short = segments[0...1].joined(separator: ".")
self.version = try? VersionNumber.parse(output)
}
/**
@@ -119,7 +115,7 @@ class ActivePhpInstallation {
- Parameter key: The key of the `ini` value that needs to be retrieved. For example, you can use `memory_limit`.
*/
private func getByteCount(key: String) -> String {
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"])
let value = Command.execute(path: Paths.php, arguments: ["-r", "echo ini_get('\(key)');"], trimNewlines: false)
// Check if the value is unlimited
if value == "-1" {
@@ -139,31 +135,20 @@ class ActivePhpInstallation {
versions of PHP, we can just check for the existence of the `valet-fpm.conf` file. If the check here fails,
that means that Valet won't work properly.
*/
func checkPhpFpmStatus() -> Bool {
func checkPhpFpmStatus() async -> Bool {
if self.version.short == "5.6" {
// The main PHP config file should contain `valet.sock` and then we're probably fine?
let fileName = "\(Paths.etcPath)/php/5.6/php-fpm.conf"
return Shell.pipe("cat \(fileName)").contains("valet.sock")
return await Shell.pipe("cat \(fileName)").out
.contains("valet.sock")
}
// Make sure to check if valet-fpm.conf exists. If it does, we should be fine :)
return Filesystem.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
return FileSystem.fileExists("\(Paths.etcPath)/php/\(self.version.short)/php-fpm.d/valet-fpm.conf")
}
// MARK: - Structs
/**
Struct containing information about the version number of the current PHP installation.
Also includes information about whether the install is considered "broken" or not.
If an error was found in the terminal output, `error` is set to `true` and the installation
can be considered broken. (The app will display this as well.)
*/
struct Version {
var short = "???"
var long = "???"
var error = false
}
/**
Struct containing information about the limits of the current PHP installation.
Includes: memory limit, max upload size and max post size.

View File

@@ -8,7 +8,7 @@
import Foundation
struct HomebrewService: Decodable, Equatable {
final class HomebrewService: Sendable, Decodable {
let name: String
let service_name: String
let running: Bool
@@ -19,10 +19,32 @@ struct HomebrewService: Decodable, Equatable {
let log_path: String?
let error_log_path: String?
init(
name: String,
service_name: String,
running: Bool,
loaded: Bool,
pid: Int? = nil,
user: String? = nil,
status: String? = nil,
log_path: String? = nil,
error_log_path: String? = nil
) {
self.name = name
self.service_name = service_name
self.running = running
self.loaded = loaded
self.pid = pid
self.user = user
self.status = status
self.log_path = log_path
self.error_log_path = error_log_path
}
/**
Dummy data for preview purposes.
*/
public static func dummy(named service: String, enabled: Bool) -> Self {
public static func dummy(named service: String, enabled: Bool) -> HomebrewService {
return HomebrewService(
name: service,
service_name: service,

View File

@@ -14,15 +14,17 @@ class PhpEnv {
init() {
self.currentInstall = ActivePhpInstallation()
}
let brewPhpAlias = Shell.pipe("\(Paths.brew) info php --json")
func determinePhpAlias() async {
let brewPhpAlias = await Shell.pipe("\(Paths.brew) info php --json").out
self.homebrewPackage = try! JSONDecoder().decode(
[HomebrewPackage].self,
from: brewPhpAlias.data(using: .utf8)!
).first!
Log.info("When on your system, the `php` formula means version \(homebrewPackage.version)!")
Log.info("[BREW] On your system, the `php` formula means version \(homebrewPackage.version)!")
}
// MARK: - Properties
@@ -43,7 +45,7 @@ class PhpEnv {
var cachedPhpInstallations: [String: PhpInstallation] = [:]
/** Information about the currently linked PHP installation. */
var currentInstall: ActivePhpInstallation
var currentInstall: ActivePhpInstallation!
/**
The version that the `php` formula via Brew is aliased to on the current system.
@@ -54,7 +56,9 @@ class PhpEnv {
As such, we take that information from Homebrew.
*/
static var brewPhpVersion: String {
static var brewPhpAlias: String {
if Homebrew.fake { return "8.2" }
return Self.shared.homebrewPackage.version
}
@@ -76,17 +80,20 @@ class PhpEnv {
return InternalSwitcher()
}
public static func detectPhpVersions() {
_ = Self.shared.detectPhpVersions()
public static func detectPhpVersions() async {
_ = await Self.shared.detectPhpVersions()
}
/**
Detects which versions of PHP are installed.
*/
public func detectPhpVersions() -> [String] {
let files = Shell.pipe("ls \(Paths.optPath) | grep php@")
public func detectPhpVersions() async -> [String] {
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
// The user may have `php` installed, but not e.g. `php@8.0`
@@ -94,7 +101,7 @@ class PhpEnv {
let phpAlias = homebrewPackage.version
// 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)
}
@@ -122,17 +129,11 @@ class PhpEnv {
*/
public func extractPhpVersions(
from versions: [String],
supported: [String],
checkBinaries: Bool = true,
generateHelpers: Bool = true
) -> [String] {
) async -> [String] {
var output: [String] = []
var supported = Constants.SupportedPhpVersions
if !Valet.enabled(feature: .supportForPhp56) {
supported.removeAll { $0 == "5.6" }
}
versions.filter { (version) -> Bool in
// Omit everything that doesn't start with php@
// (e.g. something-php@8.0 won't be detected)
@@ -143,19 +144,21 @@ class PhpEnv {
// is supported and where the binary exists (avoids broken installs)
if !output.contains(version)
&& supported.contains(version)
&& (checkBinaries ? Filesystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
&& (checkBinaries ? FileSystem.fileExists("\(Paths.optPath)/php@\(version)/bin/php") : true) {
output.append(version)
}
}
if generateHelpers {
output.forEach { PhpHelper.generate(for: $0) }
for item in output {
await PhpHelper.generate(for: item)
}
}
return output
}
public func validVersions(for constraint: String) -> [PhpVersionNumber] {
public func validVersions(for constraint: String) -> [VersionNumber] {
constraint.split(separator: "|").flatMap {
return PhpVersionNumberCollection
.make(from: self.availablePhpVersions)
@@ -169,6 +172,9 @@ class PhpEnv {
public func validate(_ version: String) -> Bool {
if self.currentInstall.version.short == version {
Log.info("Switching to version \(version) seems to have succeeded. Validation passed.")
Log.info("Keeping track that this is the new version!")
Stats.persistCurrentGlobalPhpVersion(version: version)
return true
}

View File

@@ -12,7 +12,7 @@ class PhpHelper {
static let keyPhrase = "This file was automatically generated by PHP Monitor."
public static func generate(for version: String) {
public static func generate(for version: String) async {
// Take the PHP version (e.g. "7.2") and generate a dotless version
let dotless = version.replacingOccurrences(of: ".", with: "")
@@ -20,79 +20,82 @@ class PhpHelper {
let destination = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
// Check if the ~/.config/phpmon/bin directory is in the PATH
let inPath = Shell.user.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
let inPath = Shell.PATH.contains("\(Paths.homePath)/.config/phpmon/bin")
// Check if we can create symlinks (`/usr/local/bin` must be writable)
let canWriteSymlinks = FileManager.default.isWritableFile(atPath: "/usr/local/bin/")
let canWriteSymlinks = FileSystem.isWriteableFile("/usr/local/bin/")
do {
Shell.run("mkdir -p ~/.config/phpmon/bin")
if FileManager.default.fileExists(atPath: destination) {
let contents = try String(contentsOfFile: destination)
if !contents.contains(keyPhrase) {
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
+ "(or is unreadable). Not updating this file.")
return
}
}
// Let's follow the symlink to the PHP binary folder
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
.resolvingSymlinksInPath().path
// The contents of the script!
let script = """
#!/bin/zsh
# \(keyPhrase)
# It reflects the location of PHP \(version)'s binaries on your system.
# Usage: . pm\(dotless)
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
export PATH=\(path):$PATH
"""
// Write to the destination
try script.write(
to: URL(fileURLWithPath: destination),
atomically: true,
encoding: String.Encoding.utf8
)
// Make sure the file is executable
Shell.run("chmod +x \(destination)")
// Create a symlink if the folder is not in the PATH
if !inPath {
// First, check if we can create symlinks at all
if !canWriteSymlinks {
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
return
Task { // Create the appropriate folders and check if the files exist
do {
if !FileSystem.directoryExists("~/.config/phpmon/bin") {
try FileSystem.createDirectory(
"~/.config/phpmon/bin",
withIntermediateDirectories: true
)
}
// Write the symlink
self.createSymlink(dotless)
if FileSystem.fileExists(destination) {
let contents = try String(contentsOfFile: destination)
if !contents.contains(keyPhrase) {
Log.info("The file at '\(destination)' already exists and was not generated by PHP Monitor "
+ "(or is unreadable). Not updating this file.")
return
}
}
// Let's follow the symlink to the PHP binary folder
let path = URL(fileURLWithPath: "\(Paths.optPath)/php@\(version)/bin")
.resolvingSymlinksInPath().path
// The contents of the script!
let script = """
#!/bin/zsh
# \(keyPhrase)
# It reflects the location of PHP \(version)'s binaries on your system.
# Usage: . pm\(dotless)
[[ $ZSH_EVAL_CONTEXT =~ :file$ ]] \\
&& echo "PHP Monitor has enabled this terminal to use PHP \(version)." \\
|| echo "You must run '. pm\(dotless)' (or 'source pm\(dotless)') instead!";
export PATH=\(path):$PATH
"""
try FileSystem.writeAtomicallyToFile(destination, content: script)
if !FileSystem.isExecutableFile(destination) {
try FileSystem.makeExecutable(destination)
}
// Create a symlink if the folder is not in the PATH
if !inPath {
// First, check if we can create symlinks at all
if !canWriteSymlinks {
Log.err("PHP Monitor does not have permission to symlink `/usr/local/bin/\(dotless)`.")
return
}
// Write the symlink
await self.createSymlink(dotless)
}
} catch {
Log.err(error)
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
}
} catch {
print(error)
Log.err("Could not write PHP Monitor helper for PHP \(version) to \(destination))")
}
}
private static func createSymlink(_ dotless: String) {
private static func createSymlink(_ dotless: String) async {
let source = "\(Paths.homePath)/.config/phpmon/bin/pm\(dotless)"
let destination = "/usr/local/bin/pm\(dotless)"
if !Filesystem.fileExists(destination) {
if !FileSystem.fileExists(destination) {
Log.info("Creating new symlink: \(destination)")
Shell.run("ln -s \(source) \(destination)")
await Shell.quiet("ln -s \(source) \(destination)")
return
}
if !Filesystem.fileIsSymlink(destination) {
if !FileSystem.isSymlink(destination) {
Log.info("Overwriting existing file with new symlink: \(destination)")
Shell.run("ln -fs \(source) \(destination)")
await Shell.quiet("ln -fs \(source) \(destination)")
return
}

View File

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

View 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 []
}
}

View 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
}
}

View File

@@ -35,7 +35,7 @@ class PhpConfigurationFile: CreatedFromFile {
let path = filePath.replacingOccurrences(of: "~", with: Paths.homePath)
do {
let fileContents = try String(contentsOfFile: path)
let fileContents = try FileSystem.getStringFromFile(path)
return Self.init(path: path, contents: fileContents)
} catch {
Log.warn("Could not read the PHP configuration file at: `\(filePath)`")

View File

@@ -75,16 +75,23 @@ class PhpExtension {
This simply toggles the extension in the .ini file.
You may need to restart the other services in order for this change to apply.
*/
func toggle() {
func toggle() async {
let newLine = enabled
// DISABLED: Commented out line
? "; \(line)"
// ENABLED: Line where the comment delimiter (;) is removed
: line.replacingOccurrences(of: "; ", with: "")
sed(file: file, original: line, replacement: newLine)
await sed(file: file, original: line, replacement: newLine)
enabled.toggle()
if !isRunningTests {
Task { @MainActor in
MainMenu.shared.rebuild()
}
}
}
// MARK: - Static Methods

View File

@@ -10,7 +10,7 @@ import Foundation
class PhpInstallation {
var versionNumber: PhpVersionNumber
var versionNumber: VersionNumber
/**
In order to determine details about a PHP installation, well simply run `php-config --version`
@@ -19,17 +19,18 @@ class PhpInstallation {
init(_ version: String) {
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config"
self.versionNumber = PhpVersionNumber.make(from: version)!
self.versionNumber = VersionNumber.make(from: version)!
if Filesystem.fileExists(phpConfigExecutablePath) {
if FileSystem.fileExists(phpConfigExecutablePath) {
let longVersionString = Command.execute(
path: phpConfigExecutablePath,
arguments: ["--version"]
arguments: ["--version"],
trimNewlines: false
).trimmingCharacters(in: .whitespacesAndNewlines)
// The parser should always work, or the string has to be very unusual.
// If so, the app SHOULD crash, so that the users report what's up.
self.versionNumber = try! PhpVersionNumber.parse(longVersionString)
self.versionNumber = try! VersionNumber.parse(longVersionString)
}
}

View File

@@ -20,44 +20,45 @@ class InternalSwitcher: PhpSwitcher {
the version that is switched to may or may not be identical to `php`
(without @version).
*/
func performSwitch(to version: String, completion: @escaping () -> Void) {
func performSwitch(to version: String) async {
Log.info("Switching to \(version), unlinking all versions...")
let versions = getVersionsToBeHandled(version)
let group = DispatchGroup()
PhpEnv.shared.availablePhpVersions.forEach { (available) in
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
self.disableDefaultPhpFpmPool(available)
self.stopPhpVersion(available)
group.leave()
await withTaskGroup(of: String.self, body: { group in
for available in PhpEnv.shared.availablePhpVersions {
group.addTask {
await self.disableDefaultPhpFpmPool(available)
await self.stopPhpVersion(available)
return available
}
}
}
group.notify(queue: .global(qos: .userInitiated)) {
Log.info("All versions have been unlinked!")
Log.info("Linking the new version!")
var unlinked: [String] = []
for await version in group {
unlinked.append(version)
}
Log.info("These versions have been unlinked: \(unlinked)")
Log.info("Linking the new version \(version)!")
for formula in versions {
self.startPhpVersion(formula, primary: (version == formula))
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
await self.startPhpVersion(formula, primary: (version == formula))
}
Log.info("Restarting nginx, just to be sure!")
brew("services restart nginx", sudo: true)
await brew("services restart nginx", sudo: true)
Log.info("The new version(s) have been linked!")
completion()
}
})
}
func getVersionsToBeHandled(_ primary: String) -> Set<String> {
let isolated = Valet.shared.sites.filter { site in
site.isolatedPhpVersion != nil
}.map { site in
return site.isolatedPhpVersion!.versionNumber.homebrewVersion
return site.isolatedPhpVersion!.versionNumber.short
}
var versions: Set<String> = [primary]
@@ -71,22 +72,22 @@ class InternalSwitcher: PhpSwitcher {
func requiresDisablingOfDefaultPhpFpmPool(_ version: String) -> Bool {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
return FileManager.default.fileExists(atPath: pool)
return FileSystem.fileExists(pool)
}
func disableDefaultPhpFpmPool(_ version: String) {
func disableDefaultPhpFpmPool(_ version: String) async {
let pool = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
if FileManager.default.fileExists(atPath: pool) {
if FileSystem.fileExists(pool) {
Log.info("A default `www.conf` file was found in the php-fpm.d directory for PHP \(version).")
let existing = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf")!
let new = URL(string: "file://\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon")!
let existing = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf"
let new = "\(Paths.etcPath)/php/\(version)/php-fpm.d/www.conf.disabled-by-phpmon"
do {
if FileManager.default.fileExists(atPath: new.path) {
if FileSystem.fileExists(new) {
Log.info("A moved `www.conf.disabled-by-phpmon` file was found for PHP \(version), "
+ "cleaning up so the newer `www.conf` can be moved again.")
try FileManager.default.removeItem(at: new)
try FileSystem.remove(new)
}
try FileManager.default.moveItem(at: existing, to: new)
try FileSystem.move(from: existing, to: new)
Log.info("Success: A default `www.conf` file was disabled for PHP \(version).")
} catch {
Log.err(error)
@@ -94,28 +95,28 @@ class InternalSwitcher: PhpSwitcher {
}
}
func stopPhpVersion(_ version: String) {
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
brew("unlink \(formula)")
brew("services stop \(formula)", sudo: true)
func stopPhpVersion(_ version: String) async {
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
await brew("unlink \(formula)")
await brew("services stop \(formula)", sudo: true)
Log.info("Unlinked and stopped services for \(formula)")
}
func startPhpVersion(_ version: String, primary: Bool) {
let formula = (version == PhpEnv.brewPhpVersion) ? "php" : "php@\(version)"
func startPhpVersion(_ version: String, primary: Bool) async {
let formula = (version == PhpEnv.brewPhpAlias) ? "php" : "php@\(version)"
if primary {
Log.info("\(formula) is the primary formula, linking and starting services...")
brew("link \(formula) --overwrite --force")
await brew("link \(formula) --overwrite --force")
} else {
Log.info("\(formula) is an isolated PHP version, starting services only...")
}
brew("services start \(formula)", sudo: true)
await brew("services start \(formula)", sudo: true)
if Valet.enabled(feature: .isolatedSites) && primary {
let socketVersion = version.replacingOccurrences(of: ".", with: "")
Shell.run("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
await Shell.quiet("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
}

View File

@@ -18,6 +18,6 @@ protocol PhpSwitcherDelegate: AnyObject {
protocol PhpSwitcher {
func performSwitch(to version: String, completion: @escaping () -> Void)
func performSwitch(to version: String) async
}

View 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()
}
}

View 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()
})
}
}

View 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
}

View 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]!
}
}

View 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)!
)
}
}

View 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
}

View 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)
}
}

View File

@@ -16,7 +16,7 @@
<p><b>Do you enjoy using the app?</b> Leave a <a href="https://phpmon.app/github">star on GitHub</a>!</p>
<p><b>Having issues?</b> Consult the <a href="https://phpmon.app/faq">FAQ</a> section, I did my best to ensure everything is documented.</p>
<p><b>Want to support further development of PHP Monitor?</b> You can <a href="https://phpmon.app/sponsor">financially support</a> the continued development of this app.</p>
<p><b>Get the latest on Twitter</b> Give me a <a href="https://twitter.com/nicoverbruggen">follow on Twitter</a> to learn about the latest and greatest updates of this app.</p>
<p><b>Get the latest on Mastodon.</b> Give me a <a href="https://phpc.social/@nicoverbruggen">follow on Mastodon</a> to learn about what's brewing and when new updates drop.</p>
<br>
</body>
</html>

View File

@@ -31,10 +31,18 @@ class App {
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
}
/** Just the bundle name. */
static var identifier: String {
Bundle.main.infoDictionary?["CFBundleIdentifier"] as! String
}
/** The system architecture. Paths differ based on this value. */
static var architecture: String {
if fakeArchitecture != nil { return fakeArchitecture! }
var systeminfo = utsname()
uname(&systeminfo)
let machine = withUnsafeBytes(of: &systeminfo.machine) {bufPtr->String in
let machine = withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
let data = Data(bufPtr)
if let lastIndex = data.lastIndex(where: {$0 != 0}) {
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
@@ -45,8 +53,18 @@ class App {
return machine
}
/**
A fake architecture.
When set, the real machine's system architecture is not used,
but this fixed value is used instead.
*/
static var fakeArchitecture: String?
// MARK: Variables
/** Technical information about the current environment. */
var environment = EnvironmentManager()
/** The list of preferences that are currently active. */
var preferences: [PreferenceName: Bool]!
@@ -65,9 +83,6 @@ class App {
/** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = []
/** The services manager, responsible for figuring out what services are active/inactive. */
var services = ServicesManager.shared
/** The warning manager, responsible for keeping track of warnings. */
var warnings = WarningManager.shared

View File

@@ -20,8 +20,7 @@ extension AppDelegate {
Please note that PHP Monitor needs to be running in the background for this to work.
*/
func application(_ application: NSApplication, open urls: [URL]) {
@MainActor func application(_ application: NSApplication, open urls: [URL]) {
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
Log.info("Acting on commands via phpmon:// has been disabled.")
return

View File

@@ -33,15 +33,17 @@ extension AppDelegate {
}
@IBAction func reloadDomainListPressed(_ sender: Any) {
let vc = App.shared.domainListWindowController?
.window?.contentViewController as? DomainListVC
Task { // Reload domains
let vc = App.shared.domainListWindowController?
.window?.contentViewController as? DomainListVC
if vc != nil {
// If the view exists, directly reload the list of sites
vc!.reloadDomains()
} else {
// If the view does not exist, reload the cached data that was populated when the app initially launched.
Valet.shared.reloadSites()
if vc != nil {
// If the view exists, directly reload the list of sites.
await vc!.reloadDomains()
} else {
// If the view does not exist, reload the cached data that was populated when the app launched.
await Valet.shared.reloadSites()
}
}
}

View File

@@ -13,13 +13,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// MARK: - Variables
/**
The Shell singleton that keeps track of the history of all
(invoked by PHP Monitor) shell commands. It is used to
invoke all commands in this application.
*/
let sharedShell: Shell
/**
The App singleton contains information about the state of
the application and global variables.
@@ -64,18 +57,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
*/
override init() {
logger.verbosity = .info
#if DEBUG
// logger.verbosity = .performance
logger.verbosity = .performance
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
Self.initializeTestingProfile(profile.replacingOccurrences(of: "--configuration:", with: ""))
}
#endif
if CommandLine.arguments.contains("--v") {
logger.verbosity = .performance
Log.info("Extra verbose mode has been activated.")
}
Log.separator(as: .info)
Log.info("PHP MONITOR by Nico Verbruggen")
Log.info("Version \(App.version)")
Log.separator(as: .info)
self.sharedShell = Shell.user
self.state = App.shared
self.menu = MainMenu.shared
self.paths = Paths.shared
@@ -87,6 +86,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
self.phpEnvironment = PhpEnv.shared
}
static func initializeTestingProfile(_ path: String) {
Log.info("The configuration with path `\(path)` is being requested...")
TestableConfiguration.loadFrom(path: path).apply()
}
// MARK: - Lifecycle
/**
@@ -97,8 +101,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Make sure notifications will work
setupNotifications()
// Make sure the menu performs its initial checks
Task { await menu.startup() }
Task { // Make sure the menu performs its initial checks
await paths.loadUser()
await menu.startup()
}
}
}

View File

@@ -21,7 +21,7 @@ class AppUpdateChecker {
public static func retrieveVersionFromCask(
_ initiatedFromBackground: Bool = true
) -> String {
) async -> String {
let caskFile = App.version.contains("-dev")
? Constants.Urls.DevBuildCaskFile.absoluteString
: Constants.Urls.StableBuildCaskFile.absoluteString
@@ -32,14 +32,14 @@ class AppUpdateChecker {
command = "curl -s --max-time 5"
}
return Shell.pipe(
return await Shell.pipe(
"\(command) '\(caskFile)' | grep version"
)
).out
}
public static func checkIfNewerVersionIsAvailable(
initiatedFromBackground: Bool = true
) {
) async {
if initiatedFromBackground {
if !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
Log.info("Automatic updates are disabled. No check will be performed.")
@@ -49,7 +49,7 @@ class AppUpdateChecker {
Log.info("Automatic updates are enabled, a check will be performed.")
}
let versionString = retrieveVersionFromCask(initiatedFromBackground)
let versionString = await retrieveVersionFromCask(initiatedFromBackground)
guard let onlineVersion = AppVersion.from(versionString) else {
Log.err("We couldn't check for updates!")
@@ -119,13 +119,13 @@ class AppUpdateChecker {
}
private static func notifyVersionDoesNotNeedUpgrade() {
DispatchQueue.main.async {
Task { @MainActor in
BetterAlert().withInformation(
title: "updater.alerts.is_latest_version.title".localized,
subtitle: "updater.alerts.is_latest_version.subtitle".localized(App.shortVersion),
description: ""
)
.withPrimary(text: "OK")
.withPrimary(text: "generic.ok".localized)
.show()
}
}
@@ -134,7 +134,7 @@ class AppUpdateChecker {
let devSuffix = isDev ? "-dev" : ""
let command = isDev ? "brew upgrade phpmon-dev" : "brew upgrade phpmon"
DispatchQueue.main.async {
Task { @MainActor in
BetterAlert().withInformation(
title: "updater.alerts.newer_version_available.title".localized(version.humanReadable),
subtitle: "updater.alerts.newer_version_available.subtitle".localized,
@@ -160,7 +160,7 @@ class AppUpdateChecker {
}
private static func notifyAboutConnectionIssue() {
DispatchQueue.main.async {
Task { @MainActor in
BetterAlert().withInformation(
title: "updater.alerts.cannot_check_for_update.title".localized,
subtitle: "updater.alerts.cannot_check_for_update.subtitle".localized,
@@ -174,7 +174,7 @@ class AppUpdateChecker {
NSWorkspace.shared.open(Constants.Urls.GitHubReleases)
}
)
.withPrimary(text: "OK")
.withPrimary(text: "generic.ok".localized)
.show()
}
}

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="20037"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@@ -1112,17 +1112,17 @@ Gw
<objects>
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="U5U-QR-YXS">
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="kkd-UV-SnA">
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<view key="contentView" id="IXW-35-8NJ">
<rect key="frame" x="0.0" y="0.0" width="480" height="286"/>
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<rect key="frame" x="20" y="196" width="440" height="21"/>
<rect key="frame" x="20" y="196" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@@ -1149,7 +1149,7 @@ Gw
</textFieldCell>
</textField>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<rect key="frame" x="20" y="147" width="440" height="21"/>
<rect key="frame" x="20" y="147" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@@ -1176,7 +1176,7 @@ Gw
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
</box>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="4Vi-cN-ude">
<rect key="frame" x="317" y="13" width="150" height="32"/>
<rect key="frame" x="377" y="13" width="150" height="32"/>
<buttonCell key="cell" type="push" title="[i18n] Create Proxy" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="H2Z-c5-5Vk">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -1205,7 +1205,10 @@ Gw
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<rect key="frame" x="18" y="128" width="444" height="14"/>
<rect key="frame" x="18" y="128" width="504" height="14"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
</constraints>
<textFieldCell key="cell" title="[i18n] Preview text here" id="ISE-9R-ncQ">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
@@ -1223,7 +1226,7 @@ Gw
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<rect key="frame" x="18" y="60" width="444" height="28"/>
<rect key="frame" x="18" y="60" width="504" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
@@ -1239,7 +1242,7 @@ Gw
</textFieldCell>
</textField>
<textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<rect key="frame" x="131" y="23" width="180" height="14"/>
<rect key="frame" x="191" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
@@ -1293,7 +1296,7 @@ Gw
</viewController>
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="210" y="1524"/>
<point key="canvasLocation" x="220" y="1522"/>
</scene>
<!--Window Controller-->
<scene sceneID="5Gf-7O-tdA">

View 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
}

View File

@@ -21,51 +21,39 @@ class InterApp {
let action: (String) -> Void
}
static func getCommands() -> [InterApp.Action] { return [
@MainActor static func getCommands() -> [InterApp.Action] { return [
InterApp.Action(command: "list", action: { _ in
DomainListVC.show()
}),
InterApp.Action(command: "services/stop", action: { _ in
MainMenu.shared.stopValetServices()
Task { MainMenu.shared.stopValetServices() }
}),
InterApp.Action(command: "services/restart/all", action: { _ in
MainMenu.shared.restartValetServices()
Task { MainMenu.shared.restartValetServices() }
}),
InterApp.Action(command: "services/restart/nginx", action: { _ in
MainMenu.shared.restartNginx()
Task { MainMenu.shared.restartNginx() }
}),
InterApp.Action(command: "services/restart/php", action: { _ in
MainMenu.shared.restartPhpFpm()
Task { MainMenu.shared.restartPhpFpm() }
}),
InterApp.Action(command: "services/restart/dnsmasq", action: { _ in
MainMenu.shared.restartDnsMasq()
Task { MainMenu.shared.restartDnsMasq() }
}),
InterApp.Action(command: "locate/config", action: { _ in
MainMenu.shared.openActiveConfigFolder()
Task { MainMenu.shared.openActiveConfigFolder() }
}),
InterApp.Action(command: "locate/composer", action: { _ in
MainMenu.shared.openGlobalComposerFolder()
Task { MainMenu.shared.openGlobalComposerFolder() }
}),
InterApp.Action(command: "locate/valet", action: { _ in
MainMenu.shared.openValetConfigFolder()
Task { MainMenu.shared.openValetConfigFolder() }
}),
InterApp.Action(command: "phpinfo", action: { _ in
MainMenu.shared.openPhpInfo()
Task { MainMenu.shared.openPhpInfo() }
}),
InterApp.Action(command: "switch/php/", action: { version in
if PhpEnv.shared.availablePhpVersions.contains(version) {
MainMenu.shared.switchToPhpVersion(version)
} else {
DispatchQueue.main.async {
BetterAlert().withInformation(
title: "alert.php_switch_unavailable.title".localized,
subtitle: "alert.php_switch_unavailable.subtitle".localized(version)
).withPrimary(
text: "alert.php_switch_unavailable.ok".localized
).show()
}
}
Task { MainMenu.shared.switchToAnyPhpVersion(version) }
})
]}
}

View 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
}
}
}

View 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
}
}
}

View 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)
}
}
}

View 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()
}
}
}

View File

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

View File

@@ -29,7 +29,7 @@ class Startup {
// If we get here, something's gone wrong and the check has failed...
Log.info("[FAIL] \(check.name)")
showAlert(for: check)
await showAlert(for: check)
return false
}
@@ -45,29 +45,27 @@ class Startup {
- ones that require an app restart, which prompt the user to exit the app
- ones that allow the app to continue, which allow the user to retry
*/
private func showAlert(for check: EnvironmentCheck) {
DispatchQueue.main.async {
if check.requiresAppRestart {
BetterAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: check.buttonText, action: { _ in
exit(1)
}).show()
}
@MainActor private func showAlert(for check: EnvironmentCheck) {
if check.requiresAppRestart {
BetterAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: "OK")
.show()
.withPrimary(text: check.buttonText, action: { _ in
exit(1)
}).show()
}
BetterAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: "generic.ok".localized)
.show()
}
/**
@@ -75,7 +73,7 @@ class Startup {
initialized when it is done working. The switcher must be initialized on the main thread.
*/
private func initializeSwitcher() {
DispatchQueue.main.async {
Task { @MainActor in
let appDelegate = NSApplication.shared.delegate as! AppDelegate
appDelegate.initializeSwitcher()
}
@@ -88,7 +86,7 @@ class Startup {
// The Homebrew binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !FileManager.default.fileExists(atPath: Paths.brew) },
command: { return !FileSystem.fileExists(Paths.brew) },
name: "`\(Paths.brew)` exists",
titleText: "alert.homebrew_missing.title".localized,
subtitleText: "alert.homebrew_missing.subtitle".localized,
@@ -105,7 +103,7 @@ class Startup {
// The PHP binary must exist.
// =================================================================================
EnvironmentCheck(
command: { return !Filesystem.fileExists(Paths.php) },
command: { return !FileSystem.fileExists(Paths.php) },
name: "`\(Paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
@@ -115,7 +113,9 @@ class Startup {
// Make sure we can detect one or more PHP installations.
// =================================================================================
EnvironmentCheck(
command: { return !Shell.pipe("ls \(Paths.optPath) | grep php").contains("php") },
command: {
return await !Shell.pipe("ls \(Paths.optPath) | grep php").out.contains("php")
},
name: "`ls \(Paths.optPath) | grep php` returned php result",
titleText: "startup.errors.php_opt.title".localized,
subtitleText: "startup.errors.php_opt.subtitle".localized(
@@ -128,7 +128,7 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: {
return !(Filesystem.fileExists(Paths.valet) || Filesystem.fileExists("~/.composer/vendor/bin/valet"))
return !(FileSystem.fileExists(Paths.valet) || FileSystem.fileExists("~/.composer/vendor/bin/valet"))
},
name: "`valet` binary exists",
titleText: "startup.errors.valet_executable.title".localized,
@@ -143,14 +143,14 @@ class Startup {
// functioning correctly. Let the user know that they need to run `valet trust`.
// =================================================================================
EnvironmentCheck(
command: { return !Shell.pipe("cat /private/etc/sudoers.d/brew").contains(Paths.brew) },
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/brew").out.contains(Paths.brew) },
name: "`/private/etc/sudoers.d/brew` contains brew",
titleText: "startup.errors.sudoers_brew.title".localized,
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
descriptionText: "startup.errors.sudoers_brew.desc".localized
),
EnvironmentCheck(
command: { return !Shell.pipe("cat /private/etc/sudoers.d/valet").contains(Paths.valet) },
command: { return await !Shell.pipe("cat /private/etc/sudoers.d/valet").out.contains(Paths.valet) },
name: "`/private/etc/sudoers.d/valet` contains valet",
titleText: "startup.errors.sudoers_valet.title".localized,
subtitleText: "startup.errors.sudoers_valet.subtitle".localized,
@@ -160,7 +160,10 @@ class Startup {
// Verify if the Homebrew services are running (as root).
// =================================================================================
EnvironmentCheck(
command: { return HomebrewDiagnostics.cannotLoadService() },
command: {
await HomebrewDiagnostics.loadInstalledTaps()
return await HomebrewDiagnostics.cannotLoadService("dnsmasq")
},
name: "`sudo \(Paths.brew) services info` JSON loaded",
titleText: "startup.errors.services_json_error.title".localized,
subtitleText: "startup.errors.services_json_error.subtitle".localized,
@@ -171,7 +174,7 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: {
return !Filesystem.directoryExists("~/.config/valet")
return !FileSystem.directoryExists("~/.config/valet")
},
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
@@ -200,10 +203,10 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: {
let nodePath = await Shell.pipe("which node").out
return App.architecture == "x86_64"
&& FileManager.default.fileExists(atPath: "/usr/local/bin/which")
&& Shell.pipe("which node", requiresPath: false)
.contains("env: node: No such file or directory")
&& FileSystem.fileExists("/usr/local/bin/which")
&& nodePath.contains("env: node: No such file or directory")
},
name: "`env: node` issue does not apply",
titleText: "startup.errors.which_alias_issue.title".localized,
@@ -215,7 +218,7 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: {
return valet("--version", sudo: false)
return await Shell.pipe("valet --version").out
.contains("Composer detected issues in your platform")
},
name: "`no global composer issues",
@@ -228,7 +231,7 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: {
let output = valet("--version", sudo: false)
let output = await Shell.pipe("valet --version").out
// Failure condition #1: does not contain Laravel Valet
if !output.contains("Laravel Valet") {
return true
@@ -239,7 +242,7 @@ class Startup {
.components(separatedBy: "Laravel Valet")[1]
.trimmingCharacters(in: .whitespaces)
// Extract the version number
Valet.shared.version = VersionExtractor.from(output)
Valet.shared.version = try! VersionNumber.parse(VersionExtractor.from(output)!)
// Get the actual version
return Valet.shared.version == nil
},
@@ -247,6 +250,19 @@ class Startup {
titleText: "startup.errors.valet_version_unknown.title".localized,
subtitleText: "startup.errors.valet_version_unknown.subtitle".localized,
descriptionText: "startup.errors.valet_version_unknown.desc".localized
),
// =================================================================================
// Ensure the Valet version is supported.
// =================================================================================
EnvironmentCheck(
command: {
// We currently support Valet 2, 3 or 4. Any other version should get an alert.
return ![2, 3, 4].contains(Valet.shared.version.major)
},
name: "valet version is supported",
titleText: "startup.errors.valet_version_not_supported.title".localized,
subtitleText: "startup.errors.valet_version_not_supported.subtitle".localized,
descriptionText: "startup.errors.valet_version_not_supported.desc".localized
)
]
}

View File

@@ -65,17 +65,20 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
@IBAction func pressedCreateProxy(_ sender: Any) {
let domain = self.inputDomainName.stringValue
let proxyName = self.inputProxySubject.stringValue
let secure = self.buttonSecure.state == .on ? " --secure" : ""
let secure = (self.buttonSecure.state == .on)
dismissView(outcome: .OK)
App.shared.domainListWindowController?.contentVC.setUIBusy()
DispatchQueue.global(qos: .userInitiated).async {
Shell.run("\(Paths.valet) proxy \(domain) \(proxyName)\(secure)", requiresPath: true)
Actions.restartNginx()
Task { // Ensure we proxy the site asynchronously and reload UI on main thread again
try! await ValetInteractor.shared.proxy(
domain: domain,
proxy: proxyName,
secure: secure
)
DispatchQueue.main.async {
Task { @MainActor in
App.shared.domainListWindowController?.contentVC.setUINotBusy()
App.shared.domainListWindowController?.pressedReload(nil)
}
@@ -157,8 +160,14 @@ class AddProxyVC: NSViewController, NSTextFieldDelegate {
return
}
previewText.stringValue = "domain_list.add.proxy_available"
.localized(
var translationKey = "domain_list.add.proxy_available"
if inputProxySubject.stringValue.starts(with: "https://") {
translationKey = "domain_list.add.proxy_https_warning"
}
previewText.stringValue =
translationKey.localized(
inputProxySubject.stringValue,
buttonSecure.state == .on ? "https" : "http",
inputDomainName.stringValue,

View File

@@ -51,11 +51,11 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
// MARK: - Outlet Interactions
@IBAction func pressedCreateLink(_ sender: Any) {
func createLink() async {
let path = pathControl.url!.path
let name = inputDomainName.stringValue
if !Filesystem.exists(path) {
if !FileSystem.anyExists(path) {
Alert.confirm(
onWindow: view.window!,
messageText: "domain_list.alert.folder_missing.title".localized,
@@ -70,23 +70,28 @@ class AddSiteVC: NSViewController, NSTextFieldDelegate {
}
// Adding `valet links` is a workaround for Valet malforming the config.json file
// TODO: I will have to investigate and report this behaviour if possible
Shell.run("cd '\(path)' && \(Paths.valet) link '\(name)' && valet links", requiresPath: true)
Task {
try! await ValetInteractor.shared.link(path: path, domain: name)
dismissView(outcome: .OK)
dismissView(outcome: .OK)
// Reset search
App.shared.domainListWindowController?
.searchToolbarItem
.searchField.stringValue = ""
// Reset search
App.shared.domainListWindowController?
.searchToolbarItem
.searchField.stringValue = ""
// Add the new item and scrolls to it
App.shared.domainListWindowController?
.contentVC
.addedNewSite(
name: name,
secure: buttonSecure.state == .on
)
// Add the new item and scrolls to it
await App.shared.domainListWindowController?
.contentVC
.addedNewSite(
name: name,
secureAfterLinking: buttonSecure.state == .on
)
}
}
@IBAction func pressedCreateLink(_ sender: Any) {
Task { await createLink() }
}
@IBAction func pressedCancel(_ sender: Any) {

View File

@@ -53,13 +53,13 @@ class DomainListPhpCell: NSTableCellView, DomainListCellProtocol {
@IBAction func pressedPhpVersion(_ sender: Any) {
guard let site = self.site else { return }
var validPhpSuggestions: [PhpVersionNumber] {
var validPhpSuggestions: [VersionNumber] {
if site.isolatedPhpVersion != nil {
return []
}
return PhpEnv.shared.validVersions(for: site.composerPhp).filter({ version in
version.homebrewVersion != PhpEnv.phpInstall.version.short
version.short != PhpEnv.phpInstall.version.short
})
}

View File

@@ -11,75 +11,6 @@ import Cocoa
extension DomainListVC {
@objc func toggleSecure() {
if selected is ValetSite {
toggleSecureForSite()
} else {
toggleSecureForProxy()
}
}
func toggleSecureForProxy() {
let originalSecureStatus = selectedProxy!.secured
let selectedProxy = selectedProxy!
self.waitAndExecute {
// 1. Remove the original proxy
Shell.run("\(Paths.valet) unproxy \(selectedProxy.domain)", requiresPath: true)
// 2. Add a new proxy, which is either secured/unsecured
let secure = originalSecureStatus ? "" : " --secure"
Shell.run("\(Paths.valet) proxy \(selectedProxy.domain) \(selectedProxy.target)\(secure)",
requiresPath: true)
// 3. Restart nginx
Actions.restartNginx()
// 4. Reload site list
DispatchQueue.main.async {
App.shared.domainListWindowController?.pressedReload(nil)
}
}
}
func toggleSecureForSite() {
let rowToReload = tableView.selectedRow
let originalSecureStatus = selectedSite!.secured
let action = selectedSite!.secured ? "unsecure" : "secure"
let selectedSite = selectedSite!
let command = "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() {
guard let selected = self.selected else {
return
@@ -91,7 +22,7 @@ extension DomainListVC {
title: "domain_list.alert.invalid_folder_name".localized,
subtitle: "domain_list.alert.invalid_folder_name_desc".localized
)
.withPrimary(text: "OK")
.withPrimary(text: "generic.ok".localized)
.show()
return
}
@@ -100,11 +31,11 @@ extension DomainListVC {
}
@objc func openInFinder() {
Shell.run("open '\(selectedSite!.absolutePath)'")
Task { return await Shell.quiet("open '\(selectedSite!.absolutePath)'") }
}
@objc func openInTerminal() {
Shell.run("open -b com.apple.terminal '\(selectedSite!.absolutePath)'")
Task { await Shell.quiet("open -b com.apple.terminal '\(selectedSite!.absolutePath)'") }
}
@objc func openWithEditor(sender: EditorMenuItem) {
@@ -112,30 +43,108 @@ extension DomainListVC {
editor.openDirectory(file: selectedSite!.absolutePath)
}
// MARK: - UI interaction
private func performAction(command: String, beforeCellReload: @escaping () -> Void) {
let rowToReload = tableView.selectedRow
waitAndExecute {
await Shell.quiet(command)
} completion: { [self] in
beforeCellReload()
tableView.reloadData(forRowIndexes: [rowToReload], columnIndexes: [0, 1, 2, 3, 4])
tableView.deselectRow(rowToReload)
tableView.selectRowIndexes([rowToReload], byExtendingSelection: true)
}
}
private func reloadSelectedRow() {
tableView.reloadData(forRowIndexes: [tableView.selectedRow], columnIndexes: [0, 1, 2, 3, 4])
tableView.deselectRow(tableView.selectedRow)
tableView.selectRowIndexes([tableView.selectedRow], byExtendingSelection: true)
}
// MARK: - Interactions with `valet` or terminal
@objc func toggleSecure() {
if selected is ValetSite {
Task { await toggleSecure(site: selected as! ValetSite) }
}
if selected is ValetProxy {
Task { await toggleSecure(proxy: selected as! ValetProxy) }
}
}
func toggleSecure(proxy: ValetProxy) async {
waitAndExecute {
do {
// Recreate proxy as secure or unsecured proxy
try await proxy.toggleSecure()
// Send a notification about the new status (if applicable)
self.notifyAboutModifiedSecureStatus(domain: proxy.domain, secured: proxy.secured)
// Reload the UI (do this last so we don't invalidate the proxy)
self.reloadSelectedRow()
} catch {
// Notify the user about a failed command
let error = error as! ValetInteractionError
self.notifyAboutFailedSecureStatus(command: error.command)
}
}
}
func toggleSecure(site: ValetSite) async {
waitAndExecute {
do {
// Instruct Valet to secure or unsecure a site
try await site.toggleSecure()
// Send a notification about the new status (if applicable)
self.notifyAboutModifiedSecureStatus(domain: site.name, secured: site.secured)
// Reload the UI (do this last so we don't invalidate the site)
self.reloadSelectedRow()
} catch {
// Notify the user about a failed command
let error = error as! ValetInteractionError
self.notifyAboutFailedSecureStatus(command: error.command)
}
}
}
@objc func isolateSite(sender: PhpMenuItem) {
let command = "sudo \(Paths.valet) isolate php@\(sender.version) --site '\(self.selectedSite!.name)' && exit;"
guard let site = selectedSite else {
return
}
self.performAction(command: command) {
self.selectedSite!.determineIsolated()
self.selectedSite!.determineComposerPhpVersion()
if self.selectedSite!.isolatedPhpVersion == nil {
BetterAlert()
.withInformation(
title: "domain_list.alerts_isolation_failed.title".localized,
subtitle: "domain_list.alerts_isolation_failed.subtitle".localized,
description: "domain_list.alerts_isolation_failed.desc".localized(command)
)
.withPrimary(text: "OK")
.show()
waitAndExecute {
do {
// Instruct Valet to isolate a given PHP version
try await site.isolate(version: sender.version)
// Reload the UI
self.reloadSelectedRow()
} catch {
// Notify the user about a failed command
let error = error as! ValetInteractionError
self.notifyAboutFailedSiteIsolation(command: error.command)
}
}
}
@objc func removeIsolatedSite() {
self.performAction(command: "sudo \(Paths.valet) unisolate --site '\(self.selectedSite!.name)' && exit;") {
self.selectedSite!.isolatedPhpVersion = nil
self.selectedSite!.determineComposerPhpVersion()
guard let site = selectedSite else {
return
}
waitAndExecute {
do {
// Instruct Valet to remove isolation
try await site.unisolate()
// Reload the UI
self.reloadSelectedRow()
} catch {
// Notify the user about a failed command
let error = error as! ValetInteractionError
self.notifyAboutFailedSiteIsolation(command: error.command)
}
}
}
@@ -157,9 +166,8 @@ extension DomainListVC {
style: .critical,
onFirstButtonPressed: {
self.waitAndExecute {
Shell.run("valet unlink '\(site.name)'", requiresPath: true)
} completion: {
self.reloadDomains()
await site.unlink()
await self.reloadDomainsWithoutUI()
}
}
)
@@ -179,25 +187,75 @@ extension DomainListVC {
style: .critical,
onFirstButtonPressed: {
self.waitAndExecute {
Shell.run("valet unproxy '\(proxy.domain)'", requiresPath: true)
} completion: {
self.reloadDomains()
await proxy.remove()
await self.reloadDomainsWithoutUI()
}
}
)
}
private func performAction(command: String, beforeCellReload: @escaping () -> Void) {
let rowToReload = tableView.selectedRow
waitAndExecute {
Shell.run(command, requiresPath: true)
} completion: { [self] in
beforeCellReload()
tableView.reloadData(forRowIndexes: [rowToReload], columnIndexes: [0, 1, 2, 3, 4])
tableView.deselectRow(rowToReload)
tableView.selectRowIndexes([rowToReload], byExtendingSelection: true)
@objc func useInTerminal() {
guard let site = selectedSite else {
return
}
guard let version = site.isolatedPhpVersion?.versionNumber else {
return
}
self.notifyAboutUsingIsolatedPhpVersionInTerminal(version: version)
}
// MARK: - Alerts & Modals
private func notifyAboutModifiedSecureStatus(domain: String, secured: Bool) {
LocalNotification.send(
title: "domain_list.alerts_status_changed.title".localized,
subtitle: "domain_list.alerts_status_changed.desc"
.localized(
// 1. The domain that was secured is listed
"\(domain).\(Valet.shared.config.tld)",
// 2. What the domain is is listed (secure / unsecure)
secured
? "domain_list.alerts_status_secure".localized
: "domain_list.alerts_status_unsecure".localized
),
preference: .notifyAboutSecureToggle
)
}
private func notifyAboutUsingIsolatedPhpVersionInTerminal(version: VersionNumber) {
BetterAlert()
.withInformation(
title: "domain_list.alerts_isolated_php_terminal.title".localized(version.short),
subtitle: "domain_list.alerts_isolated_php_terminal.subtitle".localized(
"\(version.major)\(version.minor)",
version.short
),
description: "domain_list.alerts_isolated_php_terminal.desc".localized
)
.withPrimary(text: "generic.ok".localized)
.show()
}
private func notifyAboutFailedSecureStatus(command: String) {
BetterAlert()
.withInformation(
title: "domain_list.alerts_status_not_changed.title".localized,
subtitle: "domain_list.alerts_status_not_changed.desc".localized(command)
)
.withPrimary(text: "generic.ok".localized)
.show()
}
private func notifyAboutFailedSiteIsolation(command: String) {
BetterAlert()
.withInformation(
title: "domain_list.alerts_isolation_failed.title".localized,
subtitle: "domain_list.alerts_isolation_failed.subtitle".localized,
description: "domain_list.alerts_isolation_failed.desc".localized(command)
)
.withPrimary(text: "generic.ok".localized)
.show()
}
}

View File

@@ -130,6 +130,13 @@ extension DomainListVC {
menu.addItem(HeaderView.asMenuItem(text: "domain_list.site_isolation".localized))
menu.addItem(NSMenuItem(title: "domain_list.isolate".localized, submenu: items))
if site.isolatedPhpVersion != nil {
menu.addItem(NSMenuItem(
title: "domain_list.use_in_terminal".localized(site.servingPhpVersion),
action: #selector(self.useInTerminal)
))
}
menu.addItem(NSMenuItem.separator())
}

View File

@@ -19,7 +19,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
// MARK: - Variables
/// List of sites that will be displayed in this view. Originates from the `Valet` object.
var domains: [DomainListable] = []
var domains: [ValetListable] = []
/// Array that contains various apps that might open a particular site directory.
var applications: [Application] {
@@ -48,7 +48,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
return domains[tableView.selectedRow] as? ValetProxy
}
var selected: DomainListable? {
var selected: ValetListable? {
if tableView.selectedRow == -1 {
return nil
}
@@ -97,7 +97,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
domains = Valet.getDomainListable()
searchedFor(text: lastSearchedFor)
} else {
reloadDomains()
Task { await reloadDomains() }
}
}
@@ -107,10 +107,12 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
Disables the UI so the user cannot interact with it.
Also shows a spinner to indicate that we're busy.
*/
public func setUIBusy() {
@MainActor public func setUIBusy() {
// If it takes more than 0.5s to set the UI to not busy, show a spinner
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { _ in
self.progressIndicator.startAnimation(true)
Task {
@MainActor in self.progressIndicator.startAnimation(true)
}
})
tableView.alphaValue = 0.3
@@ -121,7 +123,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
/**
Re-enables the UI so the user can interact with it.
*/
public func setUINotBusy() {
@MainActor public func setUINotBusy() {
timer?.invalidate()
progressIndicator.stopAnimation(nil)
tableView.alphaValue = 1.0
@@ -136,13 +138,13 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
- Parameter execute: Callback of the work that needs to happen.
- Parameter completion: Callback that is fired when the work is done.
*/
internal func waitAndExecute(_ execute: @escaping () -> Void, completion: @escaping () -> Void = {}) {
setUIBusy()
DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
execute()
internal func waitAndExecute(_ execute: @escaping () async -> Void, completion: @escaping () -> Void = {}) {
Task { // Legacy `waitAndExecute` with UI
setUIBusy()
await execute()
// For a smoother animation, expect at least a 0.2 second delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in
Task { @MainActor in
await delay(seconds: 0.2)
completion()
setUINotBusy()
}
@@ -151,15 +153,21 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
// MARK: - Site Data Loading
func reloadDomains() {
func reloadDomains() async {
waitAndExecute {
Valet.shared.reloadSites()
await Valet.shared.reloadSites()
} completion: { [self] in
domains = Valet.shared.sites
searchedFor(text: lastSearchedFor)
}
}
func reloadDomainsWithoutUI() async {
await Valet.shared.reloadSites()
domains = Valet.shared.sites
searchedFor(text: lastSearchedFor)
}
func applySortDescriptor(_ descriptor: NSSortDescriptor) {
sortDescriptor = descriptor
@@ -177,22 +185,22 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
self.domains = descriptor.ascending ? sorted.reversed() : sorted
}
func addedNewSite(name: String, secure: Bool) {
func addedNewSite(name: String, secureAfterLinking: Bool) async {
waitAndExecute {
Valet.shared.reloadSites()
await Valet.shared.reloadSites()
} completion: { [self] in
find(name, secure)
find(name, secureAfterLinking)
}
}
private func find(_ name: String, _ secure: Bool = false) {
private func find(_ name: String, _ shouldSecure: Bool = false) {
domains = Valet.getDomainListable()
searchedFor(text: "")
if let site = domains.enumerated().first(where: { $0.element.getListableName() == name }) {
DispatchQueue.main.async {
Task { @MainActor in
self.tableView.selectRowIndexes([site.offset], byExtendingSelection: false)
self.tableView.scrollRowToVisible(site.offset)
if secure && !site.element.getListableSecured() {
if shouldSecure && !site.element.getListableSecured() {
self.toggleSecure()
}
}
@@ -258,7 +266,7 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
self.applySortDescriptor(sortDescriptor)
}
DispatchQueue.main.async {
Task { @MainActor in
self.tableView.reloadData()
}
}
@@ -292,6 +300,6 @@ class DomainListVC: NSViewController, NSTableViewDelegate, NSTableViewDataSource
// MARK: - Deinitialization
deinit {
Log.perf("DomainListVC deallocated")
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
}

View File

@@ -51,7 +51,7 @@ class DomainListWindowController: PMWindowController, NSSearchFieldDelegate, NST
// MARK: - Reload functionality
@IBAction func pressedReload(_ sender: Any?) {
contentVC.reloadDomains()
Task { await contentVC.reloadDomains() }
}
@IBAction func pressedAddLink(_ sender: Any?) {

View File

@@ -8,9 +8,7 @@
import Foundation
class ComposerWindow {
private var menu: MainMenu?
@MainActor class ComposerWindow {
private var shouldNotify: Bool! = nil
private var completion: ((Bool) -> Void)! = nil
private var window: TerminalProgressWindowController?
@@ -19,21 +17,19 @@ class ComposerWindow {
Updates the global dependencies and runs the completion callback when done.
*/
func updateGlobalDependencies(notify: Bool, completion: @escaping (Bool) -> Void) {
self.menu = MainMenu.shared
self.shouldNotify = notify
self.completion = completion
Paths.shared.detectBinaryPaths()
if Paths.composer == nil {
DispatchQueue.main.async {
self.presentMissingAlert()
}
self.presentMissingAlert()
return
}
PhpEnv.shared.isBusy = true
menu?.setBusyImage()
menu?.rebuild()
MainMenu.shared.setBusyImage()
MainMenu.shared.rebuild()
window = TerminalProgressWindowController.display(
title: "alert.composer_progress.title".localized,
@@ -42,45 +38,44 @@ class ComposerWindow {
window?.setType(info: true)
DispatchQueue.global(qos: .userInitiated).async { [self] in
let task = Shell.user.createTask(
for: "\(Paths.composer!) global update", requiresPath: true
)
Task { // Start the Composer global update as a separate task
await performComposerUpdate()
}
}
DispatchQueue.main.async {
self.window?.addToConsole("\(Paths.composer!) global update\n")
}
private func performComposerUpdate() async {
do {
try await runComposerUpdateShellCommand()
} catch {
composerUpdateFailed()
}
}
task.listen(
didReceiveStandardOutputData: { string in
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))")
}
)
private func runComposerUpdateShellCommand() async throws {
let command = "\(Paths.composer!) global update"
task.launch()
task.waitUntilExit()
task.haltListening()
self.window?.addToConsole("\(command)\n")
if task.terminationStatus <= 0 {
composerUpdateSucceeded()
} else {
composerUpdateFailed()
}
let (process, _) = try await Shell.attach(
command,
didReceiveOutput: { [weak self] (incoming, _) in
guard let window = self?.window else { return }
window.addToConsole(incoming)
},
withTimeout: .minutes(5)
)
if process.terminationStatus <= 0 {
composerUpdateSucceeded()
} else {
composerUpdateFailed()
}
}
private func composerUpdateSucceeded() {
// Closing the window should happen after a slight delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [self] in
Task { @MainActor in
await delay(seconds: 1.0)
window?.close()
if shouldNotify {
LocalNotification.send(
@@ -97,7 +92,7 @@ class ComposerWindow {
private func composerUpdateFailed() {
// Showing that something failed should be shown immediately
DispatchQueue.main.async { [self] in
Task { @MainActor [self] in
window?.setType(info: false)
window?.progressView?.labelTitle.stringValue = "alert.composer_failure.title".localized
window?.progressView?.labelDescription.stringValue = "alert.composer_failure.info".localized
@@ -111,8 +106,8 @@ class ComposerWindow {
private func removeBusyStatus() {
PhpEnv.shared.isBusy = false
DispatchQueue.main.async { [self] in
menu?.updatePhpVersionInStatusBar()
Task { @MainActor in
MainMenu.shared.updatePhpVersionInStatusBar()
}
}
@@ -125,7 +120,11 @@ class ComposerWindow {
subtitle: "alert.composer_missing.subtitle".localized,
description: "alert.composer_missing.desc".localized
)
.withPrimary(text: "OK")
.withPrimary(text: "generic.ok".localized)
.show()
}
deinit {
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
}

View File

@@ -71,7 +71,7 @@ struct PhpFrameworks {
public static func detectFallbackDependency(_ basePath: String) -> String? {
for entry in Self.FileMapping {
let found = entry.value
.map { path in return Filesystem.exists(basePath + path) }
.map { path in return FileSystem.anyExists(basePath + path) }
.contains(true)
if found {

View File

@@ -9,18 +9,23 @@
import Foundation
class HomebrewDiagnostics {
/**
Determines the Homebrew taps the user has installed.
*/
public static var installedTaps: [String] = {
return Shell
public static var installedTaps: [String] = []
/**
Load which taps are installed.
*/
public static func loadInstalledTaps() async {
installedTaps = await Shell
.pipe("\(Paths.brew) tap")
.out
.split(separator: "\n")
.map { string in
return String(string)
}
}()
}
/**
Determines whether the PHP Monitor Cask is installed.
@@ -29,6 +34,17 @@ class HomebrewDiagnostics {
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.
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.
*/
public static func checkForCaskConflict() {
if hasAliasConflict() {
public static func checkForCaskConflict() async {
if await hasAliasConflict() {
presentAlertAboutConflict()
}
}
@@ -65,19 +81,21 @@ class HomebrewDiagnostics {
}
versions.forEach { version in
switcher.disableDefaultPhpFpmPool(version)
switcher.stopPhpVersion(version)
switcher.startPhpVersion(version, primary: version == primary)
Task { // Fix each pool concurrently (but perform the tasks sequentially)
await switcher.disableDefaultPhpFpmPool(version)
await switcher.stopPhpVersion(version)
await switcher.startPhpVersion(version, primary: version == primary)
}
}
}
/**
Check if the alias conflict as documented in `checkForCaskConflict` actually occurred.
*/
private static func hasAliasConflict() -> Bool {
let tapAlias = Shell.pipe("\(Paths.brew) info shivammathur/php/php --json")
private static func hasAliasConflict() async -> Bool {
let tapAlias = await Shell.pipe("brew info shivammathur/php/php --json").out
if tapAlias.contains("brew tap shivammathur/php") || tapAlias.contains("Error") {
if tapAlias.contains("brew tap shivammathur/php") || tapAlias.contains("Error") || tapAlias.isEmpty {
Log.info("The user does not appear to have tapped: shivammathur/php")
return false
} else {
@@ -89,13 +107,13 @@ class HomebrewDiagnostics {
from: tapAlias.data(using: .utf8)!
).first!
if tapPhp.version != PhpEnv.brewPhpVersion {
if tapPhp.version != PhpEnv.brewPhpAlias {
Log.warn("The `php` formula alias seems to be the different between the tap and core. "
+ "This could be a problem!")
Log.info("Determining whether both of these versions are installed...")
let bothInstalled = PhpEnv.shared.availablePhpVersions.contains(tapPhp.version)
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpVersion)
&& PhpEnv.shared.availablePhpVersions.contains(PhpEnv.brewPhpAlias)
if bothInstalled {
Log.warn("Both conflicting aliases seem to be installed, warning the user!")
@@ -116,13 +134,13 @@ class HomebrewDiagnostics {
Show this alert in case the tapped Cask does cause issues because of the conflict.
*/
private static func presentAlertAboutConflict() {
DispatchQueue.main.async {
Task { @MainActor in
BetterAlert()
.withInformation(
title: "alert.php_alias_conflict.title".localized,
subtitle: "alert.php_alias_conflict.info".localized
)
.withPrimary(text: "OK")
.withPrimary(text: "generic.ok".localized)
.show()
}
}
@@ -131,13 +149,14 @@ class HomebrewDiagnostics {
In order to see if we support the --json syntax, we'll query nginx.
If the JSON response cannot be parsed, Homebrew is probably out of date.
*/
public static func cannotLoadService(_ name: String = "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(
[HomebrewService].self,
from: Shell.pipe(
"sudo \(Paths.brew) services info \(name) --json",
requiresPath: true
).data(using: .utf8)!
from: nginxJson.data(using: .utf8)!
)
return serviceInfo == nil

View File

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

View 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)
}
}
}

View File

@@ -1,5 +1,5 @@
//
// DomainListable.swift
// ValetListable.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 12/04/2022.
@@ -8,7 +8,7 @@
import Foundation
protocol DomainListable {
protocol ValetListable {
func getListableName() -> String

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,20 +8,30 @@
import Foundation
class ValetProxy: DomainListable {
class ValetProxy: ValetListable {
var domain: String
var tld: String
var target: String
var secured: Bool = false
init(_ configuration: NginxConfigurationFile) {
self.domain = configuration.domain
self.tld = configuration.tld
self.target = configuration.proxy!
self.secured = Filesystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
init(domain: String, target: String, secure: Bool, tld: String) {
self.domain = domain
self.tld = tld
self.target = target
self.secured = false
}
// MARK: - DomainListable Protocol
convenience init(_ configuration: NginxConfigurationFile) {
self.init(
domain: configuration.domain,
target: configuration.proxy!,
secure: false,
tld: configuration.tld
)
self.determineSecured()
}
// MARK: - ValetListable Protocol
func getListableName() -> String {
return self.domain
@@ -50,4 +60,18 @@ class ValetProxy: DomainListable {
func getListableUrl() -> URL? {
return URL(string: "\(self.secured ? "https://" : "http://")\(self.domain).\(self.tld)")
}
// MARK: - Interactions
func determineSecured() {
self.secured = FileSystem.fileExists("~/.config/valet/Certificates/\(self.domain).\(self.tld).key")
}
func toggleSecure() async throws {
try await ValetInteractor.shared.toggleSecure(proxy: self)
}
func remove() async {
try! await ValetInteractor.shared.remove(proxy: self)
}
}

View File

@@ -1,17 +1,25 @@
//
// ValetSiteScanner.swift
// DomainScanner.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 19/03/2022.
// Created by Nico Verbruggen on 02/04/2022.
// Copyright © 2022 Nico Verbruggen. All rights reserved.
//
import Foundation
protocol SiteScanner {
protocol DomainScanner {
// MARK: - Sites
func resolveSiteCount(paths: [String]) -> Int
func resolveSitesFrom(paths: [String]) -> [ValetSite]
func resolveSite(path: String) -> ValetSite?
// MARK: - Proxies
func resolveProxies(directoryPath: String) -> [ValetProxy]
}

View File

@@ -1,41 +1,54 @@
//
// FakeSiteScanner.swift
// FakeDomainScanner.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 02/04/2022.
// Copyright © 2022 Nico Verbruggen. All rights reserved.
//
class FakeSiteScanner: SiteScanner {
let fakes = [
ValetSite(fakeWithName: "laravel", tld: "test", secure: true,
class FakeDomainScanner: DomainScanner {
var sites: [ValetSite] = [
FakeValetSite(fakeWithName: "laravel", tld: "test", secure: true,
path: "~/Code/laravel/framework", linked: true),
ValetSite(fakeWithName: "tailwind", tld: "test", secure: true,
FakeValetSite(fakeWithName: "tailwind", tld: "test", secure: true,
path: "~/Code/tailwind/site", linked: true, constraint: "8.0"),
ValetSite(fakeWithName: "forge", tld: "test", secure: true,
FakeValetSite(fakeWithName: "forge", tld: "test", secure: true,
path: "~/Code/laravel/forge", linked: true),
ValetSite(fakeWithName: "concord", tld: "test", secure: false,
FakeValetSite(fakeWithName: "concord", tld: "test", secure: false,
path: "~/Code/concord", linked: true, driver: "Laravel (^8.0)", constraint: "^7.4", isolated: "7.4"),
ValetSite(fakeWithName: "drupal", tld: "test", secure: false,
FakeValetSite(fakeWithName: "drupal", tld: "test", secure: false,
path: "~/Sites/drupal", linked: false, driver: "Drupal", constraint: "^7.4", isolated: "7.4"),
ValetSite(fakeWithName: "wordpress", tld: "test", secure: false,
FakeValetSite(fakeWithName: "wordpress", tld: "test", secure: false,
path: "~/Sites/wordpress", linked: false, driver: "WordPress", constraint: "^7.4", isolated: "7.4")
]
var proxies: [ValetProxy] = [
FakeValetProxy(domain: "mailgun", target: "http://127.0.0.1:9999", secure: true, tld: "test")
]
// MARK: - Sites
func resolveSiteCount(paths: [String]) -> Int {
return fakes.count
return sites.count
}
func resolveSitesFrom(paths: [String]) -> [ValetSite] {
return fakes
return sites
}
func resolveSite(path: String) -> ValetSite? {
return nil
}
// MARK: - Proxies
func resolveProxies(directoryPath: String) -> [ValetProxy] {
return proxies
}
}

View File

@@ -1,5 +1,5 @@
//
// ValetSiteScanner.swift
// ValetDomainScanner.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 02/04/2022.
@@ -8,12 +8,15 @@
import Foundation
class ValetSiteScanner: SiteScanner {
class ValetDomainScanner: DomainScanner {
// MARK: - Sites
func resolveSiteCount(paths: [String]) -> Int {
return paths.map { path in
let entries = try! FileManager.default
.contentsOfDirectory(atPath: path)
let entries = try! FileSystem
.getShallowContentsOfDirectory(path)
return entries
.map { self.isSite($0, forPath: path) }
@@ -27,8 +30,8 @@ class ValetSiteScanner: SiteScanner {
var sites: [ValetSite] = []
paths.forEach { path in
let entries = try! FileManager.default
.contentsOfDirectory(atPath: path)
let entries = try! FileSystem
.getShallowContentsOfDirectory(path)
return entries.forEach {
if let site = self.resolveSite(path: "\(path)/\($0)") {
@@ -48,24 +51,19 @@ class ValetSiteScanner: SiteScanner {
// Get the TLD from the global Valet object
let tld = Valet.shared.config.tld
// See if the file is a symlink, if so, resolve it
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
if !FileSystem.anyExists(path) {
Log.warn("Could not parse the site: \(path), skipping!")
return nil
}
// We can also determine whether the thing at the path is a directory, too
let type = attrs[FileAttributeKey.type] as! FileAttributeType
// We should also check that we can interpret the path correctly
if URL(fileURLWithPath: path).lastPathComponent == "" {
Log.warn("Could not parse the site: \(path), skipping!")
return nil
}
if type == FileAttributeType.typeSymbolicLink {
if FileSystem.isSymlink(path) {
return ValetSite(aliasPath: path, tld: tld)
} else if type == FileAttributeType.typeDirectory {
} else if FileSystem.isDirectory(path) {
return ValetSite(absolutePath: path, tld: tld)
}
@@ -79,14 +77,26 @@ class ValetSiteScanner: SiteScanner {
private func isSite(_ entry: String, forPath path: String) -> Bool {
let siteDir = path + "/" + entry
let attrs = try! FileManager.default.attributesOfItem(atPath: siteDir)
return (FileSystem.isDirectory(siteDir) || FileSystem.isSymlink(siteDir))
}
let type = attrs[FileAttributeKey.type] as! FileAttributeType
// MARK: - Proxies
if type == FileAttributeType.typeSymbolicLink || type == FileAttributeType.typeDirectory {
return true
}
return false
func resolveProxies(directoryPath: String) -> [ValetProxy] {
return try! FileManager
.default
.contentsOfDirectory(atPath: directoryPath)
.filter {
return !$0.starts(with: ".")
}
.compactMap {
return NginxConfigurationFile.from(filePath: "\(directoryPath)/\($0)")
}
.filter {
return $0.proxy != nil
}
.map {
return ValetProxy($0)
}
}
}

View File

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

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