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:
@@ -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 */,
|
||||
|
||||
@@ -81,7 +81,7 @@ class Actions {
|
||||
+ " && "
|
||||
+ cellarCommands.joined(separator: " && ")
|
||||
|
||||
try sudo(script)
|
||||
try AppleScript.runSimpleShellAsAdmin(script)
|
||||
}
|
||||
|
||||
// MARK: - Finding Config Files
|
||||
|
||||
@@ -8,17 +8,48 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class AppleScript {
|
||||
/**
|
||||
Execute a script with administrative privileges.
|
||||
Returns the output of the script.
|
||||
Execute a simple shell script with administrative privileges (as root).
|
||||
|
||||
@return Returns the output of the script.
|
||||
*/
|
||||
@discardableResult
|
||||
func sudo(_ script: String) throws -> String {
|
||||
public static func runSimpleShellAsAdmin(
|
||||
_ script: String
|
||||
) throws -> String {
|
||||
let source = "do shell script \"\(script)\" with administrator privileges"
|
||||
return try runAppleScript(script: source)
|
||||
}
|
||||
|
||||
Log.info("Running script via AppleScript as administrator: `\(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.
|
||||
|
||||
let appleScript = NSAppleScript(source: source)
|
||||
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)
|
||||
@@ -35,3 +66,4 @@ func sudo(_ script: String) throws -> String {
|
||||
|
||||
return result.stringValue ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
71
phpmon/Domain/App/Startup+Alert.swift
Normal file
71
phpmon/Domain/App/Startup+Alert.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user