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:
34
@tasks/changelog.md
Normal file
34
@tasks/changelog.md
Normal 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
29
@tasks/pretag.md
Normal 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)
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -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 you’re 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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -130,12 +130,14 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +148,22 @@ class PhpEnvironments {
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
// 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,9 +303,12 @@ class RealShell: ShellProtocol {
|
||||
didReceiveOutput(string, .stdErr)
|
||||
}
|
||||
|
||||
if !resumed {
|
||||
resumed = true
|
||||
continuation.resume(returning: (process, output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.launch()
|
||||
})
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -203,29 +203,32 @@ 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)!
|
||||
)
|
||||
guard container.filesystem.fileExists(path) else {
|
||||
return
|
||||
}
|
||||
|
||||
(self.preferredPhpVersion,
|
||||
self.preferredPhpVersionSource) = decoded.getPhpVersion()
|
||||
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()
|
||||
}
|
||||
} catch {
|
||||
Log.err("Something went wrong reading the Composer JSON file.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Checks the contents of the .valetphprc file and determine the version.
|
||||
|
||||
@@ -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() {
|
||||
if !container.phpEnvs.cachedPhpInstallations.isEmpty {
|
||||
PhpVersionManagerWindowController.show()
|
||||
} else {
|
||||
Log.err("Skipping opening version manager due to no available PHP versions.")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openPhpExtensionManager() {
|
||||
if !container.phpEnvs.cachedPhpInstallations.isEmpty {
|
||||
PhpExtensionManagerWindowController.show()
|
||||
} else {
|
||||
Log.err("Skipping opening extension manager due to no available PHP versions.")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openDonate() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 +75,27 @@ class WarningManager: ObservableObject {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Runs through all evaluations and appends any applicable warning results.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user