1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-27 22:40:08 +01:00

🚀 Version 26.02

This commit is contained in:
2026-03-07 17:32:20 +01:00
164 changed files with 4972 additions and 2205 deletions

View File

@@ -26,7 +26,9 @@ line_length:
ignores_function_declarations: true
ignores_comments: true
ignores_urls: true
warning: 120
ignores_interpolated_strings: true
ignores_multiline_strings: true
warning: 160
error: 200
analyzer_rules:

View File

@@ -0,0 +1,19 @@
Read the full prompt before checking out any files. I have added the following translations:
```
(paste placeholder. if this messsage is visible; ask me which translations were added.)
```
These were added to `phpmon/en.lproj/Localizable.strings`. I want the other files to be updated with localized versions of this.
You do not need to read out the entire other localizable files, you merely need to identify where to inject the new translations, which is below the following key: `(paste here. if this messsage is visible; ask me what key to use.)`.
---
To accomplish your task, you must:
- Identify all of the Localizable languages via the Xcode project file
- Translate the strings for each language identified
- Insert the translation below the appropriate key using `sed` (You should be able to do this by matching the key. Unlike the source English file, localization files do not have newlines or comments, so avoid adding those!)
- Validate all translations are OK via `scripts/verify_tl.sh`
- Never read out the full translation file (any `.strings` file), it will be too long! Read specific parts of files, you should have reference points. Ask me if you somehow would need to read out the file!

View File

@@ -53,7 +53,31 @@ If you'd like to create a production build, choose "Any Mac" as the target and s
## ✅ Testing
In order to properly test everything, you will want to use the _PHP Monitor DEV_ target. There are unit and UI tests both.
In order to properly test everything, you will want to use the _PHP Monitor EAP_ target. There are unit and UI tests both for this target.
### Unit tests
If you would like to run the unit tests outside of Xcode, you can run:
```sh
xcodebuild test \
-project "PHP Monitor.xcodeproj" \
-scheme "Unit Tests" \
-destination "platform=macOS" \
-parallel-testing-enabled NO
```
### UI tests
```sh
xcodebuild test \
-project "PHP Monitor.xcodeproj" \
-scheme "PHP Monitor" \
-destination "platform=macOS" \
-only-testing "UI Tests"
```
### Failures in UI tests
You may sporadically see failures in UI tests due to the following error: `Invalid parameter not satisfying: point.x != INFINITY && point.y != INFINITY`. This seems to be an issue with Xcode that Apple may need to resolve? You can retry the tests in question and they should eventually pass.
@@ -77,6 +101,22 @@ You can enable marketing mode by setting the `PHPMON_MARKETING_MODE` environment
launchctl setenv PHPMON_MARKETING_MODE true
## 👀 Previews (`#Preview`)
Xcode may have issues rendering various previews, like:
```swift
#Preview {
HeaderView(text: "Hello world").frame(width: 330.0)
}
```
I've noticed this is an issue on recent versions of Xcode (26.x) on macOS Tahoe (26.x). This likely has something to do with the new preview execution system that doesn't play nice with the particular way PHP Monitor has been set up (which is somewhat of a legacy project).
So, in order to ensure these previews render correctly, go to **Editor > Canvas > Use Legacy Previews Execution**.
This may help resolve any timeouts, but you must first build the project or you will get an error about caches.
## 🐛 Symbolication of crashes
The easiest way to symbolicate crashes is to simply rename the file to `.crash`, and drag it into Xcode.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -91,13 +91,17 @@
argument = "--v"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--ch"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--cli"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "YES">
isEnabled = "NO">
</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 = "2620"
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

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

View File

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

View File

@@ -70,7 +70,11 @@ If you have a very slow internet connection, the updater may report that the dow
If you would like to integrate with your launcher of choice, you can also download an [Alfred workflow](https://github.com/nicoverbruggen/phpmon/raw/main/integrations/phpmon.alfredworkflow) or [Raycast extension](https://www.raycast.com/nicoverbruggen/php-monitor) that works with PHP Monitor.
The app must be running in the background for these to work, and the _Allow third-party integrations_ checkbox must be enabled in Preferences (it is by default).
Keep in mind that third-party integrations are turned off by default, but you will be prompted to approve third-party integrations the very first time a third-party app attempts to connect with PHP Monitor.
You will only be prompted once to allow or disallow this, but you can always change your mind about this later, in Settings, by (un)checking the box next to "Allow third-party integrations".
(For more information about how this works and the potential security considerations, please consult the FAQ below.)
## 🔑 Is the app signed & notarized?
@@ -527,7 +531,9 @@ You can put as many apps as you'd like in the `scan_apps` array, and PHP Monitor
<details>
<summary><strong>How can the app integrate with third party tools, like Alfred or Raycast?</strong></summary>
PHP Monitor supports third party app integrations by default, and this feature is enabled in Preferences unless you disable it.
PHP Monitor supports third party app integrations, but this feature requires your approval the first time you invoke a command via a third-party app.
By default, this functionality is disabled and you will be prompted to turn it on, but this happens only once. You can change your mind later in the Settings window.
You can grab the official [Alfred workflow](https://github.com/nicoverbruggen/phpmon/raw/main/integrations/phpmon.alfredworkflow) or [Raycast extension](https://www.raycast.com/nicoverbruggen/php-monitor).
@@ -547,6 +553,10 @@ Using app callbacks, macOS and PHP Monitor allow for the following to be called:
* phpmon://phpinfo
* phpmon://switch/php/{version}
Once enabled, any application can invoke this URL scheme, so please keep in mind that enabling this functionality could lead to third-party applications invoking certain PHP Monitor without your express approval.
Also keep in mind that certain functionality may not be available if Valet is not currently installed (i.e. Standalone Mode is engaged, which severely limits the useful functionality of the app).
</details>
<details>
@@ -632,7 +642,7 @@ Thank you very much for your contributions, kind words and support.
### Loading info about PHP in the background
This app runs `php-config --version` in the background periodically, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
This app runs `php-config --version` in the background, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
PHP Monitor also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit). See also the section on *Config change detection* below.

View File

@@ -9,7 +9,6 @@
import Foundation
protocol CommandProtocol {
/**
Immediately executes a command.
@@ -39,3 +38,19 @@ protocol CommandProtocol {
) -> String
}
extension CommandProtocol {
func execute(
path: String,
arguments: [String],
trimNewlines: Bool
) -> String {
execute(
path: path,
arguments: arguments,
trimNewlines: trimNewlines,
withStandardError: false
)
}
}

View File

@@ -8,6 +8,8 @@
import Cocoa
public class RealCommand: CommandProtocol {
init() {}
public func execute(
path: String,
arguments: [String],

View File

@@ -15,15 +15,12 @@ class TestableCommand: CommandProtocol {
var commands: [String: String]
func execute(path: String, arguments: [String]) -> String {
self.execute(path: path, arguments: arguments, trimNewlines: false)
}
public func execute(path: String, arguments: [String], trimNewlines: Bool, withStandardError: Bool) -> String {
self.execute(path: path, arguments: arguments, trimNewlines: trimNewlines)
}
public func execute(path: String, arguments: [String], trimNewlines: Bool) -> String {
public func execute(
path: String,
arguments: [String],
trimNewlines: Bool,
withStandardError: Bool
) -> String {
let concatenatedCommand = "\(path) \(arguments.joined(separator: " "))"
assert(commands.keys.contains(concatenatedCommand), "Command `\(concatenatedCommand)` not found")
return self.commands[concatenatedCommand]!

View File

@@ -81,16 +81,7 @@ class Actions {
+ " && "
+ cellarCommands.joined(separator: " && ")
let source = "do shell script \"\(script)\" with administrator privileges"
Log.perf(source)
let appleScript = NSAppleScript(source: source)
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(nil)
if eventResult == nil {
throw HomebrewPermissionError(kind: .applescriptNilError)
}
try AppleScript.runSimpleShellAsAdmin(script)
}
// MARK: - Finding Config Files
@@ -106,6 +97,12 @@ class Actions {
}
public func openGlobalComposerFolder() {
// Check if we have a custom COMPOSER_HOME set
if let folder = App.shared.container.shell.exports["COMPOSER_HOME"] {
let file = URL(string: "file://\(folder)/composer.json".replacingTildeWithHomeDirectory)!
return NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
}
@@ -123,10 +120,15 @@ class Actions {
// MARK: - Other Actions
public func createTempPhpInfoFile() async -> URL {
// Clean state for temporary phpinfo files
try? container.filesystem.remove("/tmp/phpmon_phpinfo.php")
try? container.filesystem.remove("/tmp/phpmon_phpinfo.html")
// Generate a source file that we will execute immediately
try! container.filesystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
// Tell php-cgi to run the PHP and output as an .html file
await container.shell.quiet("\(paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
await container.shell.pipe("\(paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
}

View File

@@ -0,0 +1,69 @@
//
// AppleScript.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Foundation
class AppleScript {
/**
Execute a simple shell script with administrative privileges (as root).
@return Returns the output of the script.
*/
@discardableResult
public static func runSimpleShellAsAdmin(
_ script: String
) throws -> String {
let source = "do shell script \"\(script)\" with administrator privileges"
return try runAppleScript(script: source)
}
/**
Execute a shell script with administrative privileges, but sets USER to the current user, and also adds the Homebrew `bin` folder to the PATH.
Using this may be necessary for certain scripts to work correctly, like `valet trust`, which may execute `which php` as part of the PHP script it runs, and thus requires knowledge about the current user and where the PHP binaries are.
@return The output of the script.
*/
@discardableResult
public static func runShellAsAdmin(
_ script: String,
asUser user: String = App.shared.container.paths.whoami,
appendToPATH append: String = App.shared.container.paths.binPath,
) throws -> String {
let script = """
export USER=\(user) && \
export PATH=/usr/bin:/bin:/usr/sbin:/sbin:\(append) \
&& \(script)
"""
let source = "do shell script \"\(script)\" with administrator privileges"
return try runAppleScript(script: source)
}
/**
Runs a given AppleScript.
*/
private static func runAppleScript(script: String) throws -> String {
Log.info("Running via AppleScript: `\(script)`")
let appleScript = NSAppleScript(source: script)
var error: NSDictionary?
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(&error)
if let error = error {
Log.err("AppleScript error: \(error)")
throw AdminPrivilegeError(kind: .applescriptNilError)
}
guard let result = eventResult else {
Log.err("Unknown AppleScript error")
throw AdminPrivilegeError(kind: .applescriptNilError)
}
return result.stringValue ?? ""
}
}

View File

@@ -76,9 +76,7 @@ struct Constants {
*/
static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [
"8.6": Date.fromString(PhpFormulaeCutoffDate),
"8.5": Date.fromString("2025-11-20"),
"8.4": Date.fromString("2024-11-22")
"8.6": Date.fromString(PhpFormulaeCutoffDate)
]
return Set(releaseDates

View File

@@ -11,5 +11,6 @@ import Foundation
class Events {
static let ServicesUpdated = Notification.Name("ServicesUpdated")
static let PreferencesUpdated = Notification.Name("PreferencesUpdated")
}

View File

@@ -18,7 +18,7 @@ func brew(
_ command: String,
sudo: Bool = false,
) async {
await container.shell.quiet("\(sudo ? "sudo " : "")" + "\(container.paths.brew) \(command)")
await container.shell.pipe("\(sudo ? "sudo " : "")" + "\(container.paths.brew) \(command)")
}
/**
@@ -37,9 +37,9 @@ func sed(
// Check if gsed exists; it is able to follow symlinks,
// which we want to do to toggle the extension
if container.filesystem.fileExists("\(container.paths.binPath)/gsed") {
await container.shell.quiet("\(container.paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
await container.shell.pipe("\(container.paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
} else {
await container.shell.quiet("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
await container.shell.pipe("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
}
}

View File

@@ -1,5 +1,5 @@
//
// VersionParseError.swift
// Errors.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 08/02/2022.
@@ -11,7 +11,7 @@ import Foundation
// MARK: - Alertable Errors
// These errors must be resolved by the user.
struct HomebrewPermissionError: Error, AlertableError {
struct AdminPrivilegeError: Error, AlertableError {
enum Kind: String {
case applescriptNilError = "homebrew_permissions.applescript_returned_nil"
}

View File

@@ -19,6 +19,8 @@ extension NSMenuItem {
) {
self.init(title: title, action: action, keyEquivalent: keyEquivalent)
self.keyEquivalentModifierMask = keyModifier
if !Preferences.isEnabled(.hideIconsInMenu) {
if systemImage != nil {
self.image = NSImage(systemSymbolName: systemImage!, accessibilityDescription: "")
}
@@ -26,6 +28,7 @@ extension NSMenuItem {
self.image = NSImage(named: customImage!)
}
}
}
convenience init(
title: String,
@@ -52,12 +55,16 @@ extension NSMenuItem {
self.init(title: title, action: nil, keyEquivalent: keyEquivalent)
self.keyEquivalentModifierMask = keyModifier
self.toolTip = toolTip
if !Preferences.isEnabled(.hideIconsInMenu) {
if systemImage != nil {
self.image = NSImage(systemSymbolName: systemImage!, accessibilityDescription: "")
}
if customImage != nil {
self.image = NSImage(named: customImage!)
}
}
self.submenu = NSMenu(items: submenu, target: target)
}
}

View File

@@ -9,7 +9,7 @@ import SwiftUI
struct Localization {
static var preferredLanguage: String? {
if App.shared.preferences == nil {
if App.shared.container.preferences == nil {
return nil
}
@@ -65,7 +65,7 @@ extension String {
return NSLocalizedString(self, bundle: bundle, comment: "")
}
return string.replacing("Preferences", with: "Settings")
return string
}
var localizedForSwiftUI: LocalizedStringKey {
@@ -76,6 +76,13 @@ extension String {
String(format: self.localized, arguments: args)
}
func localized(for locale: String = "en") -> String {
guard let path = Localization.bundle.path(forResource: locale, ofType: "lproj"),
let bundle = Bundle(path: path)
else { return self }
return NSLocalizedString(self, bundle: bundle, comment: "")
}
func countInstances(of stringToFind: String) -> Int {
if stringToFind.isEmpty {
return 0

View File

@@ -87,7 +87,7 @@ class RealFileSystem: FileSystemProtocol {
// MARK: FS Attributes
func makeExecutable(_ path: String) throws {
_ = container.shell.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
container.shell.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
}
// MARK: - Checks

View File

@@ -178,6 +178,31 @@ class TestableFileSystem: FileSystemProtocol {
}
}
// MARK: - Transaction Helpers
func createSymlink(_ path: String, destination: String) {
let path = path.replacingTildeWithHomeDirectory
let destination = destination.replacingTildeWithHomeDirectory
accessQueue.sync {
self.createIntermediateDirectories(path)
self.files[path] = .fake(.symlink, destination)
}
}
func writeFile(_ path: String, content: String, overwrite: Bool) throws {
let path = path.replacingTildeWithHomeDirectory
try accessQueue.sync {
if !overwrite, files[path] != nil {
throw TestableFileSystemError.alreadyExists
}
self.createIntermediateDirectories(path)
self.files[path] = .fake(.text, content)
}
}
// MARK: - Checks
func isExecutableFile(_ path: String) -> Bool {

View File

@@ -32,10 +32,13 @@ class Application {
/// The full path to the application bundle (if found)
var path: String?
/// Characters that are unsafe for shell interpolation inside double quotes.
private static let unsafeCharacters: Set<Character> = ["\"", "\\", "`", "$", ";", "|", "&", "!", "#"]
/// Initializer. Used to detect a specific app of a specific type.
init(_ container: Container, _ name: String, _ type: AppType) {
self.container = container
self.name = name
self.name = String(name.filter { !Application.unsafeCharacters.contains($0) })
self.type = type
self.path = determinePath()
}
@@ -45,7 +48,7 @@ class Application {
(This will open the app if it isn't open yet.)
*/
@objc public func open(arg: String) {
Task { await container.shell.quiet("/usr/bin/open -a \"\(name)\" \"\(arg)\"") }
Task { await container.shell.pipe("/usr/bin/open -a \"\(name)\" \"\(arg)\"") }
}
/**

View File

@@ -11,6 +11,7 @@ import Foundation
/**
Run a simple blocking Shell command on the user's own system.
*/
@discardableResult
public func system(_ command: String) -> String {
let task = Process()
task.launchPath = "/bin/sh"

View File

@@ -0,0 +1,97 @@
//
// CommandTracker.swift
// PHP Monitor
//
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
@preconcurrency import Dispatch
@MainActor
class CommandTracker: ObservableObject {
nonisolated init() {}
private let maxStoredCommands = 200
@Published private(set) var commands: [LoggedCommand] = []
var activeCommands: [LoggedCommand] {
commands.filter { !$0.isCompleted }
}
@discardableResult
func track(_ command: String, id: UUID = UUID()) -> UUID {
let tracked = LoggedCommand(id: id, command: command, startedAt: Date())
commands.append(tracked)
if commands.count > maxStoredCommands {
commands.removeFirst(commands.count - maxStoredCommands)
}
return tracked.id
}
func complete(_ id: UUID) {
if let index = commands.firstIndex(where: { $0.id == id }) {
commands[index].completedAt = Date()
}
}
nonisolated func trackFromAnyThread(_ command: String) -> UUID {
let id = UUID()
Task { @MainActor in
self.track(command, id: id)
}
return id
}
nonisolated func completeFromAnyThread(_ id: UUID) {
Task { @MainActor in
self.complete(id)
}
}
}
// MARK: - Logged Command
struct LoggedCommand: Identifiable {
let id: UUID
let command: String
let startedAt: Date
var completedAt: Date?
var isCompleted: Bool {
completedAt != nil
}
func durationText(at date: Date = Date()) -> String {
if let completedAt {
return Self.formattedDuration(
completedAt.timeIntervalSince(startedAt),
isCompleted: true
)
}
return Self.formattedDuration(
date.timeIntervalSince(startedAt),
isCompleted: false
)
}
private static func formattedDuration(
_ duration: TimeInterval,
isCompleted: Bool
) -> String {
if duration >= 0.3 {
let seconds = String(format: "%.2f", duration)
let durationText = "\(seconds) s"
return isCompleted
? "command_history.completed_in".localized(durationText)
: "command_history.running_for".localized(durationText)
}
let ms = max(1, Int(duration * 1000))
let durationText = "\(ms) ms"
return isCompleted
? "command_history.completed_in".localized(durationText)
: "command_history.running_for".localized(durationText)
}
}

View File

@@ -0,0 +1,38 @@
//
// TrackedTestableCommand.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 24/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Foundation
final class TrackableTestableCommand: TestableCommand {
private let commandTracker: CommandTracker
init(commands: [String: String], _ commandTracker: CommandTracker) {
self.commandTracker = commandTracker
super.init(commands: commands)
}
override func execute(
path: String,
arguments: [String],
trimNewlines: Bool,
withStandardError: Bool
) -> String {
let commandDescription = "\(path) \(arguments.joined(separator: " "))"
let trackingId = commandTracker.trackFromAnyThread(commandDescription)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return super.execute(
path: path,
arguments: arguments,
trimNewlines: trimNewlines,
withStandardError: withStandardError
)
}
}

View File

@@ -0,0 +1,65 @@
//
// TrackedTestableShell.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 24/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Foundation
final class TrackableTestableShell: TestableShell {
private let commandTracker: CommandTracker
init(
expectations: [String: BatchFakeShellOutput],
filesystem: TestableFileSystem?,
_ commandTracker: CommandTracker
) {
self.commandTracker = commandTracker
super.init(expectations: expectations, filesystem: filesystem)
}
override func sync(_ command: String) -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return super.sync(command)
}
@discardableResult
override func pipe(_ command: String) async -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return await super.pipe(command)
}
@discardableResult
override func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return await super.pipe(command, timeout: timeout)
}
@discardableResult
override func attach(
_ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void,
withTimeout timeout: TimeInterval
) async throws -> (Process, ShellOutput) {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return try await super.attach(
command,
didReceiveOutput: didReceiveOutput,
withTimeout: timeout
)
}
}

View File

@@ -0,0 +1,38 @@
//
// TrackedCommand.swift
// PHP Monitor
//
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
final class TrackedCommand: CommandProtocol {
private let command: CommandProtocol
private let commandTracker: CommandTracker
init(command: CommandProtocol, commandTracker: CommandTracker) {
self.command = command
self.commandTracker = commandTracker
}
func execute(
path: String,
arguments: [String],
trimNewlines: Bool,
withStandardError: Bool
) -> String {
let commandDescription = "\(path) \(arguments.joined(separator: " "))"
let trackingId = commandTracker.trackFromAnyThread(commandDescription)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return command.execute(
path: path,
arguments: arguments,
trimNewlines: trimNewlines,
withStandardError: withStandardError
)
}
}

View File

@@ -0,0 +1,72 @@
//
// TrackedShell.swift
// PHP Monitor
//
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
final class TrackedShell: ShellProtocol {
private let shell: ShellProtocol
private let commandTracker: CommandTracker
init(shell: ShellProtocol, commandTracker: CommandTracker) {
self.shell = shell
self.commandTracker = commandTracker
}
var PATH: String {
shell.PATH
}
func sync(_ command: String) -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return shell.sync(command)
}
@discardableResult
func pipe(_ command: String) async -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return await shell.pipe(command)
}
@discardableResult
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return await shell.pipe(command, timeout: timeout)
}
@discardableResult
func attach(
_ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void,
withTimeout timeout: TimeInterval
) async throws -> (Process, ShellOutput) {
let trackingId = commandTracker.trackFromAnyThread(command)
defer {
commandTracker.completeFromAnyThread(trackingId)
}
return try await shell.attach(command, didReceiveOutput: didReceiveOutput, withTimeout: timeout)
}
// MARK: - Custom exports
var exports: [String: String] {
get { shell.exports }
set { shell.exports = newValue }
}
func reloadEnvPath() {
shell.reloadEnvPath()
}
}

