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

🚀 Version 26.01

This commit is contained in:
2026-01-24 15:07:44 +01:00
24 changed files with 346 additions and 136 deletions

34
@tasks/changelog.md Normal file
View File

@@ -0,0 +1,34 @@
Instructions for the changelog:
Generate two lists; one containing "What's New" (additions) and one "What's Changed" (bug fixes, modifications).
Also briefly describe the release in general, e.g. "PHP Monitor X.X is a minor update containing mostly bugfixes." or "PHP Monitor X.X contains a bunch of new features, including X, Y and Z."
Make sure the changelog does not contain too many references to internal code structure unless necessary, make it understandable to the end user of the application.
The changelog should be formatted using Markdown like the example, and should be copied to the clipboard.
---
Structure:
```
**PHP Monitor X.X** comes with features X, Y, X (brief blurb).
## What's New in vX.X
- List item, descriptive.
- List item, descriptive.
## What's Changed
- List item, descriptive.
- List item, descriptive.
```
---
- [ ] Determine latest tag
- [ ] Identify diff between latest tag and HEAD
- [ ] Go through commits to generate changelog

29
@tasks/pretag.md Normal file
View File

@@ -0,0 +1,29 @@
Before a release is tagged, you want to make sure that the latest known stable release is known.
First, identify what has changed between this tagged version and the current HEAD of the branch you wish to merge into `main` as the stable build.
Tagged releases follow the `vX.Y.Z` naming system, where X is the year, Y is the month version and Z is the patch (usually unspecified unless a patch was released).
Look for the latest tag on the `main` branch first.
Make sure all unit tests and UI tests pass prior to finalizing a build. The developer will need to manually check this and report if the tests pass or fail.
Once this has been confirmed and test pass, a sanity check needs to be done by checking if all of the changes made in the commits since the last release are:
- Bugfixes for a given issue, without any potential side effects
- New features which should have new associated tests
- Quality of life improvements that do not require new tests
If any changes seem incomplete or there's a chance that some functionality may still break despite tests passing (due to some oversight), then no release should be made and those issues should be listed first.
(These sanity checks can be done manually or assisted by an LLM.)
---
- [ ] Do all tests pass? Ask.
- [ ] Determine latest tag
- [ ] Identify diff between latest tag and HEAD
- [ ] Go through commits and sanity check based on instructions
- [ ] Determine if ready for a new release
- [ ] If ready, generate a short changelog (instructions in ./@changelog.md)

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2023 Nico Verbruggen Copyright (c) 2019-2026 Nico Verbruggen
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -2601,7 +2601,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1420; LastSwiftUpdateCheck = 1420;
LastUpgradeCheck = 2610; LastUpgradeCheck = 2620;
ORGANIZATIONNAME = "Nico Verbruggen"; ORGANIZATIONNAME = "Nico Verbruggen";
TargetAttributes = { TargetAttributes = {
C406A5EF298AD2CE00B5B85A = { C406A5EF298AD2CE00B5B85A = {
@@ -3994,7 +3994,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1810; CURRENT_PROJECT_VERSION = 1845;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = YES; DEBUG = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4013,7 +4013,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 25.12; MARKETING_VERSION = 26.01;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -4038,7 +4038,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1810; CURRENT_PROJECT_VERSION = 1845;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = NO; DEBUG = NO;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4057,7 +4057,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 25.12; MARKETING_VERSION = 26.01;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -4220,7 +4220,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1810; CURRENT_PROJECT_VERSION = 1845;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = YES; DEBUG = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4239,7 +4239,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 25.12; MARKETING_VERSION = 26.01;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME) EAP"; PRODUCT_NAME = "$(TARGET_NAME) EAP";
@@ -4413,7 +4413,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1810; CURRENT_PROJECT_VERSION = 1845;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = NO; DEBUG = NO;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4432,7 +4432,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 25.12; MARKETING_VERSION = 26.01;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME) EAP"; PRODUCT_NAME = "$(TARGET_NAME) EAP";

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2610" LastUpgradeVersion = "2620"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -97,7 +97,7 @@
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working.json" argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "NO"> isEnabled = "YES">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json" argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,8 +64,25 @@ public class Paths {
// - MARK: Detected Binaries // - MARK: Detected Binaries
/** The path to the Composer binary. Can be in multiple locations, so is detected instead. */ public var composer: String? {
public var composer: String? get { _composer.value }
set { _composer.value = newValue }
}
private let _composer = Locked<String?>(nil)
private func detectComposerBinary() {
if container.filesystem.fileExists("/usr/local/bin/composer") {
composer = "/usr/local/bin/composer"
} else if container.filesystem.fileExists("/opt/homebrew/bin/composer") {
composer = "/opt/homebrew/bin/composer"
} else if container.filesystem.fileExists("/usr/local/homebrew/bin/composer") {
composer = "/usr/local/homebrew/bin/composer"
} else {
composer = nil
Log.warn("Composer was not found.")
}
}
// - MARK: Paths // - MARK: Paths
@@ -118,23 +135,6 @@ public class Paths {
return preferredShell return preferredShell
} }
// MARK: - Flexible Binaries
// (these can be in multiple locations, so we scan common places because)
// (PHP Monitor will not use the user's own PATH)
private func detectComposerBinary() {
if container.filesystem.fileExists("/usr/local/bin/composer") {
composer = "/usr/local/bin/composer"
} else if container.filesystem.fileExists("/opt/homebrew/bin/composer") {
composer = "/opt/homebrew/bin/composer"
} else if container.filesystem.fileExists("/usr/local/homebrew/bin/composer") {
composer = "/usr/local/homebrew/bin/composer"
} else {
composer = nil
Log.warn("Composer was not found.")
}
}
// MARK: - Enum // MARK: - Enum
public enum HomebrewDir: String { public enum HomebrewDir: String {

View File

@@ -130,12 +130,14 @@ class PhpEnvironments {
} }
/** Information about the currently linked PHP installation. */ /** Information about the currently linked PHP installation. */
private let _currentInstall = Locked<ActivePhpInstallation?>(nil)
var currentInstall: ActivePhpInstallation? { var currentInstall: ActivePhpInstallation? {
didSet { get { _currentInstall.value }
set {
// Update the synchronized value
_currentInstall.value = newValue
// Let the PHP extension manager, if it exists, know the version changed // Let the PHP extension manager, if it exists, know the version changed
if let version = currentInstall?.version.short { App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion()
App.shared.phpExtensionManagerWindowController?.view?.manager.phpVersion = version
}
} }
} }
@@ -146,9 +148,22 @@ class PhpEnvironments {
but that might not be the case since not everyone keeps their but that might not be the case since not everyone keeps their
software up-to-date. software up-to-date.
As such, we take that information from Homebrew. In order for our check to be correct, we query Homebrew locally.
*/ */
static var brewPhpAlias: String? private static let _brewPhpAlias = Locked<String?>(nil)
static var brewPhpAlias: String? {
get { _brewPhpAlias.value }
set { _brewPhpAlias.value = newValue }
}
/**
Information we were able to discern from the Homebrew info command.
*/
private let _homebrewPackage = Locked<HomebrewPackage?>(nil)
var homebrewPackage: HomebrewPackage! {
get { _homebrewPackage.value }
set { _homebrewPackage.value = newValue }
}
/** /**
It's possible for the alias to be newer than the actual installed version of PHP. It's possible for the alias to be newer than the actual installed version of PHP.
@@ -191,11 +206,6 @@ class PhpEnvironments {
} }
} }
/**
Information we were able to discern from the Homebrew info command.
*/
var homebrewPackage: HomebrewPackage! = nil
// MARK: - Methods // MARK: - Methods
/** /**

View File

@@ -23,13 +23,25 @@ class PhpConfigurationFile: CreatedFromFile {
let filePath: String let filePath: String
/// The extensions found in this .ini file. /// The extensions found in this .ini file.
var extensions: [PhpExtension] private let _extensions: Locked<[PhpExtension]>
var extensions: [PhpExtension] {
get { _extensions.value }
set { _extensions.value = newValue }
}
/// The actual, structured content of the configuration file. /// The actual, structured content of the configuration file.
var content: Config private let _content: Locked<Config>
var content: Config {
get { _content.value }
set { _content.value = newValue }
}
/// The original lines of the file. /// The original lines of the file.
var lines: [String] private let _lines: Locked<[String]>
var lines: [String] {
get { _lines.value }
set { _lines.value = newValue }
}
/** Resolves a PHP configuration file (.ini) */ /** Resolves a PHP configuration file (.ini) */
static func from( static func from(
@@ -50,9 +62,13 @@ class PhpConfigurationFile: CreatedFromFile {
required init(_ container: Container, path: String, contents: String) { required init(_ container: Container, path: String, contents: String) {
self.container = container self.container = container
self.filePath = path self.filePath = path
self.lines = contents.components(separatedBy: "\n")
self.extensions = PhpExtension.from(container, lines, filePath: path) let lines = contents.components(separatedBy: "\n")
self.content = Self.parseConfig(lines: lines)
// We only need to explicitly set our locks here
self._lines = Locked(lines)
self._extensions = Locked(PhpExtension.from(container, lines, filePath: path))
self._content = Locked(Self.parseConfig(lines: lines))
} }
// MARK: API // MARK: API
@@ -89,34 +105,41 @@ class PhpConfigurationFile: CreatedFromFile {
throw ReplacementErrors.missingKey throw ReplacementErrors.missingKey
} }
// Get a thread-safe copy to work with
var localLines = lines
// Figure out what comes after the assignment // Figure out what comes after the assignment
var components = self var components = localLines[item.lineIndex]
.lines[item.lineIndex]
.components(separatedBy: "=") .components(separatedBy: "=")
// Replace the value with the new one // Replace the value with the new one
components[1] = components[1] components[1] = components[1]
.replacing(item.value, with: value) .replacing(item.value, with: value)
// Replace the specific line // Replace the specific line in the local copy
self.lines[item.lineIndex] = components.joined(separator: "=") localLines[item.lineIndex] = components.joined(separator: "=")
// Ensure the watchers aren't tripped up by config changes // Ensure the watchers aren't tripped up by config changes
try await ConfigWatchManager.withSuspended { try await ConfigWatchManager.withSuspended {
// Finally, join the string and save the file atomically again // Finally, join the string and save the file atomically
try self.lines.joined(separator: "\n") try localLines.joined(separator: "\n")
.write(toFile: self.filePath, atomically: true, encoding: .utf8) .write(toFile: self.filePath, atomically: true, encoding: .utf8)
} }
// Reload the original file self.lines = localLines
// Reload the original file (which will update all properties atomically)
self.reload() self.reload()
} }
public func reload() { public func reload() {
self.lines = try! String(contentsOfFile: self.filePath) let newLines = try! String(contentsOfFile: self.filePath)
.components(separatedBy: "\n") .components(separatedBy: "\n")
self.extensions = PhpExtension.from(container, lines, filePath: self.filePath)
self.content = Self.parseConfig(lines: lines) // Update all properties atomically
lines = newLines
extensions = PhpExtension.from(container, newLines, filePath: self.filePath)
content = Self.parseConfig(lines: newLines)
} }
// MARK: Parsing Logic // MARK: Parsing Logic

View File

@@ -7,32 +7,51 @@
// //
import Foundation import Foundation
@preconcurrency import Dispatch
class RealShell: ShellProtocol { class RealShell: ShellProtocol, @unchecked Sendable {
var container: Container init(binPath: String) {
self.binPath = binPath
init(container: Container) { self._PATH = RealShell.getPath()
self.container = container self._exports = ""
self.PATH = RealShell.getPath()
} }
private(set) var binPath: String
/** /**
The launch path of the terminal in question that is used. The launch path of the terminal in question that is used.
On macOS, we use /bin/sh since it's pretty fast. On macOS, we use /bin/sh since it's pretty fast.
*/ */
private(set) var launchPath: String = "/bin/sh" private(set) var launchPath: String = "/bin/sh"
// MARK: - Thread-safe access; public accessor
/** /**
For some commands, we need to know what's in the user's PATH. 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. The entire PATH is retrieved here, so we can set the PATH in our own terminal as necessary.
*/ */
private(set) var PATH: String internal var PATH: String {
get { shellQueue.sync { _PATH } }
set { shellQueue.sync { _PATH = newValue } }
}
/** /**
Exports are additional environment variables set by the user via the custom configuration. Exports are additional environment variables set by the user via the custom configuration.
These are populated when the configuration file is being loaded. These are populated when the configuration file is being loaded.
*/ */
var exports: String = "" internal var exports: String {
get { shellQueue.sync { _exports } }
set { shellQueue.sync { _exports = newValue } }
}
// MARK: - Thread-safe access; internal values
/** Thread-safe access to PATH and exports is ensured via this queue. */
private let shellQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_queue")
private var _PATH: String
private var _exports: String
// MARK: - Methods
/** Retrieves the user's PATH by opening an interactive shell and echoing $PATH. */ /** Retrieves the user's PATH by opening an interactive shell and echoing $PATH. */
private static func getPath() -> String { private static func getPath() -> String {
@@ -45,15 +64,10 @@ class RealShell: ShellProtocol {
let pipe = Pipe() let pipe = Pipe()
task.standardOutput = pipe task.standardOutput = pipe
task.launch() task.launch()
task.waitUntilExit()
let path = String( let path = getStringOutput(from: pipe)
data: pipe.fileHandleForReading.readDataToEndOfFile(), return path.trimmingCharacters(in: .whitespacesAndNewlines)
encoding: String.Encoding.utf8
) ?? ""
try? pipe.fileHandleForReading.close()
return path
} }
/** /**
@@ -64,7 +78,7 @@ class RealShell: ShellProtocol {
var completeCommand = "" var completeCommand = ""
// Basic export (PATH) // Basic export (PATH)
completeCommand += "export PATH=\(container.paths.binPath):$PATH && " completeCommand += "export PATH=\(binPath):$PATH && "
// Put additional exports (as defined by the user) in between // Put additional exports (as defined by the user) in between
if !self.exports.isEmpty { if !self.exports.isEmpty {
@@ -219,19 +233,30 @@ class RealShell: ShellProtocol {
process.standardError = errorPipe process.standardError = errorPipe
let output = ShellOutput.empty() let output = ShellOutput.empty()
// Only access `resumed`, `output` from serialQueue to ensure thread safety
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output") let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output")
return try await withCheckedThrowingContinuation({ continuation in return try await withCheckedThrowingContinuation({ continuation in
let timeoutTask = Task { // Guard against resuming the continuation twice (race between timeout and termination)
try? await Task.sleep(nanoseconds: timeout.nanoseconds) var resumed = false
// Only terminate if the process is still running
if process.isRunning { // Use GCD; we're already using a serial queue so legacy concurrency approach is okay
let timeoutTaskTermination = DispatchWorkItem {
guard process.isRunning else { return }
process.terminationHandler = nil process.terminationHandler = nil
process.terminate() process.terminate()
if !resumed {
resumed = true
continuation.resume(throwing: ShellError.timedOut) continuation.resume(throwing: ShellError.timedOut)
} }
} }
// Let's make sure that once our timeout occurs, our process is terminated
serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutTaskTermination)
// Set up background reading for stdout // Set up background reading for stdout
outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in
let data = fileHandle.availableData let data = fileHandle.availableData
@@ -255,7 +280,9 @@ class RealShell: ShellProtocol {
} }
process.terminationHandler = { process in process.terminationHandler = { process in
timeoutTask.cancel() serialQueue.async {
timeoutTaskTermination.cancel()
}
// Clean up readability handlers // Clean up readability handlers
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
@@ -276,9 +303,12 @@ class RealShell: ShellProtocol {
didReceiveOutput(string, .stdErr) didReceiveOutput(string, .stdErr)
} }
if !resumed {
resumed = true
continuation.resume(returning: (process, output)) continuation.resume(returning: (process, output))
} }
} }
}
process.launch() process.launch()
}) })

View File

@@ -137,9 +137,11 @@ public struct TestableConfiguration: Codable {
container.overrideWith(config: self) container.overrideWith(config: self)
Log.info("Applying temporary preference overrides...") Log.info("Applying temporary preference overrides...")
var cachedPrefs = container.preferences.cachedPreferences
preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in
container.preferences.cachedPreferences[key] = value cachedPrefs[key] = value
} }
container.preferences.cachedPreferences = cachedPrefs
if Valet.shared.installed { if Valet.shared.installed {
Log.info("Applying fake scanner...") Log.info("Applying fake scanner...")

View File

@@ -8,14 +8,14 @@
import Foundation import Foundation
class Container { class Container: @unchecked Sendable {
// MARK: - Variables // MARK: - Variables
// Primary // Primary
private(set) var shell: ShellProtocol!
private(set) var filesystem: FileSystemProtocol! private(set) var filesystem: FileSystemProtocol!
private(set) var command: CommandProtocol!
private(set) var paths: Paths! private(set) var paths: Paths!
private(set) var shell: ShellProtocol!
private(set) var command: CommandProtocol!
private(set) var webApi: WebApiProtocol! private(set) var webApi: WebApiProtocol!
// Secondary (uses primary instances above) // Secondary (uses primary instances above)
@@ -65,10 +65,10 @@ class Container {
// These are the most basic building blocks. We need these before // These are the most basic building blocks. We need these before
// any of the other classes can be initialized! // any of the other classes can be initialized!
self.shell = RealShell(container: self)
self.filesystem = RealFileSystem(container: self) self.filesystem = RealFileSystem(container: self)
self.command = RealCommand()
self.paths = Paths(container: self) self.paths = Paths(container: self)
self.shell = RealShell(binPath: paths.binPath)
self.command = RealCommand()
self.webApi = RealWebApi(container: self) self.webApi = RealWebApi(container: self)
if coreOnly { if coreOnly {
@@ -104,6 +104,9 @@ class Container {
getResponses: webApiGetResponses, getResponses: webApiGetResponses,
postResponses: webApiPostResponses postResponses: webApiPostResponses
) )
// We will also re-initialize PhpEnvironments due to altered dependencies
self.phpEnvs = PhpEnvironments(container: self)
} }
/** /**

View File

@@ -120,6 +120,12 @@ extension Startup {
// Check if we upgraded from a previous version // Check if we upgraded from a previous version
AppUpdater.checkIfUpdateWasPerformed() AppUpdater.checkIfUpdateWasPerformed()
// Mark app as having successfully booted passing all checks
Startup.hasFinishedBooting = true
// Enable the main menu item
MainMenu.shared.statusItem.button?.isEnabled = true
// Post-launch stats and update check, but only if not running tests // Post-launch stats and update check, but only if not running tests
await performPostLaunchActions() await performPostLaunchActions()
} }

View File

@@ -13,6 +13,7 @@ import NVAlert
extension Startup { extension Startup {
@MainActor static var startupTimer: Timer? @MainActor static var startupTimer: Timer?
@MainActor static var launchTime: Date? @MainActor static var launchTime: Date?
@MainActor static var hasFinishedBooting: Bool = false
/** Returns a human-readable version to indicate how many seconds elapsed since boot. */ /** Returns a human-readable version to indicate how many seconds elapsed since boot. */
@MainActor static var humanReadableSinceBootTime: String { @MainActor static var humanReadableSinceBootTime: String {

View File

@@ -203,29 +203,32 @@ class ValetSite: ValetListable {
/** /**
Checks the contents of the composer.json file and determine the notable dependencies, Checks the contents of the composer.json file and determine the notable dependencies,
as well as the requested PHP version. If no composer.json file is found, nothing happens. as well as the requested PHP version. This info is then used to determine project type.
If no composer.json file is found or is invalid, some features may be unavailable, like
for example project type inference based on dependencies.
*/ */
private func determineComposerInformation() { private func determineComposerInformation() {
let path = "\(absolutePath)/composer.json" let path = "\(absolutePath)/composer.json"
do { guard container.filesystem.fileExists(path) else {
if container.filesystem.fileExists(path) { return
let decoded = try JSONDecoder().decode( }
ComposerJson.self,
from: String(
contentsOf: URL(fileURLWithPath: path),
encoding: .utf8
).data(using: .utf8)!
)
(self.preferredPhpVersion, guard let fileContents = try? String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8),
self.preferredPhpVersionSource) = decoded.getPhpVersion() let jsonData = fileContents.data(using: .utf8) else {
Log.err("Could not read the Composer JSON file at: \(path)")
return
}
guard let decoded = try? JSONDecoder().decode(ComposerJson.self, from: jsonData) else {
Log.err("Could not parse the Composer JSON file at: \(path)")
return
}
(self.preferredPhpVersion, self.preferredPhpVersionSource) = decoded.getPhpVersion()
self.notableComposerDependencies = decoded.getNotableDependencies() self.notableComposerDependencies = decoded.getNotableDependencies()
} }
} catch {
Log.err("Something went wrong reading the Composer JSON file.")
}
}
/** /**
Checks the contents of the .valetphprc file and determine the version. Checks the contents of the .valetphprc file and determine the version.

View File

@@ -23,6 +23,7 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
override init() { override init() {
super.init() super.init()
statusItem.isVisible = !isRunningSwiftUIPreview statusItem.isVisible = !isRunningSwiftUIPreview
statusItem.button?.isEnabled = false
} }
weak var menuDelegate: NSMenuDelegate? weak var menuDelegate: NSMenuDelegate?
@@ -238,11 +239,19 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
} }
@objc func openPhpVersionManager() { @objc func openPhpVersionManager() {
if !container.phpEnvs.cachedPhpInstallations.isEmpty {
PhpVersionManagerWindowController.show() PhpVersionManagerWindowController.show()
} else {
Log.err("Skipping opening version manager due to no available PHP versions.")
}
} }
@objc func openPhpExtensionManager() { @objc func openPhpExtensionManager() {
if !container.phpEnvs.cachedPhpInstallations.isEmpty {
PhpExtensionManagerWindowController.show() PhpExtensionManagerWindowController.show()
} else {
Log.err("Skipping opening extension manager due to no available PHP versions.")
}
} }
@objc func openDonate() { @objc func openDonate() {

View File

@@ -11,20 +11,34 @@ import Foundation
class Preferences { class Preferences {
var container: Container var container: Container
var customPreferences: CustomPrefs // MARK: - Preferences
var cachedPreferences: [PreferenceName: Any?] var customPreferences: CustomPrefs {
get { _customPreferences.value }
set { _customPreferences.value = newValue }
}
var cachedPreferences: [PreferenceName: Any?] {
get { _cachedPreferences.value }
set { _cachedPreferences.value = newValue }
}
private let _customPreferences: Locked<CustomPrefs>
private let _cachedPreferences: Locked<[PreferenceName: Any?]>
// MARK: - Initialization
public init(container: Container) { public init(container: Container) {
self.container = container self.container = container
Preferences.handleFirstTimeLaunch() Preferences.handleFirstTimeLaunch()
cachedPreferences = Self.cache()
customPreferences = CustomPrefs( _cachedPreferences = Locked(Self.cache())
_customPreferences = Locked(CustomPrefs(
scanApps: [], scanApps: [],
presets: [], presets: [],
services: [], services: [],
environmentVariables: [:] environmentVariables: [:]
) ))
if isRunningSwiftUIPreview { if isRunningSwiftUIPreview {
return return

View File

@@ -35,10 +35,14 @@ class WarningManager: ObservableObject {
/// These warnings are the ones that are ready to be displayed. /// These warnings are the ones that are ready to be displayed.
@Published public var warnings: [Warning] = [] @Published public var warnings: [Warning] = []
/// This variable is thread-safe and may be modified at any time. /// Thread-safe storage for warnings being evaluated.
/// When all temporary warnings are set, you may broadcast these changes /// When all temporary warnings are set, you may broadcast these changes
/// and they will be sent to the @Published variable via the main thread. /// and they will be sent to the @Published variable via the main thread.
private var temporaryWarnings: [Warning] = [] private var temporaryWarnings: [Warning] {
get { _temporaryWarnings.value }
set { _temporaryWarnings.value = newValue }
}
private let _temporaryWarnings = Locked<[Warning]>([])
public func hasWarnings() -> Bool { public func hasWarnings() -> Bool {
return !warnings.isEmpty return !warnings.isEmpty
@@ -71,22 +75,27 @@ class WarningManager: ObservableObject {
} }
await evaluate() await evaluate()
// Only rebuild the menu if the app has finished booting
// (otherwise the menu may become interactive before all checks are done)
if await Startup.hasFinishedBooting {
await MainMenu.shared.rebuild() await MainMenu.shared.rebuild()
} }
}
/** /**
Runs through all evaluations and appends any applicable warning results. Runs through all evaluations and appends any applicable warning results.
Will automatically broadcast these warnings. Will automatically broadcast these warnings.
*/ */
private func evaluate() async { private func evaluate() async {
self.temporaryWarnings = [] var warnings: [Warning] = []
for check in self.evaluations where await check.applies() { for check in self.evaluations where await check.applies() {
Log.info("[DOCTOR] \(check.name) (!)") Log.info("[DOCTOR] \(check.name) (!)")
self.temporaryWarnings.append(check) warnings.append(check)
continue
} }
self.temporaryWarnings = warnings
await self.broadcastWarnings() await self.broadcastWarnings()
} }
} }

View File

@@ -23,8 +23,7 @@ struct PhpExtensionManagerView: View {
init() { init() {
self.searchText = "" self.searchText = ""
self.status = BusyStatus.busy() self.status = BusyStatus.busy()
let version = App.shared.container.phpEnvs.currentInstall!.version.short self.manager = BrewExtensionsObservable(phpVersion: Self.getActivePhpVersion())
self.manager = BrewExtensionsObservable(phpVersion: version)
self.status.busy = false self.status.busy = false
} }
@@ -237,6 +236,15 @@ struct PhpExtensionManagerView: View {
} }
} }
} }
static func getActivePhpVersion() -> String {
return App.shared.container.phpEnvs.currentInstall?.version.short
?? App.shared.container.phpEnvs.cachedPhpInstallations.keys.first!
}
func didUpdatePhpVersion() {
self.manager.phpVersion = Self.getActivePhpVersion()
}
} }
#Preview { #Preview {

View File

@@ -20,7 +20,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
configuration.filesystem["/opt/homebrew/bin/php"] = nil // PHP binary must be missing configuration.filesystem["/opt/homebrew/bin/php"] = nil // PHP binary must be missing
let app = launch(with: configuration) let app = launch(
waitForInitialization: false, // we expect an error during initialization
with: configuration
)
// Dialog 1: "PHP is not correctly installed" // Dialog 1: "PHP is not correctly installed"
assertAllExist([ assertAllExist([
@@ -50,7 +53,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
configuration.filesystem["/opt/homebrew/etc/php/8.4/php-fpm.d/valet-fpm.conf"] = nil configuration.filesystem["/opt/homebrew/etc/php/8.4/php-fpm.d/valet-fpm.conf"] = nil
let app = launch(with: configuration) let app = launch(
waitForInitialization: false, // we expect an error during initialization
with: configuration
)
assertExists(app.staticTexts["alert.php_fpm_broken.title".localized], 3.0) assertExists(app.staticTexts["alert.php_fpm_broken.title".localized], 3.0)
click(app.buttons["generic.ok".localized]) click(app.buttons["generic.ok".localized])
@@ -60,7 +66,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
configuration.shellOutput["valet --version"] = .instant("Laravel Valet 5.0") configuration.shellOutput["valet --version"] = .instant("Laravel Valet 5.0")
let app = launch(with: configuration) let app = launch(
waitForInitialization: false, // we expect an error during initialization
with: configuration
)
assertExists(app.staticTexts["startup.errors.valet_version_not_supported.title".localized], 3.0) assertExists(app.staticTexts["startup.errors.valet_version_not_supported.title".localized], 3.0)
click(app.buttons["generic.ok".localized]) click(app.buttons["generic.ok".localized])

View File

@@ -9,22 +9,41 @@
import XCTest import XCTest
class UITestCase: XCTestCase { class UITestCase: XCTestCase {
/** Launches the app and opens the menu. */ /**
Launches the app and opens the menu.
Defaults to waiting for the app to finish initialization.
- Parameter waitForInitialization: Waits for the PHP Monitor to pass the environment checks (startup).
- Parameter openMenu: Attempts to open the status menu when ready; requires passing environment checks.
- Parameter configuration: The TestableConfiguration to include when launching PHP Monitor.
*/
public func launch( public func launch(
waitForInitialization: Bool = true,
openMenu: Bool = false, openMenu: Bool = false,
with configuration: TestableConfiguration? = nil with configuration: TestableConfiguration? = nil,
) -> XCPMApplication { ) -> XCPMApplication {
let app = XCPMApplication() let app = XCPMApplication()
let config = configuration ?? TestableConfigurations.working let config = configuration ?? TestableConfigurations.working
app.withConfiguration(config) app.withConfiguration(config)
app.launch() app.launch()
// Note: If this fails here, make sure the menu bar item can be displayed if waitForInitialization || openMenu {
// If you use Bartender or something like this, this item may be hidden and tests will fail let statusItem = app.statusItems.firstMatch
if openMenu { let isEnabled = NSPredicate(format: "isEnabled == true")
app.statusItems.firstMatch.click() let expectation = expectation(for: isEnabled, evaluatedWith: statusItem, handler: nil)
let result = XCTWaiter().wait(for: [expectation], timeout: 15)
if result == .timedOut {
XCTFail("PHP Monitor did not initialize with an available UI element within 15 seconds.")
} }
if openMenu {
statusItem.click()
}
}
// Note: If this fails here, make sure the menu bar item can be displayed
// If you use Bartender or something like this, this item may be hidden and tests will fail
return app return app
} }