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:
@@ -26,7 +26,9 @@ line_length:
|
||||
ignores_function_declarations: true
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
warning: 120
|
||||
ignores_interpolated_strings: true
|
||||
ignores_multiline_strings: true
|
||||
warning: 160
|
||||
error: 200
|
||||
|
||||
analyzer_rules:
|
||||
|
||||
19
@tasks/new_translations.md
Normal file
19
@tasks/new_translations.md
Normal 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!
|
||||
42
DEVELOPER.md
42
DEVELOPER.md
@@ -53,7 +53,31 @@ If you'd like to create a production build, choose "Any Mac" as the target and s
|
||||
|
||||
## ✅ Testing
|
||||
|
||||
In order to properly test everything, you will want to use the _PHP Monitor DEV_ target. There are unit and UI tests both.
|
||||
In order to properly test everything, you will want to use the _PHP Monitor EAP_ target. There are unit and UI tests both for this target.
|
||||
|
||||
### Unit tests
|
||||
|
||||
If you would like to run the unit tests outside of Xcode, you can run:
|
||||
|
||||
```sh
|
||||
xcodebuild test \
|
||||
-project "PHP Monitor.xcodeproj" \
|
||||
-scheme "Unit Tests" \
|
||||
-destination "platform=macOS" \
|
||||
-parallel-testing-enabled NO
|
||||
```
|
||||
|
||||
### UI tests
|
||||
|
||||
```sh
|
||||
xcodebuild test \
|
||||
-project "PHP Monitor.xcodeproj" \
|
||||
-scheme "PHP Monitor" \
|
||||
-destination "platform=macOS" \
|
||||
-only-testing "UI Tests"
|
||||
```
|
||||
|
||||
### Failures in UI tests
|
||||
|
||||
You may sporadically see failures in UI tests due to the following error: `Invalid parameter not satisfying: point.x != INFINITY && point.y != INFINITY`. This seems to be an issue with Xcode that Apple may need to resolve? You can retry the tests in question and they should eventually pass.
|
||||
|
||||
@@ -77,6 +101,22 @@ You can enable marketing mode by setting the `PHPMON_MARKETING_MODE` environment
|
||||
|
||||
launchctl setenv PHPMON_MARKETING_MODE true
|
||||
|
||||
## 👀 Previews (`#Preview`)
|
||||
|
||||
Xcode may have issues rendering various previews, like:
|
||||
|
||||
```swift
|
||||
#Preview {
|
||||
HeaderView(text: "Hello world").frame(width: 330.0)
|
||||
}
|
||||
```
|
||||
|
||||
I've noticed this is an issue on recent versions of Xcode (26.x) on macOS Tahoe (26.x). This likely has something to do with the new preview execution system that doesn't play nice with the particular way PHP Monitor has been set up (which is somewhat of a legacy project).
|
||||
|
||||
So, in order to ensure these previews render correctly, go to **Editor > Canvas > Use Legacy Previews Execution**.
|
||||
|
||||
This may help resolve any timeouts, but you must first build the project or you will get an error about caches.
|
||||
|
||||
## 🐛 Symbolication of crashes
|
||||
|
||||
The easiest way to symbolicate crashes is to simply rename the file to `.crash`, and drag it into Xcode.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
@@ -91,13 +91,17 @@
|
||||
argument = "--v"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--ch"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--cli"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--configuration:~/.phpmon_fconf_working.json"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
16
README.md
16
README.md
@@ -70,7 +70,11 @@ If you have a very slow internet connection, the updater may report that the dow
|
||||
|
||||
If you would like to integrate with your launcher of choice, you can also download an [Alfred workflow](https://github.com/nicoverbruggen/phpmon/raw/main/integrations/phpmon.alfredworkflow) or [Raycast extension](https://www.raycast.com/nicoverbruggen/php-monitor) that works with PHP Monitor.
|
||||
|
||||
The app must be running in the background for these to work, and the _Allow third-party integrations_ checkbox must be enabled in Preferences (it is by default).
|
||||
Keep in mind that third-party integrations are turned off by default, but you will be prompted to approve third-party integrations the very first time a third-party app attempts to connect with PHP Monitor.
|
||||
|
||||
You will only be prompted once to allow or disallow this, but you can always change your mind about this later, in Settings, by (un)checking the box next to "Allow third-party integrations".
|
||||
|
||||
(For more information about how this works and the potential security considerations, please consult the FAQ below.)
|
||||
|
||||
## 🔑 Is the app signed & notarized?
|
||||
|
||||
@@ -527,7 +531,9 @@ You can put as many apps as you'd like in the `scan_apps` array, and PHP Monitor
|
||||
<details>
|
||||
<summary><strong>How can the app integrate with third party tools, like Alfred or Raycast?</strong></summary>
|
||||
|
||||
PHP Monitor supports third party app integrations by default, and this feature is enabled in Preferences unless you disable it.
|
||||
PHP Monitor supports third party app integrations, but this feature requires your approval the first time you invoke a command via a third-party app.
|
||||
|
||||
By default, this functionality is disabled and you will be prompted to turn it on, but this happens only once. You can change your mind later in the Settings window.
|
||||
|
||||
You can grab the official [Alfred workflow](https://github.com/nicoverbruggen/phpmon/raw/main/integrations/phpmon.alfredworkflow) or [Raycast extension](https://www.raycast.com/nicoverbruggen/php-monitor).
|
||||
|
||||
@@ -547,6 +553,10 @@ Using app callbacks, macOS and PHP Monitor allow for the following to be called:
|
||||
* phpmon://phpinfo
|
||||
* phpmon://switch/php/{version}
|
||||
|
||||
Once enabled, any application can invoke this URL scheme, so please keep in mind that enabling this functionality could lead to third-party applications invoking certain PHP Monitor without your express approval.
|
||||
|
||||
Also keep in mind that certain functionality may not be available if Valet is not currently installed (i.e. Standalone Mode is engaged, which severely limits the useful functionality of the app).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -632,7 +642,7 @@ Thank you very much for your contributions, kind words and support.
|
||||
|
||||
### Loading info about PHP in the background
|
||||
|
||||
This app runs `php-config --version` in the background periodically, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
|
||||
This app runs `php-config --version` in the background, usually whenever your Homebrew configuration is modified. A filesystem watcher is used to determine if anything changes in your Homebrew's `bin` directory.
|
||||
|
||||
PHP Monitor also checks your `.ini` files for extensions and loads more information about your limits (memory limit, POST limit, upload limit). See also the section on *Config change detection* below.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol CommandProtocol {
|
||||
|
||||
/**
|
||||
Immediately executes a command.
|
||||
|
||||
@@ -39,3 +38,19 @@ protocol CommandProtocol {
|
||||
) -> String
|
||||
|
||||
}
|
||||
|
||||
extension CommandProtocol {
|
||||
func execute(
|
||||
path: String,
|
||||
arguments: [String],
|
||||
trimNewlines: Bool
|
||||
) -> String {
|
||||
execute(
|
||||
path: path,
|
||||
arguments: arguments,
|
||||
trimNewlines: trimNewlines,
|
||||
withStandardError: false
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import Cocoa
|
||||
|
||||
public class RealCommand: CommandProtocol {
|
||||
init() {}
|
||||
|
||||
public func execute(
|
||||
path: String,
|
||||
arguments: [String],
|
||||
|
||||
@@ -15,15 +15,12 @@ class TestableCommand: CommandProtocol {
|
||||
|
||||
var commands: [String: String]
|
||||
|
||||
func execute(path: String, arguments: [String]) -> String {
|
||||
self.execute(path: path, arguments: arguments, trimNewlines: false)
|
||||
}
|
||||
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool, withStandardError: Bool) -> String {
|
||||
self.execute(path: path, arguments: arguments, trimNewlines: trimNewlines)
|
||||
}
|
||||
|
||||
public func execute(path: String, arguments: [String], trimNewlines: Bool) -> String {
|
||||
public func execute(
|
||||
path: String,
|
||||
arguments: [String],
|
||||
trimNewlines: Bool,
|
||||
withStandardError: Bool
|
||||
) -> String {
|
||||
let concatenatedCommand = "\(path) \(arguments.joined(separator: " "))"
|
||||
assert(commands.keys.contains(concatenatedCommand), "Command `\(concatenatedCommand)` not found")
|
||||
return self.commands[concatenatedCommand]!
|
||||
|
||||
@@ -81,16 +81,7 @@ class Actions {
|
||||
+ " && "
|
||||
+ cellarCommands.joined(separator: " && ")
|
||||
|
||||
let source = "do shell script \"\(script)\" with administrator privileges"
|
||||
|
||||
Log.perf(source)
|
||||
let appleScript = NSAppleScript(source: source)
|
||||
|
||||
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(nil)
|
||||
|
||||
if eventResult == nil {
|
||||
throw HomebrewPermissionError(kind: .applescriptNilError)
|
||||
}
|
||||
try AppleScript.runSimpleShellAsAdmin(script)
|
||||
}
|
||||
|
||||
// MARK: - Finding Config Files
|
||||
@@ -106,6 +97,12 @@ class Actions {
|
||||
}
|
||||
|
||||
public func openGlobalComposerFolder() {
|
||||
// Check if we have a custom COMPOSER_HOME set
|
||||
if let folder = App.shared.container.shell.exports["COMPOSER_HOME"] {
|
||||
let file = URL(string: "file://\(folder)/composer.json".replacingTildeWithHomeDirectory)!
|
||||
return NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
|
||||
let file = URL(string: "file://~/.composer/composer.json".replacingTildeWithHomeDirectory)!
|
||||
NSWorkspace.shared.activateFileViewerSelecting([file] as [URL])
|
||||
}
|
||||
@@ -123,10 +120,15 @@ class Actions {
|
||||
// MARK: - Other Actions
|
||||
|
||||
public func createTempPhpInfoFile() async -> URL {
|
||||
// Clean state for temporary phpinfo files
|
||||
try? container.filesystem.remove("/tmp/phpmon_phpinfo.php")
|
||||
try? container.filesystem.remove("/tmp/phpmon_phpinfo.html")
|
||||
|
||||
// Generate a source file that we will execute immediately
|
||||
try! container.filesystem.writeAtomicallyToFile("/tmp/phpmon_phpinfo.php", content: "<?php phpinfo();")
|
||||
|
||||
// Tell php-cgi to run the PHP and output as an .html file
|
||||
await container.shell.quiet("\(paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
await container.shell.pipe("\(paths.binPath)/php-cgi -q /tmp/phpmon_phpinfo.php > /tmp/phpmon_phpinfo.html")
|
||||
|
||||
return URL(string: "file:///private/tmp/phpmon_phpinfo.html")!
|
||||
}
|
||||
|
||||
69
phpmon/Common/Core/AppleScript.swift
Normal file
69
phpmon/Common/Core/AppleScript.swift
Normal 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 ?? ""
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,7 @@ struct Constants {
|
||||
*/
|
||||
static var ExperimentalPhpVersions: Set<String> {
|
||||
let releaseDates = [
|
||||
"8.6": Date.fromString(PhpFormulaeCutoffDate),
|
||||
"8.5": Date.fromString("2025-11-20"),
|
||||
"8.4": Date.fromString("2024-11-22")
|
||||
"8.6": Date.fromString(PhpFormulaeCutoffDate)
|
||||
]
|
||||
|
||||
return Set(releaseDates
|
||||
|
||||
@@ -11,5 +11,6 @@ import Foundation
|
||||
class Events {
|
||||
|
||||
static let ServicesUpdated = Notification.Name("ServicesUpdated")
|
||||
static let PreferencesUpdated = Notification.Name("PreferencesUpdated")
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func brew(
|
||||
_ command: String,
|
||||
sudo: Bool = false,
|
||||
) async {
|
||||
await container.shell.quiet("\(sudo ? "sudo " : "")" + "\(container.paths.brew) \(command)")
|
||||
await container.shell.pipe("\(sudo ? "sudo " : "")" + "\(container.paths.brew) \(command)")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,9 +37,9 @@ func sed(
|
||||
// Check if gsed exists; it is able to follow symlinks,
|
||||
// which we want to do to toggle the extension
|
||||
if container.filesystem.fileExists("\(container.paths.binPath)/gsed") {
|
||||
await container.shell.quiet("\(container.paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
await container.shell.pipe("\(container.paths.binPath)/gsed -i --follow-symlinks 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
} else {
|
||||
await container.shell.quiet("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
await container.shell.pipe("sed -i '' 's/\(e_original)/\(e_replacement)/g' \(file)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// VersionParseError.swift
|
||||
// Errors.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 08/02/2022.
|
||||
@@ -11,7 +11,7 @@ import Foundation
|
||||
// MARK: - Alertable Errors
|
||||
// These errors must be resolved by the user.
|
||||
|
||||
struct HomebrewPermissionError: Error, AlertableError {
|
||||
struct AdminPrivilegeError: Error, AlertableError {
|
||||
enum Kind: String {
|
||||
case applescriptNilError = "homebrew_permissions.applescript_returned_nil"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ extension NSMenuItem {
|
||||
) {
|
||||
self.init(title: title, action: action, keyEquivalent: keyEquivalent)
|
||||
self.keyEquivalentModifierMask = keyModifier
|
||||
|
||||
if !Preferences.isEnabled(.hideIconsInMenu) {
|
||||
if systemImage != nil {
|
||||
self.image = NSImage(systemSymbolName: systemImage!, accessibilityDescription: "")
|
||||
}
|
||||
@@ -26,6 +28,7 @@ extension NSMenuItem {
|
||||
self.image = NSImage(named: customImage!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(
|
||||
title: String,
|
||||
@@ -52,12 +55,16 @@ extension NSMenuItem {
|
||||
self.init(title: title, action: nil, keyEquivalent: keyEquivalent)
|
||||
self.keyEquivalentModifierMask = keyModifier
|
||||
self.toolTip = toolTip
|
||||
|
||||
if !Preferences.isEnabled(.hideIconsInMenu) {
|
||||
if systemImage != nil {
|
||||
self.image = NSImage(systemSymbolName: systemImage!, accessibilityDescription: "")
|
||||
}
|
||||
if customImage != nil {
|
||||
self.image = NSImage(named: customImage!)
|
||||
}
|
||||
}
|
||||
|
||||
self.submenu = NSMenu(items: submenu, target: target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
|
||||
struct Localization {
|
||||
static var preferredLanguage: String? {
|
||||
if App.shared.preferences == nil {
|
||||
if App.shared.container.preferences == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ extension String {
|
||||
return NSLocalizedString(self, bundle: bundle, comment: "")
|
||||
}
|
||||
|
||||
return string.replacing("Preferences", with: "Settings")
|
||||
return string
|
||||
}
|
||||
|
||||
var localizedForSwiftUI: LocalizedStringKey {
|
||||
@@ -76,6 +76,13 @@ extension String {
|
||||
String(format: self.localized, arguments: args)
|
||||
}
|
||||
|
||||
func localized(for locale: String = "en") -> String {
|
||||
guard let path = Localization.bundle.path(forResource: locale, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path)
|
||||
else { return self }
|
||||
return NSLocalizedString(self, bundle: bundle, comment: "")
|
||||
}
|
||||
|
||||
func countInstances(of stringToFind: String) -> Int {
|
||||
if stringToFind.isEmpty {
|
||||
return 0
|
||||
|
||||
@@ -87,7 +87,7 @@ class RealFileSystem: FileSystemProtocol {
|
||||
// MARK: — FS Attributes
|
||||
|
||||
func makeExecutable(_ path: String) throws {
|
||||
_ = container.shell.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
|
||||
container.shell.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
@@ -178,6 +178,31 @@ class TestableFileSystem: FileSystemProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transaction Helpers
|
||||
|
||||
func createSymlink(_ path: String, destination: String) {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
let destination = destination.replacingTildeWithHomeDirectory
|
||||
|
||||
accessQueue.sync {
|
||||
self.createIntermediateDirectories(path)
|
||||
self.files[path] = .fake(.symlink, destination)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(_ path: String, content: String, overwrite: Bool) throws {
|
||||
let path = path.replacingTildeWithHomeDirectory
|
||||
|
||||
try accessQueue.sync {
|
||||
if !overwrite, files[path] != nil {
|
||||
throw TestableFileSystemError.alreadyExists
|
||||
}
|
||||
|
||||
self.createIntermediateDirectories(path)
|
||||
self.files[path] = .fake(.text, content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Checks
|
||||
|
||||
func isExecutableFile(_ path: String) -> Bool {
|
||||
|
||||
@@ -32,10 +32,13 @@ class Application {
|
||||
/// The full path to the application bundle (if found)
|
||||
var path: String?
|
||||
|
||||
/// Characters that are unsafe for shell interpolation inside double quotes.
|
||||
private static let unsafeCharacters: Set<Character> = ["\"", "\\", "`", "$", ";", "|", "&", "!", "#"]
|
||||
|
||||
/// Initializer. Used to detect a specific app of a specific type.
|
||||
init(_ container: Container, _ name: String, _ type: AppType) {
|
||||
self.container = container
|
||||
self.name = name
|
||||
self.name = String(name.filter { !Application.unsafeCharacters.contains($0) })
|
||||
self.type = type
|
||||
self.path = determinePath()
|
||||
}
|
||||
@@ -45,7 +48,7 @@ class Application {
|
||||
(This will open the app if it isn't open yet.)
|
||||
*/
|
||||
@objc public func open(arg: String) {
|
||||
Task { await container.shell.quiet("/usr/bin/open -a \"\(name)\" \"\(arg)\"") }
|
||||
Task { await container.shell.pipe("/usr/bin/open -a \"\(name)\" \"\(arg)\"") }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import Foundation
|
||||
/**
|
||||
Run a simple blocking Shell command on the user's own system.
|
||||
*/
|
||||
@discardableResult
|
||||
public func system(_ command: String) -> String {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/sh"
|
||||
|
||||
97
phpmon/Common/Monitoring/CommandTracker.swift
Normal file
97
phpmon/Common/Monitoring/CommandTracker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
65
phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift
Normal file
65
phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
38
phpmon/Common/Monitoring/TrackedCommand.swift
Normal file
38
phpmon/Common/Monitoring/TrackedCommand.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
72
phpmon/Common/Monitoring/TrackedShell.swift
Normal file
72
phpmon/Common/Monitoring/TrackedShell.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,9 @@ class PhpEnvironments {
|
||||
// Update the synchronized value
|
||||
_currentInstall.value = newValue
|
||||
// Let the PHP extension manager, if it exists, know the version changed
|
||||
App.shared.phpExtensionManagerWindowController?.view.didUpdatePhpVersion()
|
||||
WindowManager
|
||||
.controller(of: PhpExtensionManagerWC.self)?
|
||||
.view.didUpdatePhpVersion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +220,7 @@ class PhpEnvironments {
|
||||
}
|
||||
|
||||
public func reloadPhpVersions() async {
|
||||
_ = await self.detectPhpVersions()
|
||||
await self.detectPhpVersions()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,6 +230,7 @@ class PhpEnvironments {
|
||||
|
||||
Returns a `Set<String>` of installations that are considered valid.
|
||||
*/
|
||||
@discardableResult
|
||||
public func detectPhpVersions() async -> Set<String> {
|
||||
let files = await container.shell.pipe("ls \(container.paths.optPath) | grep php@").out
|
||||
|
||||
|
||||
@@ -127,13 +127,13 @@ class PhpHelper {
|
||||
|
||||
if !container.filesystem.fileExists(destination) {
|
||||
Log.info("Creating new symlink: \(destination)")
|
||||
await container.shell.quiet("ln -s \(source) \(destination)")
|
||||
await container.shell.pipe("ln -s \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
if !App.shared.container.filesystem.isSymlink(destination) {
|
||||
Log.info("Overwriting existing file with new symlink: \(destination)")
|
||||
await container.shell.quiet("ln -fs \(source) \(destination)")
|
||||
await container.shell.pipe("ln -fs \(source) \(destination)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +133,11 @@ class PhpConfigurationFile: CreatedFromFile {
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
let newLines = try! String(contentsOfFile: self.filePath)
|
||||
.components(separatedBy: "\n")
|
||||
guard let newLines = try? String(contentsOfFile: self.filePath)
|
||||
.components(separatedBy: "\n") else {
|
||||
Log.warn("Could not reload PHP configuration file at: `\(self.filePath)`")
|
||||
return
|
||||
}
|
||||
|
||||
// Update all properties atomically
|
||||
lines = newLines
|
||||
|
||||
@@ -41,7 +41,6 @@ class PhpExtension {
|
||||
return String(file.split(separator: "/").last ?? "php.ini")
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
/**
|
||||
This regular expression will allow us to identify lines which activate an extension.
|
||||
|
||||
@@ -55,7 +54,6 @@ class PhpExtension {
|
||||
- Note: Extensions that are disabled in a different way will not be detected. This is intentional.
|
||||
*/
|
||||
static let extensionRegex = #"^(extension|zend_extension|;(\s?)extension|;(\s?)zend_extension)(\s?)(=)(\s?)(?<name>["]?(?:\/?.\/?)+(?:\.so)"?)$"#
|
||||
// swiftlint:enable line_length
|
||||
|
||||
/**
|
||||
When registering an extension, we do that based on the line found inside the .ini file.
|
||||
|
||||
@@ -11,6 +11,7 @@ import Foundation
|
||||
extension InternalSwitcher {
|
||||
typealias FixApplied = Bool
|
||||
|
||||
@discardableResult
|
||||
public func ensureValetConfigurationIsValidForPhpVersion(_ version: String) async -> FixApplied {
|
||||
// Early exit if Valet is not installed
|
||||
if !Valet.installed {
|
||||
@@ -71,7 +72,8 @@ extension InternalSwitcher {
|
||||
}
|
||||
|
||||
do {
|
||||
var contents = try container.filesystem.getStringFromFile("~/.composer/vendor/laravel/valet" + file.source)
|
||||
var contents = try container.filesystem
|
||||
.getStringFromFile("~/.composer/vendor/laravel/valet" + file.source)
|
||||
|
||||
for (original, replacement) in file.replacements {
|
||||
contents = contents.replacing(original, with: replacement)
|
||||
|
||||
@@ -53,7 +53,7 @@ class InternalSwitcher: PhpSwitcher {
|
||||
for formula in versions {
|
||||
if Valet.installed {
|
||||
Log.info("Ensuring that the Valet configuration is valid...")
|
||||
_ = await self.ensureValetConfigurationIsValidForPhpVersion(formula)
|
||||
await self.ensureValetConfigurationIsValidForPhpVersion(formula)
|
||||
}
|
||||
|
||||
Log.info("Will start PHP \(version)... (primary: \(version == formula))")
|
||||
@@ -112,7 +112,7 @@ class InternalSwitcher: PhpSwitcher {
|
||||
|
||||
if Valet.enabled(feature: .isolatedSites) && primary {
|
||||
let socketVersion = version.replacing(".", with: "")
|
||||
await container.shell.quiet("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
await container.shell.pipe("ln -sF ~/.config/valet/valet\(socketVersion).sock ~/.config/valet/valet.sock")
|
||||
Log.info("Symlinked new socket version (valet\(socketVersion).sock → valet.sock).")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
init(binPath: String) {
|
||||
self.binPath = binPath
|
||||
self._PATH = RealShell.getPath()
|
||||
self._exports = ""
|
||||
self._exports = [:]
|
||||
}
|
||||
|
||||
private(set) var binPath: String
|
||||
|
||||
/**
|
||||
@@ -38,8 +37,9 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
/**
|
||||
Exports are additional environment variables set by the user via the custom configuration.
|
||||
These are populated when the configuration file is being loaded.
|
||||
These are now set via via Process.environment to avoid security issues, like shell injection.
|
||||
*/
|
||||
internal var exports: String {
|
||||
internal var exports: [String: String] {
|
||||
get { shellQueue.sync { _exports } }
|
||||
set { shellQueue.sync { _exports = newValue } }
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
/** Thread-safe access to PATH and exports is ensured via this queue. */
|
||||
private let shellQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_queue")
|
||||
private var _PATH: String
|
||||
private var _exports: String
|
||||
private var _exports: [String: String]
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
@@ -75,22 +75,23 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
This process still needs to be started, or one can attach output handlers.
|
||||
*/
|
||||
private func getShellProcess(for command: String) -> Process {
|
||||
var completeCommand = ""
|
||||
|
||||
// Basic export (PATH)
|
||||
completeCommand += "export PATH=\(binPath):$PATH && "
|
||||
|
||||
// Put additional exports (as defined by the user) in between
|
||||
if !self.exports.isEmpty {
|
||||
completeCommand += "\(self.exports) && "
|
||||
}
|
||||
|
||||
completeCommand += command
|
||||
let completeCommand = "export PATH=\(binPath):$PATH && " + command
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = self.launchPath
|
||||
task.arguments = ["--noprofile", "-norc", "--login", "-c", completeCommand]
|
||||
|
||||
// Set user-defined environment variables safely via Process API
|
||||
// instead of interpolating them into the shell command string.
|
||||
let currentExports = self.exports
|
||||
if !currentExports.isEmpty {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
for (key, value) in currentExports {
|
||||
env[key] = value
|
||||
}
|
||||
task.environment = env
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -111,20 +112,39 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/**
|
||||
Set custom environment variables.
|
||||
These will be exported when a command is executed.
|
||||
Verbose logging for when executing a shell command.
|
||||
*/
|
||||
public func setCustomEnvironmentVariables(_ variables: [String: String]) {
|
||||
self.exports = variables.map { (key, value) in
|
||||
return "export \(key)=\(value)"
|
||||
}.joined(separator: "&&")
|
||||
private func log(process: Process, stdOut: String, stdErr: String) {
|
||||
var args = process.arguments ?? []
|
||||
let last = "\"" + (args.popLast() ?? "") + "\""
|
||||
var log = """
|
||||
|
||||
<~~~~~~~~~~~~~~~~~~~~~~~
|
||||
$ \(([self.launchPath] + args + [last]).joined(separator: " "))
|
||||
|
||||
[OUT]:
|
||||
\(stdOut)
|
||||
"""
|
||||
|
||||
if !stdErr.isEmpty {
|
||||
log.append("""
|
||||
[ERR]:
|
||||
\(stdErr)
|
||||
""")
|
||||
}
|
||||
|
||||
log.append("""
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~>
|
||||
|
||||
""")
|
||||
|
||||
Log.info(log)
|
||||
}
|
||||
|
||||
// MARK: - Shellable Protocol
|
||||
|
||||
@discardableResult
|
||||
func sync(_ command: String) -> ShellOutput {
|
||||
let process = getShellProcess(for: command)
|
||||
|
||||
@@ -155,6 +175,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
return .out(stdOut, stdErr)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
let process = getShellProcess(for: command)
|
||||
|
||||
@@ -190,37 +211,73 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func log(process: Process, stdOut: String, stdErr: String) {
|
||||
var args = process.arguments ?? []
|
||||
let last = "\"" + (args.popLast() ?? "") + "\""
|
||||
var log = """
|
||||
@discardableResult
|
||||
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
|
||||
let process = getShellProcess(for: command)
|
||||
|
||||
<~~~~~~~~~~~~~~~~~~~~~~~
|
||||
$ \(([self.launchPath] + args + [last]).joined(separator: " "))
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
|
||||
[OUT]:
|
||||
\(stdOut)
|
||||
"""
|
||||
|
||||
if !stdErr.isEmpty {
|
||||
log.append("""
|
||||
[ERR]:
|
||||
\(stdErr)
|
||||
""")
|
||||
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
|
||||
Log.info("[SLOW SHELL] \(command)")
|
||||
await delay(seconds: 3.0)
|
||||
}
|
||||
|
||||
log.append("""
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~>
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
""")
|
||||
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.pipe_timeout_queue")
|
||||
|
||||
Log.info(log)
|
||||
return await withCheckedContinuation { continuation in
|
||||
var resumed = false
|
||||
|
||||
let timeoutWorkItem = DispatchWorkItem {
|
||||
guard process.isRunning else { return }
|
||||
|
||||
Log.warn("Command timed out after \(timeout)s: \(command)")
|
||||
process.terminationHandler = nil
|
||||
process.terminate()
|
||||
|
||||
serialQueue.async {
|
||||
if !resumed {
|
||||
resumed = true
|
||||
continuation.resume(returning: .out("", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = await self.pipe(command)
|
||||
serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem)
|
||||
|
||||
process.terminationHandler = { [weak self] _ in
|
||||
timeoutWorkItem.cancel()
|
||||
|
||||
serialQueue.async {
|
||||
if resumed { return }
|
||||
|
||||
if process.terminationReason == .uncaughtSignal {
|
||||
Log.err("The command `\(command)` likely crashed. Returning empty output.")
|
||||
resumed = true
|
||||
continuation.resume(returning: .out("", ""))
|
||||
return
|
||||
}
|
||||
|
||||
let stdOut = RealShell.getStringOutput(from: outputPipe)
|
||||
let stdErr = RealShell.getStringOutput(from: errorPipe)
|
||||
|
||||
if Log.shared.verbosity == .cli {
|
||||
self?.log(process: process, stdOut: stdOut, stdErr: stdErr)
|
||||
}
|
||||
|
||||
resumed = true
|
||||
continuation.resume(returning: .out(stdOut, stdErr))
|
||||
}
|
||||
}
|
||||
|
||||
process.launch()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
@@ -235,7 +292,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
let output = ShellOutput.empty()
|
||||
|
||||
// Only access `resumed`, `output` from serialQueue to ensure thread safety
|
||||
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output")
|
||||
let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.attach_queue")
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
// Guard against resuming the continuation twice (race between timeout and termination)
|
||||
@@ -282,7 +339,9 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
process.terminationHandler = { process in
|
||||
serialQueue.async {
|
||||
timeoutTaskTermination.cancel()
|
||||
}
|
||||
|
||||
// Check if already resumed (timeout fired first)
|
||||
if resumed { return }
|
||||
|
||||
// Clean up readability handlers
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
@@ -292,7 +351,6 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
let remainingOut = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let remainingErr = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
serialQueue.async {
|
||||
if !remainingOut.isEmpty, let string = String(data: remainingOut, encoding: .utf8) {
|
||||
output.out += string
|
||||
didReceiveOutput(string, .stdOut)
|
||||
@@ -303,12 +361,10 @@ class RealShell: ShellProtocol, @unchecked Sendable {
|
||||
didReceiveOutput(string, .stdErr)
|
||||
}
|
||||
|
||||
if !resumed {
|
||||
resumed = true
|
||||
continuation.resume(returning: (process, output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.launch()
|
||||
})
|
||||
|
||||
@@ -8,38 +8,55 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ShellProtocol {
|
||||
protocol ShellProtocol: AnyObject {
|
||||
/**
|
||||
The PATH for the current shell.
|
||||
*/
|
||||
var PATH: String { get }
|
||||
|
||||
/**
|
||||
Run a command synchronously. Use with caution.
|
||||
Exports are additional environment variables set by the user via the custom configuration.
|
||||
These are populated when the configuration file is being loaded.
|
||||
*/
|
||||
var exports: [String: String] { get set }
|
||||
|
||||
/**
|
||||
Run a command synchronously. Use with caution!
|
||||
|
||||
Common usage:
|
||||
```
|
||||
let output = Shell.sync("php -v")
|
||||
```
|
||||
|
||||
@return The shell output. If the command times out, returns empty output.
|
||||
*/
|
||||
@discardableResult
|
||||
func sync(_ command: String) -> ShellOutput
|
||||
|
||||
/**
|
||||
Run a command asynchronously.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
|
||||
Common usage:
|
||||
```
|
||||
let output = await Shell.pipe("php -v")
|
||||
```
|
||||
|
||||
@return The shell output. If the command times out, returns empty output.
|
||||
*/
|
||||
@discardableResult
|
||||
func pipe(_ command: String) async -> ShellOutput
|
||||
|
||||
/**
|
||||
Run a command asynchronously, without returning the output of the command.
|
||||
Run a command asynchronously with a timeout.
|
||||
Returns the most relevant output (prefers error output if it exists).
|
||||
|
||||
- Parameter command: The command to execute.
|
||||
- Parameter timeout: Timeout in seconds. If the command exceeds this, it is terminated.
|
||||
|
||||
@return The shell output. If the command times out, returns empty output.
|
||||
*/
|
||||
func quiet(_ command: String) async
|
||||
@discardableResult
|
||||
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput
|
||||
|
||||
/**
|
||||
Runs a command asynchronously, and fires closure with `stdout` or `stderr` data as it comes in.
|
||||
@@ -49,8 +66,10 @@ protocol ShellProtocol {
|
||||
(Whether it is complete or not.)
|
||||
|
||||
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism.
|
||||
The end result is still the most relevant output (where error output is preferred if it exists).
|
||||
|
||||
@return A tuple, containing the `Process` and `ShellOutput` objects.
|
||||
*/
|
||||
@discardableResult
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
|
||||
@@ -13,12 +13,18 @@ public class TestableShell: ShellProtocol {
|
||||
return "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin"
|
||||
}
|
||||
|
||||
init(expectations: [String: BatchFakeShellOutput]) {
|
||||
init(expectations: [String: BatchFakeShellOutput], filesystem: TestableFileSystem? = nil) {
|
||||
self.expectations = expectations
|
||||
self.filesystem = filesystem
|
||||
}
|
||||
|
||||
var expectations: [String: BatchFakeShellOutput] = [:]
|
||||
var filesystem: TestableFileSystem?
|
||||
|
||||
// Custom exports; unused because we have preset shell output, but relevant for certain checks in-app
|
||||
var exports: [String: String] = [:]
|
||||
|
||||
@discardableResult
|
||||
func sync(_ command: String) -> ShellOutput {
|
||||
// This assertion will only fire during test builds
|
||||
assert(expectations.keys.contains(command), "No response declared for command: \(command)")
|
||||
@@ -27,18 +33,23 @@ public class TestableShell: ShellProtocol {
|
||||
return .err("No Expected Output")
|
||||
}
|
||||
|
||||
return expectation.syncOutput()
|
||||
}
|
||||
|
||||
func quiet(_ command: String) async {
|
||||
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
}
|
||||
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
|
||||
let output = expectation.syncOutput()
|
||||
applyTransactions(for: expectation)
|
||||
return output
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func pipe(_ command: String) async -> ShellOutput {
|
||||
await pipe(command, timeout: 60)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
|
||||
let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: timeout)
|
||||
return output
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func attach(
|
||||
_ command: String,
|
||||
didReceiveOutput: @escaping (String, ShellStream) -> Void,
|
||||
@@ -62,12 +73,28 @@ public class TestableShell: ShellProtocol {
|
||||
didReceiveOutput(output, type)
|
||||
}, ignoreDelay: isRunningTests)
|
||||
|
||||
applyTransactions(for: expectation)
|
||||
return (Process(), output)
|
||||
}
|
||||
|
||||
func reloadEnvPath() {
|
||||
// does nothing
|
||||
}
|
||||
|
||||
private func applyTransactions(for expectation: BatchFakeShellOutput) {
|
||||
if !expectation.transactions.isEmpty {
|
||||
assert(filesystem != nil, "Transactions require a filesystem")
|
||||
}
|
||||
|
||||
guard let filesystem else {
|
||||
return
|
||||
}
|
||||
|
||||
expectation.transactions.forEach { transaction in
|
||||
transaction.apply(to: filesystem, shell: self)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FakeShellOutput: Codable {
|
||||
@@ -86,6 +113,7 @@ struct FakeShellOutput: Codable {
|
||||
|
||||
struct BatchFakeShellOutput: Codable {
|
||||
var items: [FakeShellOutput]
|
||||
var transactions: [FakeShellTransaction] = []
|
||||
|
||||
static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput {
|
||||
return BatchFakeShellOutput(items: items)
|
||||
@@ -117,6 +145,8 @@ struct BatchFakeShellOutput: Codable {
|
||||
await delay(seconds: item.delay)
|
||||
}
|
||||
|
||||
didReceiveOutput(item.output, item.stream)
|
||||
|
||||
if item.stream == .stdErr {
|
||||
output.err += item.output
|
||||
} else if item.stream == .stdOut {
|
||||
@@ -159,3 +189,85 @@ struct BatchFakeShellOutput: Codable {
|
||||
return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Prepares a particular transaction that modifies testable state after running a shell command.
|
||||
Currently, it possible to modify the state of `TestableShell` and `TestableFileSystem`.
|
||||
*/
|
||||
struct FakeShellTransaction: Codable {
|
||||
/// Creates a symlink for a given path to a given destination in `TestableFileSystem`.
|
||||
static func symlink(_ path: String, to destination: String) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .createSymlink, path: path, destination: destination)
|
||||
}
|
||||
|
||||
/// Writes some content to a file to a path, overwriting existing entries in `TestableFileSystem`.
|
||||
static func write(_ content: String, to path: String, overwrite: Bool = true) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .writeFile, path: path, content: content, overwrite: overwrite)
|
||||
}
|
||||
|
||||
/// Removes a file from `TestableFileSystem`.
|
||||
static func remove(_ path: String) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .remove, path: path)
|
||||
}
|
||||
|
||||
/// Moves a file in `TestableFileSystem`.
|
||||
static func move(_ from: String, to: String) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .move, from: from, to: to)
|
||||
}
|
||||
|
||||
/// Creates a directory in `TestableFileSystem`.
|
||||
static func mkdir(_ path: String) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .createDirectory, path: path)
|
||||
}
|
||||
|
||||
/// Updates shell output for a particular command in `TestableShell`.
|
||||
/// Use this if you expect running a particular command twice to have a different outcome the second time, for example.
|
||||
static func shell(_ command: String, _ output: BatchFakeShellOutput) -> FakeShellTransaction {
|
||||
FakeShellTransaction(type: .setShellOutput, command: command, output: output)
|
||||
}
|
||||
|
||||
enum TransactionType: String, Codable {
|
||||
case createSymlink
|
||||
case writeFile
|
||||
case remove
|
||||
case move
|
||||
case createDirectory
|
||||
case setShellOutput
|
||||
}
|
||||
|
||||
private var type: TransactionType
|
||||
private var path: String?
|
||||
private var destination: String?
|
||||
private var content: String?
|
||||
private var overwrite: Bool?
|
||||
private var from: String?
|
||||
private var to: String?
|
||||
private var command: String?
|
||||
private var output: BatchFakeShellOutput?
|
||||
|
||||
/**
|
||||
Applies a given transaction that will modify the testable filesystem or testable shell as part of a transaction.
|
||||
*/
|
||||
func apply(to filesystem: TestableFileSystem, shell: TestableShell) {
|
||||
switch type {
|
||||
case .createSymlink:
|
||||
assert(path != nil && destination != nil, "createSymlink requires path and destination")
|
||||
filesystem.createSymlink(path!, destination: destination!)
|
||||
case .writeFile:
|
||||
assert(path != nil && content != nil && overwrite != nil, "writeFile requires path, content, overwrite")
|
||||
try? filesystem.writeFile(path!, content: content!, overwrite: overwrite!)
|
||||
case .remove:
|
||||
assert(path != nil, "remove requires path")
|
||||
try? filesystem.remove(path!)
|
||||
case .move:
|
||||
assert(from != nil && to != nil, "move requires from and to")
|
||||
try? filesystem.move(from: from!, to: to!)
|
||||
case .createDirectory:
|
||||
assert(path != nil, "createDirectory requires path")
|
||||
try? filesystem.createDirectory(path!, withIntermediateDirectories: true)
|
||||
case .setShellOutput:
|
||||
assert(command != nil && output != nil, "setShellOutput requires command and output")
|
||||
shell.expectations[command!] = output!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public struct TestableConfiguration: Codable {
|
||||
var filesystem: [String: FakeFile]
|
||||
var shellOutput: [String: BatchFakeShellOutput]
|
||||
var commandOutput: [String: String]
|
||||
var preferenceOverrides: [PreferenceName: Bool]
|
||||
var preferenceOverrides: [PreferenceName: PreferenceOverride]
|
||||
var apiGetResponses: [URL: FakeWebApiResponse]
|
||||
var apiPostResponses: [URL: FakeWebApiResponse]
|
||||
|
||||
@@ -22,7 +22,7 @@ public struct TestableConfiguration: Codable {
|
||||
filesystem: [String: FakeFile],
|
||||
shellOutput: [String: BatchFakeShellOutput],
|
||||
commandOutput: [String: String],
|
||||
preferenceOverrides: [PreferenceName: Bool],
|
||||
preferenceOverrides: [PreferenceName: PreferenceOverride],
|
||||
phpVersions: [VersionNumber],
|
||||
apiGetResponses: [URL: FakeWebApiResponse],
|
||||
apiPostResponses: [URL: FakeWebApiResponse]
|
||||
@@ -138,8 +138,13 @@ public struct TestableConfiguration: Codable {
|
||||
|
||||
Log.info("Applying temporary preference overrides...")
|
||||
var cachedPrefs = container.preferences.cachedPreferences
|
||||
preferenceOverrides.forEach { (key: PreferenceName, value: Any?) in
|
||||
cachedPrefs[key] = value
|
||||
preferenceOverrides.forEach { (key: PreferenceName, value: PreferenceOverride) in
|
||||
switch value {
|
||||
case .bool(let boolValue):
|
||||
cachedPrefs[key] = boolValue
|
||||
case .string(let stringValue):
|
||||
cachedPrefs[key] = stringValue
|
||||
}
|
||||
}
|
||||
container.preferences.cachedPreferences = cachedPrefs
|
||||
|
||||
@@ -193,3 +198,8 @@ public struct TestableConfiguration: Codable {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum PreferenceOverride: Codable {
|
||||
case bool(Bool)
|
||||
case string(String)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//
|
||||
|
||||
extension Container {
|
||||
public static func real(minimal: Bool = false) -> Container {
|
||||
public static func real(minimal: Bool = false, commandTracking: Bool = true) -> Container {
|
||||
let container = Container()
|
||||
container.bind(coreOnly: minimal)
|
||||
container.bind(coreOnly: minimal, commandTracking: commandTracking)
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class Container: @unchecked Sendable {
|
||||
private(set) var paths: Paths!
|
||||
private(set) var shell: ShellProtocol!
|
||||
private(set) var command: CommandProtocol!
|
||||
private(set) var commandTracker: CommandTracker!
|
||||
private(set) var webApi: WebApiProtocol!
|
||||
|
||||
// Secondary (uses primary instances above)
|
||||
@@ -54,7 +55,10 @@ class Container: @unchecked Sendable {
|
||||
/// - Parameter coreOnly: Only binds `shell`, `filesystem`, `command`, `paths` and `webApi`.
|
||||
/// Use this to prevent slowing down tests for a minimal container.
|
||||
///
|
||||
public func bind(coreOnly: Bool = false) {
|
||||
/// - Parameter commandTracking: When enabled, connects decorated RealShell and RealCommand.
|
||||
/// Use this if you want to disable tracking (shell) command statuses, since it's on by default.
|
||||
///
|
||||
public func bind(coreOnly: Bool = false, commandTracking: Bool = true) {
|
||||
if self.bound {
|
||||
fatalError("You cannot call `bind` on a Container more than once.")
|
||||
}
|
||||
@@ -67,8 +71,20 @@ class Container: @unchecked Sendable {
|
||||
// any of the other classes can be initialized!
|
||||
self.filesystem = RealFileSystem(container: self)
|
||||
self.paths = Paths(container: self)
|
||||
self.shell = RealShell(binPath: paths.binPath)
|
||||
self.command = RealCommand()
|
||||
self.commandTracker = CommandTracker()
|
||||
|
||||
let baseShellHandler = RealShell(binPath: paths.binPath)
|
||||
let baseCommandHandler = RealCommand()
|
||||
|
||||
// Depending on whether we need command tracking wired up, we will use different real handlers
|
||||
if commandTracking {
|
||||
self.shell = TrackedShell(shell: baseShellHandler, commandTracker: commandTracker)
|
||||
self.command = TrackedCommand(command: baseCommandHandler, commandTracker: commandTracker)
|
||||
} else {
|
||||
self.shell = baseShellHandler
|
||||
self.command = baseCommandHandler
|
||||
}
|
||||
|
||||
self.webApi = RealWebApi(container: self)
|
||||
|
||||
if coreOnly {
|
||||
@@ -95,11 +111,24 @@ class Container: @unchecked Sendable {
|
||||
fileSystemFiles: [String: FakeFile] = [:],
|
||||
commands: [String: String] = [:],
|
||||
webApiGetResponses: [URL: FakeWebApiResponse] = [:],
|
||||
webApiPostResponses: [URL: FakeWebApiResponse] = [:]
|
||||
webApiPostResponses: [URL: FakeWebApiResponse] = [:],
|
||||
commandTracking: Bool = true,
|
||||
) {
|
||||
self.shell = TestableShell(expectations: shellExpectations)
|
||||
self.filesystem = TestableFileSystem(files: fileSystemFiles)
|
||||
self.commandTracker = CommandTracker()
|
||||
|
||||
let filesystem = TestableFileSystem(files: fileSystemFiles)
|
||||
|
||||
// Depending on whether we want to fire command tracking, load different handlers
|
||||
if commandTracking {
|
||||
self.shell = TrackableTestableShell(expectations: shellExpectations, filesystem: filesystem, commandTracker)
|
||||
self.command = TrackableTestableCommand(commands: commands, commandTracker)
|
||||
} else {
|
||||
self.shell = TestableShell(expectations: shellExpectations, filesystem: filesystem)
|
||||
self.command = TestableCommand(commands: commands)
|
||||
}
|
||||
|
||||
self.filesystem = filesystem
|
||||
|
||||
self.webApi = TestableWebApi(
|
||||
getResponses: webApiGetResponses,
|
||||
postResponses: webApiPostResponses
|
||||
|
||||
@@ -41,4 +41,14 @@ extension App {
|
||||
NSApp.setActivationPolicy(!openWindows.isEmpty ? .regular : .accessory)
|
||||
}
|
||||
|
||||
/**
|
||||
Closes and invalidates all cached secondary window controllers (excluding Preferences).
|
||||
This ensures that windows are recreated fresh, with the correct localization, the next
|
||||
time they are opened. Each `close()` call triggers `windowWillClose`, which automatically
|
||||
removes the window from `openWindows` via the existing delegate mechanism.
|
||||
*/
|
||||
public func invalidateCachedWindows() {
|
||||
WindowManager.closeAll(excluding: [PreferencesWC.self])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -81,36 +81,12 @@ class App {
|
||||
*/
|
||||
var container: Container = Container()
|
||||
|
||||
/** The list of preferences that are currently active. */
|
||||
var preferences: [PreferenceName: Bool]!
|
||||
|
||||
/** The window controller of the currently active preferences window. */
|
||||
var preferencesWindowController: PreferencesWindowController?
|
||||
|
||||
/** The window controller of the currently active site list window. */
|
||||
var domainListWindowController: DomainListWindowController?
|
||||
|
||||
/** The window controller of the onboarding window. */
|
||||
var onboardingWindowController: OnboardingWindowController?
|
||||
|
||||
/** The window controller of the config manager window. */
|
||||
var phpConfigManagerWindowController: PhpConfigManagerWindowController?
|
||||
|
||||
/** The window controller of the warnings window. */
|
||||
var phpDoctorWindowController: PhpDoctorWindowController?
|
||||
|
||||
/** The window controller of the PHP version manager window. */
|
||||
var phpVersionManagerWindowController: PhpVersionManagerWindowController?
|
||||
|
||||
/** The window controller of the PHP extension manager window. */
|
||||
var phpExtensionManagerWindowController: PhpExtensionManagerWindowController?
|
||||
/** URL that was received before the app finished booting. Will be processed once the startup procedure completes. */
|
||||
var deferredURL: URL?
|
||||
|
||||
/** List of detected (installed) applications that PHP Monitor can work with. */
|
||||
var detectedApplications: [Application] = []
|
||||
|
||||
/** Timer that will periodically reload info about the user's PHP installation. */
|
||||
var timer: Timer?
|
||||
|
||||
// MARK: - Global Hotkey
|
||||
|
||||
/**
|
||||
@@ -122,7 +98,7 @@ class App {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activation Policy
|
||||
// MARK: - Windows and Activation Policy
|
||||
|
||||
/**
|
||||
Variable that keeps track of which windows are currently open.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import NVAlert
|
||||
|
||||
extension AppDelegate {
|
||||
|
||||
@@ -16,16 +17,46 @@ extension AppDelegate {
|
||||
application URL. You can use the `phpmon://` protocol to communicate with the app.
|
||||
|
||||
At this time you can trigger the site list using Alfred (or some other application)
|
||||
by opening the following URL: `phpmon://list`.
|
||||
by opening the following URL: `phpmon://list`. Various other commands are also made
|
||||
available via the `InterApp` class, which is where all commands and their different
|
||||
interactions are declared.
|
||||
|
||||
Please note that PHP Monitor needs to be running in the background for this to work.
|
||||
Please note that PHP Monitor needs to be running in the background for this to work,
|
||||
or the app will launch and the command will be deferred until the app is ready.
|
||||
*/
|
||||
@MainActor func application(_ application: NSApplication, open urls: [URL]) {
|
||||
guard Startup.hasFinishedBooting else {
|
||||
return deferURLs(urls)
|
||||
}
|
||||
|
||||
handleURLs(urls)
|
||||
}
|
||||
|
||||
@MainActor func deferURLs(_ urls: [URL]) {
|
||||
Log.info("App has not finished booting, deferring phpmon:// command...")
|
||||
App.shared.deferredURL = urls.first
|
||||
}
|
||||
|
||||
@MainActor func handleURLs(_ urls: [URL]) {
|
||||
if !Preferences.isEnabled(.allowProtocolForIntegrations) {
|
||||
|
||||
// First, we'll check if we already prompted for integrations.
|
||||
// If we have, we will not respond to the commands at all.
|
||||
if UserDefaults.standard.bool(
|
||||
forKey: PersistentAppState.didPromptForIntegrations.rawValue
|
||||
) {
|
||||
Log.info("Acting on commands via phpmon:// has been disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
// However, if that isn't the case, we will prompt the user about integrations,
|
||||
// which will give the user a chance to approve the command regardless.
|
||||
Log.info("Acting on commands via phpmon:// has been disabled. Prompting user...")
|
||||
if !promptToEnableIntegrations() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
self.interpretCommand(
|
||||
@@ -34,11 +65,34 @@ extension AppDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor private func promptToEnableIntegrations() -> Bool {
|
||||
UserDefaults.standard.set(true, forKey: PersistentAppState.didPromptForIntegrations.rawValue)
|
||||
UserDefaults.standard.synchronize()
|
||||
|
||||
if !NVAlert()
|
||||
.withInformation(
|
||||
title: "alert.enable_integrations.title".localized,
|
||||
subtitle: "alert.enable_integrations.subtitle".localized,
|
||||
description: "alert.enable_integrations.desc".localized
|
||||
)
|
||||
.withPrimary(text: "alert.enable_integrations.ok".localized)
|
||||
.withSecondary(text: "alert.enable_integrations.cancel".localized)
|
||||
.didSelectPrimary(urgency: .bringToFront) {
|
||||
return false
|
||||
}
|
||||
|
||||
Preferences.update(.allowProtocolForIntegrations, value: true, notify: true)
|
||||
return true
|
||||
}
|
||||
|
||||
private func interpretCommand(_ command: String, commands: [InterApp.Action]) {
|
||||
commands.forEach { action in
|
||||
if command.starts(with: action.command) {
|
||||
let lastElement = String(command.split(separator: "/").last!)
|
||||
action.action(lastElement)
|
||||
guard let lastElement = command.split(separator: "/").last else {
|
||||
Log.warn("Ignoring malformed phpmon:// command: '\(command)'")
|
||||
return
|
||||
}
|
||||
action.action(String(lastElement))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ extension AppDelegate {
|
||||
@IBAction func addSiteLinkPressed(_ sender: Any) {
|
||||
DomainListVC.show()
|
||||
|
||||
guard let windowController = App.shared.domainListWindowController else { return }
|
||||
guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
|
||||
windowController.pressedAddLink(nil)
|
||||
}
|
||||
|
||||
@IBAction func reloadDomainListPressed(_ sender: Any) {
|
||||
Task { // Reload domains
|
||||
let vc = App.shared.domainListWindowController?
|
||||
let vc = WindowManager
|
||||
.controller(of: DomainListWC.self)?
|
||||
.window?.contentViewController as? DomainListVC
|
||||
|
||||
if vc != nil {
|
||||
@@ -50,7 +51,7 @@ extension AppDelegate {
|
||||
@IBAction func focusSearchField(_ sender: Any) {
|
||||
DomainListVC.show()
|
||||
|
||||
guard let windowController = App.shared.domainListWindowController else { return }
|
||||
guard let windowController = WindowManager.controller(of: DomainListWC.self) else { return }
|
||||
windowController.searchToolbarItem.searchField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Cocoa
|
||||
import UserNotifications
|
||||
|
||||
@NSApplicationMain
|
||||
@main
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
static var instance: AppDelegate {
|
||||
@@ -55,6 +55,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
logger.verbosity = .performance
|
||||
Log.info("Extra verbose mode is enabled by default on DEBUG builds.")
|
||||
|
||||
// No matter what, clear PHP Guard if it's a debug build
|
||||
Stats.clearCurrentGlobalPhpVersion()
|
||||
|
||||
if let profile = CommandLine.arguments.first(where: { $0.matches(pattern: "--configuration:*") }) {
|
||||
AppDelegate.initializeTestingProfile(profile.replacing("--configuration:", with: ""))
|
||||
}
|
||||
@@ -70,6 +73,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
Log.info("Extra CLI mode has been activated via --cli flag.")
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--ch") {
|
||||
Log.info("Displaying command history window (`--ch` flag).")
|
||||
CommandHistoryWC.show()
|
||||
}
|
||||
|
||||
if state.container.filesystem.fileExists("~/.config/phpmon/verbose") {
|
||||
logger.verbosity = .cli
|
||||
Log.info("Extra CLI mode is on (`~/.config/phpmon/verbose` exists).")
|
||||
@@ -80,10 +88,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
Log.info("PHP MONITOR by Nico Verbruggen")
|
||||
Log.info("Version \(App.version)")
|
||||
Log.separator(as: .info)
|
||||
}
|
||||
|
||||
// Initialize the crash reporter
|
||||
CrashReporter.initialize()
|
||||
}
|
||||
|
||||
// Set up final singletons
|
||||
self.valet = Valet.shared
|
||||
@@ -93,8 +101,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
static func initializeTestingProfile(_ path: String) {
|
||||
Log.info("The configuration with path `\(path)` is being requested...")
|
||||
// Clear for PHP Guard
|
||||
Stats.clearCurrentGlobalPhpVersion()
|
||||
// Load the configuration file
|
||||
TestableConfiguration.loadFrom(path: path).apply()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
|
||||
<capability name="Image references" minToolsVersion="12.0"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
|
||||
@@ -12,6 +12,13 @@
|
||||
<!--Application-->
|
||||
<scene sceneID="JPo-4y-FX3">
|
||||
<objects>
|
||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
||||
<application id="hnw-xV-0zn" sceneMemberID="viewController">
|
||||
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
|
||||
<items>
|
||||
@@ -306,15 +313,6 @@
|
||||
</menuItem>
|
||||
<menuItem title="Help" id="wpr-3q-Mcd">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
|
||||
<items>
|
||||
<menuItem title="PHP Monitor Help" keyEquivalent="?" id="FKE-Sm-Kum">
|
||||
<connections>
|
||||
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
@@ -322,15 +320,8 @@
|
||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
|
||||
</connections>
|
||||
</application>
|
||||
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
||||
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="PHP_Monitor" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="menuItemSites" destination="9gy-d3-Pos" id="nul-IL-YuR"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-360" y="-94"/>
|
||||
<point key="canvasLocation" x="-412" y="-153"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="PQa-AT-b2a">
|
||||
@@ -360,7 +351,7 @@
|
||||
<!--Preferences-->
|
||||
<scene sceneID="iyi-IS-7Ps">
|
||||
<objects>
|
||||
<viewController title="Preferences" identifier="preferencesTemplateVC" storyboardIdentifier="preferencesTemplateVC" showSeguePresentationStyle="single" id="AW2-rV-rbS" customClass="GenericPreferenceVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<viewController title="Preferences" identifier="preferencesTemplateVC" storyboardIdentifier="preferencesTemplateVC" showSeguePresentationStyle="single" id="AW2-rV-rbS" customClass="PreferenceVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" wantsLayer="YES" id="Pf1-A5-3Xz">
|
||||
<rect key="frame" x="0.0" y="0.0" width="550" height="498"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
@@ -466,188 +457,6 @@
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-374" y="745.5"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="HTI-x5-rOp">
|
||||
<objects>
|
||||
<windowController storyboardIdentifier="addSiteWindow" id="N1O-Nj-C2V" sceneMemberID="viewController">
|
||||
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="yLy-XT-fuq">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
|
||||
<view key="contentView" id="7Is-aK-lDv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="N1O-Nj-C2V" id="CvY-PZ-Y6C"/>
|
||||
</connections>
|
||||
</window>
|
||||
<connections>
|
||||
<segue destination="glS-wF-sEU" kind="relationship" relationship="window.shadowedContentViewController" id="6Sa-w0-Uov"/>
|
||||
</connections>
|
||||
</windowController>
|
||||
<customObject id="d2k-57-mLZ" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-374" y="1137"/>
|
||||
</scene>
|
||||
<!--Add SiteVC-->
|
||||
<scene sceneID="6JC-H6-u4K">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="newSiteLink" id="glS-wF-sEU" customClass="AddSiteVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" id="JJJ-T9-Yuv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="js9-OW-xzC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
|
||||
<view key="contentView" id="HRC-RT-LxR">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="252"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</box>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="PVw-cM-qAB">
|
||||
<rect key="frame" x="329" y="20" width="131" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Create Link" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="WwW-Wv-I8s">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
DQ
|
||||
</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedCreateLink:" target="glS-wF-sEU" id="Vh7-K5-ubM"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SwS-o8-pbl">
|
||||
<rect key="frame" x="20" y="20" width="104" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="WHE-HW-jwp">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="qCP-Sp-gxm"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
|
||||
</connections>
|
||||
</button>
|
||||
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
|
||||
<rect key="frame" x="20" y="154" width="440" height="24"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/>
|
||||
</connections>
|
||||
</textField>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
|
||||
<rect key="frame" x="18" y="132" width="444" height="14"/>
|
||||
<textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="KZf-b0-9cm">
|
||||
<rect key="frame" x="20" y="100" width="262" height="16"/>
|
||||
<buttonCell key="cell" type="check" title="[i18n] Secure this domain after creation" bezelStyle="regularSquare" imagePosition="left" inset="2" id="vFv-Of-2yZ">
|
||||
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/>
|
||||
</connections>
|
||||
</button>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
|
||||
<rect key="frame" x="18" y="64" width="444" height="28"/>
|
||||
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges.
You may be prompted for your password or Touch ID." id="4gd-KM-5Fu">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<pathControl verticalHuggingPriority="750" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6JT-Vt-3q0">
|
||||
<rect key="frame" x="20" y="186" width="440" height="22"/>
|
||||
<pathCell key="cell" selectable="YES" refusesFirstResponder="YES" alignment="left" id="m8d-XF-kh9">
|
||||
<font key="font" metaFont="system"/>
|
||||
<url key="url" string="file:///Users/"/>
|
||||
</pathCell>
|
||||
</pathControl>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
|
||||
<rect key="frame" x="18" y="216" width="128" height="16"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT">
|
||||
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
|
||||
<rect key="frame" x="136" y="25" width="180" height="14"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="VzR-5a-cmT" firstAttribute="trailing" secondItem="ZX9-s1-23i" secondAttribute="trailing" id="06B-dj-IBU"/>
|
||||
<constraint firstItem="ZX9-s1-23i" firstAttribute="top" secondItem="6JT-Vt-3q0" secondAttribute="bottom" constant="8" symbolic="YES" id="0QU-nI-sYv"/>
|
||||
<constraint firstAttribute="bottom" secondItem="SwS-o8-pbl" secondAttribute="bottom" constant="20" symbolic="YES" id="2pB-nW-NVx"/>
|
||||
<constraint firstItem="900-Z2-tID" firstAttribute="centerY" secondItem="PVw-cM-qAB" secondAttribute="centerY" id="578-2f-4x8"/>
|
||||
<constraint firstItem="ZX9-s1-23i" firstAttribute="leading" secondItem="6JT-Vt-3q0" secondAttribute="trailing" constant="-440" id="6eF-GS-Xcn"/>
|
||||
<constraint firstItem="6JT-Vt-3q0" firstAttribute="top" secondItem="P0B-Ht-R8n" secondAttribute="bottom" constant="8" symbolic="YES" id="DGN-4k-X0h"/>
|
||||
<constraint firstItem="P0B-Ht-R8n" firstAttribute="top" secondItem="JJJ-T9-Yuv" secondAttribute="top" constant="20" symbolic="YES" id="F2r-6E-qxh"/>
|
||||
<constraint firstItem="mmQ-7e-dlb" firstAttribute="top" secondItem="KZf-b0-9cm" secondAttribute="bottom" constant="8" symbolic="YES" id="G21-Vd-tgl"/>
|
||||
<constraint firstItem="900-Z2-tID" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="SwS-o8-pbl" secondAttribute="trailing" constant="8" symbolic="YES" id="IMv-ZD-VXf"/>
|
||||
<constraint firstItem="js9-OW-xzC" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" id="IpM-ot-dBG"/>
|
||||
<constraint firstItem="VzR-5a-cmT" firstAttribute="leading" secondItem="ZX9-s1-23i" secondAttribute="leading" id="UPN-Ad-j3X"/>
|
||||
<constraint firstItem="SwS-o8-pbl" firstAttribute="top" secondItem="mmQ-7e-dlb" secondAttribute="bottom" constant="20" id="VNW-fB-2Xj"/>
|
||||
<constraint firstItem="KZf-b0-9cm" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="Vab-wq-9Nc"/>
|
||||
<constraint firstAttribute="bottom" secondItem="PVw-cM-qAB" secondAttribute="bottom" constant="20" symbolic="YES" id="VsP-Q0-zRW"/>
|
||||
<constraint firstAttribute="trailing" secondItem="PVw-cM-qAB" secondAttribute="trailing" constant="20" symbolic="YES" id="X5z-G4-CBv"/>
|
||||
<constraint firstItem="ZX9-s1-23i" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="bJ4-Yr-4ah"/>
|
||||
<constraint firstItem="KZf-b0-9cm" firstAttribute="top" secondItem="VzR-5a-cmT" secondAttribute="bottom" constant="16" id="bdw-P7-FLz"/>
|
||||
<constraint firstAttribute="trailing" secondItem="6JT-Vt-3q0" secondAttribute="trailing" constant="20" symbolic="YES" id="ctg-Gt-34Y"/>
|
||||
<constraint firstItem="PVw-cM-qAB" firstAttribute="leading" secondItem="900-Z2-tID" secondAttribute="trailing" constant="15" id="cx5-Gi-XS7"/>
|
||||
<constraint firstItem="mmQ-7e-dlb" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="fKH-1r-MIf"/>
|
||||
<constraint firstItem="js9-OW-xzC" firstAttribute="top" secondItem="JJJ-T9-Yuv" secondAttribute="top" id="ffu-hT-qSK"/>
|
||||
<constraint firstAttribute="bottom" secondItem="js9-OW-xzC" secondAttribute="bottom" id="hLd-Kd-y6k"/>
|
||||
<constraint firstAttribute="trailing" secondItem="mmQ-7e-dlb" secondAttribute="trailing" constant="20" symbolic="YES" id="hjv-Xq-cxV"/>
|
||||
<constraint firstItem="SwS-o8-pbl" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="jkg-UC-GPr"/>
|
||||
<constraint firstItem="6JT-Vt-3q0" firstAttribute="leading" secondItem="P0B-Ht-R8n" secondAttribute="leading" id="jxP-vM-eA9"/>
|
||||
<constraint firstAttribute="bottom" secondItem="PVw-cM-qAB" secondAttribute="bottom" constant="20" symbolic="YES" id="kGp-mI-1Ic"/>
|
||||
<constraint firstItem="P0B-Ht-R8n" firstAttribute="leading" secondItem="JJJ-T9-Yuv" secondAttribute="leading" constant="20" symbolic="YES" id="msC-eG-Fop"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="P0B-Ht-R8n" secondAttribute="trailing" constant="20" symbolic="YES" id="nvj-Ij-dcd"/>
|
||||
<constraint firstAttribute="trailing" secondItem="js9-OW-xzC" secondAttribute="trailing" id="rc3-XI-7CY"/>
|
||||
<constraint firstItem="VzR-5a-cmT" firstAttribute="top" secondItem="ZX9-s1-23i" secondAttribute="bottom" constant="8" symbolic="YES" id="sVP-EV-07F"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ZX9-s1-23i" secondAttribute="trailing" constant="20" symbolic="YES" id="tZ3-2X-JC9"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="KZf-b0-9cm" secondAttribute="trailing" constant="20" symbolic="YES" id="zq0-Ce-sCs"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="buttonCancel" destination="SwS-o8-pbl" id="N1v-uy-2Mi"/>
|
||||
<outlet property="buttonCreateLink" destination="PVw-cM-qAB" id="0Oo-xW-He7"/>
|
||||
<outlet property="buttonSecure" destination="KZf-b0-9cm" id="5A7-Bn-NB7"/>
|
||||
<outlet property="inputDomainName" destination="ZX9-s1-23i" id="yT6-80-Zr1"/>
|
||||
<outlet property="pathControl" destination="6JT-Vt-3q0" id="f5K-8h-VOd"/>
|
||||
<outlet property="previewText" destination="VzR-5a-cmT" id="qwd-wX-645"/>
|
||||
<outlet property="textFieldError" destination="900-Z2-tID" id="qUk-FE-IKW"/>
|
||||
<outlet property="textFieldSecure" destination="mmQ-7e-dlb" id="LeA-YS-hRM"/>
|
||||
<outlet property="textFieldTitle" destination="P0B-Ht-R8n" id="Qh8-qv-6iR"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<customObject id="6XV-bG-0N1" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="210" y="1128"/>
|
||||
</scene>
|
||||
<!--Domain ListVC-->
|
||||
<scene sceneID="aZt-6w-TFl">
|
||||
<objects>
|
||||
@@ -1031,378 +840,10 @@ Gw
|
||||
</objects>
|
||||
<point key="canvasLocation" x="323" y="722.5"/>
|
||||
</scene>
|
||||
<!--Add ProxyVC-->
|
||||
<scene sceneID="g8z-pE-RL9">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="newProxyLink" id="dwh-CF-6iv" customClass="AddProxyVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" id="U5U-QR-YXS">
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<box boxType="custom" borderWidth="0.0" title="Box" translatesAutoresizingMaskIntoConstraints="NO" id="kkd-UV-SnA">
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
|
||||
<view key="contentView" id="IXW-35-8NJ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="296"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
|
||||
<rect key="frame" x="20" y="203" width="500" height="24"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/>
|
||||
</connections>
|
||||
</textField>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
|
||||
<rect key="frame" x="18" y="231" width="325" height="14"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL">
|
||||
<font key="font" metaFont="systemMedium" size="11"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
|
||||
<rect key="frame" x="18" y="179" width="112" height="14"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e">
|
||||
<font key="font" metaFont="systemMedium" size="11"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
|
||||
<rect key="frame" x="20" y="151" width="500" height="24"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="dwh-CF-6iv" id="e9n-PM-7s8"/>
|
||||
</connections>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" constant="20" id="2ui-Jg-BUV"/>
|
||||
<constraint firstItem="mlA-Zt-Hu8" firstAttribute="top" secondItem="QCK-Z9-w7g" secondAttribute="bottom" constant="10" id="8sn-dT-SW6"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Uib-vA-HRc" secondAttribute="trailing" constant="20" symbolic="YES" id="Cue-3e-doM"/>
|
||||
<constraint firstItem="QCK-Z9-w7g" firstAttribute="leading" secondItem="SNw-oQ-bnb" secondAttribute="leading" id="N1K-69-wLz"/>
|
||||
<constraint firstItem="mlA-Zt-Hu8" firstAttribute="leading" secondItem="QCK-Z9-w7g" secondAttribute="leading" id="R74-k0-96U"/>
|
||||
<constraint firstItem="SNw-oQ-bnb" firstAttribute="leading" secondItem="IXW-35-8NJ" secondAttribute="leading" constant="20" id="WZR-f8-mgf"/>
|
||||
<constraint firstItem="SNw-oQ-bnb" firstAttribute="top" secondItem="mlA-Zt-Hu8" secondAttribute="bottom" constant="4" id="XDn-h9-dgp"/>
|
||||
<constraint firstItem="QCK-Z9-w7g" firstAttribute="top" secondItem="Uib-vA-HRc" secondAttribute="bottom" constant="4" id="fGU-al-B0w"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="mlA-Zt-Hu8" secondAttribute="trailing" constant="20" symbolic="YES" id="uFE-cU-KOg"/>
|
||||
<constraint firstItem="QCK-Z9-w7g" firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" id="xQE-yY-gPd"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<color key="fillColor" name="windowBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</box>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="4Vi-cN-ude">
|
||||
<rect key="frame" x="380" y="20" width="140" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Create Proxy" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="H2Z-c5-5Vk">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
DQ
|
||||
</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedCreateProxy:" target="dwh-CF-6iv" id="wFW-Aw-FOR"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nC0-dk-QaF">
|
||||
<rect key="frame" x="20" y="20" width="104" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="D8g-GE-7TU">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="uCc-fF-wS2"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
|
||||
</connections>
|
||||
</button>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
|
||||
<rect key="frame" x="18" y="132" width="504" height="14"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
|
||||
</constraints>
|
||||
<textFieldCell key="cell" title="[i18n] Preview text here" id="ISE-9R-ncQ">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="rJa-yg-nCn">
|
||||
<rect key="frame" x="20" y="100" width="166" height="16"/>
|
||||
<buttonCell key="cell" type="check" title="[i18n] Secure this proxy" bezelStyle="regularSquare" imagePosition="left" inset="2" id="5LI-lt-Asl">
|
||||
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/>
|
||||
</connections>
|
||||
</button>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
|
||||
<rect key="frame" x="18" y="64" width="504" height="28"/>
|
||||
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges.
You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
|
||||
<rect key="frame" x="18" y="260" width="123" height="16"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl">
|
||||
<font key="font" textStyle="headline" name=".SFNS-Bold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
|
||||
<rect key="frame" x="187" y="25" width="180" height="14"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="systemRedColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="nC0-dk-QaF" secondAttribute="bottom" constant="20" symbolic="YES" id="3Kk-fY-SB7"/>
|
||||
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="trailing" secondItem="SNw-oQ-bnb" secondAttribute="trailing" id="3So-Wu-1cz"/>
|
||||
<constraint firstItem="DAh-br-Dfx" firstAttribute="top" secondItem="U5U-QR-YXS" secondAttribute="top" constant="20" symbolic="YES" id="3im-Qd-loW"/>
|
||||
<constraint firstItem="kkd-UV-SnA" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" id="6iw-dd-hTX"/>
|
||||
<constraint firstItem="Uib-vA-HRc" firstAttribute="leading" secondItem="DAh-br-Dfx" secondAttribute="leading" id="6jA-Kj-Q7l"/>
|
||||
<constraint firstAttribute="trailing" secondItem="kkd-UV-SnA" secondAttribute="trailing" id="8YX-CO-sY2"/>
|
||||
<constraint firstAttribute="trailing" secondItem="5x7-ll-2f7" secondAttribute="trailing" constant="20" symbolic="YES" id="8jr-cl-x78"/>
|
||||
<constraint firstItem="kkd-UV-SnA" firstAttribute="top" secondItem="U5U-QR-YXS" secondAttribute="top" id="Afh-Ur-QgJ"/>
|
||||
<constraint firstItem="4Vi-cN-ude" firstAttribute="leading" secondItem="w0k-CK-0u4" secondAttribute="trailing" constant="15" id="D3C-co-B10"/>
|
||||
<constraint firstItem="w0k-CK-0u4" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="nC0-dk-QaF" secondAttribute="trailing" constant="8" symbolic="YES" id="FGk-wm-1Mu"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="rJa-yg-nCn" secondAttribute="trailing" constant="20" symbolic="YES" id="Fa7-Rc-1lj"/>
|
||||
<constraint firstAttribute="trailing" secondItem="4Vi-cN-ude" secondAttribute="trailing" constant="20" symbolic="YES" id="Fbg-C8-v6E"/>
|
||||
<constraint firstItem="5x7-ll-2f7" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="Fd0-zd-od8"/>
|
||||
<constraint firstAttribute="bottom" secondItem="4Vi-cN-ude" secondAttribute="bottom" constant="20" symbolic="YES" id="GyL-uL-sjW"/>
|
||||
<constraint firstItem="w0k-CK-0u4" firstAttribute="centerY" secondItem="4Vi-cN-ude" secondAttribute="centerY" id="HcL-wb-0s6"/>
|
||||
<constraint firstItem="rJa-yg-nCn" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="IEg-SN-bHB"/>
|
||||
<constraint firstItem="rJa-yg-nCn" firstAttribute="top" secondItem="JSZ-x8-Pqi" secondAttribute="bottom" constant="16" id="IW3-MX-3Kh"/>
|
||||
<constraint firstItem="DAh-br-Dfx" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="LY1-r0-viF"/>
|
||||
<constraint firstItem="nC0-dk-QaF" firstAttribute="top" secondItem="5x7-ll-2f7" secondAttribute="bottom" constant="20" id="OjY-dM-dOG"/>
|
||||
<constraint firstItem="nC0-dk-QaF" firstAttribute="leading" secondItem="U5U-QR-YXS" secondAttribute="leading" constant="20" symbolic="YES" id="V6L-YR-ufX"/>
|
||||
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="leading" secondItem="SNw-oQ-bnb" secondAttribute="leading" id="dpc-5M-0Cq"/>
|
||||
<constraint firstItem="5x7-ll-2f7" firstAttribute="top" secondItem="rJa-yg-nCn" secondAttribute="bottom" constant="8" symbolic="YES" id="dzE-Ob-SVG"/>
|
||||
<constraint firstAttribute="bottom" secondItem="4Vi-cN-ude" secondAttribute="bottom" constant="20" symbolic="YES" id="ny2-RO-bEI"/>
|
||||
<constraint firstAttribute="bottom" secondItem="kkd-UV-SnA" secondAttribute="bottom" id="oCP-dn-6dx"/>
|
||||
<constraint firstItem="JSZ-x8-Pqi" firstAttribute="top" secondItem="SNw-oQ-bnb" secondAttribute="bottom" constant="5" id="sX3-MK-14k"/>
|
||||
<constraint firstItem="Uib-vA-HRc" firstAttribute="top" secondItem="DAh-br-Dfx" secondAttribute="bottom" constant="15" id="tWI-S8-17J"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="DAh-br-Dfx" secondAttribute="trailing" constant="20" symbolic="YES" id="vDR-5D-1eN"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="buttonCancel" destination="nC0-dk-QaF" id="n5Q-jg-UCe"/>
|
||||
<outlet property="buttonCreateProxy" destination="4Vi-cN-ude" id="rdK-xc-N7F"/>
|
||||
<outlet property="buttonSecure" destination="rJa-yg-nCn" id="WIs-zt-f3a"/>
|
||||
<outlet property="inputDomainName" destination="SNw-oQ-bnb" id="ELH-63-cAe"/>
|
||||
<outlet property="inputProxySubject" destination="QCK-Z9-w7g" id="76U-te-Jzt"/>
|
||||
<outlet property="previewText" destination="JSZ-x8-Pqi" id="Mve-6W-Owd"/>
|
||||
<outlet property="textFieldDomainName" destination="mlA-Zt-Hu8" id="cHL-Yu-Yvx"/>
|
||||
<outlet property="textFieldError" destination="w0k-CK-0u4" id="28h-bn-igB"/>
|
||||
<outlet property="textFieldProxySubject" destination="Uib-vA-HRc" id="5tV-3l-Wbw"/>
|
||||
<outlet property="textFieldSecure" destination="5x7-ll-2f7" id="NlV-g8-rYP"/>
|
||||
<outlet property="textFieldTitle" destination="DAh-br-Dfx" id="8SA-EW-wcq"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<customObject id="VaP-ZM-OcY" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="220" y="1522"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="5Gf-7O-tdA">
|
||||
<objects>
|
||||
<windowController storyboardIdentifier="addProxyWindow" id="ogq-ok-UVi" sceneMemberID="viewController">
|
||||
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="SMz-Va-x2z">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
|
||||
<view key="contentView" id="HsN-qQ-BhO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="ogq-ok-UVi" id="9CA-sB-ZTD"/>
|
||||
</connections>
|
||||
</window>
|
||||
<connections>
|
||||
<segue destination="dwh-CF-6iv" kind="relationship" relationship="window.shadowedContentViewController" id="My6-qb-eRg"/>
|
||||
</connections>
|
||||
</windowController>
|
||||
<customObject id="5qP-qX-rbc" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-374" y="1530"/>
|
||||
</scene>
|
||||
<!--SelectionVC-->
|
||||
<scene sceneID="UXm-Ci-yEB">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="addDomainChoice" id="gOD-Gu-zDG" customClass="SelectionVC" customModule="PHP_Monitor" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" id="ysc-sm-sli">
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="181"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<visualEffectView blendingMode="behindWindow" material="toolTip" state="followsWindowActiveState" translatesAutoresizingMaskIntoConstraints="NO" id="F37-zt-gM3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="540" height="181"/>
|
||||
<subviews>
|
||||
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="FhN-AM-SkI">
|
||||
<rect key="frame" x="20" y="20" width="104" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="LxP-t4-H2W">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="Zhu-D8-cLK"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<action selector="pressedCancel:" target="gOD-Gu-zDG" id="wMp-sM-0A4"/>
|
||||
</connections>
|
||||
</button>
|
||||
<stackView distribution="fill" orientation="horizontal" alignment="top" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pYe-Qu-qnK">
|
||||
<rect key="frame" x="167" y="20" width="353" height="24"/>
|
||||
<subviews>
|
||||
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="L5n-Gw-J27">
|
||||
<rect key="frame" x="0.0" y="0.0" width="168" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Create a Link" bezelStyle="rounded" image="IconLinked" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="8UP-Sw-TP6">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent">l</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedCreateLink:" target="gOD-Gu-zDG" id="77M-Ip-GMi"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="01Z-IV-hv1">
|
||||
<rect key="frame" x="176" y="0.0" width="177" height="24"/>
|
||||
<buttonCell key="cell" type="push" title="[i18n] Create a Proxy" bezelStyle="rounded" image="IconProxy" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="bJ4-q8-1Ej">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent">p</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="pressedCreateProxy:" target="gOD-Gu-zDG" id="UDf-lD-KCS"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<visibilityPriorities>
|
||||
<integer value="1000"/>
|
||||
<integer value="1000"/>
|
||||
</visibilityPriorities>
|
||||
<customSpacing>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
</customSpacing>
|
||||
</stackView>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
|
||||
<rect key="frame" x="18" y="142" width="504" height="19"/>
|
||||
<textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd">
|
||||
<font key="font" metaFont="systemBold" size="15"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
|
||||
<rect key="frame" x="18" y="64" width="504" height="70"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/>
|
||||
</constraints>
|
||||
<textFieldCell key="cell" selectable="YES" alignment="left" id="3i9-RG-Ift">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<string key="title">[i18n] Links are used to directly serve projects. If you have a Laravel, Symfony, WordPress, etc. folder with code, you'll want to create a link and choose the folder where your code lives.
If you are in need of a proxy, you can proxy e.g. a container to a particular domain name. This can be useful in combination with Docker, for example.</string>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="FhN-AM-SkI" firstAttribute="leading" secondItem="F37-zt-gM3" secondAttribute="leading" constant="20" symbolic="YES" id="3dg-JM-MDr"/>
|
||||
<constraint firstItem="fJK-Ke-IK3" firstAttribute="top" secondItem="F37-zt-gM3" secondAttribute="top" constant="20" symbolic="YES" id="FbX-Le-O7Q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pYe-Qu-qnK" secondAttribute="trailing" constant="20" symbolic="YES" id="IJA-vN-Rbv"/>
|
||||
<constraint firstItem="urj-Xq-TrJ" firstAttribute="leading" secondItem="fJK-Ke-IK3" secondAttribute="leading" id="JcY-ae-6ZH"/>
|
||||
<constraint firstItem="urj-Xq-TrJ" firstAttribute="trailing" secondItem="fJK-Ke-IK3" secondAttribute="trailing" id="ZBI-pN-kOz"/>
|
||||
<constraint firstItem="fJK-Ke-IK3" firstAttribute="leading" secondItem="F37-zt-gM3" secondAttribute="leading" constant="20" symbolic="YES" id="d4o-6b-Dho"/>
|
||||
<constraint firstItem="urj-Xq-TrJ" firstAttribute="top" secondItem="fJK-Ke-IK3" secondAttribute="bottom" constant="8" symbolic="YES" id="hOk-eL-Eg0"/>
|
||||
<constraint firstItem="FhN-AM-SkI" firstAttribute="top" secondItem="urj-Xq-TrJ" secondAttribute="bottom" constant="20" id="kCc-Vp-Gvq"/>
|
||||
<constraint firstAttribute="bottom" secondItem="pYe-Qu-qnK" secondAttribute="bottom" constant="20" id="lPX-ZF-XZN"/>
|
||||
<constraint firstAttribute="trailing" secondItem="fJK-Ke-IK3" secondAttribute="trailing" constant="20" symbolic="YES" id="spl-Bn-xtw"/>
|
||||
<constraint firstAttribute="bottom" secondItem="FhN-AM-SkI" secondAttribute="bottom" constant="20" symbolic="YES" id="t5w-aL-tOa"/>
|
||||
<constraint firstItem="pYe-Qu-qnK" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="FhN-AM-SkI" secondAttribute="trailing" constant="8" symbolic="YES" id="y7k-sl-xqe"/>
|
||||
</constraints>
|
||||
</visualEffectView>
|
||||
<button fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="cNh-Wc-ADk">
|
||||
<rect key="frame" x="200" y="113" width="0.0" height="48"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<buttonCell key="cell" type="square" bezelStyle="shadowlessSquare" imagePosition="only" alignment="center" imageScaling="proportionallyUpOrDown" inset="2" id="OQ5-hX-qai">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="F37-zt-gM3" secondAttribute="trailing" id="ZRD-3j-s4x"/>
|
||||
<constraint firstAttribute="bottom" secondItem="F37-zt-gM3" secondAttribute="bottom" id="et1-At-Rgj"/>
|
||||
<constraint firstItem="F37-zt-gM3" firstAttribute="top" secondItem="ysc-sm-sli" secondAttribute="top" id="jp3-eE-mOy"/>
|
||||
<constraint firstItem="F37-zt-gM3" firstAttribute="leading" secondItem="ysc-sm-sli" secondAttribute="leading" id="wIo-zP-KId"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="buttonCancel" destination="FhN-AM-SkI" id="iqV-2E-q7e"/>
|
||||
<outlet property="buttonCreateLink" destination="L5n-Gw-J27" id="SHV-4l-Red"/>
|
||||
<outlet property="buttonCreateProxy" destination="01Z-IV-hv1" id="J1v-7J-4fx"/>
|
||||
<outlet property="textFieldDescription" destination="urj-Xq-TrJ" id="u1w-O0-kI3"/>
|
||||
<outlet property="textFieldTitle" destination="fJK-Ke-IK3" id="x8p-qx-HX4"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<customObject id="bZa-dD-d4J" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="250" y="1900"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="HW6-nV-trE">
|
||||
<objects>
|
||||
<windowController storyboardIdentifier="showSelectionWindow" id="t4x-Mh-iya" sceneMemberID="viewController">
|
||||
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="IeW-fo-4yK">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
|
||||
<view key="contentView" id="Oe0-yv-Jcy">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="t4x-Mh-iya" id="4oO-gI-bd2"/>
|
||||
</connections>
|
||||
</window>
|
||||
<connections>
|
||||
<segue destination="gOD-Gu-zDG" kind="relationship" relationship="window.shadowedContentViewController" id="KRt-OH-8uc"/>
|
||||
</connections>
|
||||
</windowController>
|
||||
<customObject id="hBK-Bw-dwa" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-374" y="1909"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Checkmark" width="512" height="512"/>
|
||||
<image name="IconLinked" width="25" height="25"/>
|
||||
<image name="IconProxy" width="25" height="25"/>
|
||||
<image name="Lock" width="30" height="30"/>
|
||||
<image name="arrow.clockwise" catalog="system" width="14" height="16"/>
|
||||
<image name="plus" catalog="system" width="14" height="13"/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import Foundation
|
||||
*/
|
||||
struct EnvironmentCheck {
|
||||
let command: (_ container: Container) async -> Bool
|
||||
let fixCommand: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)?
|
||||
let fixDescription: String?
|
||||
let name: String
|
||||
let titleText: String
|
||||
let subtitleText: String
|
||||
@@ -23,6 +25,8 @@ struct EnvironmentCheck {
|
||||
|
||||
init(
|
||||
command: @escaping (_ container: Container) async -> Bool,
|
||||
fix: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)? = nil,
|
||||
fixDescription: String? = nil,
|
||||
name: String,
|
||||
titleText: String,
|
||||
subtitleText: String,
|
||||
@@ -31,6 +35,8 @@ struct EnvironmentCheck {
|
||||
requiresAppRestart: Bool = false,
|
||||
) {
|
||||
self.command = command
|
||||
self.fixCommand = fix
|
||||
self.fixDescription = fixDescription
|
||||
self.name = name
|
||||
self.titleText = titleText
|
||||
self.subtitleText = subtitleText
|
||||
|
||||
@@ -19,8 +19,7 @@ class FakeServicesManager: ServicesManager {
|
||||
init(
|
||||
_ container: Container,
|
||||
formulae: [String] = ["php", "nginx", "dnsmasq"],
|
||||
status: Service.Status = .active,
|
||||
loading: Bool = false
|
||||
status: Service.Status = .active
|
||||
) {
|
||||
super.init(container)
|
||||
|
||||
@@ -32,14 +31,6 @@ class FakeServicesManager: ServicesManager {
|
||||
|
||||
self.services = []
|
||||
self.reapplyServices()
|
||||
|
||||
if loading {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.firstRunComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
private func reapplyServices() {
|
||||
|
||||
@@ -17,8 +17,6 @@ class ServicesManager: ObservableObject {
|
||||
|
||||
@Published var services = [Service]()
|
||||
|
||||
@Published var firstRunComplete: Bool = false
|
||||
|
||||
init(_ container: Container) {
|
||||
self.container = container
|
||||
|
||||
@@ -65,7 +63,7 @@ class ServicesManager: ObservableObject {
|
||||
}
|
||||
|
||||
public var hasError: Bool {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
if self.services.isEmpty {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,7 +73,7 @@ class ServicesManager: ObservableObject {
|
||||
}
|
||||
|
||||
public var statusMessage: String {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
if self.services.isEmpty {
|
||||
return "phpman.services.loading".localized
|
||||
}
|
||||
|
||||
@@ -95,7 +93,7 @@ class ServicesManager: ObservableObject {
|
||||
}
|
||||
|
||||
public var statusColor: Color {
|
||||
if self.services.isEmpty || !self.firstRunComplete {
|
||||
if self.services.isEmpty {
|
||||
return Color("StatusColorYellow")
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ actor ValetServicesDataManager {
|
||||
? "sudo \(self.container.paths.brew) services info --all --json"
|
||||
: "\(self.container.paths.brew) services info --all --json"
|
||||
|
||||
let output = await self.container.shell.pipe(command).out
|
||||
let output = await self.container.shell.pipe(command, timeout: .seconds(10)).out
|
||||
|
||||
guard let jsonData = output.data(using: .utf8) else {
|
||||
Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.")
|
||||
|
||||
@@ -16,14 +16,6 @@ class ValetServicesManager: ServicesManager {
|
||||
override init(_ container: Container) {
|
||||
self.data = ValetServicesDataManager(container)
|
||||
super.init(container)
|
||||
|
||||
// Load the initial services state
|
||||
Task {
|
||||
await self.reloadServicesStatus()
|
||||
await MainActor.run {
|
||||
firstRunComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadServicesStatus() async {
|
||||
|
||||
52
phpmon/Domain/App/Startup+Alert.swift
Normal file
52
phpmon/Domain/App/Startup+Alert.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -100,20 +100,20 @@ extension Startup {
|
||||
|
||||
// A non-default TLD is not officially supported since Valet 3.2.x
|
||||
Valet.shared.notifyAboutUnsupportedTLD()
|
||||
|
||||
// Determine which services are running
|
||||
await ServicesManager.shared.reloadServicesStatus()
|
||||
|
||||
// Find out which services are active
|
||||
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
|
||||
}
|
||||
|
||||
// Keep track of which PHP versions are currently about to release
|
||||
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
|
||||
|
||||
// Find out which services are active
|
||||
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
|
||||
|
||||
// We are ready!
|
||||
// Internals are ready!
|
||||
container.phpEnvs.isBusy = false
|
||||
|
||||
// Finally!
|
||||
Log.info("PHP Monitor is ready to serve!")
|
||||
|
||||
// Avoid showing the "startup timeout" alert
|
||||
Startup.invalidateTimeoutTimer()
|
||||
|
||||
@@ -122,6 +122,13 @@ extension Startup {
|
||||
|
||||
// Mark app as having successfully booted passing all checks
|
||||
Startup.hasFinishedBooting = true
|
||||
Log.info("PHP Monitor is ready to serve!")
|
||||
|
||||
// Process the last URL that arrived during startup
|
||||
if let url = App.shared.deferredURL {
|
||||
AppDelegate.instance.handleURLs([url])
|
||||
App.shared.deferredURL = nil
|
||||
}
|
||||
|
||||
// Enable the main menu item
|
||||
MainMenu.shared.statusItem.button?.isEnabled = true
|
||||
|
||||
@@ -22,6 +22,10 @@ extension Startup {
|
||||
|
||||
/** Starts the timeout timer that keeps track of how long the app takes to boot. */
|
||||
@MainActor func startStartupTimer() {
|
||||
// If we have a previous timer, invalidate it
|
||||
Self.startupTimer?.invalidate()
|
||||
|
||||
// Start timing; use current timestamp as "start"
|
||||
Self.launchTime = Date()
|
||||
Self.startupTimer = Timer.scheduledTimer(
|
||||
timeInterval: Constants.SlowBootThresholdInterval, target: self,
|
||||
|
||||
@@ -39,12 +39,21 @@ class Startup {
|
||||
let start = Measurement()
|
||||
if await check.succeeds() {
|
||||
Log.info("[PASS] \(check.name) (\(start.milliseconds) ms)")
|
||||
continue
|
||||
continue // continue to the next check!
|
||||
}
|
||||
|
||||
// If we get here, something's gone wrong and the check has failed...
|
||||
Log.info("[FAIL] \(check.name) (\(start.milliseconds) ms)")
|
||||
await showAlert(for: check)
|
||||
|
||||
// We will present the user with an option (potentially)
|
||||
let outcome = await showAlert(for: check)
|
||||
|
||||
// The fix ran and succeeded — continue to the next check
|
||||
if outcome == .shouldContinue {
|
||||
continue
|
||||
}
|
||||
|
||||
// No fix requested or fix failed — requires full restart
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
@@ -59,34 +68,6 @@ class Startup {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
Displays an alert for a particular check. There are two types of alerts:
|
||||
- ones that require an app restart, which prompt the user to exit the app
|
||||
- ones that allow the app to continue, which allow the user to retry
|
||||
*/
|
||||
@MainActor private func showAlert(for check: EnvironmentCheck) {
|
||||
if check.requiresAppRestart {
|
||||
NVAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: check.buttonText, action: { _ in
|
||||
exit(1)
|
||||
}).show(urgency: .bringToFront)
|
||||
}
|
||||
|
||||
NVAlert()
|
||||
.withInformation(
|
||||
title: check.titleText,
|
||||
subtitle: check.subtitleText,
|
||||
description: check.descriptionText
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show(urgency: .bringToFront)
|
||||
}
|
||||
|
||||
// MARK: - Check (List)
|
||||
|
||||
public var groups: [EnvironmentCheckGroup] = [
|
||||
@@ -118,6 +99,15 @@ class Startup {
|
||||
return await !container.shell
|
||||
.pipe("ls \(container.paths.optPath) | grep php").out.contains("php")
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let brew = container.paths.brew
|
||||
try await container.shell.attach(
|
||||
"\(brew) tap shivammathur/php && \(brew) install shivammathur/php/php",
|
||||
didReceiveOutput: didReceiveOutput,
|
||||
withTimeout: 120
|
||||
)
|
||||
},
|
||||
fixDescription: "brew tap shivammathur/php && brew install shivammathur/php/php",
|
||||
name: "`ls \(App.shared.container.paths.optPath) | grep php` returned php result",
|
||||
titleText: "startup.errors.php_opt.title".localized,
|
||||
subtitleText: "startup.errors.php_opt.subtitle".localized(
|
||||
@@ -132,6 +122,15 @@ class Startup {
|
||||
command: { container in
|
||||
return !container.filesystem.fileExists(container.paths.php)
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let brew = container.paths.brew
|
||||
try await container.shell.attach(
|
||||
"\(brew) link php",
|
||||
didReceiveOutput: didReceiveOutput,
|
||||
withTimeout: 120
|
||||
)
|
||||
},
|
||||
fixDescription: "brew link php",
|
||||
name: "`\(App.shared.container.paths.php)` exists",
|
||||
titleText: "startup.errors.php_binary.title".localized,
|
||||
subtitleText: "startup.errors.php_binary.subtitle".localized,
|
||||
@@ -142,9 +141,21 @@ class Startup {
|
||||
// =================================================================================
|
||||
EnvironmentCheck(
|
||||
command: { container in
|
||||
if container.phpEnvs.currentInstall == nil {
|
||||
container.phpEnvs.currentInstall = ActivePhpInstallation.load(container)
|
||||
}
|
||||
return await container.shell.pipe("\(container.paths.binPath)/php -v").err
|
||||
.contains("Library not loaded")
|
||||
.contains("Library not loaded") && container.phpEnvs.currentInstall != nil
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let brew = container.paths.brew
|
||||
try await container.shell.attach(
|
||||
"\(brew) tap shivammathur/php && \(brew) reinstall shivammathur/php/php && \(brew) link php",
|
||||
didReceiveOutput: didReceiveOutput,
|
||||
withTimeout: 120
|
||||
)
|
||||
},
|
||||
fixDescription: "brew reinstall php && brew link php",
|
||||
name: "no `dyld` issue (`Library not loaded`) detected",
|
||||
titleText: "startup.errors.dyld_library.title".localized,
|
||||
subtitleText: "startup.errors.dyld_library.subtitle".localized(
|
||||
@@ -176,6 +187,15 @@ class Startup {
|
||||
await container.phpEnvs.determinePhpAlias()
|
||||
return PhpEnvironments.brewPhpAlias == nil
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let brew = container.paths.brew
|
||||
try await container.shell.attach(
|
||||
"\(brew) update",
|
||||
didReceiveOutput: didReceiveOutput,
|
||||
withTimeout: 120
|
||||
)
|
||||
},
|
||||
fixDescription: "brew update",
|
||||
name: "`brew` alias is not nil and valid",
|
||||
titleText: "startup.errors.could_not_determine_alias.title".localized,
|
||||
subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized,
|
||||
@@ -209,6 +229,12 @@ class Startup {
|
||||
.pipe("cat /private/etc/sudoers.d/brew")
|
||||
.out.contains(container.paths.brew)
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let valet = container.paths.binPath.appending("/valet")
|
||||
let result = try AppleScript.runShellAsAdmin("\(valet) trust")
|
||||
didReceiveOutput(result, .stdOut)
|
||||
},
|
||||
fixDescription: "valet trust",
|
||||
name: "`/private/etc/sudoers.d/brew` contains brew",
|
||||
titleText: "startup.errors.sudoers_brew.title".localized,
|
||||
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
|
||||
@@ -231,6 +257,15 @@ class Startup {
|
||||
command: { container in
|
||||
return !container.filesystem.directoryExists("~/.config/valet")
|
||||
},
|
||||
fix: { container, didReceiveOutput in
|
||||
let valet = container.paths.binPath.appending("/valet")
|
||||
try await container.shell.attach(
|
||||
"\(valet) install",
|
||||
didReceiveOutput: didReceiveOutput,
|
||||
withTimeout: 120
|
||||
)
|
||||
},
|
||||
fixDescription: "valet install",
|
||||
name: "`.config/valet` not empty (Valet installed)",
|
||||
titleText: "startup.errors.valet_not_installed.title".localized,
|
||||
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,
|
||||
|
||||
78
phpmon/Domain/App/WindowManager.swift
Normal file
78
phpmon/Domain/App/WindowManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,10 @@ import NVAlert
|
||||
let (process, _) = try await container.shell.attach(
|
||||
command,
|
||||
didReceiveOutput: { [weak self] (incoming, _) in
|
||||
Task { @MainActor in
|
||||
guard let window = self?.window else { return }
|
||||
window.addToConsole(incoming)
|
||||
}
|
||||
},
|
||||
withTimeout: .minutes(5)
|
||||
)
|
||||
|
||||
@@ -39,18 +39,8 @@ class BrewPermissionFixer {
|
||||
return
|
||||
}
|
||||
|
||||
let appleScript = NSAppleScript(
|
||||
source: "do shell script \"\(buildBrokenFormulaeScript())\" with administrator privileges"
|
||||
)
|
||||
|
||||
let eventResult: NSAppleEventDescriptor? = appleScript?
|
||||
.executeAndReturnError(nil)
|
||||
|
||||
if eventResult == nil {
|
||||
throw HomebrewPermissionError(
|
||||
kind: .applescriptNilError
|
||||
)
|
||||
}
|
||||
let script = buildBrokenFormulaeScript()
|
||||
try AppleScript.runSimpleShellAsAdmin(script)
|
||||
|
||||
Log.info("Ownership was taken of the folder(s) at: " + broken
|
||||
.map({ $0.path })
|
||||
|
||||
@@ -33,9 +33,9 @@ extension BrewCommand {
|
||||
}
|
||||
if text.contains("==> Installing") {
|
||||
if let subject = extractContext(from: text) {
|
||||
return (0.60, "phpman.steps.installing".localized + "\n(\(subject))")
|
||||
return (0.60, "phpman.steps.installing_package".localized + "\n(\(subject))")
|
||||
}
|
||||
return (0.60, "phpman.steps.installing".localized)
|
||||
return (0.60, "phpman.steps.installing_package".localized)
|
||||
}
|
||||
if text.contains("==> Downloading") {
|
||||
if let subject = extractContext(from: text) {
|
||||
|
||||
@@ -71,7 +71,7 @@ class RemovePhpExtensionCommand: BrewCommand {
|
||||
await performExtensionCleanup(for: ext)
|
||||
}
|
||||
|
||||
_ = await container.phpEnvs.detectPhpVersions()
|
||||
await container.phpEnvs.detectPhpVersions()
|
||||
|
||||
await Actions(container).restartPhpFpm(version: phpExtension.phpVersion)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class ModifyPhpVersionCommand: BrewCommand {
|
||||
}
|
||||
|
||||
// Re-check the installed versions
|
||||
_ = await container.phpEnvs.detectPhpVersions()
|
||||
await container.phpEnvs.detectPhpVersions()
|
||||
|
||||
// After performing operations, attempt to run repairs if needed
|
||||
try await self.repairBrokenPackages(onProgress)
|
||||
@@ -186,7 +186,7 @@ class ModifyPhpVersionCommand: BrewCommand {
|
||||
await BrewDiagnostics.shared.checkForOutdatedPhpInstallationSymlinks()
|
||||
|
||||
// Check which version of PHP are now installed
|
||||
_ = await container.phpEnvs.detectPhpVersions()
|
||||
await container.phpEnvs.detectPhpVersions()
|
||||
|
||||
// Keep track of the currently installed version
|
||||
await MainMenu.shared.refreshActiveInstallation()
|
||||
|
||||
@@ -74,7 +74,7 @@ class RemovePhpVersionCommand: BrewCommand {
|
||||
if process.terminationStatus == 0 {
|
||||
onProgress(.create(value: 0.95, title: getCommandTitle(), description: "phpman.steps.reloading".localized))
|
||||
|
||||
_ = await container.phpEnvs.detectPhpVersions()
|
||||
await container.phpEnvs.detectPhpVersions()
|
||||
|
||||
await MainMenu.shared.refreshActiveInstallation()
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import Foundation
|
||||
|
||||
class Packagist {
|
||||
static func getLatestStableVersion(packageName: String) async throws -> VersionNumber {
|
||||
guard let url = URL(string: "https://repo.packagist.org/p2/\(packageName).json") else {
|
||||
guard let encodedName = packageName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||
let url = URL(string: "https://repo.packagist.org/p2/\(encodedName).json") else {
|
||||
throw PackagistError.invalidURL
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class ValetUpgrader {
|
||||
}
|
||||
|
||||
@MainActor private static func notifyAboutCompletion() {
|
||||
return NVAlert().withInformation(
|
||||
NVAlert().withInformation(
|
||||
title: "valet_upgraded.title".localized,
|
||||
subtitle: "valet_upgraded.subtitle".localized,
|
||||
description: "valet_upgraded.description".localized,
|
||||
@@ -85,7 +85,7 @@ class ValetUpgrader {
|
||||
}
|
||||
|
||||
@MainActor private static func notifyAboutUpgrade(latest: String, constraint: String, passing: Bool) {
|
||||
let alert = NVAlert().withInformation(
|
||||
return NVAlert().withInformation(
|
||||
title: "valet_upgrade_available.title".localized,
|
||||
subtitle: "valet_upgrade_available.subtitle".localized(latest),
|
||||
description: passing
|
||||
@@ -97,13 +97,9 @@ class ValetUpgrader {
|
||||
ValetUpgrader.upgradeValet()
|
||||
})
|
||||
.withSecondary(text: "valet_upgrade_available.cancel".localized)
|
||||
|
||||
if !passing {
|
||||
_ = alert.withTertiary(text: "valet_upgrade_available.open_composer".localized, action: { _ in
|
||||
.withTertiary(if: !passing, text: "valet_upgrade_available.open_composer".localized, action: { _ in
|
||||
MainMenu.shared.openGlobalComposerFolder()
|
||||
})
|
||||
}
|
||||
|
||||
alert.show(urgency: .bringToFront)
|
||||
.show(urgency: .bringToFront)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ class ValetInteractor {
|
||||
// MARK: - Managing Domains
|
||||
|
||||
public func link(path: String, domain: String) async throws {
|
||||
await container.shell.quiet("cd '\(path)' && \(container.paths.valet) link '\(domain)' && valet links")
|
||||
await container.shell.pipe("cd '\(path)' && \(container.paths.valet) link '\(domain)' && valet links")
|
||||
}
|
||||
|
||||
public func unlink(site: ValetSite) async throws {
|
||||
await container.shell.quiet("valet unlink '\(site.name)'")
|
||||
await container.shell.pipe("valet unlink '\(site.name)'")
|
||||
}
|
||||
|
||||
public func proxy(domain: String, proxy: String, secure: Bool) async throws {
|
||||
@@ -46,12 +46,12 @@ class ValetInteractor {
|
||||
? "\(container.paths.valet) proxy \(domain) \(proxy) --secure"
|
||||
: "\(container.paths.valet) proxy \(domain) \(proxy)"
|
||||
|
||||
await container.shell.quiet(command)
|
||||
await container.shell.pipe(command)
|
||||
await Actions(container).restartNginx()
|
||||
}
|
||||
|
||||
public func remove(proxy: ValetProxy) async throws {
|
||||
await container.shell.quiet("valet unproxy '\(proxy.domain)'")
|
||||
await container.shell.pipe("valet unproxy '\(proxy.domain)'")
|
||||
}
|
||||
|
||||
// MARK: - Modifying Domains
|
||||
@@ -73,7 +73,7 @@ class ValetInteractor {
|
||||
}
|
||||
|
||||
// Run the command
|
||||
await container.shell.quiet(command)
|
||||
await container.shell.pipe(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineSecured()
|
||||
@@ -98,7 +98,7 @@ class ValetInteractor {
|
||||
|
||||
// Run the commands
|
||||
for command in commands {
|
||||
await container.shell.quiet(command)
|
||||
await container.shell.pipe(command)
|
||||
}
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
@@ -117,7 +117,7 @@ class ValetInteractor {
|
||||
let command = "sudo \(container.paths.valet) isolate php@\(version) --site '\(site.name)'"
|
||||
|
||||
// Run the command
|
||||
await container.shell.quiet(command)
|
||||
await container.shell.pipe(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineIsolated()
|
||||
@@ -133,7 +133,7 @@ class ValetInteractor {
|
||||
let command = "sudo \(container.paths.valet) unisolate --site '\(site.name)'"
|
||||
|
||||
// Run the command
|
||||
await container.shell.quiet(command)
|
||||
await container.shell.pipe(command)
|
||||
|
||||
// Check if the secured status has actually changed
|
||||
site.determineIsolated()
|
||||
|
||||
@@ -24,7 +24,7 @@ extension Valet {
|
||||
)
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.withTertiary(text: "alert.do_not_tell_again".localized, action: { alert in
|
||||
Preferences.update(.warnAboutNonStandardTLD, value: false)
|
||||
Preferences.update(.warnAboutNonStandardTLD, value: false, notify: true)
|
||||
alert.close(with: .alertThirdButtonReturn)
|
||||
})
|
||||
.show(urgency: .urgentRequestAttention)
|
||||
|
||||
@@ -56,7 +56,7 @@ extension MainMenu {
|
||||
.withPrimary(text: "generic.ok".localized)
|
||||
.show(urgency: .bringToFront)
|
||||
} failure: { error in
|
||||
NVAlert.show(for: error as! HomebrewPermissionError)
|
||||
NVAlert.show(for: error as! AdminPrivilegeError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ extension MainMenu {
|
||||
}
|
||||
|
||||
private func reloadDomainListData() async {
|
||||
if let window = App.shared.domainListWindowController {
|
||||
if let window = WindowManager.controller(of: DomainListWC.self) {
|
||||
await window.contentVC.reloadDomains()
|
||||
} else {
|
||||
await Valet.shared.reloadSites()
|
||||
|
||||
@@ -138,6 +138,12 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showCommandHistory() {
|
||||
Task { @MainActor in
|
||||
CommandHistoryWindowController.show()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showIncompatiblePhpVersionsAlert() {
|
||||
Task { @MainActor in
|
||||
NVAlert().withInformation(
|
||||
|
||||
@@ -325,7 +325,8 @@ extension StatusMenu {
|
||||
// FIRST AID
|
||||
HeaderView.asMenuItem(text: "mi_first_aid".localized),
|
||||
NSMenuItem(title: "mi_view_onboarding".localized, action: #selector(MainMenu.showWelcomeTour)),
|
||||
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings))
|
||||
NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings)),
|
||||
NSMenuItem(title: "mi_view_command_history".localized, action: #selector(MainMenu.showCommandHistory))
|
||||
]
|
||||
|
||||
if Valet.installed {
|
||||
|
||||
@@ -47,6 +47,11 @@ class PhpGuard {
|
||||
// At this point, the version is *not* a match
|
||||
Log.info("PHP Guard noticed a different PHP version. An alert will be displayed!")
|
||||
|
||||
// Exit early if we're running tests; PHP Guard may interfere
|
||||
if isRunningSwiftUIPreview || isRunningTests {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
NVAlert()
|
||||
.withInformation(
|
||||
|
||||
@@ -14,14 +14,6 @@ struct CustomPrefs: Decodable {
|
||||
let services: [String]?
|
||||
let environmentVariables: [String: String]?
|
||||
|
||||
var exportAsString: String {
|
||||
return self.environmentVariables!
|
||||
.map { (key, value) in
|
||||
return "export \(key)=\(value)"
|
||||
}
|
||||
.joined(separator: "&&")
|
||||
}
|
||||
|
||||
public func hasPresets() -> Bool {
|
||||
return self.presets != nil && !self.presets!.isEmpty
|
||||
}
|
||||
@@ -31,7 +23,11 @@ struct CustomPrefs: Decodable {
|
||||
}
|
||||
|
||||
public func hasEnvironmentVariables() -> Bool {
|
||||
return self.environmentVariables != nil && !self.environmentVariables!.keys.isEmpty
|
||||
guard let variables = self.environmentVariables else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !variables.isEmpty
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -45,36 +41,39 @@ struct CustomPrefs: Decodable {
|
||||
extension Preferences {
|
||||
func loadCustomPreferences() async {
|
||||
// Ensure the configuration directory is created if missing
|
||||
await container.shell.quiet("mkdir -p ~/.config/phpmon")
|
||||
await container.shell.pipe("mkdir -p ~/.config/phpmon")
|
||||
|
||||
// Move the legacy file
|
||||
await moveOutdatedConfigurationFile()
|
||||
|
||||
// Attempt to load the file if it exists
|
||||
let url = URL(fileURLWithPath: "\(container.paths.homePath)/.config/phpmon/config.json")
|
||||
if container.filesystem.fileExists(url.path) {
|
||||
|
||||
if container.filesystem.fileExists("~/.config/phpmon/config.json") {
|
||||
Log.info("A custom ~/.config/phpmon/config.json file was found. Attempting to parse...")
|
||||
loadCustomPreferencesFile(url)
|
||||
loadCustomPreferencesFile()
|
||||
} else {
|
||||
Log.info("There was no /.config/phpmon/config.json file to be loaded.")
|
||||
}
|
||||
}
|
||||
|
||||
func moveOutdatedConfigurationFile() async {
|
||||
if container.filesystem.fileExists("~/.phpmon.conf.json") && !container.filesystem.fileExists("~/.config/phpmon/config.json") {
|
||||
if container.filesystem.fileExists("~/.phpmon.conf.json")
|
||||
&& !container.filesystem.fileExists("~/.config/phpmon/config.json") {
|
||||
Log.info("An outdated configuration file was found. Moving it...")
|
||||
await container.shell.quiet("cp ~/.phpmon.conf.json ~/.config/phpmon/config.json")
|
||||
await container.shell.pipe("cp ~/.phpmon.conf.json ~/.config/phpmon/config.json")
|
||||
Log.info("The configuration file was copied successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
func loadCustomPreferencesFile(_ url: URL) {
|
||||
do {
|
||||
customPreferences = try JSONDecoder().decode(
|
||||
CustomPrefs.self,
|
||||
from: try! String(contentsOf: url, encoding: .utf8).data(using: .utf8)!
|
||||
)
|
||||
func loadCustomPreferencesFile() {
|
||||
guard let data = try? container.filesystem.getStringFromFile("~/.config/phpmon/config.json").data(using: .utf8) else {
|
||||
Log.warn("The ~/.config/phpmon/config.json file could not be read as UTF-8.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let customPreferences = try? JSONDecoder().decode(CustomPrefs.self, from: data) else {
|
||||
Log.warn("The ~/.config/phpmon/config.json file seems to be malformed.")
|
||||
return
|
||||
}
|
||||
|
||||
Log.info("The ~/.config/phpmon/config.json file was successfully parsed.")
|
||||
|
||||
@@ -87,13 +86,13 @@ extension Preferences {
|
||||
}
|
||||
|
||||
if customPreferences.hasEnvironmentVariables() {
|
||||
let exports = customPreferences.environmentVariables ?? [:]
|
||||
|
||||
Log.info("Configuring the additional exports...")
|
||||
if let shell = App.shared.container.shell as? RealShell {
|
||||
shell.exports = customPreferences.exportAsString
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.warn("The ~/.config/phpmon/config.json file seems to be missing or malformed.")
|
||||
Log.info("Custom exports: \(exports.description)")
|
||||
|
||||
// Assign the new exports values
|
||||
container.shell.exports = exports
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
/**
|
||||
These are the keys used for every preference in the app.
|
||||
To add a new key, undertake the following steps:
|
||||
|
||||
- Declare a new enum value with a string representation.
|
||||
- Update the mapping below to specify if it's a boolean, string or other.
|
||||
- Go to `Preferences` and update `handleFirstTimeLaunch` to set a default.
|
||||
- Add the preference to `GeneralPreferencesVC` in the correct class.
|
||||
*/
|
||||
enum PreferenceName: String, Codable {
|
||||
// GENERAL
|
||||
@@ -23,6 +29,7 @@ enum PreferenceName: String, Codable {
|
||||
case shouldDisplayDynamicIcon = "use_dynamic_icon"
|
||||
case iconTypeToDisplay = "icon_type_to_display"
|
||||
case fullPhpVersionDynamicIcon = "full_php_in_menu_bar"
|
||||
case hideIconsInMenu = "hide_icons_in_menu"
|
||||
|
||||
// WARNINGS
|
||||
case warnAboutNonStandardTLD = "warn_about_non_standard_tld"
|
||||
@@ -53,14 +60,17 @@ enum PreferenceName: String, Codable {
|
||||
static var mapping: [PreferenceType: [PreferenceName]] = [
|
||||
.boolean: [
|
||||
// Preferences
|
||||
.shouldDisplayDynamicIcon,
|
||||
.fullPhpVersionDynamicIcon,
|
||||
.autoServiceRestartAfterExtensionToggle,
|
||||
.autoComposerGlobalUpdateAfterSwitch,
|
||||
.allowProtocolForIntegrations,
|
||||
.automaticBackgroundUpdateCheck,
|
||||
.showPhpDoctorSuggestions,
|
||||
|
||||
// Appearance
|
||||
.shouldDisplayDynamicIcon,
|
||||
.fullPhpVersionDynamicIcon,
|
||||
.hideIconsInMenu,
|
||||
|
||||
// Notifications
|
||||
.warnAboutNonStandardTLD,
|
||||
.notifyAboutVersionChange,
|
||||
@@ -111,6 +121,7 @@ enum PersistentAppState: String {
|
||||
case lastAutomaticUpdateCheck = "last_automatic_update_check"
|
||||
case userFavorites = "user_favorites"
|
||||
case updateCheckFailureCount = "update_check_failure_count"
|
||||
case didPromptForIntegrations = "did_prompt_for_integrations"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
95
phpmon/Domain/Preferences/PreferenceVC+WindowRestore.swift
Normal file
95
phpmon/Domain/Preferences/PreferenceVC+WindowRestore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// PreferencesVC.swift
|
||||
// PreferenceVC.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 30/03/2021.
|
||||
@@ -9,7 +9,7 @@
|
||||
import Cocoa
|
||||
import Carbon
|
||||
|
||||
class GenericPreferenceVC: NSViewController {
|
||||
class PreferenceVC: NSViewController {
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@@ -28,7 +28,8 @@ class GenericPreferenceVC: NSViewController {
|
||||
Log.perf("deinit: \(String(describing: self)).\(#function)")
|
||||
}
|
||||
|
||||
func addView(when condition: Bool, _ view: NSView) -> GenericPreferenceVC {
|
||||
@discardableResult
|
||||
func addView(when condition: Bool, _ view: NSView) -> PreferenceVC {
|
||||
if condition {
|
||||
self.views.append(view)
|
||||
}
|
||||
@@ -36,18 +37,6 @@ class GenericPreferenceVC: NSViewController {
|
||||
return self
|
||||
}
|
||||
|
||||
func getDynamicIconPV() -> NSView {
|
||||
return CheckboxPreferenceView.make(
|
||||
sectionText: "prefs.dynamic_icon".localized,
|
||||
descriptionText: "prefs.dynamic_icon_desc".localized,
|
||||
checkboxText: "prefs.dynamic_icon_title".localized,
|
||||
preference: .shouldDisplayDynamicIcon,
|
||||
action: {
|
||||
MainMenu.shared.refreshIcon()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func getLanguageOptionsPV() -> NSView {
|
||||
var options = Bundle.main.localizations
|
||||
.filter({ $0 != "Base"})
|
||||
@@ -65,17 +54,46 @@ class GenericPreferenceVC: NSViewController {
|
||||
options: options,
|
||||
preference: .languageOverride,
|
||||
action: {
|
||||
// Track which windows we will need to reopen
|
||||
let windowsToReopen = self.captureOpenWindowsForLanguageSwitch()
|
||||
|
||||
// Rebuild the menu
|
||||
MainMenu.shared.refreshIcon()
|
||||
MainMenu.shared.rebuild()
|
||||
|
||||
if let window = App.shared.preferencesWindowController?.window {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "alert.language_changed.title".localized
|
||||
alert.informativeText = "alert.language_changed.subtitle".localized
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "generic.ok".localized)
|
||||
alert.beginSheetModal(for: window)
|
||||
// Close all windows
|
||||
App.shared.invalidateCachedWindows()
|
||||
|
||||
// Re-open the preferences window controller
|
||||
WindowManager.close(PreferencesWC.self)
|
||||
PreferencesWindowController.show()
|
||||
|
||||
// Finally, open all other windows again
|
||||
self.reopenWindows(afterLanguageChange: windowsToReopen)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func getDynamicIconPV() -> NSView {
|
||||
return CheckboxPreferenceView.make(
|
||||
sectionText: "prefs.dynamic_icon".localized,
|
||||
descriptionText: "prefs.dynamic_icon_desc".localized,
|
||||
checkboxText: "prefs.dynamic_icon_title".localized,
|
||||
preference: .shouldDisplayDynamicIcon,
|
||||
action: {
|
||||
MainMenu.shared.refreshIcon()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func getMenuIconsPV() -> NSView {
|
||||
return CheckboxPreferenceView.make(
|
||||
sectionText: "prefs.hide_menu_icons".localized,
|
||||
descriptionText: "prefs.hide_menu_icons_desc".localized,
|
||||
checkboxText: "prefs.hide_menu_icons_title".localized,
|
||||
preference: .hideIconsInMenu,
|
||||
action: {
|
||||
MainMenu.shared.rebuild()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -265,4 +283,5 @@ class GenericPreferenceVC: NSViewController {
|
||||
listeningForHotkeyView = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -45,6 +45,33 @@ class Preferences {
|
||||
}
|
||||
}
|
||||
|
||||
static func registerPreferenceDefaults(_ configuration: [PreferenceName: Any]) {
|
||||
let tuple = configuration.map { (key: PreferenceName, value: Any) in
|
||||
return (key.rawValue, value)
|
||||
}
|
||||
|
||||
let defaults = Dictionary(uniqueKeysWithValues: tuple)
|
||||
UserDefaults.standard.register(defaults: defaults)
|
||||
}
|
||||
|
||||
static func registerPersistentAppStateDefaults(_ configuration: [PersistentAppState: Any]) {
|
||||
let tuple = configuration.map { (key: PersistentAppState, value: Any) in
|
||||
return (key.rawValue, value)
|
||||
}
|
||||
|
||||
let defaults = Dictionary(uniqueKeysWithValues: tuple)
|
||||
UserDefaults.standard.register(defaults: defaults)
|
||||
}
|
||||
|
||||
static func registerInternalAppStateDefaults(_ configuration: [InternalStats: Any]) {
|
||||
let tuple = configuration.map { (key: InternalStats, value: Any) in
|
||||
return (key.rawValue, value)
|
||||
}
|
||||
|
||||
let defaults = Dictionary(uniqueKeysWithValues: tuple)
|
||||
UserDefaults.standard.register(defaults: defaults)
|
||||
}
|
||||
|
||||
// MARK: - First Time Run
|
||||
|
||||
/**
|
||||
@@ -58,50 +85,53 @@ class Preferences {
|
||||
```
|
||||
*/
|
||||
static func handleFirstTimeLaunch() {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
self.registerPreferenceDefaults([
|
||||
/// Preferences: General
|
||||
PreferenceName.autoServiceRestartAfterExtensionToggle.rawValue: true,
|
||||
PreferenceName.autoComposerGlobalUpdateAfterSwitch.rawValue: false,
|
||||
PreferenceName.allowProtocolForIntegrations.rawValue: true,
|
||||
PreferenceName.automaticBackgroundUpdateCheck.rawValue: true,
|
||||
PreferenceName.showPhpDoctorSuggestions.rawValue: true,
|
||||
PreferenceName.languageOverride.rawValue: "",
|
||||
.autoServiceRestartAfterExtensionToggle: true,
|
||||
.autoComposerGlobalUpdateAfterSwitch: false,
|
||||
.allowProtocolForIntegrations: false,
|
||||
.automaticBackgroundUpdateCheck: true,
|
||||
.showPhpDoctorSuggestions: true,
|
||||
.languageOverride: "",
|
||||
|
||||
/// Preferences: Appearance
|
||||
PreferenceName.shouldDisplayDynamicIcon.rawValue: true,
|
||||
PreferenceName.iconTypeToDisplay.rawValue: MenuBarIcon.iconPhp.rawValue,
|
||||
PreferenceName.fullPhpVersionDynamicIcon.rawValue: false,
|
||||
.shouldDisplayDynamicIcon: true,
|
||||
.iconTypeToDisplay: MenuBarIcon.iconPhp.rawValue,
|
||||
.fullPhpVersionDynamicIcon: false,
|
||||
.hideIconsInMenu: false,
|
||||
|
||||
/// Preferences: Notifications
|
||||
PreferenceName.warnAboutNonStandardTLD.rawValue: true,
|
||||
PreferenceName.notifyAboutVersionChange.rawValue: true,
|
||||
PreferenceName.notifyAboutPhpFpmRestart.rawValue: true,
|
||||
PreferenceName.notifyAboutServices.rawValue: true,
|
||||
PreferenceName.notifyAboutPresets.rawValue: true,
|
||||
PreferenceName.notifyAboutSecureToggle.rawValue: true,
|
||||
PreferenceName.notifyAboutGlobalComposerStatus.rawValue: true,
|
||||
.warnAboutNonStandardTLD: true,
|
||||
.notifyAboutVersionChange: true,
|
||||
.notifyAboutPhpFpmRestart: true,
|
||||
.notifyAboutServices: true,
|
||||
.notifyAboutPresets: true,
|
||||
.notifyAboutSecureToggle: true,
|
||||
.notifyAboutGlobalComposerStatus: true,
|
||||
|
||||
/// Preferences: UI Preferences
|
||||
PreferenceName.displayDriver.rawValue: true,
|
||||
PreferenceName.displayGlobalVersionSwitcher.rawValue: true,
|
||||
PreferenceName.displayServicesManager.rawValue: true,
|
||||
PreferenceName.displayValetIntegration.rawValue: true,
|
||||
PreferenceName.displayPhpConfigFinder.rawValue: true,
|
||||
PreferenceName.displayComposerToolkit.rawValue: true,
|
||||
PreferenceName.displayLimitsWidget.rawValue: true,
|
||||
PreferenceName.displayExtensions.rawValue: true,
|
||||
PreferenceName.displayPresets.rawValue: true,
|
||||
PreferenceName.displayMisc.rawValue: true,
|
||||
.displayDriver: true,
|
||||
.displayGlobalVersionSwitcher: true,
|
||||
.displayServicesManager: true,
|
||||
.displayValetIntegration: true,
|
||||
.displayPhpConfigFinder: true,
|
||||
.displayComposerToolkit: true,
|
||||
.displayLimitsWidget: true,
|
||||
.displayExtensions: true,
|
||||
.displayPresets: true,
|
||||
.displayMisc: true
|
||||
])
|
||||
|
||||
/// Persistent App State
|
||||
PersistentAppState.lastAutomaticUpdateCheck.rawValue: 0,
|
||||
PersistentAppState.updateCheckFailureCount.rawValue: 0,
|
||||
registerPersistentAppStateDefaults([
|
||||
.lastAutomaticUpdateCheck: 0,
|
||||
.updateCheckFailureCount: 0
|
||||
])
|
||||
|
||||
/// Stats
|
||||
InternalStats.switchCount.rawValue: 0,
|
||||
InternalStats.launchCount.rawValue: 0,
|
||||
InternalStats.didSeeSponsorEncouragement.rawValue: false,
|
||||
InternalStats.lastGlobalPhpVersion.rawValue: ""
|
||||
registerInternalAppStateDefaults([
|
||||
.switchCount: 0,
|
||||
.launchCount: 0,
|
||||
.didSeeSponsorEncouragement: false,
|
||||
.lastGlobalPhpVersion: ""
|
||||
])
|
||||
|
||||
if UserDefaults.standard.bool(forKey: PersistentAppState.wasLaunchedBefore.rawValue) {
|
||||
@@ -168,7 +198,7 @@ class Preferences {
|
||||
})
|
||||
}
|
||||
|
||||
static func update(_ preference: PreferenceName, value: Any?) {
|
||||
static func update(_ preference: PreferenceName, value: Any?, notify: Bool = false) {
|
||||
if value == nil {
|
||||
UserDefaults.standard.removeObject(forKey: preference.rawValue)
|
||||
} else {
|
||||
@@ -178,5 +208,9 @@ class Preferences {
|
||||
|
||||
// Update the preferences cache in memory!
|
||||
App.shared.container.preferences.cachedPreferences = Preferences.cache()
|
||||
|
||||
if notify {
|
||||
NotificationCenter.default.post(name: Events.PreferencesUpdated, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class GeneralPreferencesVC: GenericPreferenceVC {
|
||||
class GeneralPreferencesVC: PreferenceVC {
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public static func fromStoryboard() -> GenericPreferenceVC {
|
||||
public static func fromStoryboard() -> PreferenceVC {
|
||||
let vc = NSStoryboard(name: "Main", bundle: nil)
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
|
||||
|
||||
return vc
|
||||
.addView(when: true, vc.getLanguageOptionsPV())
|
||||
@@ -29,25 +29,26 @@ class GeneralPreferencesVC: GenericPreferenceVC {
|
||||
}
|
||||
}
|
||||
|
||||
class AppearancePreferencesVC: GenericPreferenceVC {
|
||||
class AppearancePreferencesVC: PreferenceVC {
|
||||
|
||||
public static func fromStoryboard() -> GenericPreferenceVC {
|
||||
public static func fromStoryboard() -> PreferenceVC {
|
||||
let vc = NSStoryboard(name: "Main", bundle: nil)
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
|
||||
|
||||
_ = vc.addView(when: true, vc.getDynamicIconPV())
|
||||
vc.addView(when: true, vc.getDynamicIconPV())
|
||||
.addView(when: true, vc.getIconOptionsPV())
|
||||
.addView(when: true, vc.getIconDensityPV())
|
||||
.addView(when: true, vc.getMenuIconsPV())
|
||||
|
||||
return vc
|
||||
}
|
||||
}
|
||||
|
||||
class MenuStructurePreferencesVC: GenericPreferenceVC {
|
||||
class MenuStructurePreferencesVC: PreferenceVC {
|
||||
|
||||
public static func fromStoryboard() -> GenericPreferenceVC {
|
||||
public static func fromStoryboard() -> PreferenceVC {
|
||||
let vc = NSStoryboard(name: "Main", bundle: nil)
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
|
||||
|
||||
return vc
|
||||
.addView(when: true, vc.displayFeature("prefs.display_global_version_switcher", .displayGlobalVersionSwitcher, true))
|
||||
@@ -63,11 +64,11 @@ class MenuStructurePreferencesVC: GenericPreferenceVC {
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationPreferencesVC: GenericPreferenceVC {
|
||||
class NotificationPreferencesVC: PreferenceVC {
|
||||
|
||||
public static func fromStoryboard() -> GenericPreferenceVC {
|
||||
public static func fromStoryboard() -> PreferenceVC {
|
||||
let vc = NSStoryboard(name: "Main", bundle: nil)
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! PreferenceVC
|
||||
|
||||
return vc.addView(when: true, vc.getNotifyAboutVersionChangePV())
|
||||
.addView(when: true, vc.getNotifyAboutPresetsPV())
|
||||
|
||||
@@ -19,7 +19,7 @@ extension PreferencesWindowController {
|
||||
return
|
||||
}
|
||||
|
||||
guard let vc = tabVC.tabViewItems[tabVC.selectedTabViewItemIndex].viewController as? GenericPreferenceVC else {
|
||||
guard let vc = tabVC.tabViewItems[tabVC.selectedTabViewItemIndex].viewController as? PreferenceVC else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,19 +30,35 @@ class PreferencesWindowController: PMWindowController {
|
||||
window.delegate = delegate ?? windowController
|
||||
window.styleMask = [.titled, .closable, .miniaturizable]
|
||||
|
||||
App.shared.preferencesWindowController = windowController
|
||||
WindowManager.setController(windowController)
|
||||
}
|
||||
|
||||
public static func show(delegate: NSWindowDelegate? = nil) {
|
||||
var justCreated = false
|
||||
|
||||
if App.shared.preferencesWindowController == nil {
|
||||
if !WindowManager.hasController(for: PreferencesWC.self) {
|
||||
Self.create(delegate: delegate)
|
||||
justCreated = true
|
||||
}
|
||||
|
||||
guard let preferencesWC = App.shared.preferencesWindowController else {
|
||||
guard let preferencesWC = WindowManager.controller(of: PreferencesWC.self) else {
|
||||
return
|
||||
}
|
||||
|
||||
if justCreated {
|
||||
addContentTabs(to: preferencesWC)
|
||||
}
|
||||
|
||||
WindowManager.show(PreferencesWC.self)
|
||||
|
||||
if justCreated {
|
||||
preferencesWC.positionWindowInTopRightCorner()
|
||||
}
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private static func addContentTabs(to preferencesWC: PreferencesWC) {
|
||||
guard let tabVC = preferencesWC.contentViewController as? NSTabViewController else {
|
||||
return
|
||||
}
|
||||
@@ -58,25 +74,12 @@ class PreferencesWindowController: PMWindowController {
|
||||
width: tabVC.view.frame.size.width,
|
||||
height: tabVC.view.frame.size.height
|
||||
)
|
||||
|
||||
justCreated = true
|
||||
}
|
||||
|
||||
App.shared.preferencesWindowController?.showWindow(self)
|
||||
|
||||
if justCreated {
|
||||
App.shared.preferencesWindowController?.positionWindowInTopRightCorner()
|
||||
}
|
||||
|
||||
App.shared.preferencesWindowController?.window?.orderFrontRegardless()
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
// MARK: - Tabs
|
||||
|
||||
struct PrefTabView {
|
||||
let viewController: GenericPreferenceVC
|
||||
let viewController: PreferenceVC
|
||||
let label: String
|
||||
let icon: String
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ class CheckboxPreferenceBehavior: CheckboxPreferenceViewBehavior {
|
||||
self.preference = preference
|
||||
self.button = button
|
||||
self.button.state = Preferences.isEnabled(self.preference) ? .on : .off
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(refreshState),
|
||||
name: Events.PreferencesUpdated, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func refreshState() {
|
||||
self.button.state = Preferences.isEnabled(self.preference) ? .on : .off
|
||||
}
|
||||
|
||||
public func toggled(checked: Bool) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import Cocoa
|
||||
|
||||
class HotkeyPreferenceView: NSView, XibLoadable {
|
||||
|
||||
weak var delegate: GenericPreferenceVC?
|
||||
weak var delegate: PreferenceVC?
|
||||
|
||||
@IBOutlet weak var labelSection: NSTextField!
|
||||
@IBOutlet weak var labelDescription: NSTextField!
|
||||
@@ -19,7 +19,7 @@ class HotkeyPreferenceView: NSView, XibLoadable {
|
||||
@IBOutlet weak var buttonSetShortcut: NSButton!
|
||||
@IBOutlet weak var buttonClearShortcut: NSButton!
|
||||
|
||||
static func make(sectionText: String, descriptionText: String, _ prefsVC: GenericPreferenceVC) -> NSView {
|
||||
static func make(sectionText: String, descriptionText: String, _ prefsVC: PreferenceVC) -> NSView {
|
||||
let view = Self.createFromXib()!
|
||||
view.labelSection.stringValue = sectionText
|
||||
view.labelDescription.stringValue = descriptionText
|
||||
|
||||
@@ -73,9 +73,23 @@ class SelectPreferenceView: NSView, XibLoadable {
|
||||
view.preference = preference
|
||||
view.action = action
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
view, selector: #selector(view.refreshState),
|
||||
name: Events.PreferencesUpdated, object: nil
|
||||
)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@objc func refreshState() {
|
||||
let value = Preferences.preferences[preference] as! String
|
||||
self.options.enumerated().forEach { option in
|
||||
if option.element.value == value {
|
||||
self.popupButton.selectItem(at: option.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func valueChanged(_ sender: Any) {
|
||||
let index = self.popupButton.indexOfSelectedItem
|
||||
Preferences.update(self.preference, value: self.options[index].value)
|
||||
|
||||
@@ -276,7 +276,7 @@ struct Preset: Codable, Equatable {
|
||||
private func persistRevert() async {
|
||||
let data = try! JSONEncoder().encode(self.revertSnapshot)
|
||||
|
||||
await container.shell.quiet("mkdir -p ~/.config/phpmon")
|
||||
await container.shell.pipe("mkdir -p ~/.config/phpmon")
|
||||
|
||||
try! String(data: data, encoding: .utf8)!
|
||||
.write(
|
||||
|
||||
@@ -27,7 +27,7 @@ class InstallHomebrew {
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
"""
|
||||
|
||||
_ = try await container.shell.attach(script, didReceiveOutput: { (string: String, _: ShellStream) in
|
||||
try await container.shell.attach(script, didReceiveOutput: { (string: String, _: ShellStream) in
|
||||
print(string)
|
||||
}, withTimeout: 60 * 10)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,19 @@ class ZshRunCommand {
|
||||
/**
|
||||
Adds a given line to .zshrc, which may be needed to adjust the PATH.
|
||||
*/
|
||||
@discardableResult
|
||||
private func add(_ text: String) async -> Bool {
|
||||
// Escape single quotes to prevent shell injection
|
||||
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
|
||||
|
||||
// Actually add the line to .zshrc
|
||||
let outcome = await container.shell.pipe("""
|
||||
touch ~/.zshrc && \
|
||||
grep -qxF '\(text)' ~/.zshrc \
|
||||
|| echo '\n\n\(text)\n' >> ~/.zshrc
|
||||
grep -qxF '\(escaped)' ~/.zshrc \
|
||||
|| echo '\n\n\(escaped)\n' >> ~/.zshrc
|
||||
""")
|
||||
|
||||
// Validate the command executed correctly
|
||||
if outcome.hasError {
|
||||
return false
|
||||
}
|
||||
@@ -39,13 +45,13 @@ class ZshRunCommand {
|
||||
Adds Homebrew binaries to the PATH.
|
||||
*/
|
||||
public func addHomebrewPath() async {
|
||||
_ = await add("export PATH=$HOME/bin:/opt/homebrew/bin:$PATH")
|
||||
await add("export PATH=$HOME/bin:/opt/homebrew/bin:$PATH")
|
||||
}
|
||||
|
||||
/**
|
||||
Adds PHP Monitor binaries to the PATH.
|
||||
*/
|
||||
public func addPhpMonitorPath() async {
|
||||
_ = await add("export PATH=$HOME/bin:~/.config/phpmon/bin:$PATH")
|
||||
await add("export PATH=$HOME/bin:~/.config/phpmon/bin:$PATH")
|
||||
}
|
||||
}
|
||||
|
||||
27
phpmon/Domain/SwiftUI/Common/Buttons/SimpleButton.swift
Normal file
27
phpmon/Domain/SwiftUI/Common/Buttons/SimpleButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift
Normal file
106
phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextView.swift
Normal file
41
phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextView.swift
Normal 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()
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
26
phpmon/Domain/SwiftUI/Common/Views/ErrorView.swift
Normal file
26
phpmon/Domain/SwiftUI/Common/Views/ErrorView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
128
phpmon/Domain/SwiftUI/Domains/AddProxyView.swift
Normal file
128
phpmon/Domain/SwiftUI/Domains/AddProxyView.swift
Normal 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 }
|
||||
)
|
||||
}
|
||||
150
phpmon/Domain/SwiftUI/Domains/AddSiteView.swift
Normal file
150
phpmon/Domain/SwiftUI/Domains/AddSiteView.swift
Normal 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)
|
||||
}
|
||||
51
phpmon/Domain/SwiftUI/Domains/SelectDomainTypeView.swift
Normal file
51
phpmon/Domain/SwiftUI/Domains/SelectDomainTypeView.swift
Normal 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: {})
|
||||
}
|
||||
@@ -48,7 +48,7 @@ struct VersionPopoverView: View {
|
||||
LazyVGrid(columns: self.rows, alignment: .leading, spacing: 5, content: {
|
||||
ForEach(validPhpVersions, id: \.self) { version in
|
||||
Button("site_link.isolate_php".localized(version.short), action: {
|
||||
App.shared.domainListWindowController?.contentVC
|
||||
WindowManager.controller(of: DomainListWC.self)?.contentVC
|
||||
.isolateSite(site: site, version: version.short)
|
||||
parent?.close()
|
||||
}).padding(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// MiniHeaderView.swift
|
||||
// HeaderView.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 10/06/2022.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user