diff --git a/@tasks/changelog.md b/@tasks/changelog.md new file mode 100644 index 00000000..b9cfcb46 --- /dev/null +++ b/@tasks/changelog.md @@ -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 \ No newline at end of file diff --git a/@tasks/pretag.md b/@tasks/pretag.md new file mode 100644 index 00000000..cd4571ad --- /dev/null +++ b/@tasks/pretag.md @@ -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) + diff --git a/LICENSE b/LICENSE index 3f2c5e49..0cfbd0ea 100644 --- a/LICENSE +++ b/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 diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index b330969a..317d27c9 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -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"; diff --git a/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme b/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme index 050548e3..037af8da 100644 --- a/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme +++ b/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme @@ -1,6 +1,6 @@ + isEnabled = "YES"> Sonoma (14.0+)
Sequoia (15.0+)
Tahoe (26.0+) | macOS 13.5+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.6 (w/ Valet 4.x)| 3.0 or higher recommended
2.16.2 minimum | +| 26 | ✅ Universal binary | ✅ Yes | Ventura (13.5+)
Sonoma (14.0+)
Sequoia (15.0+)
Tahoe (26.0+) | macOS 13.5+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.6 (w/ Valet 4.x)| 3.0 or higher recommended
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+)
Sonoma (14.0+)
Sequoia (15.0+)
Tahoe (26.0+) | macOS 13.5+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.6 (w/ Valet 4.x)| 3.0 or higher recommended
2.16.2 minimum | | 7.1 | ✅ Universal binary | ❌ | Monterey (12.4+)
Ventura (13.0+)
Sonoma (14.0+)
Sequoia (15.0+) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.5 (w/ Valet 4.x)| 3.0 or higher recommended
2.16.2 minimum | | 7.0 | ✅ Universal binary | ❌ | Monterey (12.4+)
Ventura (13.0+)
Sonoma (14.0) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended
2.16.2 minimum | | 6.2 | ✅ Universal binary | ❌ | Monterey (12.4+)
Ventura (13.0+)
Sonoma (14.0) | macOS 12.4+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)
PHP 7.0—PHP 8.4 (w/ Valet 3.x)
PHP 7.1-PHP 8.4 (w/ Valet 4.x)| 3.0 or higher recommended
2.16.2 minimum | diff --git a/phpmon/Common/Core/Paths.swift b/phpmon/Common/Core/Paths.swift index c5ad8fc4..7b0d8f0e 100644 --- a/phpmon/Common/Core/Paths.swift +++ b/phpmon/Common/Core/Paths.swift @@ -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(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 { diff --git a/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift b/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift index 02b2d287..f6930f9c 100644 --- a/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift +++ b/phpmon/Common/PHP/PHP Version/PhpEnvironments.swift @@ -130,25 +130,40 @@ class PhpEnvironments { } /** Information about the currently linked PHP installation. */ + private let _currentInstall = Locked(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(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(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 /** diff --git a/phpmon/Common/PHP/PhpConfigurationFile.swift b/phpmon/Common/PHP/PhpConfigurationFile.swift index 8499633f..4b8bb7ff 100644 --- a/phpmon/Common/PHP/PhpConfigurationFile.swift +++ b/phpmon/Common/PHP/PhpConfigurationFile.swift @@ -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 + 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 diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index 9887fa7c..d92a7ee8 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -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)) + } } } diff --git a/phpmon/Common/Testables/TestableConfiguration.swift b/phpmon/Common/Testables/TestableConfiguration.swift index 9299ea2f..18d6b67f 100644 --- a/phpmon/Common/Testables/TestableConfiguration.swift +++ b/phpmon/Common/Testables/TestableConfiguration.swift @@ -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...") diff --git a/phpmon/Container/Container.swift b/phpmon/Container/Container.swift index f9892739..4a722a99 100644 --- a/phpmon/Container/Container.swift +++ b/phpmon/Container/Container.swift @@ -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) } /** diff --git a/phpmon/Domain/App/Startup+Launch.swift b/phpmon/Domain/App/Startup+Launch.swift index fafc9659..ff370d4c 100644 --- a/phpmon/Domain/App/Startup+Launch.swift +++ b/phpmon/Domain/App/Startup+Launch.swift @@ -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() } diff --git a/phpmon/Domain/App/Startup+Timers.swift b/phpmon/Domain/App/Startup+Timers.swift index 738c1f0d..b79d8149 100644 --- a/phpmon/Domain/App/Startup+Timers.swift +++ b/phpmon/Domain/App/Startup+Timers.swift @@ -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 { diff --git a/phpmon/Domain/Integrations/Valet/Sites/ValetSite.swift b/phpmon/Domain/Integrations/Valet/Sites/ValetSite.swift index 5ff8e6f2..f5f3cf24 100644 --- a/phpmon/Domain/Integrations/Valet/Sites/ValetSite.swift +++ b/phpmon/Domain/Integrations/Valet/Sites/ValetSite.swift @@ -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() } /** diff --git a/phpmon/Domain/Menu/MainMenu.swift b/phpmon/Domain/Menu/MainMenu.swift index d4ddb55a..fb61efc1 100644 --- a/phpmon/Domain/Menu/MainMenu.swift +++ b/phpmon/Domain/Menu/MainMenu.swift @@ -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() { diff --git a/phpmon/Domain/Preferences/Preferences.swift b/phpmon/Domain/Preferences/Preferences.swift index 1c1c9461..98c28fdb 100644 --- a/phpmon/Domain/Preferences/Preferences.swift +++ b/phpmon/Domain/Preferences/Preferences.swift @@ -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 + 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 diff --git a/phpmon/Modules/PHP Doctor/Data/WarningManager.swift b/phpmon/Modules/PHP Doctor/Data/WarningManager.swift index abc08d2a..ce504ff3 100644 --- a/phpmon/Modules/PHP Doctor/Data/WarningManager.swift +++ b/phpmon/Modules/PHP Doctor/Data/WarningManager.swift @@ -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() } } diff --git a/phpmon/Modules/PHP Extension Manager/UI/PhpExtensionManagerView.swift b/phpmon/Modules/PHP Extension Manager/UI/PhpExtensionManagerView.swift index 8362b3a6..9deff5e0 100644 --- a/phpmon/Modules/PHP Extension Manager/UI/PhpExtensionManagerView.swift +++ b/phpmon/Modules/PHP Extension Manager/UI/PhpExtensionManagerView.swift @@ -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 { diff --git a/tests/ui/StartupTest.swift b/tests/ui/StartupTest.swift index 4f413487..ee2cb0e7 100644 --- a/tests/ui/StartupTest.swift +++ b/tests/ui/StartupTest.swift @@ -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]) diff --git a/tests/ui/UITestCase.swift b/tests/ui/UITestCase.swift index 41aa5508..8160fce5 100644 --- a/tests/ui/UITestCase.swift +++ b/tests/ui/UITestCase.swift @@ -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 }