diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index df7c0f33..01567a53 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -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 = ""; }; 033D45A22B0D531D00070080 /* PhpExtensionManagerView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PhpExtensionManagerView+Actions.swift"; sourceTree = ""; }; 033E9DF62F44AD5200685F62 /* AppleScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScript.swift; sourceTree = ""; }; + 033E9DFB2F44D92A00685F62 /* Startup+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Alert.swift"; sourceTree = ""; }; 034515442EC4F3A000472561 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 034515452EC4F3C000472561 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; 034515462EC4FB9100472561 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -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 */, diff --git a/phpmon/Common/Core/Actions.swift b/phpmon/Common/Core/Actions.swift index 28fd8d88..02a2a5fa 100644 --- a/phpmon/Common/Core/Actions.swift +++ b/phpmon/Common/Core/Actions.swift @@ -81,7 +81,7 @@ class Actions { + " && " + cellarCommands.joined(separator: " && ") - try sudo(script) + try AppleScript.runSimpleShellAsAdmin(script) } // MARK: - Finding Config Files diff --git a/phpmon/Common/Core/AppleScript.swift b/phpmon/Common/Core/AppleScript.swift index eab66c64..e28c2e04 100644 --- a/phpmon/Common/Core/AppleScript.swift +++ b/phpmon/Common/Core/AppleScript.swift @@ -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 ?? "" + } } diff --git a/phpmon/Common/Shell/ShellProtocol.swift b/phpmon/Common/Shell/ShellProtocol.swift index a0988fdb..459fee69 100644 --- a/phpmon/Common/Shell/ShellProtocol.swift +++ b/phpmon/Common/Shell/ShellProtocol.swift @@ -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( diff --git a/phpmon/Domain/App/Startup+Alert.swift b/phpmon/Domain/App/Startup+Alert.swift new file mode 100644 index 00000000..118b5161 --- /dev/null +++ b/phpmon/Domain/App/Startup+Alert.swift @@ -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 + } +} diff --git a/phpmon/Domain/App/Startup.swift b/phpmon/Domain/App/Startup.swift index e357a79d..b3625414 100644 --- a/phpmon/Domain/App/Startup.swift +++ b/phpmon/Domain/App/Startup.swift @@ -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, diff --git a/phpmon/Domain/Integrations/Homebrew/Behaviors/BrewPermissionFixer.swift b/phpmon/Domain/Integrations/Homebrew/Behaviors/BrewPermissionFixer.swift index 586122a2..36a3af98 100644 --- a/phpmon/Domain/Integrations/Homebrew/Behaviors/BrewPermissionFixer.swift +++ b/phpmon/Domain/Integrations/Homebrew/Behaviors/BrewPermissionFixer.swift @@ -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 })