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_function_declarations: true
ignores_comments: true ignores_comments: true
ignores_urls: true ignores_urls: true
warning: 120 ignores_interpolated_strings: true
ignores_multiline_strings: true
warning: 160
error: 200 error: 200
analyzer_rules: 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 ## ✅ 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. 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 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 ## 🐛 Symbolication of crashes
The easiest way to symbolicate crashes is to simply rename the file to `.crash`, and drag it into Xcode. 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"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "2630"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -91,13 +91,17 @@
argument = "--v" argument = "--v"
isEnabled = "NO"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument
argument = "--ch"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--cli" argument = "--cli"
isEnabled = "NO"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working.json" argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "YES"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json" argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "2630"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" 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. 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? ## 🔑 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> <details>
<summary><strong>How can the app integrate with third party tools, like Alfred or Raycast?</strong></summary> <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). 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://phpinfo
* phpmon://switch/php/{version} * 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>
<details> <details>
@@ -632,7 +642,7 @@ Thank you very much for your contributions, kind words and support.
### Loading info about PHP in the background ### 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. 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 import Foundation
protocol CommandProtocol { protocol CommandProtocol {
/** /**
Immediately executes a command. Immediately executes a command.
@@ -39,3 +38,19 @@ protocol CommandProtocol {
) -> String ) -> 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 import Cocoa
public class RealCommand: CommandProtocol { public class RealCommand: CommandProtocol {
init() {}
public func execute( public func execute(
path: String, path: String,
arguments: [String], arguments: [String],

View File

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

View File

@@ -81,16 +81,7 @@ class Actions {
+ " && " + " && "
+ cellarCommands.joined(separator: " && ") + cellarCommands.joined(separator: " && ")
let source = "do shell script \"\(script)\" with administrator privileges" try AppleScript.runSimpleShellAsAdmin(script)
Log.perf(source)
let appleScript = NSAppleScript(source: source)
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(nil)
if eventResult == nil {
throw HomebrewPermissionError(kind: .applescriptNilError)
}
} }
// MARK: - Finding Config Files // MARK: - Finding Config Files
@@ -106,6 +97,12 @@ class Actions {
} }
public func openGlobalComposerFolder() { 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)! let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL]) NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
} }
@@ -123,10 +120,15 @@ class Actions {
// MARK: - Other Actions // MARK: - Other Actions
public func createTempPhpInfoFile() async -> URL { 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();") try! container.filesystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
// Tell php-cgi to run the PHP and output as an .html file // 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")! 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> { static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [ let releaseDates = [
"8.6": Date.fromString(PhpFormulaeCutoffDate), "8.6": Date.fromString(PhpFormulaeCutoffDate)
"8.5": Date.fromString("2025-11-20"),
"8.4": Date.fromString("2024-11-22")
] ]
return Set(releaseDates return Set(releaseDates

View File

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

View File

@@ -18,7 +18,7 @@ func brew(
_ command: String, _ command: String,
sudo: Bool = false, sudo: Bool = false,
) async { ) 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, // Check if gsed exists; it is able to follow symlinks,
// which we want to do to toggle the extension // which we want to do to toggle the extension
if container.filesystem.fileExists("\(container.paths.binPath)/gsed") { 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 { } 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 // PHP Monitor
// //
// Created by Nico Verbruggen on 08/02/2022. // Created by Nico Verbruggen on 08/02/2022.
@@ -11,7 +11,7 @@ import Foundation
// MARK: - Alertable Errors // MARK: - Alertable Errors
// These errors must be resolved by the user. // These errors must be resolved by the user.
struct HomebrewPermissionError: Error, AlertableError { struct AdminPrivilegeError: Error, AlertableError {
enum Kind: String { enum Kind: String {
case applescriptNilError = "homebrew_permissions.applescript_returned_nil" case applescriptNilError = "homebrew_permissions.applescript_returned_nil"
} }

View File

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

View File

@@ -9,7 +9,7 @@ import SwiftUI
struct Localization { struct Localization {
static var preferredLanguage: String? { static var preferredLanguage: String? {
if App.shared.preferences == nil { if App.shared.container.preferences == nil {
return nil return nil
} }
@@ -65,7 +65,7 @@ extension String {
return NSLocalizedString(self, bundle: bundle, comment: "") return NSLocalizedString(self, bundle: bundle, comment: "")
} }
return string.replacing("Preferences", with: "Settings") return string
} }
var localizedForSwiftUI: LocalizedStringKey { var localizedForSwiftUI: LocalizedStringKey {
@@ -76,6 +76,13 @@ extension String {
String(format: self.localized, arguments: args) 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 { func countInstances(of stringToFind: String) -> Int {
if stringToFind.isEmpty { if stringToFind.isEmpty {
return 0 return 0

View File

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

View File

@@ -32,10 +32,13 @@ class Application {
/// The full path to the application bundle (if found) /// The full path to the application bundle (if found)
var path: String? 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. /// Initializer. Used to detect a specific app of a specific type.
init(_ container: Container, _ name: String, _ type: AppType) { init(_ container: Container, _ name: String, _ type: AppType) {
self.container = container self.container = container
self.name = name self.name = String(name.filter { !Application.unsafeCharacters.contains($0) })
self.type = type self.type = type
self.path = determinePath() self.path = determinePath()
} }
@@ -45,7 +48,7 @@ class Application {
(This will open the app if it isn't open yet.) (This will open the app if it isn't open yet.)
*/ */
@objc public func open(arg: String) { @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. Run a simple blocking Shell command on the user's own system.
*/ */
@discardableResult
public func system(_ command: String) -> String { public func system(_ command: String) -> String {
let task = Process() let task = Process()
task.launchPath = "/bin/sh" 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 // Update the synchronized value
_currentInstall.value = newValue _currentInstall.value = newValue
// Let the PHP extension manager, if it exists, know the version changed // Let the PHP extension manager, if it exists, know the version changed
App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion() WindowManager
.controller(of: PhpExtensionManagerWC.self)?
.view.didUpdatePhpVersion()
} }
} }
@@ -218,7 +220,7 @@ class PhpEnvironments {
} }
public func reloadPhpVersions() async { 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. Returns a `Set<String>` of installations that are considered valid.
*/ */
@discardableResult
public func detectPhpVersions() async -> Set<String> { public func detectPhpVersions() async -> Set<String> {
let files = await container.shell.pipe("ls \(container.paths.optPath) | grep php@").out 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) { if !container.filesystem.fileExists(destination) {
Log.info("Creating new symlink: \(destination)") Log.info("Creating new symlink: \(destination)")
await container.shell.quiet("ln -s \(source) \(destination)") await container.shell.pipe("ln -s \(source) \(destination)")
return return
} }
if !App.shared.container.filesystem.isSymlink(destination) { if !App.shared.container.filesystem.isSymlink(destination) {
Log.info("Overwriting existing file with new symlink: \(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 return
} }

View File

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

View File

@@ -41,7 +41,6 @@ class PhpExtension {
return String(file.split(separator: "/").last ?? "php.ini") 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. 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. - 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)"?)$"# 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. 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 { extension InternalSwitcher {
typealias FixApplied = Bool typealias FixApplied = Bool
@discardableResult
public func ensureValetConfigurationIsValidForPhpVersion(_ version: String) async -> FixApplied { public func ensureValetConfigurationIsValidForPhpVersion(_ version: String) async -> FixApplied {
// Early exit if Valet is not installed // Early exit if Valet is not installed
if !Valet.installed { if !Valet.installed {
@@ -71,7 +72,8 @@ extension InternalSwitcher {
} }
do { 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 { for (original, replacement) in file.replacements {
contents = contents.replacing(original, with: replacement) contents = contents.replacing(original, with: replacement)

View File

@@ -53,7 +53,7 @@ class InternalSwitcher: PhpSwitcher {
for formula in versions { for formula in versions {
if Valet.installed { if Valet.installed {
Log.info("Ensuring that the Valet configuration is valid...") 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))") Log.info("Will start PHP \(version)... (primary: \(version == formula))")
@@ -112,7 +112,7 @@ class InternalSwitcher: PhpSwitcher {
if Valet.enabled(feature: .isolatedSites) && primary { if Valet.enabled(feature: .isolatedSites) && primary {
let socketVersion = version.replacing(".", with: "") 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).") 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) { init(binPath: String) {
self.binPath = binPath self.binPath = binPath
self._PATH = RealShell.getPath() self._PATH = RealShell.getPath()
self._exports = "" self._exports = [:]
} }
private(set) var binPath: String 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. Exports are additional environment variables set by the user via the custom configuration.
These are populated when the configuration file is being loaded. These are populated when the configuration file is being loaded.
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 } } get { shellQueue.sync { _exports } }
set { shellQueue.sync { _exports = newValue } } 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. */ /** Thread-safe access to PATH and exports is ensured via this queue. */
private let shellQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_queue") private let shellQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_queue")
private var _PATH: String private var _PATH: String
private var _exports: String private var _exports: [String: String]
// MARK: - Methods // MARK: - Methods
@@ -75,22 +75,23 @@ class RealShell: ShellProtocol, @unchecked Sendable {
This process still needs to be started, or one can attach output handlers. This process still needs to be started, or one can attach output handlers.
*/ */
private func getShellProcess(for command: String) -> Process { private func getShellProcess(for command: String) -> Process {
var completeCommand = "" let completeCommand = "export PATH=\(binPath):$PATH && " + command
// 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 task = Process() let task = Process()
task.launchPath = self.launchPath task.launchPath = self.launchPath
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand] 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 return task
} }
@@ -111,20 +112,39 @@ class RealShell: ShellProtocol, @unchecked Sendable {
return result return result
} }
// MARK: - Public API
/** /**
Set custom environment variables. Verbose logging for when executing a shell command.
These will be exported when a command is executed.
*/ */
public func setCustomEnvironmentVariables(_ variables: [String: String]) { private func log(process: Process, stdOut: String, stdErr: String) {
self.exports = variables.map { (key, value) in var args = process.arguments ?? []
return "export \(key)=\(value)" let last = "\"" + (args.popLast() ?? "") + "\""
}.joined(separator: "&&") 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 // MARK: - Shellable Protocol
@discardableResult
func sync(_ command: String) -> ShellOutput { func sync(_ command: String) -> ShellOutput {
let process = getShellProcess(for: command) let process = getShellProcess(for: command)
@@ -155,6 +175,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
return .out(stdOut, stdErr) return .out(stdOut, stdErr)
} }
@discardableResult
func pipe(_ command: String) async -> ShellOutput { func pipe(_ command: String) async -> ShellOutput {
let process = getShellProcess(for: command) let process = getShellProcess(for: command)
@@ -190,37 +211,73 @@ class RealShell: ShellProtocol, @unchecked Sendable {
} }
} }
private func log(process: Process, stdOut: String, stdErr: String) { @discardableResult
var args = process.arguments ?? [] func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
let last = "\"" + (args.popLast() ?? "") + "\"" let process = getShellProcess(for: command)
var log = """
<~~~~~~~~~~~~~~~~~~~~~~~ let outputPipe = Pipe()
$ \(([self.launchPath] + args + [last]).joined(separator: " ")) let errorPipe = Pipe()
[OUT]: if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
\(stdOut) Log.info("[SLOW SHELL] \(command)")
""" await delay(seconds: 3.0)
if !stdErr.isEmpty {
log.append("""
[ERR]:
\(stdErr)
""")
} }
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 { serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem)
_ = await self.pipe(command)
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( func attach(
_ command: String, _ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void, didReceiveOutput: @escaping (String, ShellStream) -> Void,
@@ -235,7 +292,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
let output = ShellOutput.empty() let output = ShellOutput.empty()
// Only access `resumed`, `output` from serialQueue to ensure thread safety // 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 return try await withCheckedThrowingContinuation({ continuation in
// Guard against resuming the continuation twice (race between timeout and termination) // Guard against resuming the continuation twice (race between timeout and termination)
@@ -282,7 +339,9 @@ class RealShell: ShellProtocol, @unchecked Sendable {
process.terminationHandler = { process in process.terminationHandler = { process in
serialQueue.async { serialQueue.async {
timeoutTaskTermination.cancel() timeoutTaskTermination.cancel()
}
// Check if already resumed (timeout fired first)
if resumed { return }
// Clean up readability handlers // Clean up readability handlers
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
@@ -292,7 +351,6 @@ class RealShell: ShellProtocol, @unchecked Sendable {
let remainingOut = outputPipe.fileHandleForReading.readDataToEndOfFile() let remainingOut = outputPipe.fileHandleForReading.readDataToEndOfFile()
let remainingErr = errorPipe.fileHandleForReading.readDataToEndOfFile() let remainingErr = errorPipe.fileHandleForReading.readDataToEndOfFile()
serialQueue.async {
if !remainingOut.isEmpty, let string = String(data: remainingOut, encoding: .utf8) { if !remainingOut.isEmpty, let string = String(data: remainingOut, encoding: .utf8) {
output.out += string output.out += string
didReceiveOutput(string, .stdOut) didReceiveOutput(string, .stdOut)
@@ -303,12 +361,10 @@ class RealShell: ShellProtocol, @unchecked Sendable {
didReceiveOutput(string, .stdErr) didReceiveOutput(string, .stdErr)
} }
if !resumed {
resumed = true resumed = true
continuation.resume(returning: (process, output)) continuation.resume(returning: (process, output))
} }
} }
}
process.launch() process.launch()
}) })

View File

@@ -8,38 +8,55 @@
import Foundation import Foundation
protocol ShellProtocol { protocol ShellProtocol: AnyObject {
/** /**
The PATH for the current shell. The PATH for the current shell.
*/ */
var PATH: String { get } 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: Common usage:
``` ```
let output = Shell.sync("php -v") let output = Shell.sync("php -v")
``` ```
@return The shell output. If the command times out, returns empty output.
*/ */
@discardableResult
func sync(_ command: String) -> ShellOutput func sync(_ command: String) -> ShellOutput
/** /**
Run a command asynchronously. Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists).
Common usage: Common usage:
``` ```
let output = await Shell.pipe("php -v") 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 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). 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. 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.) (Whether it is complete or not.)
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism. 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( func attach(
_ command: String, _ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void, 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" 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.expectations = expectations
self.filesystem = filesystem
} }
var expectations: [String: BatchFakeShellOutput] = [:] 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 { func sync(_ command: String) -> ShellOutput {
// This assertion will only fire during test builds // This assertion will only fire during test builds
assert(expectations.keys.contains(command), "No response declared for command: \(command)") 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 .err("No Expected Output")
} }
return expectation.syncOutput() let output = expectation.syncOutput()
} applyTransactions(for: expectation)
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)
return output 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( func attach(
_ command: String, _ command: String,
didReceiveOutput: @escaping (String, ShellStream) -> Void, didReceiveOutput: @escaping (String, ShellStream) -> Void,
@@ -62,12 +73,28 @@ public class TestableShell: ShellProtocol {
didReceiveOutput(output, type) didReceiveOutput(output, type)
}, ignoreDelay: isRunningTests) }, ignoreDelay: isRunningTests)
applyTransactions(for: expectation)
return (Process(), output) return (Process(), output)
} }
func reloadEnvPath() { func reloadEnvPath() {
// does nothing // 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 { struct FakeShellOutput: Codable {
@@ -86,6 +113,7 @@ struct FakeShellOutput: Codable {
struct BatchFakeShellOutput: Codable { struct BatchFakeShellOutput: Codable {
var items: [FakeShellOutput] var items: [FakeShellOutput]
var transactions: [FakeShellTransaction] = []
static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput { static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput {
return BatchFakeShellOutput(items: items) return BatchFakeShellOutput(items: items)
@@ -117,6 +145,8 @@ struct BatchFakeShellOutput: Codable {
await delay(seconds: item.delay) await delay(seconds: item.delay)
} }
didReceiveOutput(item.output, item.stream)
if item.stream == .stdErr { if item.stream == .stdErr {
output.err += item.output output.err += item.output
} else if item.stream == .stdOut { } else if item.stream == .stdOut {
@@ -159,3 +189,85 @@ struct BatchFakeShellOutput: Codable {
return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true) 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 filesystem: [String: FakeFile]
var shellOutput: [String: BatchFakeShellOutput] var shellOutput: [String: BatchFakeShellOutput]
var commandOutput: [String: String] var commandOutput: [String: String]
var preferenceOverrides: [PreferenceName: Bool] var preferenceOverrides: [PreferenceName: PreferenceOverride]
var apiGetResponses: [URL: FakeWebApiResponse] var apiGetResponses: [URL: FakeWebApiResponse]
var apiPostResponses: [URL: FakeWebApiResponse] var apiPostResponses: [URL: FakeWebApiResponse]
@@ -22,7 +22,7 @@ public struct TestableConfiguration: Codable {
filesystem: [String: FakeFile], filesystem: [String: FakeFile],
shellOutput: [String: BatchFakeShellOutput], shellOutput: [String: BatchFakeShellOutput],
commandOutput: [String: String], commandOutput: [String: String],
preferenceOverrides: [PreferenceName: Bool], preferenceOverrides: [PreferenceName: PreferenceOverride],
phpVersions: [VersionNumber], phpVersions: [VersionNumber],
apiGetResponses: [URL: FakeWebApiResponse], apiGetResponses: [URL: FakeWebApiResponse],
apiPostResponses: [URL: FakeWebApiResponse] apiPostResponses: [URL: FakeWebApiResponse]
@@ -138,8 +138,13 @@ public struct TestableConfiguration: Codable {
Log.info("Applying temporary preference overrides...") Log.info("Applying temporary preference overrides...")
var cachedPrefs = container.preferences.cachedPreferences var cachedPrefs = container.preferences.cachedPreferences
preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in preferenceOverrides.forEach { (key: PreferenceName, value: PreferenceOverride) in
cachedPrefs[key] = value switch value {
case .bool(let boolValue):
cachedPrefs[key] = boolValue
case .string(let stringValue):
cachedPrefs[key] = stringValue
}
} }
container.preferences.cachedPreferences = cachedPrefs 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 { extension Container {
public static func real(minimal: Bool = false) -> Container { public static func real(minimal: Bool = false, commandTracking: Bool = true) -> Container {
let container = Container() let container = Container()
container.bind(coreOnly: minimal) container.bind(coreOnly: minimal, commandTracking: commandTracking)
return container return container
} }
} }

View File

@@ -16,6 +16,7 @@ class Container: @unchecked Sendable {
private(set) var paths: Paths! private(set) var paths: Paths!
private(set) var shell: ShellProtocol! private(set) var shell: ShellProtocol!
private(set) var command: CommandProtocol! private(set) var command: CommandProtocol!
private(set) var commandTracker: CommandTracker!
private(set) var webApi: WebApiProtocol! private(set) var webApi: WebApiProtocol!
// Secondary (uses primary instances above) // Secondary (uses primary instances above)
@@ -54,7 +55,10 @@ class Container: @unchecked Sendable {
/// - Parameter coreOnly: Only binds `shell`, `filesystem`, `command`, `paths` and `webApi`. /// - Parameter coreOnly: Only binds `shell`, `filesystem`, `command`, `paths` and `webApi`.
/// Use this to prevent slowing down tests for a minimal container. /// 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 { if self.bound {
fatalError("You cannot call `bind` on a Container more than once.") 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! // any of the other classes can be initialized!
self.filesystem = RealFileSystem(container: self) self.filesystem = RealFileSystem(container: self)
self.paths = Paths(container: self) self.paths = Paths(container: self)
self.shell = RealShell(binPath: paths.binPath) self.commandTracker = CommandTracker()
self.command = RealCommand()
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) self.webApi = RealWebApi(container: self)
if coreOnly { if coreOnly {
@@ -95,11 +111,24 @@ class Container: @unchecked Sendable {
fileSystemFiles: [String: FakeFile] = [:], fileSystemFiles: [String: FakeFile] = [:],
commands: [String: String] = [:], commands: [String: String] = [:],
webApiGetResponses: [URL: FakeWebApiResponse] = [:], webApiGetResponses: [URL: FakeWebApiResponse] = [:],
webApiPostResponses: [URL: FakeWebApiResponse] = [:] webApiPostResponses: [URL: FakeWebApiResponse] = [:],
commandTracking: Bool = true,
) { ) {
self.shell = TestableShell(expectations: shellExpectations) self.commandTracker = CommandTracker()
self.filesystem = TestableFileSystem(files: fileSystemFiles)
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.command = TestableCommand(commands: commands)
}
self.filesystem = filesystem
self.webApi = TestableWebApi( self.webApi = TestableWebApi(
getResponses: webApiGetResponses, getResponses: webApiGetResponses,
postResponses: webApiPostResponses postResponses: webApiPostResponses

View File

@@ -41,4 +41,14 @@ extension App {
NSApp.setActivationPolicy(!openWindows.isEmpty ? .regular : .accessory) 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() var container: Container = Container()
/** The list of preferences that are currently active. */ /** URL that was received before the app finished booting. Will be processed once the startup procedure completes. */
var preferences: [PreferenceName: Bool]! var deferredURL: URL?
/** 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?
/** List of detected (installed) applications that PHP Monitor can work with. */ /** List of detected (installed) applications that PHP Monitor can work with. */
var detectedApplications: [Application] = [] var detectedApplications: [Application] = []
/** Timer that will periodically reload info about the user's PHP installation. */
var timer: Timer?
// MARK: - Global Hotkey // 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. Variable that keeps track of which windows are currently open.

View File

@@ -8,6 +8,7 @@
import Cocoa import Cocoa
import Foundation import Foundation
import NVAlert
extension AppDelegate { extension AppDelegate {
@@ -16,16 +17,46 @@ extension AppDelegate {
application URL. You can use the `phpmon://` protocol to communicate with the app. 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) 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]) { @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) { 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.") Log.info("Acting on commands via phpmon:// has been disabled.")
return 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 } guard let url = urls.first else { return }
self.interpretCommand( 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]) { private func interpretCommand(_ command: String, commands: [InterApp.Action]) {
commands.forEach { action in commands.forEach { action in
if command.starts(with: action.command) { if command.starts(with: action.command) {
let lastElement = String(command.split(separator: "/").last!) guard let lastElement = command.split(separator: "/").last else {
action.action(lastElement) 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) { @IBAction func addSiteLinkPressed(_ sender: Any) {
DomainListVC.show() DomainListVC.show()
guard let windowController = App.shared.domainListWindowController else { return } guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
windowController.pressedAddLink(nil) windowController.pressedAddLink(nil)
} }
@IBAction func reloadDomainListPressed(_ sender: Any) { @IBAction func reloadDomainListPressed(_ sender: Any) {
Task { // Reload domains Task { // Reload domains
let vc = App.shared.domainListWindowController? let vc = WindowManager
.controller(of: DomainListWC.self)?
.window?.contentViewController as? DomainListVC .window?.contentViewController as? DomainListVC
if vc != nil { if vc != nil {
@@ -50,7 +51,7 @@ extension AppDelegate {
@IBAction func focusSearchField(_ sender: Any) { @IBAction func focusSearchField(_ sender: Any) {
DomainListVC.show() DomainListVC.show()
guard let windowController = App.shared.domainListWindowController else { return } guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
windowController.searchToolbarItem.searchField.becomeFirstResponder() windowController.searchToolbarItem.searchField.becomeFirstResponder()
} }

View File

@@ -8,7 +8,7 @@
import Cocoa import Cocoa
import UserNotifications import UserNotifications
@NSApplicationMain @main
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
static var instance: AppDelegate { static var instance: AppDelegate {
@@ -55,6 +55,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
logger.verbosity = .performance logger.verbosity = .performance
Log.info("Extra verbose mode is enabled by default on DEBUG builds.") 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:*") }) { if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
AppDelegate.initializeTestingProfile(profile.replacing("--configuration:", with: "")) 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.") 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") { if state.container.filesystem.fileExists("~/.config/phpmon/verbose") {
logger.verbosity = .cli logger.verbosity = .cli
Log.info("Extra CLI mode is on (`~/.config/phpmon/verbose` exists).") 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("PHP MONITOR by Nico Verbruggen")
Log.info("Version \(App.version)") Log.info("Version \(App.version)")
Log.separator(as: .info) Log.separator(as: .info)
}
// Initialize the crash reporter // Initialize the crash reporter
CrashReporter.initialize() CrashReporter.initialize()
}
// Set up final singletons // Set up final singletons
self.valet = Valet.shared self.valet = Valet.shared
@@ -93,8 +101,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
static func initializeTestingProfile(_ path: String) { static func initializeTestingProfile(_ path: String) {
Log.info("The configuration with path `\(path)` is being requested...") Log.info("The configuration with path `\(path)` is being requested...")
// Clear for PHP Guard
Stats.clearCurrentGlobalPhpVersion()
// Load the configuration file // Load the configuration file
TestableConfiguration.loadFrom(path: path).apply() TestableConfiguration.loadFrom(path: path).apply()
} }

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-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> <dependencies>
<deployment identifier="macosx"/> <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="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/> <capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@@ -12,6 +12,13 @@
<!--Application--> <!--Application-->
<scene sceneID="JPo-4y-FX3"> <scene sceneID="JPo-4y-FX3">
<objects> <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"> <application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6"> <menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items> <items>
@@ -306,15 +313,6 @@
</menuItem> </menuItem>
<menuItem title="Help" id="wpr-3q-Mcd"> <menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/> <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> </menuItem>
</items> </items>
</menu> </menu>
@@ -322,15 +320,8 @@
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/> <outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections> </connections>
</application> </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> </objects>
<point key="canvasLocation" x="-360" y="-94"/> <point key="canvasLocation" x="-412" y="-153"/>
</scene> </scene>
<!--Window Controller--> <!--Window Controller-->
<scene sceneID="PQa-AT-b2a"> <scene sceneID="PQa-AT-b2a">
@@ -360,7 +351,7 @@
<!--Preferences--> <!--Preferences-->
<scene sceneID="iyi-IS-7Ps"> <scene sceneID="iyi-IS-7Ps">
<objects> <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"> <view key="view" wantsLayer="YES" id="Pf1-A5-3Xz">
<rect key="frame" x="0.0" y="0.0" width="550" height="498"/> <rect key="frame" x="0.0" y="0.0" width="550" height="498"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
@@ -466,188 +457,6 @@
</objects> </objects>
<point key="canvasLocation" x="-374" y="745.5"/> <point key="canvasLocation" x="-374" y="745.5"/>
</scene> </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--> <!--Domain ListVC-->
<scene sceneID="aZt-6w-TFl"> <scene sceneID="aZt-6w-TFl">
<objects> <objects>
@@ -1031,378 +840,10 @@ Gw
</objects> </objects>
<point key="canvasLocation" x="323" y="722.5"/> <point key="canvasLocation" x="323" y="722.5"/>
</scene> </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> </scenes>
<resources> <resources>
<image name="Checkmark" width="512" height="512"/> <image name="Checkmark" width="512" height="512"/>
<image name="IconLinked" width="25" height="25"/> <image name="IconLinked" width="25" height="25"/>
<image name="IconProxy" width="25" height="25"/>
<image name="Lock" width="30" height="30"/> <image name="Lock" width="30" height="30"/>
<image name="arrow.clockwise" catalog="system" width="14" height="16"/> <image name="arrow.clockwise" catalog="system" width="14" height="16"/>
<image name="plus" catalog="system" width="14" height="13"/> <image name="plus" catalog="system" width="14" height="13"/>

View File

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

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ actor ValetServicesDataManager {
? "sudo \(self.container.paths.brew) services info --all --json" ? "sudo \(self.container.paths.brew) services info --all --json"
: "\(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 { guard let jsonData = output.data(using: .utf8) else {
Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.") 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) { override init(_ container: Container) {
self.data = ValetServicesDataManager(container) self.data = ValetServicesDataManager(container)
super.init(container) super.init(container)
// Load the initial services state
Task {
await self.reloadServicesStatus()
await MainActor.run {
firstRunComplete = true
}
}
} }
override func reloadServicesStatus() async { 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 // A non-default TLD is not officially supported since Valet 3.2.x
Valet.shared.notifyAboutUnsupportedTLD() 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 // Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)") Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
// Find out which services are active // Internals are ready!
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// We are ready!
container.phpEnvs.isBusy = false container.phpEnvs.isBusy = false
// Finally!
Log.info("PHP Monitor is ready to serve!")
// Avoid showing the "startup timeout" alert // Avoid showing the "startup timeout" alert
Startup.invalidateTimeoutTimer() Startup.invalidateTimeoutTimer()
@@ -122,6 +122,13 @@ extension Startup {
// Mark app as having successfully booted passing all checks // Mark app as having successfully booted passing all checks
Startup.hasFinishedBooting = true 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 // Enable the main menu item
MainMenu.shared.statusItem.button?.isEnabled = true 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. */ /** Starts the timeout timer that keeps track of how long the app takes to boot. */
@MainActor func startStartupTimer() { @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.launchTime = Date()
Self.startupTimer = Timer.scheduledTimer( Self.startupTimer = Timer.scheduledTimer(
timeInterval: Constants.SlowBootThresholdInterval, target: self, timeInterval: Constants.SlowBootThresholdInterval, target: self,

View File

@@ -39,12 +39,21 @@ class Startup {
let start = Measurement() let start = Measurement()
if await check.succeeds() { if await check.succeeds() {
Log.info("[PASS] \(check.name) (\(start.milliseconds) ms)") 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... // If we get here, something's gone wrong and the check has failed...
Log.info("[FAIL] \(check.name) (\(start.milliseconds) ms)") 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 return false
} }
} else { } else {
@@ -59,34 +68,6 @@ class Startup {
return true 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) // MARK: - Check (List)
public var groups: [EnvironmentCheckGroup] = [ public var groups: [EnvironmentCheckGroup] = [
@@ -118,6 +99,15 @@ class Startup {
return await !container.shell return await !container.shell
.pipe("ls \(container.paths.optPath) | grep php").out.contains("php") .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", name: "`ls \(App.shared.container.paths.optPath) | grep php` returned php result",
titleText: "startup.errors.php_opt.title".localized, titleText: "startup.errors.php_opt.title".localized,
subtitleText: "startup.errors.php_opt.subtitle".localized( subtitleText: "startup.errors.php_opt.subtitle".localized(
@@ -132,6 +122,15 @@ class Startup {
command: { container in command: { container in
return !container.filesystem.fileExists(container.paths.php) 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", name: "`\(App.shared.container.paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized, titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized, subtitleText: "startup.errors.php_binary.subtitle".localized,
@@ -142,9 +141,21 @@ class Startup {
// ================================================================================= // =================================================================================
EnvironmentCheck( EnvironmentCheck(
command: { container in 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 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", name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized, titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized( subtitleText: "startup.errors.dyld_library.subtitle".localized(
@@ -176,6 +187,15 @@ class Startup {
await container.phpEnvs.determinePhpAlias() await container.phpEnvs.determinePhpAlias()
return PhpEnvironments.brewPhpAlias == nil 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", name: "`brew` alias is not nil and valid",
titleText: "startup.errors.could_not_determine_alias.title".localized, titleText: "startup.errors.could_not_determine_alias.title".localized,
subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized, subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized,
@@ -209,6 +229,12 @@ class Startup {
.pipe("cat /private/etc/sudoers.d/brew") .pipe("cat /private/etc/sudoers.d/brew")
.out.contains(container.paths.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", name: "`/private/etc/sudoers.d/brew` contains brew",
titleText: "startup.errors.sudoers_brew.title".localized, titleText: "startup.errors.sudoers_brew.title".localized,
subtitleText: "startup.errors.sudoers_brew.subtitle".localized, subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
@@ -231,6 +257,15 @@ class Startup {
command: { container in command: { container in
return !container.filesystem.directoryExists("~/.config/valet") 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)", name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized, titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".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( let (process, _) = try await container.shell.attach(
command, command,
didReceiveOutput: { [weak self] (incoming, _) in didReceiveOutput: { [weak self] (incoming, _) in
Task { @MainActor in
guard let window = self?.window else { return } guard let window = self?.window else { return }
window.addToConsole(incoming) window.addToConsole(incoming)
}
}, },
withTimeout: .minutes(5) withTimeout: .minutes(5)
) )

View File

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

View File

@@ -33,9 +33,9 @@ extension BrewCommand {
} }
if text.contains("==> Installing") { if text.contains("==> Installing") {
if let subject = extractContext(from: text) { 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 text.contains("==> Downloading") {
if let subject = extractContext(from: text) { if let subject = extractContext(from: text) {

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ import Foundation
class Packagist { class Packagist {
static func getLatestStableVersion(packageName: String) async throws -> VersionNumber { 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 throw PackagistError.invalidURL
} }

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ extension Valet {
) )
.withPrimary(text: "generic.ok".localized) .withPrimary(text: "generic.ok".localized)
.withTertiary(text: "alert.do_not_tell_again".localized, action: { alert in .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) alert.close(with: .alertThirdButtonReturn)
}) })
.show(urgency: .urgentRequestAttention) .show(urgency: .urgentRequestAttention)

View File

@@ -56,7 +56,7 @@ extension MainMenu {
.withPrimary(text: "generic.ok".localized) .withPrimary(text: "generic.ok".localized)
.show(urgency: .bringToFront) .show(urgency: .bringToFront)
} failure: { error in } 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 { private func reloadDomainListData() async {
if let window = App.shared.domainListWindowController { if let window = WindowManager.controller(of: DomainListWC.self) {
await window.contentVC.reloadDomains() await window.contentVC.reloadDomains()
} else { } else {
await Valet.shared.reloadSites() 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() { @objc func showIncompatiblePhpVersionsAlert() {
Task { @MainActor in Task { @MainActor in
NVAlert().withInformation( NVAlert().withInformation(

View File

@@ -325,7 +325,8 @@ extension StatusMenu {
// FIRST AID // FIRST AID
HeaderView.asMenuItem(text: "mi_first_aid".localized), HeaderView.asMenuItem(text: "mi_first_aid".localized),
NSMenuItem(title: "mi_view_onboarding".localized, action: #selector(MainMenu.showWelcomeTour)), 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 { if Valet.installed {

View File

@@ -47,6 +47,11 @@ class PhpGuard {
// At this point, the version is *not* a match // At this point, the version is *not* a match
Log.info("PHP Guard noticed a different PHP version. An alert will be displayed!") 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 Task { @MainActor in
NVAlert() NVAlert()
.withInformation( .withInformation(

View File

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

View File

@@ -8,6 +8,12 @@
/** /**
These are the keys used for every preference in the app. 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 { enum PreferenceName: String, Codable {
// GENERAL // GENERAL
@@ -23,6 +29,7 @@ enum PreferenceName: String, Codable {
case shouldDisplayDynamicIcon = "use_dynamic_icon" case shouldDisplayDynamicIcon = "use_dynamic_icon"
case iconTypeToDisplay = "icon_type_to_display" case iconTypeToDisplay = "icon_type_to_display"
case fullPhpVersionDynamicIcon = "full_php_in_menu_bar" case fullPhpVersionDynamicIcon = "full_php_in_menu_bar"
case hideIconsInMenu = "hide_icons_in_menu"
// WARNINGS // WARNINGS
case warnAboutNonStandardTLD = "warn_about_non_standard_tld" case warnAboutNonStandardTLD = "warn_about_non_standard_tld"
@@ -53,14 +60,17 @@ enum PreferenceName: String, Codable {
static var mapping: [PreferenceType: [PreferenceName]] = [ static var mapping: [PreferenceType: [PreferenceName]] = [
.boolean: [ .boolean: [
// Preferences // Preferences
.shouldDisplayDynamicIcon,
.fullPhpVersionDynamicIcon,
.autoServiceRestartAfterExtensionToggle, .autoServiceRestartAfterExtensionToggle,
.autoComposerGlobalUpdateAfterSwitch, .autoComposerGlobalUpdateAfterSwitch,
.allowProtocolForIntegrations, .allowProtocolForIntegrations,
.automaticBackgroundUpdateCheck, .automaticBackgroundUpdateCheck,
.showPhpDoctorSuggestions, .showPhpDoctorSuggestions,
// Appearance
.shouldDisplayDynamicIcon,
.fullPhpVersionDynamicIcon,
.hideIconsInMenu,
// Notifications // Notifications
.warnAboutNonStandardTLD, .warnAboutNonStandardTLD,
.notifyAboutVersionChange, .notifyAboutVersionChange,
@@ -111,6 +121,7 @@ enum PersistentAppState: String {
case lastAutomaticUpdateCheck = "last_automatic_update_check" case lastAutomaticUpdateCheck = "last_automatic_update_check"
case userFavorites = "user_favorites" case userFavorites = "user_favorites"
case updateCheckFailureCount = "update_check_failure_count" 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 // PHP Monitor
// //
// Created by Nico Verbruggen on 30/03/2021. // Created by Nico Verbruggen on 30/03/2021.
@@ -9,7 +9,7 @@
import Cocoa import Cocoa
import Carbon import Carbon
class GenericPreferenceVC: NSViewController { class PreferenceVC: NSViewController {
// MARK: - Content // MARK: - Content
@@ -28,7 +28,8 @@ class GenericPreferenceVC: NSViewController {
Log.perf("deinit: \(String(describing: self)).\(#function)") 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 { if condition {
self.views.append(view) self.views.append(view)
} }
@@ -36,18 +37,6 @@ class GenericPreferenceVC: NSViewController {
return self 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 { func getLanguageOptionsPV() -> NSView {
var options = Bundle.main.localizations var options = Bundle.main.localizations
.filter({ $0 != "Base"}) .filter({ $0 != "Base"})
@@ -65,17 +54,46 @@ class GenericPreferenceVC: NSViewController {
options: options, options: options,
preference: .languageOverride, preference: .languageOverride,
action: { action: {
// Track which windows we will need to reopen
let windowsToReopen = self.captureOpenWindowsForLanguageSwitch()
// Rebuild the menu
MainMenu.shared.refreshIcon() MainMenu.shared.refreshIcon()
MainMenu.shared.rebuild() MainMenu.shared.rebuild()
if let window = App.shared.preferencesWindowController?.window { // Close all windows
let alert = NSAlert() App.shared.invalidateCachedWindows()
alert.messageText = "alert.language_changed.title".localized
alert.informativeText = "alert.language_changed.subtitle".localized // Re-open the preferences window controller
alert.alertStyle = .warning WindowManager.close(PreferencesWC.self)
alert.addButton(withTitle: "generic.ok".localized) PreferencesWindowController.show()
alert.beginSheetModal(for: window)
// 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 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 // MARK: - First Time Run
/** /**
@@ -58,50 +85,53 @@ class Preferences {
``` ```
*/ */
static func handleFirstTimeLaunch() { static func handleFirstTimeLaunch() {
UserDefaults.standard.register(defaults: [ self.registerPreferenceDefaults([
/// Preferences: General /// Preferences: General
PreferenceName.autoServiceRestartAfterExtensionToggle.rawValue: true, .autoServiceRestartAfterExtensionToggle: true,
PreferenceName.autoComposerGlobalUpdateAfterSwitch.rawValue: false, .autoComposerGlobalUpdateAfterSwitch: false,
PreferenceName.allowProtocolForIntegrations.rawValue: true, .allowProtocolForIntegrations: false,
PreferenceName.automaticBackgroundUpdateCheck.rawValue: true, .automaticBackgroundUpdateCheck: true,
PreferenceName.showPhpDoctorSuggestions.rawValue: true, .showPhpDoctorSuggestions: true,
PreferenceName.languageOverride.rawValue: "", .languageOverride: "",
/// Preferences: Appearance /// Preferences: Appearance
PreferenceName.shouldDisplayDynamicIcon.rawValue: true, .shouldDisplayDynamicIcon: true,
PreferenceName.iconTypeToDisplay.rawValue: MenuBarIcon.iconPhp.rawValue, .iconTypeToDisplay: MenuBarIcon.iconPhp.rawValue,
PreferenceName.fullPhpVersionDynamicIcon.rawValue: false, .fullPhpVersionDynamicIcon: false,
.hideIconsInMenu: false,
/// Preferences: Notifications /// Preferences: Notifications
PreferenceName.warnAboutNonStandardTLD.rawValue: true, .warnAboutNonStandardTLD: true,
PreferenceName.notifyAboutVersionChange.rawValue: true, .notifyAboutVersionChange: true,
PreferenceName.notifyAboutPhpFpmRestart.rawValue: true, .notifyAboutPhpFpmRestart: true,
PreferenceName.notifyAboutServices.rawValue: true, .notifyAboutServices: true,
PreferenceName.notifyAboutPresets.rawValue: true, .notifyAboutPresets: true,
PreferenceName.notifyAboutSecureToggle.rawValue: true, .notifyAboutSecureToggle: true,
PreferenceName.notifyAboutGlobalComposerStatus.rawValue: true, .notifyAboutGlobalComposerStatus: true,
/// Preferences: UI Preferences /// Preferences: UI Preferences
PreferenceName.displayDriver.rawValue: true, .displayDriver: true,
PreferenceName.displayGlobalVersionSwitcher.rawValue: true, .displayGlobalVersionSwitcher: true,
PreferenceName.displayServicesManager.rawValue: true, .displayServicesManager: true,
PreferenceName.displayValetIntegration.rawValue: true, .displayValetIntegration: true,
PreferenceName.displayPhpConfigFinder.rawValue: true, .displayPhpConfigFinder: true,
PreferenceName.displayComposerToolkit.rawValue: true, .displayComposerToolkit: true,
PreferenceName.displayLimitsWidget.rawValue: true, .displayLimitsWidget: true,
PreferenceName.displayExtensions.rawValue: true, .displayExtensions: true,
PreferenceName.displayPresets.rawValue: true, .displayPresets: true,
PreferenceName.displayMisc.rawValue: true, .displayMisc: true
])
/// Persistent App State registerPersistentAppStateDefaults([
PersistentAppState.lastAutomaticUpdateCheck.rawValue: 0, .lastAutomaticUpdateCheck: 0,
PersistentAppState.updateCheckFailureCount.rawValue: 0, .updateCheckFailureCount: 0
])
/// Stats registerInternalAppStateDefaults([
InternalStats.switchCount.rawValue: 0, .switchCount: 0,
InternalStats.launchCount.rawValue: 0, .launchCount: 0,
InternalStats.didSeeSponsorEncouragement.rawValue: false, .didSeeSponsorEncouragement: false,
InternalStats.lastGlobalPhpVersion.rawValue: "" .lastGlobalPhpVersion: ""
]) ])
if UserDefaults.standard.bool(forKey: PersistentAppState.wasLaunchedBefore.rawValue) { 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 { if value == nil {
UserDefaults.standard.removeObject(forKey: preference.rawValue) UserDefaults.standard.removeObject(forKey: preference.rawValue)
} else { } else {
@@ -178,5 +208,9 @@ class Preferences {
// Update the preferences cache in memory! // Update the preferences cache in memory!
App.shared.container.preferences.cachedPreferences = Preferences.cache() 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 Foundation
import Cocoa import Cocoa
class GeneralPreferencesVC: GenericPreferenceVC { class GeneralPreferencesVC: PreferenceVC {
// MARK: - Lifecycle // MARK: - Lifecycle
public static func fromStoryboard() -> GenericPreferenceVC { public static func fromStoryboard() -> PreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil) let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC .instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc return vc
.addView(when: true, vc.getLanguageOptionsPV()) .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) 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.getIconOptionsPV())
.addView(when: true, vc.getIconDensityPV()) .addView(when: true, vc.getIconDensityPV())
.addView(when: true, vc.getMenuIconsPV())
return vc 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) let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC .instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc return vc
.addView(when: true, vc.displayFeature("prefs.display_global_version_switcher", .displayGlobalVersionSwitcher, true)) .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) let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC .instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
return vc.addView(when: true, vc.getNotifyAboutVersionChangePV()) return vc.addView(when: true, vc.getNotifyAboutVersionChangePV())
.addView(when: true, vc.getNotifyAboutPresetsPV()) .addView(when: true, vc.getNotifyAboutPresetsPV())

View File

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

View File

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

View File

@@ -65,6 +65,15 @@ class CheckboxPreferenceBehavior: CheckboxPreferenceViewBehavior {
self.preference = preference self.preference = preference
self.button = button self.button = button
self.button.state = Preferences.isEnabled(self.preference) ? .on : .off 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) { public func toggled(checked: Bool) {

View File

@@ -11,7 +11,7 @@ import Cocoa
class HotkeyPreferenceView: NSView, XibLoadable { class HotkeyPreferenceView: NSView, XibLoadable {
weak var delegate: GenericPreferenceVC? weak var delegate: PreferenceVC?
@IBOutlet weak var labelSection: NSTextField! @IBOutlet weak var labelSection: NSTextField!
@IBOutlet weak var labelDescription: NSTextField! @IBOutlet weak var labelDescription: NSTextField!
@@ -19,7 +19,7 @@ class HotkeyPreferenceView: NSView, XibLoadable {
@IBOutlet weak var buttonSetShortcut: NSButton! @IBOutlet weak var buttonSetShortcut: NSButton!
@IBOutlet weak var buttonClearShortcut: 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()! let view = Self.createFromXib()!
view.labelSection.stringValue = sectionText view.labelSection.stringValue = sectionText
view.labelDescription.stringValue = descriptionText view.labelDescription.stringValue = descriptionText

View File

@@ -73,9 +73,23 @@ class SelectPreferenceView: NSView, XibLoadable {
view.preference = preference view.preference = preference
view.action = action view.action = action
NotificationCenter.default.addObserver(
view, selector: #selector(view.refreshState),
name: Events.PreferencesUpdated, object: nil
)
return view 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) { @IBAction func valueChanged(_ sender: Any) {
let index = self.popupButton.indexOfSelectedItem let index = self.popupButton.indexOfSelectedItem
Preferences.update(self.preference, value: self.options[index].value) Preferences.update(self.preference, value: self.options[index].value)

View File

@@ -276,7 +276,7 @@ struct Preset: Codable, Equatable {
private func persistRevert() async { private func persistRevert() async {
let data = try! JSONEncoder().encode(self.revertSnapshot) 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)! try! String(data: data, encoding: .utf8)!
.write( .write(

View File

@@ -27,7 +27,7 @@ class InstallHomebrew {
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" "$(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) print(string)
}, withTimeout: 60 * 10) }, 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. Adds a given line to .zshrc, which may be needed to adjust the PATH.
*/ */
@discardableResult
private func add(_ text: String) async -> Bool { 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(""" let outcome = await container.shell.pipe("""
touch ~/.zshrc && \ touch ~/.zshrc && \
grep -qxF '\(text)' ~/.zshrc \ grep -qxF '\(escaped)' ~/.zshrc \
|| echo '\n\n\(text)\n' >> ~/.zshrc || echo '\n\n\(escaped)\n' >> ~/.zshrc
""") """)
// Validate the command executed correctly
if outcome.hasError { if outcome.hasError {
return false return false
} }
@@ -39,13 +45,13 @@ class ZshRunCommand {
Adds Homebrew binaries to the PATH. Adds Homebrew binaries to the PATH.
*/ */
public func addHomebrewPath() async { 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. Adds PHP Monitor binaries to the PATH.
*/ */
public func addPhpMonitorPath() async { 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: { LazyVGrid(columns: self.rows, alignment: .leading, spacing: 5, content: {
ForEach(validPhpVersions, id: \.self) { version in ForEach(validPhpVersions, id: \.self) { version in
Button("site_link.isolate_php".localized(version.short), action: { 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) .isolateSite(site: site, version: version.short)
parent?.close() parent?.close()
}).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0)) }).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))

View File

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

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