1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-02-04 03:20:06 +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
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
of this software and associated documentation files (the "Software"), to deal

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
LastUpgradeVersion = "2620"
version = "1.3">
<BuildAction
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 |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 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
@@ -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 |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 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.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 |

View File

@@ -64,8 +64,25 @@ public class Paths {
// - 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
@@ -118,23 +135,6 @@ public class Paths {
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
public enum HomebrewDir: String {

View File

@@ -130,25 +130,40 @@ class PhpEnvironments {
}
/** Information about the currently linked PHP installation. */
private let _currentInstall = Locked<ActivePhpInstallation?>(nil)
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
if let version = currentInstall?.version.short {
App.shared.phpExtensionManagerWindowController?.view?.manager.phpVersion = version
}
App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion()
}
}
/**
The version that the `php` formula via Brew is aliased to on the current system.
If you're up to date, `php` will be aliased to the latest version,
but that might not be the case since not everyone keeps their
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.
@@ -191,11 +206,6 @@ class PhpEnvironments {
}
}
/**
Information we were able to discern from the Homebrew info command.
*/
var homebrewPackage: HomebrewPackage! = nil
// MARK: - Methods
/**

View File

@@ -23,13 +23,25 @@ class PhpConfigurationFile: CreatedFromFile {
let filePath: String
/// 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.
var content: Config
private let _content: Locked<Config>
var content: Config {
get { _content.value }
set { _content.value = newValue }
}
/// 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) */
static func from(
@@ -50,9 +62,13 @@ class PhpConfigurationFile: CreatedFromFile {
required init(_ container: Container, path: String, contents: String) {
self.container = container
self.filePath = path
self.lines = contents.components(separatedBy: "\n")
self.extensions = PhpExtension.from(container, lines, filePath: path)
self.content = Self.parseConfig(lines: lines)
let lines = contents.components(separatedBy: "\n")
// 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
@@ -89,34 +105,41 @@ class PhpConfigurationFile: CreatedFromFile {
throw ReplacementErrors.missingKey
}
// Get a thread-safe copy to work with
var localLines = lines
// Figure out what comes after the assignment
var components = self
.lines[item.lineIndex]
var components = localLines[item.lineIndex]
.components(separatedBy: "=")
// Replace the value with the new one
components[1] = components[1]
.replacing(item.value, with: value)
// Replace the specific line
self.lines[item.lineIndex] = components.joined(separator: "=")
// Replace the specific line in the local copy
localLines[item.lineIndex] = components.joined(separator: "=")
// Ensure the watchers aren't tripped up by config changes
try await ConfigWatchManager.withSuspended {
// Finally, join the string and save the file atomically again
try self.lines.joined(separator: "\n")
// Finally, join the string and save the file atomically
try localLines.joined(separator: "\n")
.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()
}
public func reload() {
self.lines = try! String(contentsOfFile: self.filePath)
let newLines = try! String(contentsOfFile: self.filePath)
.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

View File

@@ -7,32 +7,51 @@
//
import Foundation
@preconcurrency import Dispatch
class RealShell: ShellProtocol {
var container: Container
init(container: Container) {
self.container = container
self.PATH = RealShell.getPath()
class RealShell: ShellProtocol, @unchecked Sendable {
init(binPath: String) {
self.binPath = binPath
self._PATH = RealShell.getPath()
self._exports = ""
}
private(set) var binPath: String
/**
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"
// MARK: - Thread-safe access; public accessor
/**
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
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.
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. */
private static func getPath() -> String {
@@ -45,15 +64,10 @@ class RealShell: ShellProtocol {
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
task.waitUntilExit()
let path = String(
data: pipe.fileHandleForReading.readDataToEndOfFile(),
encoding: String.Encoding.utf8
) ?? ""
try? pipe.fileHandleForReading.close()
return path
let path = getStringOutput(from: pipe)
return path.trimmingCharacters(in: .whitespacesAndNewlines)
}
/**
@@ -64,7 +78,7 @@ class RealShell: ShellProtocol {
var completeCommand = ""
// 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
if !self.exports.isEmpty {
@@ -219,19 +233,30 @@ class RealShell: ShellProtocol {
process.standardError = errorPipe
let output = ShellOutput.empty()
// Only access `resumed`, `output` from serialQueue to ensure thread safety
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output")
return try await withCheckedThrowingContinuation({ continuation in
let timeoutTask = Task {
try? await Task.sleep(nanoseconds: timeout.nanoseconds)
// Only terminate if the process is still running
if process.isRunning {
process.terminationHandler = nil
process.terminate()
// Guard against resuming the continuation twice (race between timeout and termination)
var resumed = false
// 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.terminate()
if !resumed {
resumed = true
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
outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in
let data = fileHandle.availableData
@@ -255,7 +280,9 @@ class RealShell: ShellProtocol {
}
process.terminationHandler = { process in
timeoutTask.cancel()
serialQueue.async {
timeoutTaskTermination.cancel()
}
// Clean up readability handlers
outputPipe.fileHandleForReading.readabilityHandler = nil
@@ -276,7 +303,10 @@ class RealShell: ShellProtocol {
didReceiveOutput(string, .stdErr)
}
continuation.resume(returning: (process, output))
if !resumed {
resumed = true
continuation.resume(returning: (process, output))
}
}
}

View File

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

View File

@@ -8,14 +8,14 @@
import Foundation
class Container {
class Container: @unchecked Sendable {
// MARK: - Variables
// Primary
private(set) var shell: ShellProtocol!
private(set) var filesystem: FileSystemProtocol!
private(set) var command: CommandProtocol!
private(set) var paths: Paths!
private(set) var shell: ShellProtocol!
private(set) var command: CommandProtocol!
private(set) var webApi: WebApiProtocol!
// Secondary (uses primary instances above)
@@ -65,10 +65,10 @@ class Container {
// These are the most basic building blocks. We need these before
// any of the other classes can be initialized!
self.shell = RealShell(container: self)
self.filesystem = RealFileSystem(container: self)
self.command = RealCommand()
self.paths = Paths(container: self)
self.shell = RealShell(binPath: paths.binPath)
self.command = RealCommand()
self.webApi = RealWebApi(container: self)
if coreOnly {
@@ -104,6 +104,9 @@ class Container {
getResponses: webApiGetResponses,
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
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
await performPostLaunchActions()
}

View File

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

View File

@@ -203,28 +203,31 @@ class ValetSite: ValetListable {
/**
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() {
let path = "\(absolutePath)/composer.json"
do {
if container.filesystem.fileExists(path) {
let decoded = try JSONDecoder().decode(
ComposerJson.self,
from: String(
contentsOf: URL(fileURLWithPath: path),
encoding: .utf8
).data(using: .utf8)!
)
(self.preferredPhpVersion,
self.preferredPhpVersionSource) = decoded.getPhpVersion()
self.notableComposerDependencies = decoded.getNotableDependencies()
}
} catch {
Log.err("Something went wrong reading the Composer JSON file.")
guard container.filesystem.fileExists(path) else {
return
}
guard let fileContents = try? String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8),
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()
}
/**

View File

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

View File

@@ -11,20 +11,34 @@ import Foundation
class Preferences {
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) {
self.container = container
Preferences.handleFirstTimeLaunch()
cachedPreferences = Self.cache()
customPreferences = CustomPrefs(
_cachedPreferences = Locked(Self.cache())
_customPreferences = Locked(CustomPrefs(
scanApps: [],
presets: [],
services: [],
environmentVariables: [:]
)
))
if isRunningSwiftUIPreview {
return

View File

@@ -35,10 +35,14 @@ class WarningManager: ObservableObject {
/// These warnings are the ones that are ready to be displayed.
@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
/// 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 {
return !warnings.isEmpty
@@ -71,7 +75,12 @@ class WarningManager: ObservableObject {
}
await evaluate()
await MainMenu.shared.rebuild()
// 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()
}
}
/**
@@ -79,14 +88,14 @@ class WarningManager: ObservableObject {
Will automatically broadcast these warnings.
*/
private func evaluate() async {
self.temporaryWarnings = []
var warnings: [Warning] = []
for check in self.evaluations where await check.applies() {
Log.info("[DOCTOR] \(check.name) (!)")
self.temporaryWarnings.append(check)
continue
warnings.append(check)
}
self.temporaryWarnings = warnings
await self.broadcastWarnings()
}
}

View File

@@ -23,8 +23,7 @@ struct PhpExtensionManagerView: View {
init() {
self.searchText = ""
self.status = BusyStatus.busy()
let version = App.shared.container.phpEnvs.currentInstall!.version.short
self.manager = BrewExtensionsObservable(phpVersion: version)
self.manager = BrewExtensionsObservable(phpVersion: Self.getActivePhpVersion())
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 {

View File

@@ -20,7 +20,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working
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"
assertAllExist([
@@ -50,7 +53,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working
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)
click(app.buttons["generic.ok".localized])
@@ -60,7 +66,10 @@ final class StartupTest: UITestCase {
var configuration = TestableConfigurations.working
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)
click(app.buttons["generic.ok".localized])

View File

@@ -9,22 +9,41 @@
import XCTest
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(
waitForInitialization: Bool = true,
openMenu: Bool = false,
with configuration: TestableConfiguration? = nil
with configuration: TestableConfiguration? = nil,
) -> XCPMApplication {
let app = XCPMApplication()
let config = configuration ?? TestableConfigurations.working
app.withConfiguration(config)
app.launch()
// 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
if openMenu {
app.statusItems.firstMatch.click()
if waitForInitialization || openMenu {
let statusItem = app.statusItems.firstMatch
let isEnabled = NSPredicate(format: "isEnabled == true")
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
}