View File

@@ -137,7 +137,9 @@ class PhpEnvironments {
// Update the synchronized value
_currentInstall.value = newValue
// Let the PHP extension manager, if it exists, know the version changed
App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion()
WindowManager
.controller(of: PhpExtensionManagerWC.self)?
.view.didUpdatePhpVersion()
}
}
@@ -218,7 +220,7 @@ class PhpEnvironments {
}
public func reloadPhpVersions() async {
_ = await self.detectPhpVersions()
await self.detectPhpVersions()
}
/**
@@ -228,6 +230,7 @@ class PhpEnvironments {
Returns a `Set<String>` of installations that are considered valid.
*/
@discardableResult
public func detectPhpVersions() async -> Set<String> {
let files = await container.shell.pipe("ls \(container.paths.optPath) | grep php@").out

View File

@@ -127,13 +127,13 @@ class PhpHelper {
if !container.filesystem.fileExists(destination) {
Log.info("Creating new symlink: \(destination)")
await container.shell.quiet("ln -s \(source) \(destination)")
await container.shell.pipe("ln -s \(source) \(destination)")
return
}
if !App.shared.container.filesystem.isSymlink(destination) {
Log.info("Overwriting existing file with new symlink: \(destination)")
await container.shell.quiet("ln -fs \(source) \(destination)")
await container.shell.pipe("ln -fs \(source) \(destination)")
return
}

View File

@@ -133,8 +133,11 @@ class PhpConfigurationFile: CreatedFromFile {
}
public func reload() {
let newLines = try! String(contentsOfFile: self.filePath)
.components(separatedBy: "\n")
guard let newLines = try? String(contentsOfFile: self.filePath)
.components(separatedBy: "\n") else {
Log.warn("Could not reload PHP configuration file at: `\(self.filePath)`")
return
}
// Update all properties atomically
lines = newLines

View File

@@ -41,7 +41,6 @@ class PhpExtension {
return String(file.split(separator: "/").last ?? "php.ini")
}
// swiftlint:disable line_length
/**
This regular expression will allow us to identify lines which activate an extension.
@@ -55,7 +54,6 @@ class PhpExtension {
- Note: Extensions that are disabled in a different way will not be detected. This is intentional.
*/
static let extensionRegex = #"^(extension|zend_extension|;(\s?)extension|;(\s?)zend_extension)(\s?)(=)(\s?)(?<name>["]?(?:\/?.\/?)+(?:\.so)"?)$"#
// swiftlint:enable line_length
/**
When registering an extension, we do that based on the line found inside the .ini file.

View File

@@ -11,6 +11,7 @@ import Foundation
extension InternalSwitcher {
typealias FixApplied = Bool
@discardableResult
public func ensureValetConfigurationIsValidForPhpVersion(_ version: String) async -> FixApplied {
// Early exit if Valet is not installed
if !Valet.installed {
@@ -71,7 +72,8 @@ extension InternalSwitcher {
}
do {
var contents = try container.filesystem.getStringFromFile("~/.composer/vendor/laravel/valet" + file.source)
var contents = try container.filesystem
.getStringFromFile("~/.composer/vendor/laravel/valet" + file.source)
for (original, replacement) in file.replacements {
contents = contents.replacing(original, with: replacement)

View File

@@ -53,7 +53,7 @@ class InternalSwitcher: PhpSwitcher {
for formula in versions {
if Valet.installed {
Log.info("Ensuring that the Valet configuration is valid...")
_ = await self.ensureValetConfigurationIsValidForPhpVersion(formula)
await self.ensureValetConfigurationIsValidForPhpVersion(formula)
}
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
@@ -112,7 +112,7 @@ class InternalSwitcher: PhpSwitcher {
if Valet.enabled(feature: .isolatedSites) && primary {
let socketVersion = version.replacing(".", with: "")
await container.shell.quiet("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
await container.shell.pipe("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
}
}

View File

@@ -13,9 +13,8 @@ class RealShell: ShellProtocol, @unchecked Sendable {
init(binPath: String) {
self.binPath = binPath
self._PATH = RealShell.getPath()
self._exports = ""
self._exports = [:]
}
private(set) var binPath: String
/**
@@ -38,8 +37,9 @@ class RealShell: ShellProtocol, @unchecked Sendable {
/**
Exports are additional environment variables set by the user via the custom configuration.
These are populated when the configuration file is being loaded.
These are now set via via Process.environment to avoid security issues, like shell injection.
*/
internal var exports: String {
internal var exports: [String: String] {
get { shellQueue.sync { _exports } }
set { shellQueue.sync { _exports = newValue } }
}
@@ -49,7 +49,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
/** 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
private var _exports: [String: String]
// MARK: - Methods
@@ -75,22 +75,23 @@ class RealShell: ShellProtocol, @unchecked Sendable {
This process still needs to be started, or one can attach output handlers.
*/
private func getShellProcess(for command: String) -> Process {
var completeCommand = ""
// Basic export (PATH)
completeCommand += "export PATH=\(binPath):$PATH && "
// Put additional exports (as defined by the user) in between
if !self.exports.isEmpty {
completeCommand += "\(self.exports) && "
}
completeCommand += command
let completeCommand = "export PATH=\(binPath):$PATH && " + command
let task = Process()
task.launchPath = self.launchPath
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
// Set user-defined environment variables safely via Process API
// instead of interpolating them into the shell command string.
let currentExports = self.exports
if !currentExports.isEmpty {
var env = ProcessInfo.processInfo.environment
for (key, value) in currentExports {
env[key] = value
}
task.environment = env
}
return task
}
@@ -111,20 +112,39 @@ class RealShell: ShellProtocol, @unchecked Sendable {
return result
}
// MARK: - Public API
/**
Set custom environment variables.
These will be exported when a command is executed.
Verbose logging for when executing a shell command.
*/
public func setCustomEnvironmentVariables(_ variables: [String: String]) {
self.exports = variables.map { (key, value) in
return "export \(key)=\(value)"
}.joined(separator: "&&")
private func log(process: Process, stdOut: String, stdErr: String) {
var args = process.arguments ?? []
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
<~~~~~~~~~~~~~~~~~~~~~~~
$ \(([self.launchPath] + args + [last]).joined(separator: " "))
[OUT]:
\(stdOut)
"""
if !stdErr.isEmpty {
log.append("""
[ERR]:
\(stdErr)
""")
}
log.append("""
~~~~~~~~~~~~~~~~~~~~~~~~>
""")
Log.info(log)
}
// MARK: - Shellable Protocol
@discardableResult
func sync(_ command: String) -> ShellOutput {
let process = getShellProcess(for: command)
@@ -155,6 +175,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
return .out(stdOut, stdErr)
}
@discardableResult
func pipe(_ command: String) async -> ShellOutput {
let process = getShellProcess(for: command)
@@ -190,37 +211,73 @@ class RealShell: ShellProtocol, @unchecked Sendable {
}
}
private func log(process: Process, stdOut: String, stdErr: String) {
var args = process.arguments ?? []
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
@discardableResult
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
let process = getShellProcess(for: command)
<~~~~~~~~~~~~~~~~~~~~~~~
$ \(([self.launchPath] + args + [last]).joined(separator: " "))
let outputPipe = Pipe()
let errorPipe = Pipe()
[OUT]:
\(stdOut)
"""
if !stdErr.isEmpty {
log.append("""
[ERR]:
\(stdErr)
""")
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
Log.info("[SLOW SHELL] \(command)")
await delay(seconds: 3.0)
}
log.append("""
~~~~~~~~~~~~~~~~~~~~~~~~>
process.standardOutput = outputPipe
process.standardError = errorPipe
""")
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.pipe_timeout_queue")
Log.info(log)
return await withCheckedContinuation { continuation in
var resumed = false
let timeoutWorkItem = DispatchWorkItem {
guard process.isRunning else { return }
Log.warn("Command timed out after \(timeout)s: \(command)")
process.terminationHandler = nil
process.terminate()
serialQueue.async {
if !resumed {
resumed = true
continuation.resume(returning: .out("", ""))
}
}
}
func quiet(_ command: String) async {
_ = await self.pipe(command)
serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem)
process.terminationHandler = { [weak self] _ in
timeoutWorkItem.cancel()
serialQueue.async {
if resumed { return }
if process.terminationReason == .uncaughtSignal {
Log.err("The command `\(command)` likely crashed. Returning empty output.")
resumed = true
continuation.resume(returning: .out("", ""))
return
}
let stdOut = RealShell.getStringOutput(from: outputPipe)
let stdErr = RealShell.getStringOutput(from: errorPipe)
if Log.shared.verbosity == .cli {
self?.log(process: process, stdOut: stdOut, stdErr: stdErr)
}
resumed = true
continuation.resume(returning: .out(stdOut, stdErr))
}
}
process.launch()
}
}
@discardableResult
func attach(
_ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void,
@@ -235,7 +292,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
let output = ShellOutput.empty()
// Only access `resumed`, `output` from serialQueue to ensure thread safety
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output")
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.attach_queue")
return try await withCheckedThrowingContinuation({ continuation in
// Guard against resuming the continuation twice (race between timeout and termination)
@@ -282,7 +339,9 @@ class RealShell: ShellProtocol, @unchecked Sendable {
process.terminationHandler = { process in
serialQueue.async {
timeoutTaskTermination.cancel()
}
// Check if already resumed (timeout fired first)
if resumed { return }
// Clean up readability handlers
outputPipe.fileHandleForReading.readabilityHandler = nil
@@ -292,7 +351,6 @@ class RealShell: ShellProtocol, @unchecked Sendable {
let remainingOut = outputPipe.fileHandleForReading.readDataToEndOfFile()
let remainingErr = errorPipe.fileHandleForReading.readDataToEndOfFile()
serialQueue.async {
if !remainingOut.isEmpty, let string = String(data: remainingOut, encoding: .utf8) {
output.out += string
didReceiveOutput(string, .stdOut)
@@ -303,12 +361,10 @@ class RealShell: ShellProtocol, @unchecked Sendable {
didReceiveOutput(string, .stdErr)
}
if !resumed {
resumed = true
continuation.resume(returning: (process, output))
}
}
}
process.launch()
})

View File

@@ -8,38 +8,55 @@
import Foundation
protocol ShellProtocol {
protocol ShellProtocol: AnyObject {
/**
The PATH for the current shell.
*/
var PATH: String { get }
/**
Run a command synchronously. Use with caution.
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: String] { get set }
/**
Run a command synchronously. Use with caution!
Common usage:
```
let output = Shell.sync("php -v")
```
@return The shell output. If the command times out, returns empty output.
*/
@discardableResult
func sync(_ command: String) -> ShellOutput
/**
Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists).
Common usage:
```
let output = await Shell.pipe("php -v")
```
@return The shell output. If the command times out, returns empty output.
*/
@discardableResult
func pipe(_ command: String) async -> ShellOutput
/**
Run a command asynchronously, without returning the output of the command.
Run a command asynchronously with a timeout.
Returns the most relevant output (prefers error output if it exists).
- Parameter command: The command to execute.
- Parameter timeout: Timeout in seconds. If the command exceeds this, it is terminated.
@return The shell output. If the command times out, returns empty output.
*/
func quiet(_ command: String) async
@discardableResult
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput
/**
Runs a command asynchronously, and fires closure with `stdout` or `stderr` data as it comes in.
@@ -49,8 +66,10 @@ protocol ShellProtocol {
(Whether it is complete or not.)
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism.
The end result is still the most relevant output (where error output is preferred if it exists).
@return A tuple, containing the `Process` and `ShellOutput` objects.
*/
@discardableResult
func attach(
_ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void,

View File

@@ -13,12 +13,18 @@ public class TestableShell: ShellProtocol {
return "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin"
}
init(expectations: [String: BatchFakeShellOutput]) {
init(expectations: [String: BatchFakeShellOutput], filesystem: TestableFileSystem? = nil) {
self.expectations = expectations
self.filesystem = filesystem
}
var expectations: [String: BatchFakeShellOutput] = [:]
var filesystem: TestableFileSystem?
// Custom exports; unused because we have preset shell output, but relevant for certain checks in-app
var exports: [String: String] = [:]
@discardableResult
func sync(_ command: String) -> ShellOutput {
// This assertion will only fire during test builds
assert(expectations.keys.contains(command), "No response declared for command: \(command)")
@@ -27,18 +33,23 @@ public class TestableShell: ShellProtocol {
return .err("No Expected Output")
}
return expectation.syncOutput()
}
func quiet(_ command: String) async {
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
}
func pipe(_ command: String) async -> ShellOutput {
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
let output = expectation.syncOutput()
applyTransactions(for: expectation)
return output
}
@discardableResult
func pipe(_ command: String) async -> ShellOutput {
await pipe(command, timeout: 60)
}
@discardableResult
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: timeout)
return output
}
@discardableResult
func attach(
_ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void,
@@ -62,12 +73,28 @@ public class TestableShell: ShellProtocol {
didReceiveOutput(output, type)
}, ignoreDelay: isRunningTests)
applyTransactions(for: expectation)
return (Process(), output)
}
func reloadEnvPath() {
// does nothing
}
private func applyTransactions(for expectation: BatchFakeShellOutput) {
if !expectation.transactions.isEmpty {
assert(filesystem != nil, "Transactions require a filesystem")
}
guard let filesystem else {
return
}
expectation.transactions.forEach { transaction in
transaction.apply(to: filesystem, shell: self)
}
}
}
struct FakeShellOutput: Codable {
@@ -86,6 +113,7 @@ struct FakeShellOutput: Codable {
struct BatchFakeShellOutput: Codable {
var items: [FakeShellOutput]
var transactions: [FakeShellTransaction] = []
static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput {
return BatchFakeShellOutput(items: items)
@@ -117,6 +145,8 @@ struct BatchFakeShellOutput: Codable {
await delay(seconds: item.delay)
}
didReceiveOutput(item.output, item.stream)
if item.stream == .stdErr {
output.err += item.output
} else if item.stream == .stdOut {
@@ -159,3 +189,85 @@ struct BatchFakeShellOutput: Codable {
return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true)
}
}
/**
Prepares a particular transaction that modifies testable state after running a shell command.
Currently, it possible to modify the state of `TestableShell` and `TestableFileSystem`.
*/
struct FakeShellTransaction: Codable {
/// Creates a symlink for a given path to a given destination in `TestableFileSystem`.
static func symlink(_ path: String, to destination: String) -> FakeShellTransaction {
FakeShellTransaction(type: .createSymlink, path: path, destination: destination)
}
/// Writes some content to a file to a path, overwriting existing entries in `TestableFileSystem`.
static func write(_ content: String, to path: String, overwrite: Bool = true) -> FakeShellTransaction {
FakeShellTransaction(type: .writeFile, path: path, content: content, overwrite: overwrite)
}
/// Removes a file from `TestableFileSystem`.
static func remove(_ path: String) -> FakeShellTransaction {
FakeShellTransaction(type: .remove, path: path)
}
/// Moves a file in `TestableFileSystem`.
static func move(_ from: String, to: String) -> FakeShellTransaction {
FakeShellTransaction(type: .move, from: from, to: to)
}
/// Creates a directory in `TestableFileSystem`.
static func mkdir(_ path: String) -> FakeShellTransaction {
FakeShellTransaction(type: .createDirectory, path: path)
}
/// Updates shell output for a particular command in `TestableShell`.
/// Use this if you expect running a particular command twice to have a different outcome the second time, for example.
static func shell(_ command: String, _ output: BatchFakeShellOutput) -> FakeShellTransaction {
FakeShellTransaction(type: .setShellOutput, command: command, output: output)
}
enum TransactionType: String, Codable {
case createSymlink
case writeFile
case remove
case move
case createDirectory
case setShellOutput
}
private var type: TransactionType
private var path: String?
private var destination: String?
private var content: String?
private var overwrite: Bool?
private var from: String?
private var to: String?
private var command: String?
private var output: BatchFakeShellOutput?
/**
Applies a given transaction that will modify the testable filesystem or testable shell as part of a transaction.
*/
func apply(to filesystem: TestableFileSystem, shell: TestableShell) {
switch type {
case .createSymlink:
assert(path != nil && destination != nil, "createSymlink requires path and destination")
filesystem.createSymlink(path!, destination: destination!)
case .writeFile:
assert(path != nil && content != nil && overwrite != nil, "writeFile requires path, content, overwrite")
try? filesystem.writeFile(path!, content: content!, overwrite: overwrite!)
case .remove:
assert(path != nil, "remove requires path")
try? filesystem.remove(path!)
case .move:
assert(from != nil && to != nil, "move requires from and to")
try? filesystem.move(from: from!, to: to!)
case .createDirectory:
assert(path != nil, "createDirectory requires path")
try? filesystem.createDirectory(path!, withIntermediateDirectories: true)
case .setShellOutput:
assert(command != nil && output != nil, "setShellOutput requires command and output")
shell.expectations[command!] = output!
}
}
}

View File

@@ -13,7 +13,7 @@ public struct TestableConfiguration: Codable {
var filesystem: [String: FakeFile]
var shellOutput: [String: BatchFakeShellOutput]
var commandOutput: [String: String]
var preferenceOverrides: [PreferenceName: Bool]
var preferenceOverrides: [PreferenceName: PreferenceOverride]
var apiGetResponses: [URL: FakeWebApiResponse]
var apiPostResponses: [URL: FakeWebApiResponse]
@@ -22,7 +22,7 @@ public struct TestableConfiguration: Codable {
filesystem: [String: FakeFile],
shellOutput: [String: BatchFakeShellOutput],
commandOutput: [String: String],
preferenceOverrides: [PreferenceName: Bool],
preferenceOverrides: [PreferenceName: PreferenceOverride],
phpVersions: [VersionNumber],
apiGetResponses: [URL: FakeWebApiResponse],
apiPostResponses: [URL: FakeWebApiResponse]
@@ -138,8 +138,13 @@ public struct TestableConfiguration: Codable {
Log.info("Applying temporary preference overrides...")
var cachedPrefs = container.preferences.cachedPreferences
preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in
cachedPrefs[key] = value
preferenceOverrides.forEach { (key: PreferenceName, value: PreferenceOverride) in
switch value {
case .bool(let boolValue):
cachedPrefs[key] = boolValue
case .string(let stringValue):
cachedPrefs[key] = stringValue
}
}
container.preferences.cachedPreferences = cachedPrefs
@@ -193,3 +198,8 @@ public struct TestableConfiguration: Codable {
)
}
}
enum PreferenceOverride: Codable {
case bool(Bool)
case string(String)
}

View File

@@ -7,9 +7,9 @@
//
extension Container {
public static func real(minimal: Bool = false) -> Container {
public static func real(minimal: Bool = false, commandTracking: Bool = true) -> Container {
let container = Container()
container.bind(coreOnly: minimal)
container.bind(coreOnly: minimal, commandTracking: commandTracking)
return container
}
}

View File

@@ -16,6 +16,7 @@ class Container: @unchecked Sendable {
private(set) var paths: Paths!
private(set) var shell: ShellProtocol!
private(set) var command: CommandProtocol!
private(set) var commandTracker: CommandTracker!
private(set) var webApi: WebApiProtocol!
// Secondary (uses primary instances above)
@@ -54,7 +55,10 @@ class Container: @unchecked Sendable {
/// - Parameter coreOnly: Only binds `shell`, `filesystem`, `command`, `paths` and `webApi`.
/// Use this to prevent slowing down tests for a minimal container.
///
public func bind(coreOnly: Bool = false) {
/// - Parameter commandTracking: When enabled, connects decorated RealShell and RealCommand.
/// Use this if you want to disable tracking (shell) command statuses, since it's on by default.
///
public func bind(coreOnly: Bool = false, commandTracking: Bool = true) {
if self.bound {
fatalError("You cannot call `bind` on a Container more than once.")
}
@@ -67,8 +71,20 @@ class Container: @unchecked Sendable {
// any of the other classes can be initialized!
self.filesystem = RealFileSystem(container: self)
self.paths = Paths(container: self)
self.shell = RealShell(binPath: paths.binPath)
self.command = RealCommand()
self.commandTracker = CommandTracker()
let baseShellHandler = RealShell(binPath: paths.binPath)
let baseCommandHandler = RealCommand()
// Depending on whether we need command tracking wired up, we will use different real handlers
if commandTracking {
self.shell = TrackedShell(shell: baseShellHandler, commandTracker: commandTracker)
self.command = TrackedCommand(command: baseCommandHandler, commandTracker: commandTracker)
} else {
self.shell = baseShellHandler
self.command = baseCommandHandler
}
self.webApi = RealWebApi(container: self)
if coreOnly {
@@ -95,11 +111,24 @@ class Container: @unchecked Sendable {
fileSystemFiles: [String: FakeFile] = [:],
commands: [String: String] = [:],
webApiGetResponses: [URL: FakeWebApiResponse] = [:],
webApiPostResponses: [URL: FakeWebApiResponse] = [:]
webApiPostResponses: [URL: FakeWebApiResponse] = [:],
commandTracking: Bool = true,
) {
self.shell = TestableShell(expectations: shellExpectations)
self.filesystem = TestableFileSystem(files: fileSystemFiles)
self.commandTracker = CommandTracker()
let filesystem = TestableFileSystem(files: fileSystemFiles)
// Depending on whether we want to fire command tracking, load different handlers
if commandTracking {
self.shell = TrackableTestableShell(expectations: shellExpectations, filesystem: filesystem, commandTracker)
self.command = TrackableTestableCommand(commands: commands, commandTracker)
} else {
self.shell = TestableShell(expectations: shellExpectations, filesystem: filesystem)
self.command = TestableCommand(commands: commands)
}
self.filesystem = filesystem
self.webApi = TestableWebApi(
getResponses: webApiGetResponses,
postResponses: webApiPostResponses

View File

@@ -41,4 +41,14 @@ extension App {
NSApp.setActivationPolicy(!openWindows.isEmpty ? .regular : .accessory)
}
/**
Closes and invalidates all cached secondary window controllers (excluding Preferences).
This ensures that windows are recreated fresh, with the correct localization, the next
time they are opened. Each `close()` call triggers `windowWillClose`, which automatically
removes the window from `openWindows` via the existing delegate mechanism.
*/
public func invalidateCachedWindows() {
WindowManager.closeAll(excluding: [PreferencesWC.self])
}
}

View File

@@ -81,36 +81,12 @@ class App {
*/
var container: Container = Container()
/** The list of preferences that are currently active. */
var preferences: [PreferenceName: Bool]!
/** The window controller of the currently active preferences window. */
var preferencesWindowController: PreferencesWindowController?
/** The window controller of the currently active site list window. */
var domainListWindowController: DomainListWindowController?
/** The window controller of the onboarding window. */
var onboardingWindowController: OnboardingWindowController?
/** The window controller of the config manager window. */
var phpConfigManagerWindowController: PhpConfigManagerWindowController?
/** The window controller of the warnings window. */
var phpDoctorWindowController: PhpDoctorWindowController?
/** The window controller of the PHP version manager window. */
var phpVersionManagerWindowController: PhpVersionManagerWindowController?
/** The window controller of the PHP extension manager window. */
var phpExtensionManagerWindowController: PhpExtensionManagerWindowController?
/** URL that was received before the app finished booting. Will be processed once the startup procedure completes. */
var deferredURL: URL?
/** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = []
/** Timer that will periodically reload info about the user's PHP installation. */
var timer: Timer?
// MARK: - Global Hotkey
/**
@@ -122,7 +98,7 @@ class App {
}
}
// MARK: - Activation Policy
// MARK: - Windows and Activation Policy
/**
Variable that keeps track of which windows are currently open.

View File

@@ -8,6 +8,7 @@
import Cocoa
import Foundation
import NVAlert
extension AppDelegate {
@@ -16,16 +17,46 @@ extension AppDelegate {
application URL. You can use the `phpmon://` protocol to communicate with the app.
At this time you can trigger the site list using Alfred (or some other application)
by opening the following URL: `phpmon://list`.
by opening the following URL: `phpmon://list`. Various other commands are also made
available via the `InterApp` class, which is where all commands and their different
interactions are declared.
Please note that PHP Monitor needs to be running in the background for this to work.
Please note that PHP Monitor needs to be running in the background for this to work,
or the app will launch and the command will be deferred until the app is ready.
*/
@MainActor func application(_ application: NSApplication, open urls: [URL]) {
guard Startup.hasFinishedBooting else {
return deferURLs(urls)
}
handleURLs(urls)
}
@MainActor func deferURLs(_ urls: [URL]) {
Log.info("App has not finished booting, deferring phpmon:// command...")
App.shared.deferredURL = urls.first
}
@MainActor func handleURLs(_ urls: [URL]) {
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
// First, we'll check if we already prompted for integrations.
// If we have, we will not respond to the commands at all.
if UserDefaults.standard.bool(
forKey: PersistentAppState.didPromptForIntegrations.rawValue
) {
Log.info("Acting on commands via phpmon:// has been disabled.")
return
}
// However, if that isn't the case, we will prompt the user about integrations,
// which will give the user a chance to approve the command regardless.
Log.info("Acting on commands via phpmon:// has been disabled. Prompting user...")
if !promptToEnableIntegrations() {
return
}
}
guard let url = urls.first else { return }
self.interpretCommand(
@@ -34,11 +65,34 @@ extension AppDelegate {
)
}
@MainActor private func promptToEnableIntegrations() -> Bool {
UserDefaults.standard.set(true, forKey: PersistentAppState.didPromptForIntegrations.rawValue)
UserDefaults.standard.synchronize()
if !NVAlert()
.withInformation(
title: "alert.enable_integrations.title".localized,
subtitle: "alert.enable_integrations.subtitle".localized,
description: "alert.enable_integrations.desc".localized
)
.withPrimary(text: "alert.enable_integrations.ok".localized)
.withSecondary(text: "alert.enable_integrations.cancel".localized)
.didSelectPrimary(urgency: .bringToFront) {
return false
}
Preferences.update(.allowProtocolForIntegrations, value: true, notify: true)
return true
}
private func interpretCommand(_ command: String, commands: [InterApp.Action]) {
commands.forEach { action in
if command.starts(with: action.command) {
let lastElement = String(command.split(separator: "/").last!)
action.action(lastElement)
guard let lastElement = command.split(separator: "/").last else {
Log.warn("Ignoring malformed phpmon:// command: '\(command)'")
return
}
action.action(String(lastElement))
}
}
}

View File

@@ -28,13 +28,14 @@ extension AppDelegate {
@IBAction func addSiteLinkPressed(_ sender: Any) {
DomainListVC.show()
guard let windowController = App.shared.domainListWindowController else { return }
guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
windowController.pressedAddLink(nil)
}
@IBAction func reloadDomainListPressed(_ sender: Any) {
Task { // Reload domains
let vc = App.shared.domainListWindowController?
let vc = WindowManager
.controller(of: DomainListWC.self)?
.window?.contentViewController as? DomainListVC
if vc != nil {
@@ -50,7 +51,7 @@ extension AppDelegate {
@IBAction func focusSearchField(_ sender: Any) {
DomainListVC.show()
guard let windowController = App.shared.domainListWindowController else { return }
guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
windowController.searchToolbarItem.searchField.becomeFirstResponder()
}

View File

@@ -8,7 +8,7 @@
import Cocoa
import UserNotifications
@NSApplicationMain
@main
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
static var instance: AppDelegate {
@@ -55,6 +55,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
logger.verbosity = .performance
Log.info("Extra verbose mode is enabled by default on DEBUG builds.")
// No matter what, clear PHP Guard if it's a debug build
Stats.clearCurrentGlobalPhpVersion()
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
AppDelegate.initializeTestingProfile(profile.replacing("--configuration:", with: ""))
}
@@ -70,6 +73,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
Log.info("Extra CLI mode has been activated via --cli flag.")
}
if CommandLine.arguments.contains("--ch") {
Log.info("Displaying command history window (`--ch` flag).")
CommandHistoryWC.show()
}
if state.container.filesystem.fileExists("~/.config/phpmon/verbose") {
logger.verbosity = .cli
Log.info("Extra CLI mode is on (`~/.config/phpmon/verbose` exists).")
@@ -80,10 +88,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
Log.info("PHP MONITOR by Nico Verbruggen")
Log.info("Version \(App.version)")
Log.separator(as: .info)
}
// Initialize the crash reporter
CrashReporter.initialize()
}
// Set up final singletons
self.valet = Valet.shared
@@ -93,8 +101,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
static func initializeTestingProfile(_ path: String) {
Log.info("The configuration with path `\(path)` is being requested...")
// Clear for PHP Guard
Stats.clearCurrentGlobalPhpVersion()
// Load the configuration file
TestableConfiguration.loadFrom(path: path).apply()
}

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@@ -12,6 +12,13 @@
<!--Application-->
<scene sceneID="JPo-4y-FX3">
<objects>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
<connections>
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
</connections>
</customObject>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
@@ -306,15 +313,6 @@
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="PHP Monitor Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
@@ -322,15 +320,8 @@
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections>
</application>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
<connections>
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
</connections>
</customObject>
</objects>
<point key="canvasLocation" x="-360" y="-94"/>
<point key="canvasLocation" x="-412" y="-153"/>
</scene>
<!--Window Controller-->
<scene sceneID="PQa-AT-b2a">
@@ -360,7 +351,7 @@
<!--Preferences-->
<scene sceneID="iyi-IS-7Ps">
<objects>
<viewController title="Preferences" identifier="preferencesTemplateVC" storyboardIdentifier="preferencesTemplateVC" showSeguePresentationStyle="single" id="AW2-rV-rbS" customClass="GenericPreferenceVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<viewController title="Preferences" identifier="preferencesTemplateVC" storyboardIdentifier="preferencesTemplateVC" showSeguePresentationStyle="single" id="AW2-rV-rbS" customClass="PreferenceVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" wantsLayer="YES" id="Pf1-A5-3Xz">
<rect key="frame" x="0.0" y="0.0" width="550" height="498"/>
<autoresizingMask key="autoresizingMask"/>
@@ -466,188 +457,6 @@
</objects>
<point key="canvasLocation" x="-374" y="745.5"/>
</scene>
<!--Window Controller-->
<scene sceneID="HTI-x5-rOp">
<objects>
<windowController storyboardIdentifier="addSiteWindow" id="N1O-Nj-C2V" sceneMemberID="viewController">
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="yLy-XT-fuq">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<view key="contentView" id="7Is-aK-lDv">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="N1O-Nj-C2V" id="CvY-PZ-Y6C"/>
</connections>
</window>
<connections>
<segue destination="glS-wF-sEU" kind="relationship" relationship="window.shadowedContentViewController" id="6Sa-w0-Uov"/>
</connections>
</windowController>
<customObject id="d2k-57-mLZ" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-374" y="1137"/>
</scene>
<!--Add SiteVC-->
<scene sceneID="6JC-H6-u4K">
<objects>
<viewController storyboardIdentifier="newSiteLink" id="glS-wF-sEU" customClass="AddSiteVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="JJJ-T9-Yuv">
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="js9-OW-xzC">
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
<view key="contentView" id="HRC-RT-LxR">
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
</box>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="PVw-cM-qAB">
<rect key="frame" x="329" y="20" width="131" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Create Link" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="WwW-Wv-I8s">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
DQ
</string>
</buttonCell>
<connections>
<action selector="pressedCreateLink:" target="glS-wF-sEU" id="Vh7-K5-ubM"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SwS-o8-pbl">
<rect key="frame" x="20" y="20" width="104" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="WHE-HW-jwp">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="qCP-Sp-gxm"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
</connections>
</button>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
<rect key="frame" x="20" y="154" width="440" height="24"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<connections>
<outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/>
</connections>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
<rect key="frame" x="18" y="132" width="444" height="14"/>
<textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="KZf-b0-9cm">
<rect key="frame" x="20" y="100" width="262" height="16"/>
<buttonCell key="cell" type="check" title="[i18n] Secure this domain after creation" bezelStyle="regularSquare" imagePosition="left" inset="2" id="vFv-Of-2yZ">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
<rect key="frame" x="18" y="64" width="444" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<pathControl verticalHuggingPriority="750" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6JT-Vt-3q0">
<rect key="frame" x="20" y="186" width="440" height="22"/>
<pathCell key="cell" selectable="YES" refusesFirstResponder="YES" alignment="left" id="m8d-XF-kh9">
<font key="font" metaFont="system"/>
<url key="url" string="file:///Users/"/>
</pathCell>
</pathControl>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
<rect key="frame" x="18" y="216" width="128" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT">
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
<rect key="frame" x="136" y="25" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<constraints>
<constraint firstItem="VzR-5a-cmT" firstAttribute="trailing" secondItem="ZX9-s1-23i" secondAttribute="trailing" id="06B-dj-IBU"/>
<constraint firstItem="ZX9-s1-23i" firstAttribute="top" secondItem="6JT-Vt-3q0" secondAttribute="bottom" constant="8" symbolic="YES" id="0QU-nI-sYv"/>
<constraint firstAttribute="bottom" secondItem="SwS-o8-pbl" secondAttribute="bottom" constant="20" symbolic="YES" id="2pB-nW-NVx"/>
<constraint firstItem="900-Z2-tID" firstAttribute="centerY" secondItem="PVw-cM-qAB" secondAttribute="centerY" id="578-2f-4x8"/>
<constraint firstItem="ZX9-s1-23i" firstAttribute="leading" secondItem="6JT-Vt-3q0" secondAttribute="trailing" constant="-440" id="6eF-GS-Xcn"/>
<constraint firstItem="6JT-Vt-3q0" firstAttribute="top" secondItem="P0B-Ht-R8n" secondAttribute="bottom" constant="8" symbolic="YES" id="DGN-4k-X0h"/>
<constraint firstItem="P0B-Ht-R8n" firstAttribute="top" secondItem="JJJ-T9-Yuv" secondAttribute="top" constant="20" symbolic="YES" id="F2r-6E-qxh"/>
<constraint firstItem="mmQ-7e-dlb" firstAttribute="top" secondItem="KZf-b0-9cm" secondAttribute="bottom" constant="8" symbolic="YES" id="G21-Vd-tgl"/>
<constraint firstItem="900-Z2-tID" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="SwS-o8-pbl" secondAttribute="trailing" constant="8" symbolic="YES" id="IMv-ZD-VXf"/>
<constraint firstItem="js9-OW-xzC" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" id="IpM-ot-dBG"/>
<constraint firstItem="VzR-5a-cmT" firstAttribute="leading" secondItem="ZX9-s1-23i" secondAttribute="leading" id="UPN-Ad-j3X"/>
<constraint firstItem="SwS-o8-pbl" firstAttribute="top" secondItem="mmQ-7e-dlb" secondAttribute="bottom" constant="20" id="VNW-fB-2Xj"/>
<constraint firstItem="KZf-b0-9cm" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="Vab-wq-9Nc"/>
<constraint firstAttribute="bottom" secondItem="PVw-cM-qAB" secondAttribute="bottom" constant="20" symbolic="YES" id="VsP-Q0-zRW"/>
<constraint firstAttribute="trailing" secondItem="PVw-cM-qAB" secondAttribute="trailing" constant="20" symbolic="YES" id="X5z-G4-CBv"/>
<constraint firstItem="ZX9-s1-23i" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="bJ4-Yr-4ah"/>
<constraint firstItem="KZf-b0-9cm" firstAttribute="top" secondItem="VzR-5a-cmT" secondAttribute="bottom" constant="16" id="bdw-P7-FLz"/>
<constraint firstAttribute="trailing" secondItem="6JT-Vt-3q0" secondAttribute="trailing" constant="20" symbolic="YES" id="ctg-Gt-34Y"/>
<constraint firstItem="PVw-cM-qAB" firstAttribute="leading" secondItem="900-Z2-tID" secondAttribute="trailing" constant="15" id="cx5-Gi-XS7"/>
<constraint firstItem="mmQ-7e-dlb" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="fKH-1r-MIf"/>
<constraint firstItem="js9-OW-xzC" firstAttribute="top" secondItem="JJJ-T9-Yuv" secondAttribute="top" id="ffu-hT-qSK"/>
<constraint firstAttribute="bottom" secondItem="js9-OW-xzC" secondAttribute="bottom" id="hLd-Kd-y6k"/>
<constraint firstAttribute="trailing" secondItem="mmQ-7e-dlb" secondAttribute="trailing" constant="20" symbolic="YES" id="hjv-Xq-cxV"/>
<constraint firstItem="SwS-o8-pbl" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="jkg-UC-GPr"/>
<constraint firstItem="6JT-Vt-3q0" firstAttribute="leading" secondItem="P0B-Ht-R8n" secondAttribute="leading" id="jxP-vM-eA9"/>
<constraint firstAttribute="bottom" secondItem="PVw-cM-qAB" secondAttribute="bottom" constant="20" symbolic="YES" id="kGp-mI-1Ic"/>
<constraint firstItem="P0B-Ht-R8n" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="msC-eG-Fop"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="P0B-Ht-R8n" secondAttribute="trailing" constant="20" symbolic="YES" id="nvj-Ij-dcd"/>
<constraint firstAttribute="trailing" secondItem="js9-OW-xzC" secondAttribute="trailing" id="rc3-XI-7CY"/>
<constraint firstItem="VzR-5a-cmT" firstAttribute="top" secondItem="ZX9-s1-23i" secondAttribute="bottom" constant="8" symbolic="YES" id="sVP-EV-07F"/>
<constraint firstAttribute="trailing" secondItem="ZX9-s1-23i" secondAttribute="trailing" constant="20" symbolic="YES" id="tZ3-2X-JC9"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="KZf-b0-9cm" secondAttribute="trailing" constant="20" symbolic="YES" id="zq0-Ce-sCs"/>
</constraints>
</view>
<connections>
<outlet property="buttonCancel" destination="SwS-o8-pbl" id="N1v-uy-2Mi"/>
<outlet property="buttonCreateLink" destination="PVw-cM-qAB" id="0Oo-xW-He7"/>
<outlet property="buttonSecure" destination="KZf-b0-9cm" id="5A7-Bn-NB7"/>
<outlet property="inputDomainName" destination="ZX9-s1-23i" id="yT6-80-Zr1"/>
<outlet property="pathControl" destination="6JT-Vt-3q0" id="f5K-8h-VOd"/>
<outlet property="previewText" destination="VzR-5a-cmT" id="qwd-wX-645"/>
<outlet property="textFieldError" destination="900-Z2-tID" id="qUk-FE-IKW"/>
<outlet property="textFieldSecure" destination="mmQ-7e-dlb" id="LeA-YS-hRM"/>
<outlet property="textFieldTitle" destination="P0B-Ht-R8n" id="Qh8-qv-6iR"/>
</connections>
</viewController>
<customObject id="6XV-bG-0N1" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="210" y="1128"/>
</scene>
<!--Domain ListVC-->
<scene sceneID="aZt-6w-TFl">
<objects>
@@ -1031,378 +840,10 @@ Gw
</objects>
<point key="canvasLocation" x="323" y="722.5"/>
</scene>
<!--Add ProxyVC-->
<scene sceneID="g8z-pE-RL9">
<objects>
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="U5U-QR-YXS">
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="kkd-UV-SnA">
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
<view key="contentView" id="IXW-35-8NJ">
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<rect key="frame" x="20" y="203" width="500" height="24"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<connections>
<outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/>
</connections>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
<rect key="frame" x="18" y="231" width="325" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL">
<font key="font" metaFont="systemMedium" size="11"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
<rect key="frame" x="18" y="179" width="112" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e">
<font key="font" metaFont="systemMedium" size="11"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<rect key="frame" x="20" y="151" width="500" height="24"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<connections>
<outlet property="delegate" destination="dwh-CF-6iv" id="e9n-PM-7s8"/>
</connections>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" constant="20" id="2ui-Jg-BUV"/>
<constraint firstItem="mlA-Zt-Hu8" firstAttribute="top" secondItem="QCK-Z9-w7g" secondAttribute="bottom" constant="10" id="8sn-dT-SW6"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Uib-vA-HRc" secondAttribute="trailing" constant="20" symbolic="YES" id="Cue-3e-doM"/>
<constraint firstItem="QCK-Z9-w7g" firstAttribute="leading" secondItem="SNw-oQ-bnb" secondAttribute="leading" id="N1K-69-wLz"/>
<constraint firstItem="mlA-Zt-Hu8" firstAttribute="leading" secondItem="QCK-Z9-w7g" secondAttribute="leading" id="R74-k0-96U"/>
<constraint firstItem="SNw-oQ-bnb" firstAttribute="leading" secondItem="IXW-35-8NJ" secondAttribute="leading" constant="20" id="WZR-f8-mgf"/>
<constraint firstItem="SNw-oQ-bnb" firstAttribute="top" secondItem="mlA-Zt-Hu8" secondAttribute="bottom" constant="4" id="XDn-h9-dgp"/>
<constraint firstItem="QCK-Z9-w7g" firstAttribute="top" secondItem="Uib-vA-HRc" secondAttribute="bottom" constant="4" id="fGU-al-B0w"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="mlA-Zt-Hu8" secondAttribute="trailing" constant="20" symbolic="YES" id="uFE-cU-KOg"/>
<constraint firstItem="QCK-Z9-w7g" firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" id="xQE-yY-gPd"/>
</constraints>
</view>
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
</box>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="4Vi-cN-ude">
<rect key="frame" x="380" y="20" width="140" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Create Proxy" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="H2Z-c5-5Vk">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
DQ
</string>
</buttonCell>
<connections>
<action selector="pressedCreateProxy:" target="dwh-CF-6iv" id="wFW-Aw-FOR"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nC0-dk-QaF">
<rect key="frame" x="20" y="20" width="104" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="D8g-GE-7TU">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="uCc-fF-wS2"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<rect key="frame" x="18" y="132" width="504" height="14"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
</constraints>
<textFieldCell key="cell" title="[i18n] Preview text here" id="ISE-9R-ncQ">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="rJa-yg-nCn">
<rect key="frame" x="20" y="100" width="166" height="16"/>
<buttonCell key="cell" type="check" title="[i18n] Secure this proxy" bezelStyle="regularSquare" imagePosition="left" inset="2" id="5LI-lt-Asl">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/>
</connections>
</button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<rect key="frame" x="18" y="64" width="504" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
<rect key="frame" x="18" y="260" width="123" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl">
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<rect key="frame" x="187" y="25" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="nC0-dk-QaF" secondAttribute="bottom" constant="20" symbolic="YES" id="3Kk-fY-SB7"/>
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" id="3So-Wu-1cz"/>
<constraint firstItem="DAh-br-Dfx" firstAttribute="top" secondItem="U5U-QR-YXS" secondAttribute="top" constant="20" symbolic="YES" id="3im-Qd-loW"/>
<constraint firstItem="kkd-UV-SnA" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" id="6iw-dd-hTX"/>
<constraint firstItem="Uib-vA-HRc" firstAttribute="leading" secondItem="DAh-br-Dfx" secondAttribute="leading" id="6jA-Kj-Q7l"/>
<constraint firstAttribute="trailing" secondItem="kkd-UV-SnA" secondAttribute="trailing" id="8YX-CO-sY2"/>
<constraint firstAttribute="trailing" secondItem="5x7-ll-2f7" secondAttribute="trailing" constant="20" symbolic="YES" id="8jr-cl-x78"/>
<constraint firstItem="kkd-UV-SnA" firstAttribute="top" secondItem="U5U-QR-YXS" secondAttribute="top" id="Afh-Ur-QgJ"/>
<constraint firstItem="4Vi-cN-ude" firstAttribute="leading" secondItem="w0k-CK-0u4" secondAttribute="trailing" constant="15" id="D3C-co-B10"/>
<constraint firstItem="w0k-CK-0u4" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="nC0-dk-QaF" secondAttribute="trailing" constant="8" symbolic="YES" id="FGk-wm-1Mu"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="rJa-yg-nCn" secondAttribute="trailing" constant="20" symbolic="YES" id="Fa7-Rc-1lj"/>
<constraint firstAttribute="trailing" secondItem="4Vi-cN-ude" secondAttribute="trailing" constant="20" symbolic="YES" id="Fbg-C8-v6E"/>
<constraint firstItem="5x7-ll-2f7" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="Fd0-zd-od8"/>
<constraint firstAttribute="bottom" secondItem="4Vi-cN-ude" secondAttribute="bottom" constant="20" symbolic="YES" id="GyL-uL-sjW"/>
<constraint firstItem="w0k-CK-0u4" firstAttribute="centerY" secondItem="4Vi-cN-ude" secondAttribute="centerY" id="HcL-wb-0s6"/>
<constraint firstItem="rJa-yg-nCn" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="IEg-SN-bHB"/>
<constraint firstItem="rJa-yg-nCn" firstAttribute="top" secondItem="JSZ-x8-Pqi" secondAttribute="bottom" constant="16" id="IW3-MX-3Kh"/>
<constraint firstItem="DAh-br-Dfx" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="LY1-r0-viF"/>
<constraint firstItem="nC0-dk-QaF" firstAttribute="top" secondItem="5x7-ll-2f7" secondAttribute="bottom" constant="20" id="OjY-dM-dOG"/>
<constraint firstItem="nC0-dk-QaF" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="V6L-YR-ufX"/>
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="leading" secondItem="SNw-oQ-bnb" secondAttribute="leading" id="dpc-5M-0Cq"/>
<constraint firstItem="5x7-ll-2f7" firstAttribute="top" secondItem="rJa-yg-nCn" secondAttribute="bottom" constant="8" symbolic="YES" id="dzE-Ob-SVG"/>
<constraint firstAttribute="bottom" secondItem="4Vi-cN-ude" secondAttribute="bottom" constant="20" symbolic="YES" id="ny2-RO-bEI"/>
<constraint firstAttribute="bottom" secondItem="kkd-UV-SnA" secondAttribute="bottom" id="oCP-dn-6dx"/>
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="top" secondItem="SNw-oQ-bnb" secondAttribute="bottom" constant="5" id="sX3-MK-14k"/>
<constraint firstItem="Uib-vA-HRc" firstAttribute="top" secondItem="DAh-br-Dfx" secondAttribute="bottom" constant="15" id="tWI-S8-17J"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="DAh-br-Dfx" secondAttribute="trailing" constant="20" symbolic="YES" id="vDR-5D-1eN"/>
</constraints>
</view>
<connections>
<outlet property="buttonCancel" destination="nC0-dk-QaF" id="n5Q-jg-UCe"/>
<outlet property="buttonCreateProxy" destination="4Vi-cN-ude" id="rdK-xc-N7F"/>
<outlet property="buttonSecure" destination="rJa-yg-nCn" id="WIs-zt-f3a"/>
<outlet property="inputDomainName" destination="SNw-oQ-bnb" id="ELH-63-cAe"/>
<outlet property="inputProxySubject" destination="QCK-Z9-w7g" id="76U-te-Jzt"/>
<outlet property="previewText" destination="JSZ-x8-Pqi" id="Mve-6W-Owd"/>
<outlet property="textFieldDomainName" destination="mlA-Zt-Hu8" id="cHL-Yu-Yvx"/>
<outlet property="textFieldError" destination="w0k-CK-0u4" id="28h-bn-igB"/>
<outlet property="textFieldProxySubject" destination="Uib-vA-HRc" id="5tV-3l-Wbw"/>
<outlet property="textFieldSecure" destination="5x7-ll-2f7" id="NlV-g8-rYP"/>
<outlet property="textFieldTitle" destination="DAh-br-Dfx" id="8SA-EW-wcq"/>
</connections>
</viewController>
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="220" y="1522"/>
</scene>
<!--Window Controller-->
<scene sceneID="5Gf-7O-tdA">
<objects>
<windowController storyboardIdentifier="addProxyWindow" id="ogq-ok-UVi" sceneMemberID="viewController">
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="SMz-Va-x2z">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<view key="contentView" id="HsN-qQ-BhO">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="ogq-ok-UVi" id="9CA-sB-ZTD"/>
</connections>
</window>
<connections>
<segue destination="dwh-CF-6iv" kind="relationship" relationship="window.shadowedContentViewController" id="My6-qb-eRg"/>
</connections>
</windowController>
<customObject id="5qP-qX-rbc" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-374" y="1530"/>
</scene>
<!--SelectionVC-->
<scene sceneID="UXm-Ci-yEB">
<objects>
<viewController storyboardIdentifier="addDomainChoice" id="gOD-Gu-zDG" customClass="SelectionVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="ysc-sm-sli">
<rect key="frame" x="0.0" y="0.0" width="540" height="181"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<visualEffectView blendingMode="behindWindow" material="toolTip" state="followsWindowActiveState" translatesAutoresizingMaskIntoConstraints="NO" id="F37-zt-gM3">
<rect key="frame" x="0.0" y="0.0" width="540" height="181"/>
<subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="FhN-AM-SkI">
<rect key="frame" x="20" y="20" width="104" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="LxP-t4-H2W">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="Zhu-D8-cLK"/>
</constraints>
<connections>
<action selector="pressedCancel:" target="gOD-Gu-zDG" id="wMp-sM-0A4"/>
</connections>
</button>
<stackView distribution="fill" orientation="horizontal" alignment="top" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pYe-Qu-qnK">
<rect key="frame" x="167" y="20" width="353" height="24"/>
<subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="L5n-Gw-J27">
<rect key="frame" x="0.0" y="0.0" width="168" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Create a Link" bezelStyle="rounded" image="IconLinked" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="8UP-Sw-TP6">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent">l</string>
</buttonCell>
<connections>
<action selector="pressedCreateLink:" target="gOD-Gu-zDG" id="77M-Ip-GMi"/>
</connections>
</button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="01Z-IV-hv1">
<rect key="frame" x="176" y="0.0" width="177" height="24"/>
<buttonCell key="cell" type="push" title="[i18n] Create a Proxy" bezelStyle="rounded" image="IconProxy" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="bJ4-q8-1Ej">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent">p</string>
</buttonCell>
<connections>
<action selector="pressedCreateProxy:" target="gOD-Gu-zDG" id="UDf-lD-KCS"/>
</connections>
</button>
</subviews>
<visibilityPriorities>
<integer value="1000"/>
<integer value="1000"/>
</visibilityPriorities>
<customSpacing>
<real value="3.4028234663852886e+38"/>
<real value="3.4028234663852886e+38"/>
</customSpacing>
</stackView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
<rect key="frame" x="18" y="142" width="504" height="19"/>
<textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd">
<font key="font" metaFont="systemBold" size="15"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
<rect key="frame" x="18" y="64" width="504" height="70"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/>
</constraints>
<textFieldCell key="cell" selectable="YES" alignment="left" id="3i9-RG-Ift">
<font key="font" metaFont="smallSystem"/>
<string key="title">[i18n] Links are used to directly serve projects. If you have a Laravel, Symfony, WordPress, etc. folder with code, you'll want to create a link and choose the folder where your code lives.If you are in need of a proxy, you can proxy e.g. a container to a particular domain name. This can be useful in combination with Docker, for example.</string>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<constraints>
<constraint firstItem="FhN-AM-SkI" firstAttribute="leading" secondItem="F37-zt-gM3" secondAttribute="leading" constant="20" symbolic="YES" id="3dg-JM-MDr"/>
<constraint firstItem="fJK-Ke-IK3" firstAttribute="top" secondItem="F37-zt-gM3" secondAttribute="top" constant="20" symbolic="YES" id="FbX-Le-O7Q"/>
<constraint firstAttribute="trailing" secondItem="pYe-Qu-qnK" secondAttribute="trailing" constant="20" symbolic="YES" id="IJA-vN-Rbv"/>
<constraint firstItem="urj-Xq-TrJ" firstAttribute="leading" secondItem="fJK-Ke-IK3" secondAttribute="leading" id="JcY-ae-6ZH"/>
<constraint firstItem="urj-Xq-TrJ" firstAttribute="trailing" secondItem="fJK-Ke-IK3" secondAttribute="trailing" id="ZBI-pN-kOz"/>
<constraint firstItem="fJK-Ke-IK3" firstAttribute="leading" secondItem="F37-zt-gM3" secondAttribute="leading" constant="20" symbolic="YES" id="d4o-6b-Dho"/>
<constraint firstItem="urj-Xq-TrJ" firstAttribute="top" secondItem="fJK-Ke-IK3" secondAttribute="bottom" constant="8" symbolic="YES" id="hOk-eL-Eg0"/>
<constraint firstItem="FhN-AM-SkI" firstAttribute="top" secondItem="urj-Xq-TrJ" secondAttribute="bottom" constant="20" id="kCc-Vp-Gvq"/>
<constraint firstAttribute="bottom" secondItem="pYe-Qu-qnK" secondAttribute="bottom" constant="20" id="lPX-ZF-XZN"/>
<constraint firstAttribute="trailing" secondItem="fJK-Ke-IK3" secondAttribute="trailing" constant="20" symbolic="YES" id="spl-Bn-xtw"/>
<constraint firstAttribute="bottom" secondItem="FhN-AM-SkI" secondAttribute="bottom" constant="20" symbolic="YES" id="t5w-aL-tOa"/>
<constraint firstItem="pYe-Qu-qnK" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="FhN-AM-SkI" secondAttribute="trailing" constant="8" symbolic="YES" id="y7k-sl-xqe"/>
</constraints>
</visualEffectView>
<button fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="cNh-Wc-ADk">
<rect key="frame" x="200" y="113" width="0.0" height="48"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="square" bezelStyle="shadowlessSquare" imagePosition="only" alignment="center" imageScaling="proportionallyUpOrDown" inset="2" id="OQ5-hX-qai">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
</button>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="F37-zt-gM3" secondAttribute="trailing" id="ZRD-3j-s4x"/>
<constraint firstAttribute="bottom" secondItem="F37-zt-gM3" secondAttribute="bottom" id="et1-At-Rgj"/>
<constraint firstItem="F37-zt-gM3" firstAttribute="top" secondItem="ysc-sm-sli" secondAttribute="top" id="jp3-eE-mOy"/>
<constraint firstItem="F37-zt-gM3" firstAttribute="leading" secondItem="ysc-sm-sli" secondAttribute="leading" id="wIo-zP-KId"/>
</constraints>
</view>
<connections>
<outlet property="buttonCancel" destination="FhN-AM-SkI" id="iqV-2E-q7e"/>
<outlet property="buttonCreateLink" destination="L5n-Gw-J27" id="SHV-4l-Red"/>
<outlet property="buttonCreateProxy" destination="01Z-IV-hv1" id="J1v-7J-4fx"/>
<outlet property="textFieldDescription" destination="urj-Xq-TrJ" id="u1w-O0-kI3"/>
<outlet property="textFieldTitle" destination="fJK-Ke-IK3" id="x8p-qx-HX4"/>
</connections>
</viewController>
<customObject id="bZa-dD-d4J" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="250" y="1900"/>
</scene>
<!--Window Controller-->
<scene sceneID="HW6-nV-trE">
<objects>
<windowController storyboardIdentifier="showSelectionWindow" id="t4x-Mh-iya" sceneMemberID="viewController">
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="IeW-fo-4yK">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<view key="contentView" id="Oe0-yv-Jcy">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="t4x-Mh-iya" id="4oO-gI-bd2"/>
</connections>
</window>
<connections>
<segue destination="gOD-Gu-zDG" kind="relationship" relationship="window.shadowedContentViewController" id="KRt-OH-8uc"/>
</connections>
</windowController>
<customObject id="hBK-Bw-dwa" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-374" y="1909"/>
</scene>
</scenes>
<resources>
<image name="Checkmark" width="512" height="512"/>
<image name="IconLinked" width="25" height="25"/>
<image name="IconProxy" width="25" height="25"/>
<image name="Lock" width="30" height="30"/>
<image name="arrow.clockwise" catalog="system" width="14" height="16"/>
<image name="plus" catalog="system" width="14" height="13"/>

View File

@@ -14,6 +14,8 @@ import Foundation
*/
struct EnvironmentCheck {
let command: (_ container: Container) async -> Bool
let fixCommand: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)?
let fixDescription: String?
let name: String
let titleText: String
let subtitleText: String
@@ -23,6 +25,8 @@ struct EnvironmentCheck {
init(
command: @escaping (_ container: Container) async -> Bool,
fix: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)? = nil,
fixDescription: String? = nil,
name: String,
titleText: String,
subtitleText: String,
@@ -31,6 +35,8 @@ struct EnvironmentCheck {
requiresAppRestart: Bool = false,
) {
self.command = command
self.fixCommand = fix
self.fixDescription = fixDescription
self.name = name
self.titleText = titleText
self.subtitleText = subtitleText

View File

@@ -19,8 +19,7 @@ class FakeServicesManager: ServicesManager {
init(
_ container: Container,
formulae: [String] = ["php", "nginx", "dnsmasq"],
status: Service.Status = .active,
loading: Bool = false
status: Service.Status = .active
) {
super.init(container)
@@ -32,14 +31,6 @@ class FakeServicesManager: ServicesManager {
self.services = []
self.reapplyServices()
if loading {
return
}
Task { @MainActor in
self.firstRunComplete = true
}
}
private func reapplyServices() {

View File

@@ -17,8 +17,6 @@ class ServicesManager: ObservableObject {
@Published var services = [Service]()
@Published var firstRunComplete: Bool = false
init(_ container: Container) {
self.container = container
@@ -65,7 +63,7 @@ class ServicesManager: ObservableObject {
}
public var hasError: Bool {
if self.services.isEmpty || !self.firstRunComplete {
if self.services.isEmpty {
return false
}
@@ -75,7 +73,7 @@ class ServicesManager: ObservableObject {
}
public var statusMessage: String {
if self.services.isEmpty || !self.firstRunComplete {
if self.services.isEmpty {
return "phpman.services.loading".localized
}
@@ -95,7 +93,7 @@ class ServicesManager: ObservableObject {
}
public var statusColor: Color {
if self.services.isEmpty || !self.firstRunComplete {
if self.services.isEmpty {
return Color("StatusColorYellow")
}

View File

@@ -102,7 +102,7 @@ actor ValetServicesDataManager {
? "sudo \(self.container.paths.brew) services info --all --json"
: "\(self.container.paths.brew) services info --all --json"
let output = await self.container.shell.pipe(command).out
let output = await self.container.shell.pipe(command, timeout: .seconds(10)).out
guard let jsonData = output.data(using: .utf8) else {
Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.")

View File

@@ -16,14 +16,6 @@ class ValetServicesManager: ServicesManager {
override init(_ container: Container) {
self.data = ValetServicesDataManager(container)
super.init(container)
// Load the initial services state
Task {
await self.reloadServicesStatus()
await MainActor.run {
firstRunComplete = true
}
}
}
override func reloadServicesStatus() async {

View File

@@ -0,0 +1,52 @@
//
// Startup+Fixes.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 17/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Foundation
import AppKit
import NVAlert
extension Startup {
/**
The potential outcome of an environment check failure alert.
*/
enum EnvironmentAlertOutcome {
/** The automatic fix ran and succeeded. Continue to the next check. */
case shouldContinue
/** No automatic fix was requested, show alert and require retry of all startup checks. */
case shouldRetryStartup
}
/**
Displays an alert for a particular check. For checks that require an app restart,
a simple NVAlert is shown with a quit button. For all other checks, the new
StartupAlertWindowController is used to show the enhanced startup alert.
*/
@MainActor internal func showAlert(for check: EnvironmentCheck) async -> EnvironmentAlertOutcome {
// Ensure that the timeout does not fire until we restart
Self.startupTimer?.invalidate()
if check.requiresAppRestart {
NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: check.buttonText, action: { _ in
exit(1)
}).show(urgency: .bringToFront)
// We can never return here, since quitting the app is the only option
}
// Create and show the enhanced startup alert window
let controller = StartupAlertWindowController.create(for: check)
return await controller.showModal()
}
}

View File

@@ -100,20 +100,20 @@ extension Startup {
// A non-default TLD is not officially supported since Valet 3.2.x
Valet.shared.notifyAboutUnsupportedTLD()
// Determine which services are running
await ServicesManager.shared.reloadServicesStatus()
// Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
}
// Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
// Find out which services are active
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// We are ready!
// Internals are ready!
container.phpEnvs.isBusy = false
// Finally!
Log.info("PHP Monitor is ready to serve!")
// Avoid showing the "startup timeout" alert
Startup.invalidateTimeoutTimer()
@@ -122,6 +122,13 @@ extension Startup {
// Mark app as having successfully booted passing all checks
Startup.hasFinishedBooting = true
Log.info("PHP Monitor is ready to serve!")
// Process the last URL that arrived during startup
if let url = App.shared.deferredURL {
AppDelegate.instance.handleURLs([url])
App.shared.deferredURL = nil
}
// Enable the main menu item
MainMenu.shared.statusItem.button?.isEnabled = true

View File

@@ -22,6 +22,10 @@ extension Startup {
/** Starts the timeout timer that keeps track of how long the app takes to boot. */
@MainActor func startStartupTimer() {
// If we have a previous timer, invalidate it
Self.startupTimer?.invalidate()
// Start timing; use current timestamp as "start"
Self.launchTime = Date()
Self.startupTimer = Timer.scheduledTimer(
timeInterval: Constants.SlowBootThresholdInterval, target: self,

View File

@@ -39,12 +39,21 @@ class Startup {
let start = Measurement()
if await check.succeeds() {
Log.info("[PASS] \(check.name) (\(start.milliseconds) ms)")
continue
continue // continue to the next check!
}
// If we get here, something's gone wrong and the check has failed...
Log.info("[FAIL] \(check.name) (\(start.milliseconds) ms)")
await showAlert(for: check)
// We will present the user with an option (potentially)
let outcome = await showAlert(for: check)
// The fix ran and succeeded continue to the next check
if outcome == .shouldContinue {
continue
}
// No fix requested or fix failed requires full restart
return false
}
} else {
@@ -59,34 +68,6 @@ class Startup {
return true
}
/**
Displays an alert for a particular check. There are two types of alerts:
- ones that require an app restart, which prompt the user to exit the app
- ones that allow the app to continue, which allow the user to retry
*/
@MainActor private func showAlert(for check: EnvironmentCheck) {
if check.requiresAppRestart {
NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: check.buttonText, action: { _ in
exit(1)
}).show(urgency: .bringToFront)
}
NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: "generic.ok".localized)
.show(urgency: .bringToFront)
}
// MARK: - Check (List)
public var groups: [EnvironmentCheckGroup] = [
@@ -118,6 +99,15 @@ class Startup {
return await !container.shell
.pipe("ls \(container.paths.optPath) | grep php").out.contains("php")
},
fix: { container, didReceiveOutput in
let brew = container.paths.brew
try await container.shell.attach(
"\(brew) tap shivammathur/php && \(brew) install shivammathur/php/php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew tap shivammathur/php && brew install shivammathur/php/php",
name: "`ls \(App.shared.container.paths.optPath) | grep php` returned php result",
titleText: "startup.errors.php_opt.title".localized,
subtitleText: "startup.errors.php_opt.subtitle".localized(
@@ -132,6 +122,15 @@ class Startup {
command: { container in
return !container.filesystem.fileExists(container.paths.php)
},
fix: { container, didReceiveOutput in
let brew = container.paths.brew
try await container.shell.attach(
"\(brew) link php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew link php",
name: "`\(App.shared.container.paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
@@ -142,9 +141,21 @@ class Startup {
// =================================================================================
EnvironmentCheck(
command: { container in
if container.phpEnvs.currentInstall == nil {
container.phpEnvs.currentInstall = ActivePhpInstallation.load(container)
}
return await container.shell.pipe("\(container.paths.binPath)/php -v").err
.contains("Library not loaded")
.contains("Library not loaded") && container.phpEnvs.currentInstall != nil
},
fix: { container, didReceiveOutput in
let brew = container.paths.brew
try await container.shell.attach(
"\(brew) tap shivammathur/php && \(brew) reinstall shivammathur/php/php && \(brew) link php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew reinstall php && brew link php",
name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized(
@@ -176,6 +187,15 @@ class Startup {
await container.phpEnvs.determinePhpAlias()
return PhpEnvironments.brewPhpAlias == nil
},
fix: { container, didReceiveOutput in
let brew = container.paths.brew
try await container.shell.attach(
"\(brew) update",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew update",
name: "`brew` alias is not nil and valid",
titleText: "startup.errors.could_not_determine_alias.title".localized,
subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized,
@@ -209,6 +229,12 @@ class Startup {
.pipe("cat /private/etc/sudoers.d/brew")
.out.contains(container.paths.brew)
},
fix: { container, didReceiveOutput in
let valet = container.paths.binPath.appending("/valet")
let result = try AppleScript.runShellAsAdmin("\(valet) trust")
didReceiveOutput(result, .stdOut)
},
fixDescription: "valet trust",
name: "`/private/etc/sudoers.d/brew` contains brew",
titleText: "startup.errors.sudoers_brew.title".localized,
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
@@ -231,6 +257,15 @@ class Startup {
command: { container in
return !container.filesystem.directoryExists("~/.config/valet")
},
fix: { container, didReceiveOutput in
let valet = container.paths.binPath.appending("/valet")
try await container.shell.attach(
"\(valet) install",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "valet install",
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,

View File

@@ -0,0 +1,78 @@
//
// WindowCoordinator.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 23/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Cocoa
typealias PreferencesWC = PreferencesWindowController
typealias DomainListWC = DomainListWindowController
typealias OnboardingWC = OnboardingWindowController
typealias PhpConfigManagerWC = PhpConfigManagerWindowController
typealias PhpDoctorWC = PhpDoctorWindowController
typealias PhpVersionManagerWC = PhpVersionManagerWindowController
typealias PhpExtensionManagerWC = PhpExtensionManagerWindowController
typealias CommandHistoryWC = CommandHistoryWindowController
let WindowManager = WindowCoordinator.shared
final class WindowCoordinator {
static let shared = WindowCoordinator()
private var controllers: [ObjectIdentifier: NSWindowController] = [:]
private init() {}
func setController<T: NSWindowController>(_ controller: T) {
controllers[ObjectIdentifier(T.self)] = controller
}
func hasController<T: NSWindowController>(for type: T.Type) -> Bool {
return controllers[ObjectIdentifier(T.self)] != nil
}
func controller<T: NSWindowController>(of type: T.Type) -> T? {
return controllers[ObjectIdentifier(T.self)] as? T
}
func window<T: NSWindowController>(for type: T.Type) -> NSWindow? {
return controllers[ObjectIdentifier(T.self)]?.window
}
func withWindow<T: NSWindowController>(for type: T.Type, _ handler: (NSWindow) -> Void) {
guard let window = window(for: type) else { return }
handler(window)
}
@discardableResult
func show<T: NSWindowController>(_ type: T.Type) -> T? {
guard let controller = controller(of: type) else { return nil }
controller.showWindow(self)
NSApp.activate(ignoringOtherApps: true)
controller.window?.orderFrontRegardless()
return controller
}
func unset<T: NSWindowController>(_ type: T.Type) {
controllers[ObjectIdentifier(T.self)] = nil
}
func close<T: NSWindowController>(_ type: T.Type) {
controllers[ObjectIdentifier(T.self)]?.close()
controllers[ObjectIdentifier(T.self)] = nil
}
func closeAll(excluding types: [NSWindowController.Type] = []) {
let excluded = Set(types.map { ObjectIdentifier($0) })
controllers.keys
.filter { !excluded.contains($0) }
.forEach { key in
controllers[key]?.close()
controllers[key] = nil
}
}
}

View File

@@ -71,8 +71,10 @@ import NVAlert
let (process, _) = try await container.shell.attach(
command,
didReceiveOutput: { [weak self] (incoming, _) in
Task { @MainActor in
guard let window = self?.window else { return }
window.addToConsole(incoming)
}
},
withTimeout: .minutes(5)
)

View File

@@ -39,18 +39,8 @@ class BrewPermissionFixer {
return
}
let appleScript = NSAppleScript(
source: "do shell script \"\(buildBrokenFormulaeScript())\" with administrator privileges"
)
let eventResult: NSAppleEventDescriptor? = appleScript?
.executeAndReturnError(nil)
if eventResult == nil {
throw HomebrewPermissionError(
kind: .applescriptNilError
)
}
let script = buildBrokenFormulaeScript()
try AppleScript.runSimpleShellAsAdmin(script)
Log.info("Ownership was taken of the folder(s) at: " + broken
.map({ $0.path })

View File

@@ -33,9 +33,9 @@ extension BrewCommand {
}
if text.contains("==> Installing") {
if let subject = extractContext(from: text) {
return (0.60, "phpman.steps.installing".localized + "\n(\(subject))")
return (0.60, "phpman.steps.installing_package".localized + "\n(\(subject))")
}
return (0.60, "phpman.steps.installing".localized)
return (0.60, "phpman.steps.installing_package".localized)
}
if text.contains("==> Downloading") {
if let subject = extractContext(from: text) {

View File

@@ -71,7 +71,7 @@ class RemovePhpExtensionCommand: BrewCommand {
await performExtensionCleanup(for: ext)
}
_ = await container.phpEnvs.detectPhpVersions()
await container.phpEnvs.detectPhpVersions()
await Actions(container).restartPhpFpm(version: phpExtension.phpVersion)

View File

@@ -82,7 +82,7 @@ class ModifyPhpVersionCommand: BrewCommand {
}
// Re-check the installed versions
_ = await container.phpEnvs.detectPhpVersions()
await container.phpEnvs.detectPhpVersions()
// After performing operations, attempt to run repairs if needed
try await self.repairBrokenPackages(onProgress)
@@ -186,7 +186,7 @@ class ModifyPhpVersionCommand: BrewCommand {
await BrewDiagnostics.shared.checkForOutdatedPhpInstallationSymlinks()
// Check which version of PHP are now installed
_ = await container.phpEnvs.detectPhpVersions()
await container.phpEnvs.detectPhpVersions()
// Keep track of the currently installed version
await MainMenu.shared.refreshActiveInstallation()

View File

@@ -74,7 +74,7 @@ class RemovePhpVersionCommand: BrewCommand {
if process.terminationStatus == 0 {
onProgress(.create(value: 0.95, title: getCommandTitle(), description: "phpman.steps.reloading".localized))
_ = await container.phpEnvs.detectPhpVersions()
await container.phpEnvs.detectPhpVersions()
await MainMenu.shared.refreshActiveInstallation()

View File

@@ -10,7 +10,8 @@ import Foundation
class Packagist {
static func getLatestStableVersion(packageName: String) async throws -> VersionNumber {
guard let url = URL(string: "https://repo.packagist.org/p2/\(packageName).json") else {
guard let encodedName = packageName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "https://repo.packagist.org/p2/\(encodedName).json") else {
throw PackagistError.invalidURL
}

View File

@@ -73,7 +73,7 @@ class ValetUpgrader {
}
@MainActor private static func notifyAboutCompletion() {
return NVAlert().withInformation(
NVAlert().withInformation(
title: "valet_upgraded.title".localized,
subtitle: "valet_upgraded.subtitle".localized,
description: "valet_upgraded.description".localized,
@@ -85,7 +85,7 @@ class ValetUpgrader {
}
@MainActor private static func notifyAboutUpgrade(latest: String, constraint: String, passing: Bool) {
let alert = NVAlert().withInformation(
return NVAlert().withInformation(
title: "valet_upgrade_available.title".localized,
subtitle: "valet_upgrade_available.subtitle".localized(latest),
description: passing
@@ -97,13 +97,9 @@ class ValetUpgrader {
ValetUpgrader.upgradeValet()
})
.withSecondary(text: "valet_upgrade_available.cancel".localized)
if !passing {
_ = alert.withTertiary(text: "valet_upgrade_available.open_composer".localized, action: { _ in
.withTertiary(if: !passing, text: "valet_upgrade_available.open_composer".localized, action: { _ in
MainMenu.shared.openGlobalComposerFolder()
})
}
alert.show(urgency: .bringToFront)
.show(urgency: .bringToFront)
}
}

View File

@@ -34,11 +34,11 @@ class ValetInteractor {
// MARK: - Managing Domains
public func link(path: String, domain: String) async throws {
await container.shell.quiet("cd '\(path)' && \(container.paths.valet) link '\(domain)' && valet links")
await container.shell.pipe("cd '\(path)' && \(container.paths.valet) link '\(domain)' && valet links")
}
public func unlink(site: ValetSite) async throws {
await container.shell.quiet("valet unlink '\(site.name)'")
await container.shell.pipe("valet unlink '\(site.name)'")
}
public func proxy(domain: String, proxy: String, secure: Bool) async throws {
@@ -46,12 +46,12 @@ class ValetInteractor {
? "\(container.paths.valet) proxy \(domain) \(proxy) --secure"
: "\(container.paths.valet) proxy \(domain) \(proxy)"
await container.shell.quiet(command)
await container.shell.pipe(command)
await Actions(container).restartNginx()
}
public func remove(proxy: ValetProxy) async throws {
await container.shell.quiet("valet unproxy '\(proxy.domain)'")
await container.shell.pipe("valet unproxy '\(proxy.domain)'")
}
// MARK: - Modifying Domains
@@ -73,7 +73,7 @@ class ValetInteractor {
}
// Run the command
await container.shell.quiet(command)
await container.shell.pipe(command)
// Check if the secured status has actually changed
site.determineSecured()
@@ -98,7 +98,7 @@ class ValetInteractor {
// Run the commands
for command in commands {
await container.shell.quiet(command)
await container.shell.pipe(command)
}
// Check if the secured status has actually changed
@@ -117,7 +117,7 @@ class ValetInteractor {
let command = "sudo \(container.paths.valet) isolate php@\(version) --site '\(site.name)'"
// Run the command
await container.shell.quiet(command)
await container.shell.pipe(command)
// Check if the secured status has actually changed
site.determineIsolated()
@@ -133,7 +133,7 @@ class ValetInteractor {
let command = "sudo \(container.paths.valet) unisolate --site '\(site.name)'"
// Run the command
await container.shell.quiet(command)
await container.shell.pipe(command)
// Check if the secured status has actually changed
site.determineIsolated()

View File

@@ -24,7 +24,7 @@ extension Valet {
)
.withPrimary(text: "generic.ok".localized)
.withTertiary(text: "alert.do_not_tell_again".localized, action: { alert in
Preferences.update(.warnAboutNonStandardTLD, value: false)
Preferences.update(.warnAboutNonStandardTLD, value: false, notify: true)
alert.close(with: .alertThirdButtonReturn)
})
.show(urgency: .urgentRequestAttention)

View File

@@ -56,7 +56,7 @@ extension MainMenu {
.withPrimary(text: "generic.ok".localized)
.show(urgency: .bringToFront)
} failure: { error in
NVAlert.show(for: error as! HomebrewPermissionError)
NVAlert.show(for: error as! AdminPrivilegeError)
}
}

View File

@@ -117,7 +117,7 @@ extension MainMenu {
}
private func reloadDomainListData() async {
if let window = App.shared.domainListWindowController {
if let window = WindowManager.controller(of: DomainListWC.self) {
await window.contentVC.reloadDomains()
} else {
await Valet.shared.reloadSites()

View File

@@ -138,6 +138,12 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
}
}
@objc func showCommandHistory() {
Task { @MainActor in
CommandHistoryWindowController.show()
}
}
@objc func showIncompatiblePhpVersionsAlert() {
Task { @MainActor in
NVAlert().withInformation(

View File

@@ -325,7 +325,8 @@ extension StatusMenu {
// FIRST AID
HeaderView.asMenuItem(text: "mi_first_aid".localized),
NSMenuItem(title: "mi_view_onboarding".localized, action: #selector(MainMenu.showWelcomeTour)),
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings))
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings)),
NSMenuItem(title: "mi_view_command_history".localized, action: #selector(MainMenu.showCommandHistory))
]
if Valet.installed {

View File

@@ -47,6 +47,11 @@ class PhpGuard {
// At this point, the version is *not* a match
Log.info("PHP Guard noticed a different PHP version. An alert will be displayed!")
// Exit early if we're running tests; PHP Guard may interfere
if isRunningSwiftUIPreview || isRunningTests {
return
}
Task { @MainActor in
NVAlert()
.withInformation(

View File

@@ -14,14 +14,6 @@ struct CustomPrefs: Decodable {
let services: [String]?
let environmentVariables: [String: String]?
var exportAsString: String {
return self.environmentVariables!
.map { (key, value) in
return "export \(key)=\(value)"
}
.joined(separator: "&&")
}
public func hasPresets() -> Bool {
return self.presets != nil && !self.presets!.isEmpty
}
@@ -31,7 +23,11 @@ struct CustomPrefs: Decodable {
}
public func hasEnvironmentVariables() -> Bool {
return self.environmentVariables != nil && !self.environmentVariables!.keys.isEmpty
guard let variables = self.environmentVariables else {
return false
}
return !variables.isEmpty
}
private enum CodingKeys: String, CodingKey {
@@ -45,36 +41,39 @@ struct CustomPrefs: Decodable {
extension Preferences {
func loadCustomPreferences() async {
// Ensure the configuration directory is created if missing
await container.shell.quiet("mkdir -p ~/.config/phpmon")
await container.shell.pipe("mkdir -p ~/.config/phpmon")
// Move the legacy file
await moveOutdatedConfigurationFile()
// Attempt to load the file if it exists
let url = URL(fileURLWithPath: "\(container.paths.homePath)/.config/phpmon/config.json")
if container.filesystem.fileExists(url.path) {
if container.filesystem.fileExists("~/.config/phpmon/config.json") {
Log.info("A custom ~/.config/phpmon/config.json file was found. Attempting to parse...")
loadCustomPreferencesFile(url)
loadCustomPreferencesFile()
} else {
Log.info("There was no /.config/phpmon/config.json file to be loaded.")
}
}
func moveOutdatedConfigurationFile() async {
if container.filesystem.fileExists("~/.phpmon.conf.json") && !container.filesystem.fileExists("~/.config/phpmon/config.json") {
if container.filesystem.fileExists("~/.phpmon.conf.json")
&& !container.filesystem.fileExists("~/.config/phpmon/config.json") {
Log.info("An outdated configuration file was found. Moving it...")
await container.shell.quiet("cp ~/.phpmon.conf.json ~/.config/phpmon/config.json")
await container.shell.pipe("cp ~/.phpmon.conf.json ~/.config/phpmon/config.json")
Log.info("The configuration file was copied successfully!")
}
}
func loadCustomPreferencesFile(_ url: URL) {
do {
customPreferences = try JSONDecoder().decode(
CustomPrefs.self,
from: try! String(contentsOf: url, encoding: .utf8).data(using: .utf8)!
)
func loadCustomPreferencesFile() {
guard let data = try? container.filesystem.getStringFromFile("~/.config/phpmon/config.json").data(using: .utf8) else {
Log.warn("The ~/.config/phpmon/config.json file could not be read as UTF-8.")
return
}
guard let customPreferences = try? JSONDecoder().decode(CustomPrefs.self, from: data) else {
Log.warn("The ~/.config/phpmon/config.json file seems to be malformed.")
return
}
Log.info("The ~/.config/phpmon/config.json file was successfully parsed.")
@@ -87,13 +86,13 @@ extension Preferences {
}
if customPreferences.hasEnvironmentVariables() {
let exports = customPreferences.environmentVariables ?? [:]
Log.info("Configuring the additional exports...")
if let shell = App.shared.container.shell as? RealShell {
shell.exports = customPreferences.exportAsString
}
}
} catch {
Log.warn("The ~/.config/phpmon/config.json file seems to be missing or malformed.")
Log.info("Custom exports: \(exports.description)")
// Assign the new exports values
container.shell.exports = exports
}
}
}

View File

@@ -8,6 +8,12 @@
/**
These are the keys used for every preference in the app.
To add a new key, undertake the following steps:
- Declare a new enum value with a string representation.
- Update the mapping below to specify if it's a boolean, string or other.
- Go to `Preferences` and update `handleFirstTimeLaunch` to set a default.
- Add the preference to `GeneralPreferencesVC` in the correct class.
*/
enum PreferenceName: String, Codable {
// GENERAL
@@ -23,6 +29,7 @@ enum PreferenceName: String, Codable {
case shouldDisplayDynamicIcon = "use_dynamic_icon"
case iconTypeToDisplay = "icon_type_to_display"
case fullPhpVersionDynamicIcon = "full_php_in_menu_bar"
case hideIconsInMenu = "hide_icons_in_menu"
// WARNINGS
case warnAboutNonStandardTLD = "warn_about_non_standard_tld"
@@ -53,14 +60,17 @@ enum PreferenceName: String, Codable {
static var mapping: [PreferenceType: [PreferenceName]] = [
.boolean: [
// Preferences
.shouldDisplayDynamicIcon,
.fullPhpVersionDynamicIcon,
.autoServiceRestartAfterExtensionToggle,
.autoComposerGlobalUpdateAfterSwitch,
.allowProtocolForIntegrations,
.automaticBackgroundUpdateCheck,
.showPhpDoctorSuggestions,
// Appearance
.shouldDisplayDynamicIcon,
.fullPhpVersionDynamicIcon,
.hideIconsInMenu,
// Notifications
.warnAboutNonStandardTLD,
.notifyAboutVersionChange,
@@ -111,6 +121,7 @@ enum PersistentAppState: String {
case lastAutomaticUpdateCheck = "last_automatic_update_check"
case userFavorites = "user_favorites"
case updateCheckFailureCount = "update_check_failure_count"
case didPromptForIntegrations = "did_prompt_for_integrations"
}
/**

View File

@@ -0,0 +1,95 @@
//
// PreferenceVC+WindowsRestore.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 23/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Cocoa
extension PreferenceVC {
struct WindowSnapshot {
let name: String
let frame: NSRect?
}
func captureOpenWindowsForLanguageSwitch() -> [WindowSnapshot] {
App.shared.openWindows.compactMap { windowName in
switch windowName {
case "DomainList":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: DomainListWC.self)?.frame
)
case "Onboarding":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: OnboardingWC.self)?.frame
)
case "ConfigManager":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: PhpConfigManagerWC.self)?.frame
)
case "Warnings":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: PhpDoctorWC.self)?.frame
)
case "PhpVersionManager":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: PhpVersionManagerWC.self)?.frame
)
case "PhpExtensionManager":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: PhpExtensionManagerWC.self)?.frame
)
case "CommandHistory":
return WindowSnapshot(
name: windowName,
frame: WindowManager.window(for: CommandHistoryWC.self)?.frame
)
default:
return nil
}
}
}
func reopenWindows(afterLanguageChange snapshots: [WindowSnapshot]) {
for snapshot in snapshots {
switch snapshot.name {
case "DomainList":
DomainListVC.show()
applyFrame(snapshot.frame, for: DomainListWC.self)
case "Onboarding":
OnboardingWindowController.show()
applyFrame(snapshot.frame, for: OnboardingWC.self)
case "ConfigManager":
PhpConfigManagerWindowController.show()
applyFrame(snapshot.frame, for: PhpConfigManagerWC.self)
case "Warnings":
PhpDoctorWindowController.show()
applyFrame(snapshot.frame, for: PhpDoctorWC.self)
case "PhpVersionManager":
PhpVersionManagerWindowController.show()
applyFrame(snapshot.frame, for: PhpVersionManagerWC.self)
case "PhpExtensionManager":
PhpExtensionManagerWindowController.show()
applyFrame(snapshot.frame, for: PhpExtensionManagerWC.self)
case "CommandHistory":
CommandHistoryWindowController.show()
applyFrame(snapshot.frame, for: CommandHistoryWC.self)
default:
continue
}
}
}
private func applyFrame<T: NSWindowController>(_ frame: NSRect?, for type: T.Type) {
guard let frame else { return }
WindowManager.window(for: type)?.setFrame(frame, display: true)
}
}

View File

@@ -1,5 +1,5 @@
//
// PreferencesVC.swift
// PreferenceVC.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 30/03/2021.
@@ -9,7 +9,7 @@
import Cocoa
import Carbon
class GenericPreferenceVC: NSViewController {
class PreferenceVC: NSViewController {
// MARK: - Content
@@ -28,7 +28,8 @@ class GenericPreferenceVC: NSViewController {
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
func addView(when condition: Bool, _ view: NSView) -> GenericPreferenceVC {
@discardableResult
func addView(when condition: Bool, _ view: NSView) -> PreferenceVC {
if condition {
self.views.append(view)
}
@@ -36,18 +37,6 @@ class GenericPreferenceVC: NSViewController {
return self
}
func getDynamicIconPV() -> NSView {
return CheckboxPreferenceView.make(
sectionText: "prefs.dynamic_icon".localized,
descriptionText: "prefs.dynamic_icon_desc".localized,
checkboxText: "prefs.dynamic_icon_title".localized,
preference: .shouldDisplayDynamicIcon,
action: {
MainMenu.shared.refreshIcon()
}
)
}
func getLanguageOptionsPV() -> NSView {
var options = Bundle.main.localizations
.filter({ $0 != "Base"})
@@ -65,17 +54,46 @@ class GenericPreferenceVC: NSViewController {
options: options,
preference: .languageOverride,
action: {
// Track which windows we will need to reopen
let windowsToReopen = self.captureOpenWindowsForLanguageSwitch()
// Rebuild the menu
MainMenu.shared.refreshIcon()
MainMenu.shared.rebuild()
if let window = App.shared.preferencesWindowController?.window {
let alert = NSAlert()
alert.messageText = "alert.language_changed.title".localized
alert.informativeText = "alert.language_changed.subtitle".localized
alert.alertStyle = .warning
alert.addButton(withTitle: "generic.ok".localized)
alert.beginSheetModal(for: window)
// Close all windows
App.shared.invalidateCachedWindows()
// Re-open the preferences window controller
WindowManager.close(PreferencesWC.self)
PreferencesWindowController.show()
// Finally, open all other windows again
self.reopenWindows(afterLanguageChange: windowsToReopen)
}
)
}
func getDynamicIconPV() -> NSView {
return CheckboxPreferenceView.make(
sectionText: "prefs.dynamic_icon".localized,
descriptionText: "prefs.dynamic_icon_desc".localized,
checkboxText: "prefs.dynamic_icon_title".localized,
preference: .shouldDisplayDynamicIcon,
action: {
MainMenu.shared.refreshIcon()
}
)
}
func getMenuIconsPV() -> NSView {
return CheckboxPreferenceView.make(
sectionText: "prefs.hide_menu_icons".localized,
descriptionText: "prefs.hide_menu_icons_desc".localized,
checkboxText: "prefs.hide_menu_icons_title".localized,
preference: .hideIconsInMenu,
action: {
MainMenu.shared.rebuild()
}
)
}
@@ -265,4 +283,5 @@ class GenericPreferenceVC: NSViewController {
listeningForHotkeyView = nil
}
}
}

View File

@@ -45,6 +45,33 @@ class Preferences {
}
}
static func registerPreferenceDefaults(_ configuration: [PreferenceName: Any]) {
let tuple = configuration.map { (key: PreferenceName, value: Any) in
return (key.rawValue, value)
}
let defaults = Dictionary(uniqueKeysWithValues: tuple)
UserDefaults.standard.register(defaults: defaults)
}
static func registerPersistentAppStateDefaults(_ configuration: [PersistentAppState: Any]) {
let tuple = configuration.map { (key: PersistentAppState, value: Any) in
return (key.rawValue, value)
}
let defaults = Dictionary(uniqueKeysWithValues: tuple)
UserDefaults.standard.register(defaults: defaults)
}
static func registerInternalAppStateDefaults(_ configuration: [InternalStats: Any]) {
let tuple = configuration.map { (key: InternalStats, value: Any) in
return (key.rawValue, value)
}
let defaults = Dictionary(uniqueKeysWithValues: tuple)
UserDefaults.standard.register(defaults: defaults)
}
// MARK: - First Time Run
/**
@@ -58,50 +85,53 @@ class Preferences {
```
*/
static func handleFirstTimeLaunch() {
UserDefaults.standard.register(defaults: [
self.registerPreferenceDefaults([
/// Preferences: General
PreferenceName.autoServiceRestartAfterExtensionToggle.rawValue: true,
PreferenceName.autoComposerGlobalUpdateAfterSwitch.rawValue: false,
PreferenceName.allowProtocolForIntegrations.rawValue: true,
PreferenceName.automaticBackgroundUpdateCheck.rawValue: true,
PreferenceName.showPhpDoctorSuggestions.rawValue: true,
PreferenceName.languageOverride.rawValue: "",
.autoServiceRestartAfterExtensionToggle: true,
.autoComposerGlobalUpdateAfterSwitch: false,
.allowProtocolForIntegrations: false,
.automaticBackgroundUpdateCheck: true,
.showPhpDoctorSuggestions: true,
.languageOverride: "",
/// Preferences: Appearance
PreferenceName.shouldDisplayDynamicIcon.rawValue: true,
PreferenceName.iconTypeToDisplay.rawValue: MenuBarIcon.iconPhp.rawValue,
PreferenceName.fullPhpVersionDynamicIcon.rawValue: false,
.shouldDisplayDynamicIcon: true,
.iconTypeToDisplay: MenuBarIcon.iconPhp.rawValue,
.fullPhpVersionDynamicIcon: false,
.hideIconsInMenu: false,
/// Preferences: Notifications
PreferenceName.warnAboutNonStandardTLD.rawValue: true,
PreferenceName.notifyAboutVersionChange.rawValue: true,
PreferenceName.notifyAboutPhpFpmRestart.rawValue: true,
PreferenceName.notifyAboutServices.rawValue: true,
PreferenceName.notifyAboutPresets.rawValue: true,
PreferenceName.notifyAboutSecureToggle.rawValue: true,
PreferenceName.notifyAboutGlobalComposerStatus.rawValue: true,
.warnAboutNonStandardTLD: true,
.notifyAboutVersionChange: true,
.notifyAboutPhpFpmRestart: true,
.notifyAboutServices: true,
.notifyAboutPresets: true,
.notifyAboutSecureToggle: true,
.notifyAboutGlobalComposerStatus: true,
/// Preferences: UI Preferences
PreferenceName.displayDriver.rawValue: true,
PreferenceName.displayGlobalVersionSwitcher.rawValue: true,
PreferenceName.displayServicesManager.rawValue: true,
PreferenceName.displayValetIntegration.rawValue: true,
PreferenceName.displayPhpConfigFinder.rawValue: true,
PreferenceName.displayComposerToolkit.rawValue: true,
PreferenceName.displayLimitsWidget.rawValue: true,
PreferenceName.displayExtensions.rawValue: true,
PreferenceName.displayPresets.rawValue: true,
PreferenceName.displayMisc.rawValue: true,
.displayDriver: true,
.displayGlobalVersionSwitcher: true,
.displayServicesManager: true,
.displayValetIntegration: true,
.displayPhpConfigFinder: true,
.displayComposerToolkit: true,
.displayLimitsWidget: true,
.displayExtensions: true,
.displayPresets: true,
.displayMisc: true
])
/// Persistent App State
PersistentAppState.lastAutomaticUpdateCheck.rawValue: 0,
PersistentAppState.updateCheckFailureCount.rawValue: 0,
registerPersistentAppStateDefaults([
.lastAutomaticUpdateCheck: 0,
.updateCheckFailureCount: 0
])
/// Stats
InternalStats.switchCount.rawValue: 0,
InternalStats.launchCount.rawValue: 0,
InternalStats.didSeeSponsorEncouragement.rawValue: false,
InternalStats.lastGlobalPhpVersion.rawValue: ""
registerInternalAppStateDefaults([
.switchCount: 0,
.launchCount: 0,
.didSeeSponsorEncouragement: false,
.lastGlobalPhpVersion: ""
])
if UserDefaults.standard.bool(forKey: PersistentAppState.wasLaunchedBefore.rawValue) {
@@ -168,7 +198,7 @@ class Preferences {
})
}
static func update(_ preference: PreferenceName, value: Any?) {
static func update(_ preference: PreferenceName, value: Any?, notify: Bool = false) {
if value == nil {
UserDefaults.standard.removeObject(forKey: preference.rawValue)
} else {
@@ -178,5 +208,9 @@ class Preferences {
// Update the preferences cache in memory!
App.shared.container.preferences.cachedPreferences = Preferences.cache()
if notify {
NotificationCenter.default.post(name: Events.PreferencesUpdated, object: nil)
}
}
}

View File

@@ -9,13 +9,13 @@
import Foundation
import Cocoa
class GeneralPreferencesVC: GenericPreferenceVC {
class GeneralPreferencesVC: PreferenceVC {
// MARK: - Lifecycle
public static func fromStoryboard() -> GenericPreferenceVC {
public static func fromStoryboard() -> PreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc
.addView(when: true, vc.getLanguageOptionsPV())
@@ -29,25 +29,26 @@ class GeneralPreferencesVC: GenericPreferenceVC {
}
}
class AppearancePreferencesVC: GenericPreferenceVC {
class AppearancePreferencesVC: PreferenceVC {
public static func fromStoryboard() -> GenericPreferenceVC {
public static func fromStoryboard() -> PreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
_ = vc.addView(when: true, vc.getDynamicIconPV())
vc.addView(when: true, vc.getDynamicIconPV())
.addView(when: true, vc.getIconOptionsPV())
.addView(when: true, vc.getIconDensityPV())
.addView(when: true, vc.getMenuIconsPV())
return vc
}
}
class MenuStructurePreferencesVC: GenericPreferenceVC {
class MenuStructurePreferencesVC: PreferenceVC {
public static func fromStoryboard() -> GenericPreferenceVC {
public static func fromStoryboard() -> PreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc
.addView(when: true, vc.displayFeature("prefs.display_global_version_switcher", .displayGlobalVersionSwitcher, true))
@@ -63,11 +64,11 @@ class MenuStructurePreferencesVC: GenericPreferenceVC {
}
}
class NotificationPreferencesVC: GenericPreferenceVC {
class NotificationPreferencesVC: PreferenceVC {
public static func fromStoryboard() -> GenericPreferenceVC {
public static func fromStoryboard() -> PreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc.addView(when: true, vc.getNotifyAboutVersionChangePV())
.addView(when: true, vc.getNotifyAboutPresetsPV())

View File

@@ -19,7 +19,7 @@ extension PreferencesWindowController {
return
}
guard let vc = tabVC.tabViewItems[tabVC.selectedTabViewItemIndex].viewController as? GenericPreferenceVC else {
guard let vc = tabVC.tabViewItems[tabVC.selectedTabViewItemIndex].viewController as? PreferenceVC else {
return
}

View File

@@ -30,19 +30,35 @@ class PreferencesWindowController: PMWindowController {
window.delegate = delegate ?? windowController
window.styleMask = [.titled, .closable, .miniaturizable]
App.shared.preferencesWindowController = windowController
WindowManager.setController(windowController)
}
public static func show(delegate: NSWindowDelegate? = nil) {
var justCreated = false
if App.shared.preferencesWindowController == nil {
if !WindowManager.hasController(for: PreferencesWC.self) {
Self.create(delegate: delegate)
justCreated = true
}
guard let preferencesWC = App.shared.preferencesWindowController else {
guard let preferencesWC = WindowManager.controller(of: PreferencesWC.self) else {
return
}
if justCreated {
addContentTabs(to: preferencesWC)
}
WindowManager.show(PreferencesWC.self)
if justCreated {
preferencesWC.positionWindowInTopRightCorner()
}
NSApp.activate(ignoringOtherApps: true)
}
private static func addContentTabs(to preferencesWC: PreferencesWC) {
guard let tabVC = preferencesWC.contentViewController as? NSTabViewController else {
return
}
@@ -58,25 +74,12 @@ class PreferencesWindowController: PMWindowController {
width: tabVC.view.frame.size.width,
height: tabVC.view.frame.size.height
)
justCreated = true
}
App.shared.preferencesWindowController?.showWindow(self)
if justCreated {
App.shared.preferencesWindowController?.positionWindowInTopRightCorner()
}
App.shared.preferencesWindowController?.window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Tabs
struct PrefTabView {
let viewController: GenericPreferenceVC
let viewController: PreferenceVC
let label: String
let icon: String
}

View File

@@ -65,6 +65,15 @@ class CheckboxPreferenceBehavior: CheckboxPreferenceViewBehavior {
self.preference = preference
self.button = button
self.button.state = Preferences.isEnabled(self.preference) ? .on : .off
NotificationCenter.default.addObserver(
self, selector: #selector(refreshState),
name: Events.PreferencesUpdated, object: nil
)
}
@objc func refreshState() {
self.button.state = Preferences.isEnabled(self.preference) ? .on : .off
}
public func toggled(checked: Bool) {

View File

@@ -11,7 +11,7 @@ import Cocoa
class HotkeyPreferenceView: NSView, XibLoadable {
weak var delegate: GenericPreferenceVC?
weak var delegate: PreferenceVC?
@IBOutlet weak var labelSection: NSTextField!
@IBOutlet weak var labelDescription: NSTextField!
@@ -19,7 +19,7 @@ class HotkeyPreferenceView: NSView, XibLoadable {
@IBOutlet weak var buttonSetShortcut: NSButton!
@IBOutlet weak var buttonClearShortcut: NSButton!
static func make(sectionText: String, descriptionText: String, _ prefsVC: GenericPreferenceVC) -> NSView {
static func make(sectionText: String, descriptionText: String, _ prefsVC: PreferenceVC) -> NSView {
let view = Self.createFromXib()!
view.labelSection.stringValue = sectionText
view.labelDescription.stringValue = descriptionText

View File

@@ -73,9 +73,23 @@ class SelectPreferenceView: NSView, XibLoadable {
view.preference = preference
view.action = action
NotificationCenter.default.addObserver(
view, selector: #selector(view.refreshState),
name: Events.PreferencesUpdated, object: nil
)
return view
}
@objc func refreshState() {
let value = Preferences.preferences[preference] as! String
self.options.enumerated().forEach { option in
if option.element.value == value {
self.popupButton.selectItem(at: option.offset)
}
}
}
@IBAction func valueChanged(_ sender: Any) {
let index = self.popupButton.indexOfSelectedItem
Preferences.update(self.preference, value: self.options[index].value)

View File

@@ -276,7 +276,7 @@ struct Preset: Codable, Equatable {
private func persistRevert() async {
let data = try! JSONEncoder().encode(self.revertSnapshot)
await container.shell.quiet("mkdir -p ~/.config/phpmon")
await container.shell.pipe("mkdir -p ~/.config/phpmon")
try! String(data: data, encoding: .utf8)!
.write(

View File

@@ -27,7 +27,7 @@ class InstallHomebrew {
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
"""
_ = try await container.shell.attach(script, didReceiveOutput: { (string: String, _: ShellStream) in
try await container.shell.attach(script, didReceiveOutput: { (string: String, _: ShellStream) in
print(string)
}, withTimeout: 60 * 10)
}

View File

@@ -21,13 +21,19 @@ class ZshRunCommand {
/**
Adds a given line to .zshrc, which may be needed to adjust the PATH.
*/
@discardableResult
private func add(_ text: String) async -> Bool {
// Escape single quotes to prevent shell injection
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
// Actually add the line to .zshrc
let outcome = await container.shell.pipe("""
touch ~/.zshrc && \
grep -qxF '\(text)' ~/.zshrc \
|| echo '\n\n\(text)\n' >> ~/.zshrc
grep -qxF '\(escaped)' ~/.zshrc \
|| echo '\n\n\(escaped)\n' >> ~/.zshrc
""")
// Validate the command executed correctly
if outcome.hasError {
return false
}
@@ -39,13 +45,13 @@ class ZshRunCommand {
Adds Homebrew binaries to the PATH.
*/
public func addHomebrewPath() async {
_ = await add("export PATH=$HOME/bin:/opt/homebrew/bin:$PATH")
await add("export PATH=$HOME/bin:/opt/homebrew/bin:$PATH")
}
/**
Adds PHP Monitor binaries to the PATH.
*/
public func addPhpMonitorPath() async {
_ = await add("export PATH=$HOME/bin:~/.config/phpmon/bin:$PATH")
await add("export PATH=$HOME/bin:~/.config/phpmon/bin:$PATH")
}
}

View File

@@ -0,0 +1,27 @@
//
// SimpleButton.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 18/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
public struct SimpleButton: View {
public let title: String
public let imageName: String
public let action: () -> Void
public var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(imageName)
.resizable()
.scaledToFit()
.frame(width: 14, height: 14) // Standard macOS icon size
Text(title)
}
}
}
}

View File

@@ -0,0 +1,106 @@
//
// CodeBlockTextView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 27/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
import AppKit
/**
A text view that draws rounded backgrounds behind inline code spans.
Code spans are identified by the `.codeSpan` attributed string key,
which is set by `MarkdownTextViewRepresentable` during string building.
*/
class CodeBlockTextView: NSTextView {
// MARK: - Appearance
// Color
private lazy var appColor: NSColor = NSColor(named: "AppColor") ?? .systemBlue
// Padding
private let codePaddingX: CGFloat = 4
private let codePaddingY: CGFloat = 0
// Corner radius
private let codeCornerRadius: CGFloat = 2
// MARK: - Copy
/**
When copying selected text, we sanitize it so special layout characters
are stripped and code spans are wrapped in backticks again.
*/
override func copy(_ sender: Any?) {
guard let textStorage, selectedRange().length > 0 else {
super.copy(sender)
return
}
let selected = textStorage.attributedSubstring(from: selectedRange())
let result = NSMutableString()
selected.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: selected.length)) { value, range, _ in
let fragment = (selected.string as NSString).substring(with: range)
.filter { $0 != .thinSpace && $0 != .nbThinSpace && $0 != .wordJoiner }
.map { $0 == .nbSpace ? " " : String($0) }
.joined()
if value != nil {
result.append("`\(fragment)`")
} else {
result.append(fragment)
}
}
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(result as String, forType: .string)
}
// MARK: - Drawing
override func draw(_ dirtyRect: NSRect) {
drawCodeBackgrounds()
super.draw(dirtyRect)
}
override var intrinsicContentSize: NSSize {
guard let textContainer, let layoutManager else {
return super.intrinsicContentSize
}
layoutManager.ensureLayout(for: textContainer)
let rect = layoutManager.usedRect(for: textContainer)
return NSSize(width: NSView.noIntrinsicMetric, height: ceil(rect.height))
}
/**
Draws a rounded background rect behind each code span, using per-line
rects so a wrapped span still gets tight individual backgrounds.
*/
private func drawCodeBackgrounds() {
guard let textStorage, let layoutManager, let textContainer else { return }
textStorage.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in
guard value != nil else { return }
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
layoutManager.enumerateEnclosingRects(
forGlyphRange: glyphRange,
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
in: textContainer
) { lineRect, _ in
let rect = lineRect.offsetBy(dx: self.textContainerInset.width, dy: self.textContainerInset.height)
let paddedRect = rect.insetBy(dx: -self.codePaddingX, dy: -self.codePaddingY)
let path = NSBezierPath(roundedRect: paddedRect, xRadius: self.codeCornerRadius, yRadius: self.codeCornerRadius)
self.appColor.withAlphaComponent(0.15).setFill()
path.fill()
}
}
}
}

View File

@@ -0,0 +1,41 @@
//
// MarkdownTextView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 27/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct MarkdownTextView: View {
let string: String
let fontSize: CGFloat
let textColor: NSColor
init(_ string: String, fontSize: CGFloat = 12, textColor: NSColor = .labelColor) {
self.string = string
self.fontSize = fontSize
self.textColor = textColor
}
var body: some View {
MarkdownTextViewRepresentable(string: string, fontSize: fontSize, textColor: textColor)
}
}
// MARK: - Previews
#Preview("Inline code") {
MarkdownTextView("startup.errors.php_binary.desc".localized(
"/opt/homebrew/bin/php"
))
.frame(width: 460)
.padding()
}
#Preview("No code") {
MarkdownTextView("startup.errors.valet_version_not_supported.desc".localized)
.frame(width: 460)
.padding()
}

View File

@@ -0,0 +1,233 @@
//
// MarkdownTextViewRepresentable.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 27/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
import AppKit
/**
Bridges a `CodeBlockTextView` into SwiftUI and builds an attributed string
from a simplified Markdown subset (inline code, bold and italic).
*/
struct MarkdownTextViewRepresentable: NSViewRepresentable {
let string: String
let fontSize: CGFloat
let textColor: NSColor
// MARK: - NSViewRepresentable
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> CodeBlockTextView {
let textView = CodeBlockTextView()
textView.isEditable = false
textView.isSelectable = true
textView.drawsBackground = false
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.setContentHuggingPriority(.defaultHigh, for: .vertical)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.setContentCompressionResistancePriority(.required, for: .vertical)
configure(textView, coordinator: context.coordinator)
return textView
}
func updateNSView(
_ textView: CodeBlockTextView,
context: Context
) {
let coordinator = context.coordinator
guard string != coordinator.lastString || fontSize != coordinator.lastFontSize || textColor != coordinator.lastTextColor else { return }
configure(textView, coordinator: coordinator)
}
private func configure(
_ textView: CodeBlockTextView,
coordinator: Coordinator
) {
coordinator.lastString = string
coordinator.lastFontSize = fontSize
coordinator.lastTextColor = textColor
let attributed = Self.buildAttributedString(from: string, fontSize: fontSize, textColor: textColor)
textView.textStorage?.setAttributedString(attributed)
textView.invalidateIntrinsicContentSize()
}
class Coordinator {
var lastString: String?
var lastFontSize: CGFloat?
var lastTextColor: NSColor?
}
// MARK: - Attributed String Builder
// swiftlint:disable force_try
private static let codeRegex = try! NSRegularExpression(pattern: "`([^`]+)`")
private static let boldRegex = try! NSRegularExpression(pattern: "\\*\\*([^*]+)\\*\\*")
private static let italicRegex = try! NSRegularExpression(pattern: "(?<!\\*)\\*([^*]+)\\*(?!\\*)")
// swiftlint:enable force_try
static func buildAttributedString(
from string: String,
fontSize: CGFloat,
textColor: NSColor = .labelColor
) -> NSAttributedString {
let result = NSMutableAttributedString()
let font = NSFont.systemFont(ofSize: fontSize)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 2
paragraphStyle.paragraphSpacing = -4
let defaultAttributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: textColor,
.paragraphStyle: paragraphStyle
]
result.append(NSAttributedString(string: string, attributes: defaultAttributes))
// Apply markup passes (order matters: code first to avoid matching * inside code spans)
handleCodeMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle)
// Collect code span ranges once for bold and italic passes
let codeRanges = codeSpanRanges(in: result)
// Handle bold markup
handleStyledMarkup(
in: result,
regex: boldRegex,
font: NSFont.boldSystemFont(ofSize: fontSize),
paragraphStyle: paragraphStyle,
textColor: textColor,
codeRanges: codeRanges
)
// Handle italic markup
handleStyledMarkup(
in: result,
regex: italicRegex,
font: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
paragraphStyle: paragraphStyle,
textColor: textColor,
codeRanges: codeRanges
)
return result
}
// MARK: - Markup Handlers
/**
Replaces `` `code` `` with monospaced font and padding spaces.
The leading thin space is breakable so the code span can shift to the next line
as a whole, while the trailing narrow no-break space stays glued to the span.
Spaces and hyphens inside the code span are made non-breaking so the layout
engine never splits the code span across lines.
*/
private static func handleCodeMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle
) {
let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular)
let fullRange = NSRange(location: 0, length: result.length)
let matches = codeRegex.matches(in: result.string, range: fullRange).reversed()
for match in matches {
let innerRange = match.range(at: 1)
// Make the code span non-breaking by replacing spaces and joining hyphens
let innerText = (result.string as NSString).substring(with: innerRange)
.replacingOccurrences(of: " ", with: String(Character.nbSpace))
.replacingOccurrences(of: "-", with: "-\(Character.wordJoiner)")
let spaceAttributes: [NSAttributedString.Key: Any] = [
.font: codeFont,
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraphStyle
]
let replacement = NSMutableAttributedString()
replacement.append(NSAttributedString(string: String(Character.thinSpace), attributes: spaceAttributes))
replacement.append(NSAttributedString(
string: innerText,
attributes: spaceAttributes.merging([.codeSpan: true]) { _, new in new }
))
replacement.append(NSAttributedString(string: String(Character.nbThinSpace), attributes: spaceAttributes))
result.replaceCharacters(in: match.range, with: replacement)
}
}
/**
Collects all ranges marked as code spans.
*/
private static func codeSpanRanges(
in result: NSMutableAttributedString
) -> [NSRange] {
var ranges: [NSRange] = []
result.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: result.length)) { value, range, _ in
if value != nil { ranges.append(range) }
}
return ranges
}
// swiftlint:disable function_parameter_count
/**
Replaces markup like `**bold**` or `*italic*` with the appropriate font,
skipping any matches that overlap with an already-processed code span.
*/
private static func handleStyledMarkup(
in result: NSMutableAttributedString,
regex: NSRegularExpression,
font: NSFont,
paragraphStyle: NSParagraphStyle,
textColor: NSColor,
codeRanges: [NSRange]
) {
let fullRange = NSRange(location: 0, length: result.length)
let matches = regex.matches(in: result.string, range: fullRange).filter { match in
!codeRanges.contains { NSIntersectionRange(match.range, $0).length > 0 }
}
for match in matches.reversed() {
let innerRange = match.range(at: 1)
let innerText = (result.string as NSString).substring(with: innerRange)
let replacement = NSAttributedString(
string: innerText,
attributes: [
.font: font,
.foregroundColor: textColor,
.paragraphStyle: paragraphStyle
]
)
result.replaceCharacters(in: match.range, with: replacement)
}
}
// swiftlint:enable function_parameter_count
}
// MARK: - Shared Constants
extension NSAttributedString.Key {
/// Marks a range as being part of an inline code span, used by `CodeBlockTextView` to draw backgrounds.
static let codeSpan = NSAttributedString.Key("PHPMonitorCodeSpan")
}
extension Character {
static let thinSpace: Character = "\u{2009}"
static let nbThinSpace: Character = "\u{202F}"
static let nbSpace: Character = "\u{00A0}"
static let wordJoiner: Character = "\u{2060}"
}

View File

@@ -0,0 +1,26 @@
//
// ErrorView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 19/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct ErrorView: View {
let message: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
Text(message)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.statusColorRed.opacity(0.1))
.font(.system(size: 11))
}
}

View File

@@ -0,0 +1,128 @@
//
// AddProxyView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 18/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct AddProxyView: View {
let tld: String
var onCancel: () -> Void
var onConfirm: (String, String, Bool) -> Void
var domainExists: (String) -> Bool
@State private var domainName: String = ""
@State private var proxySubject: String = "http://127.0.0.1:80"
@State private var secure: Bool = false
private var validationError: String? {
if domainName.isEmpty {
return "domain_list.add.errors.empty".localized
}
if proxySubject.isEmpty {
return "domain_list.add.errors.empty_proxy".localized
}
if proxySubject.range(of: #"(http:\/\/|https:\/\/)(.+)(:)(\d+)$"#, options: .regularExpression) == nil {
return "domain_list.add.errors.subject_invalid".localized
}
if domainExists(domainName) {
return "domain_list.add.errors.already_exists".localized
}
return nil
}
private var isValid: Bool { validationError == nil }
private var preview: String {
guard !proxySubject.isEmpty, !domainName.isEmpty else {
return "domain_list.add.empty_fields".localized
}
let key = proxySubject.starts(with: "https://")
? "domain_list.add.proxy_https_warning"
: "domain_list.add.proxy_available"
return key.localized(
proxySubject,
secure ? "https" : "http",
domainName,
tld
)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
Text("domain_list.add.set_up_proxy")
.font(.system(size: 16, weight: .bold, design: .default))
Text("domain_list.add.proxy_subject")
.foregroundColor(.secondary)
.font(.system(size: 11))
TextField("", text: Binding(
get: { proxySubject },
set: { proxySubject = $0.replacing(" ", with: "-") }
))
Text("domain_list.add.domain_name")
.foregroundColor(.secondary)
.font(.system(size: 11))
TextField("", text: Binding(
get: { domainName },
set: { domainName = $0.replacing(" ", with: "-") }
))
Text(preview)
.foregroundColor(.secondary)
.font(.system(size: 11))
Toggle(
"domain_list.add.secure_after_creation".localized(domainName, tld),
isOn: $secure
)
Text("domain_list.add.secure_description")
.foregroundColor(.secondary)
.font(.system(size: 11))
.fixedSize(horizontal: false, vertical: true)
}
.padding(20)
Divider()
VStack(alignment: .leading, spacing: 0) {
if let error = validationError {
ErrorView(message: error)
}
if validationError != nil {
Divider()
}
HStack {
Button("domain_list.add.cancel".localized) {
onCancel()
}
Spacer()
SimpleButton(
title: "domain_list.add.create_proxy".localized,
imageName: "IconProxy",
action: { onConfirm(domainName, proxySubject, secure) }
)
.disabled(!isValid)
}.padding(20)
}
}
.frame(width: 550)
}
}
#Preview {
AddProxyView(
tld: "test",
onCancel: {},
onConfirm: { _, _, _ in },
domainExists: { _ in false }
)
}

View File

@@ -0,0 +1,150 @@
//
// AddSiteView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 18/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
import AppKit
private struct PathControl: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> NSPathControl {
let control = NSPathControl()
control.isEditable = false
control.url = url
return control
}
func updateNSView(_ nsView: NSPathControl, context: Context) {
nsView.url = url
}
}
struct AddSiteView: View {
let path: String
let tld: String
var onCancel: () -> Void
var onConfirm: (String, Bool) -> Void
var domainExists: (String) -> Bool
@State private var domainName: String
@State private var secure: Bool = false
init(
path: String,
tld: String,
onCancel: @escaping () -> Void,
onConfirm: @escaping (String, Bool) -> Void,
domainExists: @escaping (String) -> Bool
) {
self.path = path
self.tld = tld
self.onCancel = onCancel
self.onConfirm = onConfirm
self.domainExists = domainExists
let initial = String(path
.split(separator: "/")
.last ?? "")
.lowercased()
_domainName = State(initialValue: initial)
}
private var validationError: String? {
if domainName.isEmpty {
return "domain_list.add.errors.empty".localized
}
if domainExists(domainName) {
return "domain_list.add.errors.already_exists".localized
}
return nil
}
private var isValid: Bool { validationError == nil && !domainName.isEmpty }
private var preview: String {
guard !domainName.isEmpty else {
return "domain_list.add.empty_fields".localized
}
return "domain_list.add.folder_available".localized(
secure ? "https" : "http",
domainName,
tld
)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 15) {
Text("domain_list.add.link_folder")
.font(.system(size: 16, weight: .bold, design: .default))
PathControl(url: URL(fileURLWithPath: path))
.frame(height: 22)
TextField("domain_list.add.domain_name_placeholder".localized, text: Binding(
get: { domainName },
set: { domainName = $0.replacing(" ", with: "-") }
))
Text(preview)
.foregroundColor(.secondary)
.font(.system(size: 11))
Toggle(
"domain_list.add.secure_after_creation".localized(domainName, tld),
isOn: $secure
)
Text("domain_list.add.secure_description")
.foregroundColor(.secondary)
.font(.system(size: 11))
.fixedSize(horizontal: false, vertical: true)
}
.padding(20)
Divider()
VStack(alignment: .leading, spacing: 0) {
if let error = validationError {
ErrorView(message: error)
}
if validationError != nil {
Divider()
}
HStack {
Button("domain_list.add.cancel".localized) {
onCancel()
}
Spacer()
SimpleButton(
title: "domain_list.add.create_link".localized,
imageName: "IconLinked",
action: { onConfirm(domainName, secure) }
)
.disabled(!isValid)
}.padding(20)
}
}
.frame(width: 550)
}
}
#Preview {
AddSiteView(
path: "/Users/nico/Code/my-website",
tld: "test",
onCancel: {},
onConfirm: { _, _ in },
domainExists: { _ in false }
).frame(height: 350)
}

View File

@@ -0,0 +1,51 @@
//
// SelectDomainView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 18/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct SelectDomainTypeView: View {
var onCancel: () -> Void
var onCreateLink: () -> Void
var onCreateProxy: () -> Void
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 15) {
Text("selection.title")
.font(.system(size: 16, weight: .bold, design: .default))
Text("selection.description")
}.padding(25)
Divider()
HStack {
Button("selection.cancel".localized) {
onCancel()
}
Spacer()
SimpleButton(
title: "selection.create_link".localized,
imageName: "IconLinked",
action: { onCreateLink() }
)
SimpleButton(
title: "selection.create_proxy".localized,
imageName: "IconProxy",
action: { onCreateProxy() }
)
}
.padding(.all, 20)
.padding(.top, -10)
}
.frame(width: 600)
}
}
#Preview {
SelectDomainTypeView(onCancel: {}, onCreateLink: {}, onCreateProxy: {})
}

View File

@@ -48,7 +48,7 @@ struct VersionPopoverView: View {
LazyVGrid(columns: self.rows, alignment: .leading, spacing: 5, content: {
ForEach(validPhpVersions, id: \.self) { version in
Button("site_link.isolate_php".localized(version.short), action: {
App.shared.domainListWindowController?.contentVC
WindowManager.controller(of: DomainListWC.self)?.contentVC
.isolateSite(site: site, version: version.short)
parent?.close()
}).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))

View File

@@ -1,5 +1,5 @@
//
// MiniHeaderView.swift
// HeaderView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 10/06/2022.

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