From 3a826b7e515a08f1773e2dd6a08f977d75563aa5 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Tue, 21 Mar 2023 22:15:05 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=20WIP:=20Remove=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PHP Monitor.xcodeproj/project.pbxproj | 10 - phpmon/Common/PHP/PhpVersionInstaller.swift | 271 ------------------ .../Homebrew/Commands/BrewCommand.swift | 16 +- .../Commands/InstallPhpVersionCommand.swift | 7 +- .../Commands/RemovePhpVersionCommand.swift | 107 ++++++- phpmon/Domain/Menu/MainMenu.swift | 8 - .../SwiftUI/PhpManager/PhpFormulaeView.swift | 21 +- phpmon/Localizable.strings | 2 +- 8 files changed, 135 insertions(+), 307 deletions(-) delete mode 100644 phpmon/Common/PHP/PhpVersionInstaller.swift diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 3c28048..2cc78b1 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -781,10 +781,6 @@ C4F780CC25D80B75000DBC97 /* ActivePhpInstallation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41C1B4A22B019FF00E7CF16 /* ActivePhpInstallation.swift */; }; C4F780CD25D80B75000DBC97 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = C476FF9722B0DD830098105B /* Alert.swift */; }; C4F780CE25D80B75000DBC97 /* LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C474B00524C0E98C00066A22 /* LocalNotification.swift */; }; - C4F8764E29BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */; }; - C4F8764F29BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */; }; - C4F8765029BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */; }; - C4F8765129BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */; }; C4F8C0A422D4F12C002EFE61 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8C0A322D4F12C002EFE61 /* DateExtension.swift */; }; C4FACE80288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE7F288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift */; }; C4FACE81288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE7F288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift */; }; @@ -1068,7 +1064,6 @@ C4F7809B25D80344000DBC97 /* CommandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandTest.swift; sourceTree = ""; }; C4F780A725D80AE8000DBC97 /* php.ini */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = php.ini; sourceTree = ""; }; C4F780AD25D80B37000DBC97 /* PhpExtensionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpExtensionTest.swift; sourceTree = ""; }; - C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpVersionInstaller.swift; sourceTree = ""; }; C4F8C0A322D4F12C002EFE61 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; C4F8C0A522D4FA41002EFE61 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C4FACE7F288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesWindowController+Hotkey.swift"; sourceTree = ""; }; @@ -1145,7 +1140,6 @@ 54B20EDF263AA22C00D3250E /* PHP */ = { isa = PBXGroup; children = ( - C4F8764D29BFAF00006BBE26 /* PhpVersionInstaller.swift */, C48D6C6E279CD29C00F26D7E /* PHP Version */, C4D9ADC2277610E4007277F4 /* Switcher */, C4F30B01278E169B00755FCE /* Homebrew */, @@ -2286,7 +2280,6 @@ C4205A7E27F4D21800191A39 /* ValetProxy.swift in Sources */, C4C8E818276F54D8003AC782 /* App+ConfigWatch.swift in Sources */, C4E49DE728F764050026AC4E /* ActiveCommand.swift in Sources */, - C4F8764E29BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */, 54FCFD30276C8DA4004CE748 /* HotkeyPreferenceView.swift in Sources */, C450C8C628C919EC002A2B4B /* PreferenceName.swift in Sources */, C4E4404627C56F4700D225E1 /* ValetSite.swift in Sources */, @@ -2580,7 +2573,6 @@ C471E80828F9BAD40021E251 /* PhpExtension.swift in Sources */, C471E7F928F9BACB0021E251 /* PhpSwitcher.swift in Sources */, C471E82A28F9BB330021E251 /* ValetListable.swift in Sources */, - C4F8765029BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */, C471E82728F9BB310021E251 /* BrewDiagnostics.swift in Sources */, C471E81C28F9BB250021E251 /* BetterAlert.swift in Sources */, C471E7DB28F9BA8F0021E251 /* RealShell.swift in Sources */, @@ -2778,7 +2770,6 @@ C471E80028F9BAD10021E251 /* Xdebug.swift in Sources */, C471E7F528F9BAC80021E251 /* PhpEnv.swift in Sources */, C471E7ED28F9BAC30021E251 /* Process.swift in Sources */, - C4F8765129BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */, C471E81128F9BAE80021E251 /* NSMenuItemExtension.swift in Sources */, C471E7CC28F9BA5B0021E251 /* TestableShell.swift in Sources */, C471E80C28F9BAE80021E251 /* NSWindowExtension.swift in Sources */, @@ -2820,7 +2811,6 @@ C485707128BF452E00539B36 /* WarningManager.swift in Sources */, C41CA5EE2774F8EE00A2C80E /* DomainListVC+Actions.swift in Sources */, C4FACE81288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift in Sources */, - C4F8764F29BFAF00006BBE26 /* PhpVersionInstaller.swift in Sources */, C40934A3298EEB2C00D25014 /* CaskFile.swift in Sources */, 54D9E0B727E4F51E003B9AD9 /* HotKey.swift in Sources */, C413E43528DA3EB100AE33C7 /* TestableShellTest.swift in Sources */, diff --git a/phpmon/Common/PHP/PhpVersionInstaller.swift b/phpmon/Common/PHP/PhpVersionInstaller.swift deleted file mode 100644 index 84973ad..0000000 --- a/phpmon/Common/PHP/PhpVersionInstaller.swift +++ /dev/null @@ -1,271 +0,0 @@ -// -// PhpVersionInstaller.swift -// PHP Monitor -// -// Created by Nico Verbruggen on 13/03/2023. -// Copyright © 2023 Nico Verbruggen. All rights reserved. -// - -import Foundation -import Cocoa - -public enum PhpInstallAction { - case install - case remove - case upgrade - case purge -} - -public class PhpVersionInstaller { - // TODO: Remove - public static var installables = [ - // "8.2": "php", - "8.1": "php@8.1", - "8.0": "php@8.0", - "7.4": "shivammathur/php/php@7.4", - "7.3": "shivammathur/php/php@7.3", - "7.2": "shivammathur/php/php@7.2", - "7.1": "shivammathur/php/php@7.1", - "7.0": "shivammathur/php/php@7.0" - ] - - // swiftlint:disable cyclomatic_complexity function_body_length - /** - Performs the desired action on the provided PHP version. - */ - public static func modifyPhpVersion(version: String, action: PhpInstallAction) async { - let title = { - switch action { - case .install: - return "Installing PHP \(version)" - case .upgrade: - return "Upgrading to PHP \(version)" - case .remove: - return "Removing PHP \(version)" - case .purge: - return "Purging PHP \(version)" - } - }() - - let description = { - switch action { - case .install: - return "Please wait while Homebrew installs PHP \(version)..." - case .upgrade: - return "Please wait while Homebrew upgrades PHP \(version)..." - case .remove: - return "Please wait while Homebrew uninstalls PHP \(version)..." - case .purge: - return "Please wait while Homebrew purges PHP \(version)" - } - }() - - let subject = ProgressViewSubject( - title: title, - description: description - ) - - let installables = Self.installables - - if installables.keys.contains(version) { - let windowController = await ProgressWindowView.display(subject) - await NSApp.activate(ignoringOtherApps: true) - await windowController.window?.makeKeyAndOrderFront(nil) - - let formula = installables[version]! - - var command: String! - - if action == .install { - if formula.contains("shivammathur") && !BrewDiagnostics.installedTaps.contains("shivammathur/php") { - await Shell.quiet("brew tap shivammathur/php") - } - - command = """ - export HOMEBREW_NO_INSTALL_UPGRADE=1 \ - && export HOMEBREW_NO_INSTALL_CLEANUP=1 \ - && brew install \(formula) --force - """ - } - - if action == .upgrade { - fatalError("This is not supported yet.") - } - - if action == .purge || action == .remove { - // Removal always requires permission - do { - try await PhpVersionInstaller.fixPermissions(for: formula) - } catch { - Task { @MainActor in - subject.progress = 1 - subject.title = "Could not take permission of required folder" - subject.description = "Please try again!" - } - return - } - - // Actually do the removal - command = "brew remove \(formula) --force --ignore-dependencies" - - // Check if the permissions are correct; if not, fix permissions - if action == .purge { - command += " --zap" - } - } - - let (process, _) = try! await Shell.attach( - command, - didReceiveOutput: { text, _ in - if action == .install { - if !text.isEmpty { - Log.perf(text) - } - - // Check if we can recognize any of the typical progress steps - if let (number, text) = Self.reportInstallationProgress(text) { - Task { @MainActor in - subject.progress = number - subject.description = text - } - } - } - }, - withTimeout: .minutes(5) - ) - - if process.terminationStatus <= 0 { - Task { @MainActor in - subject.progress = 1 - } - - await PhpEnv.detectPhpVersions() - await MainMenu.shared.refreshActiveInstallation() - - Task { @MainActor in - windowController.close() - } - } else { - // Do not close the window and notify about failure - Task { @MainActor in - subject.title = "Operation failed: something went wrong" - subject.progress = 1 - subject.description = "Oops. You may close this window." - } - } - } else { - Log.err("\(version) is not contained within installable list") - } - } - - /** Installs a given PHP version. Never requires administrative privileges. */ - public static func installPhpVersion(version: String) async { - await self.modifyPhpVersion(version: version, action: .install) - } - - /** Uninstalls a given PHP version. Might require administrative privileges. */ - public static func removePhpVersion(version: String) async { - await self.modifyPhpVersion(version: version, action: .remove) - } - - /** - Takes ownership of the /BREW_PATH/Cellar/php/x.y.z/bin folder (if required). - - This might not be required if the user has only used that version of PHP - with site isolation, so this method checks if it's required first. - */ - public static func fixPermissions(for formula: String) async throws { - // Omit the prefix - let path = formula.replacingOccurrences(of: "shivammathur/php/", with: "") - - // Binary path needs to be checked for ownership - let binaryPath = "\(Paths.optPath)/\(path)/bin" - - // Check if it's even necessary to perform the fix - if !isOwnedByRoot(path: binaryPath) { - return - } - - Log.info("The ownership of the folder at `\(binaryPath)` is currently not correct. Will prompt to take ownership!") - - let script = """ - \(Paths.brew) services stop \(formula) \ - && chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(path) - """ - - let appleScript = NSAppleScript(source: - "do shell script \"\(script)\" with administrator privileges" - ) - - let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(nil) - - if eventResult == nil { - throw HomebrewPermissionError(kind: .applescriptNilError) - } - - Log.info("Ownership was taken of the folder at `\(binaryPath)`.") - } - - /** - Checks if a given path is owned by root. If so, ownership might need to be taken. - */ - private static func isOwnedByRoot(path: String) -> Bool { - do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - if let owner = attributes[.ownerAccountName] as? String { - return owner == "root" - } - } catch { - return true - } - - return true - } - - private static func reportInstallationProgress(_ text: String) -> (Double, String)? { - if text.contains("Fetching") { - return (0.1, "Fetching...") - } - if text.contains("Downloading") { - return (0.25, "Downloading...") - } - if text.contains("Already downloaded") || text.contains("Downloaded") { - return (0.50, "Downloaded!") - } - if text.contains("Installing") { - return (0.60, "Installing...") - } - if text.contains("Pouring") { - return (0.80, "Pouring... this can take a while!") - } - if text.contains("Summary") { - return (1, "The installation is done!") - } - return nil - } - - /** - Determine which action will be available in the PHP version manager. - Some versions will be available to be removed, some to be installed. - */ - public static var availableActions: [(version: String, action: PhpInstallAction)] { - var operations: [(version: String, action: PhpInstallAction)] = [] - - let installed = PhpEnv.shared.cachedPhpInstallations.keys - let unsupported = PhpEnv.shared.incompatiblePhpVersions - - for installable in installables.keys { - // While technically possible to uninstall the main formula (`php`) - // this should be disabled in the UI... this data should be correct though - let availableOperation: PhpInstallAction = - installed.contains(installable) || unsupported.contains(installable) ? .remove : .install - - operations.append((version: installable, action: availableOperation)) - } - - operations.sort { $1.version < $0.version } - - return operations - } - -} diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/BrewCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/BrewCommand.swift index cc9fc0e..d4c7e3f 100644 --- a/phpmon/Domain/Integrations/Homebrew/Commands/BrewCommand.swift +++ b/phpmon/Domain/Integrations/Homebrew/Commands/BrewCommand.swift @@ -8,6 +8,14 @@ import Foundation +protocol BrewCommand { + func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws +} + +extension BrewCommand { + +} + struct BrewCommandProgress { let value: Double let title: String @@ -18,14 +26,6 @@ struct BrewCommandProgress { } } -protocol BrewCommand { - func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws -} - -extension BrewCommand { - -} - struct BrewCommandError: Error { let error: String } diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpVersionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpVersionCommand.swift index d25a390..a1bfcd0 100644 --- a/phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpVersionCommand.swift +++ b/phpmon/Domain/Integrations/Homebrew/Commands/InstallPhpVersionCommand.swift @@ -35,7 +35,7 @@ class InstallPhpVersionCommand: BrewCommand { let command = """ export HOMEBREW_NO_INSTALL_UPGRADE=true; \ export HOMEBREW_NO_INSTALL_CLEANUP=true; \ - brew install \(formula) --force + \(Paths.brew) install \(formula) --force """ let (process, _) = try! await Shell.attach( @@ -45,7 +45,6 @@ class InstallPhpVersionCommand: BrewCommand { Log.perf(text) } - // Check if we can recognize any of the typical progress steps if let (number, text) = self.reportInstallationProgress(text) { onProgress(.create(value: number, title: progressTitle, description: text)) } @@ -65,10 +64,10 @@ class InstallPhpVersionCommand: BrewCommand { private func reportInstallationProgress(_ text: String) -> (Double, String)? { if text.contains("Fetching") { - return (0.1, text) + return (0.1, "Fetching...") } if text.contains("Downloading") { - return (0.25, text) + return (0.25, "Downloading package data...") } if text.contains("Already downloaded") || text.contains("Downloaded") { return (0.50, "Downloaded!") diff --git a/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift b/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift index e4c2364..36ab697 100644 --- a/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift +++ b/phpmon/Domain/Integrations/Homebrew/Commands/RemovePhpVersionCommand.swift @@ -8,6 +8,109 @@ import Foundation -class RemovePhpVersionCommand: Brew { - // TODO +class RemovePhpVersionCommand: BrewCommand { + let formula: String + let version: String + + init(formula: String) { + self.version = formula + .replacingOccurrences(of: "php@", with: "") + .replacingOccurrences(of: "shivammathur/php/", with: "") + self.formula = formula + } + + func execute(onProgress: @escaping (BrewCommandProgress) -> Void) async throws { + let progressTitle = "Removing PHP \(version)..." + + onProgress(.create( + value: 0.2, + title: progressTitle, + description: "Please wait while Homebrew removes PHP \(version)..." + )) + + let command = """ + export HOMEBREW_NO_INSTALL_UPGRADE=true; \ + export HOMEBREW_NO_INSTALL_CLEANUP=true; \ + \(Paths.brew) remove \(formula) --force --ignore-dependencies + """ + + do { + try await self.fixPermissions(for: formula) + } catch { + return + } + + let (process, _) = try! await Shell.attach( + command, + didReceiveOutput: { text, _ in + if !text.isEmpty { + Log.perf(text) + } + }, + withTimeout: .minutes(5) + ) + + if process.terminationStatus <= 0 { + onProgress(.create(value: 0.95, title: progressTitle, description: "Reloading PHP versions...")) + await PhpEnv.detectPhpVersions() + await MainMenu.shared.refreshActiveInstallation() + onProgress(.create(value: 1, title: progressTitle, description: "The operation has succeeded.")) + } else { + throw BrewCommandError(error: "The command failed to run correctly.") + } + } + + /** + Takes ownership of the /BREW_PATH/Cellar/php/x.y.z/bin folder (if required). + + This might not be required if the user has only used that version of PHP + with site isolation, so this method checks if it's required first. + */ + private func fixPermissions(for formula: String) async throws { + // Omit the prefix + let path = formula.replacingOccurrences(of: "shivammathur/php/", with: "") + + // Binary path needs to be checked for ownership + let binaryPath = "\(Paths.optPath)/\(path)/bin" + + // Check if it's even necessary to perform the fix + if !isOwnedByRoot(path: binaryPath) { + return + } + + Log.info("Need to take ownership of `\(binaryPath)`...") + + let script = """ + \(Paths.brew) services stop \(formula) \ + && chown -R \(Paths.whoami):admin \(Paths.cellarPath)/\(path) + """ + + let appleScript = NSAppleScript( + source: "do shell script \"\(script)\" with administrator privileges" + ) + + let eventResult: NSAppleEventDescriptor? = appleScript?.executeAndReturnError(nil) + + if eventResult == nil { + throw HomebrewPermissionError(kind: .applescriptNilError) + } + + Log.info("Ownership was taken of the folder at `\(binaryPath)`.") + } + + /** + Checks if a given path is owned by root. If so, ownership might need to be taken. + */ + private func isOwnedByRoot(path: String) -> Bool { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + if let owner = attributes[.ownerAccountName] as? String { + return owner == "root" + } + } catch { + return true + } + + return true + } } diff --git a/phpmon/Domain/Menu/MainMenu.swift b/phpmon/Domain/Menu/MainMenu.swift index 8e2d181..d2c479c 100644 --- a/phpmon/Domain/Menu/MainMenu.swift +++ b/phpmon/Domain/Menu/MainMenu.swift @@ -206,14 +206,6 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate Task { await AppUpdater().checkForUpdates(userInitiated: true) } } - @objc func installPhpVersion(sender: PhpMenuItem) { - Task { await PhpVersionInstaller.installPhpVersion(version: sender.version) } - } - - @objc func removePhpVersion(sender: PhpMenuItem) { - Task { await PhpVersionInstaller.removePhpVersion(version: sender.version) } - } - // MARK: - Menu Delegate func menuWillOpen(_ menu: NSMenu) { diff --git a/phpmon/Domain/SwiftUI/PhpManager/PhpFormulaeView.swift b/phpmon/Domain/SwiftUI/PhpManager/PhpFormulaeView.swift index 242327b..eb76faa 100644 --- a/phpmon/Domain/SwiftUI/PhpManager/PhpFormulaeView.swift +++ b/phpmon/Domain/SwiftUI/PhpManager/PhpFormulaeView.swift @@ -61,6 +61,7 @@ struct PhpFormulaeView: View { .frame(maxWidth: .infinity, alignment: .leading) Text("phpman.disclaimer".localizedForSwiftUI) .font(.system(size: 12)) + .foregroundColor(.gray) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -122,17 +123,16 @@ struct PhpFormulaeView: View { .frame(maxWidth: .infinity, alignment: .leading) if formula.isInstalled { Button("Uninstall") { - // handle uninstall action here + Task { await self.uninstall(formula) } } } else { Button("Install") { - // handle install action here Task { await self.install(formula) } } } if formula.hasUpgrade { Button("Update") { - // handle uninstall action here + Task { await self.install(formula) } } } } @@ -160,6 +160,21 @@ struct PhpFormulaeView: View { } } } + + public func uninstall(_ formula: BrewFormula) async { + let command = RemovePhpVersionCommand(formula: formula.name) + try! await command.execute { progress in + Task { @MainActor in + self.status.title = progress.title + self.status.description = progress.description + self.status.busy = progress.value != 1 + + if progress.value == 1 { + await self.handler.refreshPhpVersions(loadOutdated: false) + } + } + } + } } struct PhpFormulaeView_Previews: PreviewProvider { diff --git a/phpmon/Localizable.strings b/phpmon/Localizable.strings index 5d3fdff..df4360c 100644 --- a/phpmon/Localizable.strings +++ b/phpmon/Localizable.strings @@ -92,7 +92,7 @@ "phpman.title" = "PHP Manager"; "phpman.description" = "**PHP Manager** lets you install different PHP versions via Homebrew."; -"phpman.disclaimer" = "PHP Manager may ask for administrative privileges to take ownership of certain folders during certain operations. If you prefer it, you can also manually install PHP versions via the terminal."; +"phpman.disclaimer" = "Please note that installing or upgrading PHP versions may cause other Homebrew packages to be upgraded as well, but only if Homebrew would otherwise have broken those other packages via a shared dependency. (More in the FAQ!)"; "phpman.refresh.button" = "Search for Updates"; "phpman.refresh.button.description" = "You can press this button to check (again) if any updates are available to installed PHP versions. When you first open this window, PHP Monitor already does this check.";