1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-25 21:50:08 +01:00

♻️ Reworked various startup fixes

Some further testing is required, and an improved UX is also something that needs to be considered.
This commit is contained in:
2026-02-17 18:20:05 +01:00
parent b95faa6895
commit 5408d61ba0
7 changed files with 176 additions and 84 deletions

View File

@@ -56,6 +56,10 @@
033E9DF82F44AD5400685F62 /* AppleScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DF62F44AD5200685F62 /* AppleScript.swift */; };
033E9DF92F44AD5400685F62 /* AppleScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DF62F44AD5200685F62 /* AppleScript.swift */; };
033E9DFA2F44AD5400685F62 /* AppleScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DF62F44AD5200685F62 /* AppleScript.swift */; };
033E9DFC2F44D93000685F62 /* Startup+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */; };
033E9DFD2F44D93000685F62 /* Startup+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */; };
033E9DFE2F44D93000685F62 /* Startup+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */; };
033E9DFF2F44D93000685F62 /* Startup+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */; };
035983A12E97FA9100218DC7 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A02E92A2A800A62A12 /* Container.swift */; };
035983A22E97FA9100218DC7 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A02E92A2A800A62A12 /* Container.swift */; };
035983A32E97FA9100218DC7 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A02E92A2A800A62A12 /* Container.swift */; };
@@ -1046,6 +1050,7 @@
033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovePhpExtensionCommand.swift; sourceTree = "<group>"; };
033D45A22B0D531D00070080 /* PhpExtensionManagerView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PhpExtensionManagerView+Actions.swift"; sourceTree = "<group>"; };
033E9DF62F44AD5200685F62 /* AppleScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScript.swift; sourceTree = "<group>"; };
033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Alert.swift"; sourceTree = "<group>"; };
034515442EC4F3A000472561 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
034515452EC4F3C000472561 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
034515462EC4FB9100472561 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -2223,6 +2228,7 @@
C4B97B7A275CF20A003F3378 /* App+GlobalHotkey.swift */,
C4EED88827A48778006D7272 /* InterAppHandler.swift */,
C4D8016522B1584700C6DA1B /* Startup.swift */,
033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */,
0379C49E2ED71CFC0035D7EA /* Startup+Launch.swift */,
03BFF5262E312C39007F96FA /* Startup+Timers.swift */,
C495F5AE28A42E080087F70A /* EnvironmentCheck.swift */,
@@ -2862,6 +2868,7 @@
C41C02A927E61A65009F26CB /* FakeValetSite.swift in Sources */,
C4E2E85C28FC282B003B070C /* TestableConfiguration.swift in Sources */,
C4C0E8DF27F88AEB002D32A9 /* FakeDomainScanner.swift in Sources */,
033E9DFF2F44D93000685F62 /* Startup+Alert.swift in Sources */,
037F44192EDB27BA002EBF75 /* Debouncer.swift in Sources */,
C44B3A4628E5C70100718CB1 /* TimeIntervalExtension.swift in Sources */,
03C099452EA15C8E00B76D43 /* Container+Real.swift in Sources */,
@@ -3259,6 +3266,7 @@
C471E81328F9BAE80021E251 /* XibLoadable.swift in Sources */,
C4D3661C291173EA006BD146 /* DictionaryExtension.swift in Sources */,
C4B79ECD29CA475900A483EE /* RemovePhpVersionCommand.swift in Sources */,
033E9DFE2F44D93000685F62 /* Startup+Alert.swift in Sources */,
C471E7F128F9BAC70021E251 /* VersionNumber.swift in Sources */,
C471E7DC28F9BA8F0021E251 /* ShellProtocol.swift in Sources */,
);
@@ -3350,6 +3358,7 @@
C471E8C528F9BB8F0021E251 /* AddProxyVC.swift in Sources */,
C471E8C628F9BB8F0021E251 /* PMTableView.swift in Sources */,
C471E8C728F9BB8F0021E251 /* Warning.swift in Sources */,
033E9DFC2F44D93000685F62 /* Startup+Alert.swift in Sources */,
C471E8C828F9BB8F0021E251 /* WarningManager.swift in Sources */,
C46DC7A72C7B5BCA00F19D17 /* Favorites.swift in Sources */,
C471E8C928F9BB8F0021E251 /* PhpDoctorWindowController.swift in Sources */,
@@ -3713,6 +3722,7 @@
C4AF9F7D275454A900D44ED0 /* ValetVersionExtractorTest.swift in Sources */,
C4B56362276AB0A500F12CCB /* VersionExtractorTest.swift in Sources */,
039C291D2E8AA39A007F5FAB /* TestableWebApiTest.swift in Sources */,
033E9DFD2F44D93000685F62 /* Startup+Alert.swift in Sources */,
C4B585452770FE3900DA4FBE /* RealCommand.swift in Sources */,
C4F780C525D80B75000DBC97 /* MenuBarImageGenerator.swift in Sources */,
C4F780B725D80B5D000DBC97 /* App.swift in Sources */,

