1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-11-07 05:10:06 +01:00

🏗 WIP: Remove command

This commit is contained in:
2023-03-21 22:15:05 +01:00
parent 2d8ad9e9bc
commit 3a826b7e51
8 changed files with 135 additions and 307 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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!")

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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.";