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