View File

@@ -81,7 +81,7 @@ class Actions {
+ " && "
+ cellarCommands.joined(separator: " && ")
try sudo(script)
try AppleScript.runSimpleShellAsAdmin(script)
}
// MARK: - Finding Config Files

View File

@@ -8,30 +8,62 @@
import Foundation
/**
Execute a script with administrative privileges.
Returns the output of the script.
*/
@discardableResult
func sudo(_ script: String) throws -> String {
let source = "do shell script \"\(script)\" with administrator privileges"
class AppleScript {
/**
Execute a simple shell script with administrative privileges (as root).
Log.info("Running script via AppleScript as administrator: `\(source)`")
let appleScript = NSAppleScript(source: source)
var error: NSDictionary?
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(&error)
if let error = error {
Log.err("AppleScript error: \(error)")
throw AdminPrivilegeError(kind: .applescriptNilError)
@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)
}
guard let result = eventResult else {
Log.err("Unknown AppleScript error")
throw AdminPrivilegeError(kind: .applescriptNilError)
/**
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)
}
return result.stringValue ?? ""
/**
Runs a given AppleScript.
*/
private static func runAppleScript(script: String) throws -> String {
Log.info("Running via AppleScript: `\(script)`")
let appleScript = NSAppleScript(source: script)
var error: NSDictionary?
let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(&error)
if let error = error {
Log.err("AppleScript error: \(error)")
throw AdminPrivilegeError(kind: .applescriptNilError)
}
guard let result = eventResult else {
Log.err("Unknown AppleScript error")
throw AdminPrivilegeError(kind: .applescriptNilError)
}
return result.stringValue ?? ""
}
}

View File

@@ -15,24 +15,27 @@ protocol ShellProtocol {
var PATH: String { get }
/**
Run a command synchronously. Use with caution.
Run a command synchronously. Use with caution!
Common usage:
```
let output = Shell.sync("php -v")
```
@return The shell output. If the command times out, returns empty output.
*/
@discardableResult
func sync(_ command: String) -> ShellOutput
/**
Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists).
Common usage:
```
let output = await Shell.pipe("php -v")
```
@return The shell output. If the command times out, returns empty output.
*/
@discardableResult
func pipe(_ command: String) async -> ShellOutput
@@ -43,7 +46,8 @@ protocol ShellProtocol {
- Parameter command: The command to execute.
- Parameter timeout: Timeout in seconds. If the command exceeds this, it is terminated.
- Returns: The shell output. If the command times out, returns empty output.
@return The shell output. If the command times out, returns empty output.
*/
@discardableResult
func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput
@@ -56,7 +60,8 @@ protocol ShellProtocol {
(Whether it is complete or not.)
Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism.
The end result is still the most relevant output (where error output is preferred if it exists).
@return A tuple, containing the `Process` and `ShellOutput` objects.
*/
@discardableResult
func attach(

View File

@@ -0,0 +1,71 @@
//
// 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 was requested, will try and continue if it worked. */
case shouldRunFix
/** No automatic fix was requested, show alert and require retry of all startup checks. */
case shouldRetryStartup
}
/**
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 internal func showAlert(for check: EnvironmentCheck) -> 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)
}
// Verify if an automatic fix is available
let hasAutomaticFix = check.fixCommand != nil
// Present an alert with one or two buttons (depending on fix)
let outcome = NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: hasAutomaticFix ? "startup.fix_for_me".localized : "startup.fix_manually".localized)
.withSecondary(if: hasAutomaticFix, text: "startup.fix_manually".localized)
.withTertiary(if: hasAutomaticFix, text: "", action: { _ in
NSWorkspace.shared.open(Constants.Urls.FrequentlyAskedQuestions)
})
.runModal(urgency: .bringToFront)
// If there's an automatic fix and we chose to fix it, return outcome
if hasAutomaticFix && outcome == .alertFirstButtonReturn {
return .shouldRunFix
}
// In any other situation, we will require a retry of the startup
return .shouldRetryStartup
}
}

View File

@@ -39,7 +39,7 @@ class Startup {
let start = Measurement()
if await check.succeeds() {
Log.info("[PASS] \(check.name) (\(start.milliseconds) ms)")
continue
continue // continue to the next check!
}
// If we get here, something's gone wrong and the check has failed...
@@ -48,8 +48,9 @@ class Startup {
// We will present the user with an option (potentially)
let outcome = await showAlert(for: check)
// If the user requested an automatic fix, do this
if outcome == .shouldRunFix {
// First, validate there's a fix
// Verify a fix actually exists
guard let command = check.fixCommand else {
return false
}
@@ -57,13 +58,16 @@ class Startup {
// We will try to run the fix, it may fail!
do {
try await command(App.shared.container)
return await check.succeeds()
guard await check.succeeds() else {
return false
}
continue // continue to the next check!
} catch {
// our fix failed :(
return false
}
}
// No fix requested, this is just a failure
return false
}
} else {
@@ -78,58 +82,6 @@ class Startup {
return true
}
enum EnvironmentAlertOutcome {
case shouldRunFix
case shouldRetryStartup
}
/**
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) -> 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)
}
// Verify if an automatic fix is available
let hasAutomaticFix = check.fixCommand != nil
// Present an alert with one or two buttons (depending on fix)
let outcome = NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: hasAutomaticFix ? "startup.fix_for_me".localized : "startup.fix_manually".localized)
.withSecondary(if: hasAutomaticFix, text: "startup.fix_manually".localized)
.withTertiary(if: hasAutomaticFix, text: "", action: { _ in
NSWorkspace.shared.open(Constants.Urls.FrequentlyAskedQuestions)
})
.runModal(urgency: .bringToFront)
// If there's an automatic fix and we chose to fix it, return outcome
if hasAutomaticFix && outcome == .alertFirstButtonReturn {
return .shouldRunFix
}
// In any other situation, we will require a retry of the startup
return .shouldRetryStartup
}
// MARK: - Check (List)
public var groups: [EnvironmentCheckGroup] = [
@@ -161,6 +113,10 @@ class Startup {
return await !container.shell
.pipe("ls \(container.paths.optPath) | grep php").out.contains("php")
},
fix: { container in
let brew = container.paths.brew
await container.shell.pipe("\(brew) tap shivammathur/php && \(brew) install shivammathur/php/php")
},
name: "`ls \(App.shared.container.paths.optPath) | grep php` returned php result",
titleText: "startup.errors.php_opt.title".localized,
subtitleText: "startup.errors.php_opt.subtitle".localized(
@@ -175,6 +131,11 @@ class Startup {
command: { container in
return !container.filesystem.fileExists(container.paths.php)
},
fix: { container in
// See if we can't link PHP
let brew = container.paths.brew
await container.shell.pipe("\(brew) link php")
},
name: "`\(App.shared.container.paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
@@ -188,6 +149,10 @@ class Startup {
return await container.shell.pipe("\(container.paths.binPath)/php -v").err
.contains("Library not loaded")
},
fix: { container in
let brew = App.shared.container.paths.brew
await container.shell.pipe("\(brew) tap shivammathur/php && \(brew) reinstall shivammathur/php/php && \(brew) link php")
},
name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized(
@@ -219,6 +184,10 @@ class Startup {
await container.phpEnvs.determinePhpAlias()
return PhpEnvironments.brewPhpAlias == nil
},
fix: { container in
let brew = container.paths.brew
await container.shell.pipe("\(brew) update")
},
name: "`brew` alias is not nil and valid",
titleText: "startup.errors.could_not_determine_alias.title".localized,
subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized,
@@ -253,7 +222,8 @@ class Startup {
.out.contains(container.paths.brew)
},
fix: { container in
try sudo("export USER=\(container.paths.whoami) && export PATH=/bin:/usr/bin:\(container.paths.binPath) && valet trust")
let valet = container.paths.binPath.appending("/valet")
try AppleScript.runShellAsAdmin("\(valet) trust")
},
name: "`/private/etc/sudoers.d/brew` contains brew",
titleText: "startup.errors.sudoers_brew.title".localized,
@@ -277,6 +247,10 @@ class Startup {
command: { container in
return !container.filesystem.directoryExists("~/.config/valet")
},
fix: { container in
let valet = container.paths.binPath.appending("/valet")
await container.shell.pipe("\(valet) install")
},
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,

View File

@@ -40,7 +40,7 @@ class BrewPermissionFixer {
}
let script = buildBrokenFormulaeScript()
try sudo(script)
try AppleScript.runSimpleShellAsAdmin(script)
Log.info("Ownership was taken of the folder(s) at: " + broken
.map({ $0.path })