mirror of https://github.com/buresdv/Cork
~ Refactoring of install process
This commit is contained in:
parent
35eeb39096
commit
6e569376a1
|
|
@ -7,7 +7,17 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum PackageInstallationProcessSteps
|
enum PackageInstallationProcessSteps: Equatable
|
||||||
{
|
{
|
||||||
case ready, searching, presentingSearchResults, installing, finished, fatalError, requiresSudoPassword, wrongArchitecture, binaryAlreadyExists, anotherProcessAlreadyRunning, installationTerminatedUnexpectedly
|
case ready
|
||||||
|
case searching
|
||||||
|
case presentingSearchResults
|
||||||
|
case installing(packageToInstall: BrewPackage)
|
||||||
|
case finished
|
||||||
|
case fatalError(packageThatWasGettingInstalled: BrewPackage)
|
||||||
|
case requiresSudoPassword(packageThatWasGettingInstalled: BrewPackage)
|
||||||
|
case wrongArchitecture(packageThatWasGettingInstalled: BrewPackage)
|
||||||
|
case binaryAlreadyExists(packageThatWasGettingInstalled: BrewPackage)
|
||||||
|
case anotherProcessAlreadyRunning
|
||||||
|
case installationTerminatedUnexpectedly
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// Package Install Initialization Error.swift
|
||||||
|
// Cork
|
||||||
|
//
|
||||||
|
// Created by David Bureš - P on 22.04.2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PackageInstallationInitializationError: Error
|
||||||
|
{
|
||||||
|
case couldNotStartInstallProcessWithPackage(package: BrewPackage?)
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,11 @@ import CorkShared
|
||||||
|
|
||||||
class InstallationProgressTracker: ObservableObject
|
class InstallationProgressTracker: ObservableObject
|
||||||
{
|
{
|
||||||
@Published var packageBeingInstalled: PackageInProgressOfBeingInstalled = .init(package: .init(name: "", type: .formula, installedOn: nil, versions: [], sizeInBytes: 0, downloadCount: nil), installationStage: .downloadingCask, packageInstallationProgress: 0)
|
@Published var installationStage: PackageInstallationStage = .downloadingCask
|
||||||
|
@Published var installationProgress: Double = 0
|
||||||
|
|
||||||
|
@Published var realTimeTerminalOutput: [RealTimeTerminalLine] = .init()
|
||||||
|
|
||||||
@Published var numberOfPackageDependencies: Int = 0
|
@Published var numberOfPackageDependencies: Int = 0
|
||||||
@Published var numberInLineOfPackageCurrentlyBeingFetched: Int = 0
|
@Published var numberInLineOfPackageCurrentlyBeingFetched: Int = 0
|
||||||
@Published var numberInLineOfPackageCurrentlyBeingInstalled: Int = 0
|
@Published var numberInLineOfPackageCurrentlyBeingInstalled: Int = 0
|
||||||
|
|
@ -38,30 +41,28 @@ class InstallationProgressTracker: ObservableObject
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func installPackage(using brewData: BrewDataStorage, cachedPackagesTracker: CachedPackagesTracker) async throws -> TerminalOutput
|
func installPackage(packageToInstall: BrewPackage, using brewData: BrewDataStorage, cachedPackagesTracker: CachedPackagesTracker) async throws -> TerminalOutput
|
||||||
{
|
{
|
||||||
let package: BrewPackage = packageBeingInstalled.package
|
AppConstants.shared.logger.debug("Installing package \(packageToInstall.name, privacy: .auto)")
|
||||||
|
|
||||||
AppConstants.shared.logger.debug("Installing package \(package.name, privacy: .auto)")
|
|
||||||
|
|
||||||
var installationResult: TerminalOutput = .init(standardOutput: "", standardError: "")
|
var installationResult: TerminalOutput = .init(standardOutput: "", standardError: "")
|
||||||
|
|
||||||
if package.type == .formula
|
if packageToInstall.type == .formula
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Package \(package.name, privacy: .public) is Formula")
|
AppConstants.shared.logger.info("Package \(packageToInstall.name, privacy: .public) is Formula")
|
||||||
|
|
||||||
let output: String = try await installFormula(using: brewData).joined(separator: "")
|
let output: String = try await installFormula(packageToInstall).joined(separator: "")
|
||||||
|
|
||||||
installationResult.standardOutput.append(output)
|
installationResult.standardOutput.append(output)
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = 10
|
installationProgress = 10
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .finished
|
installationStage = .finished
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Package is Cask")
|
AppConstants.shared.logger.info("Package is Cask")
|
||||||
try await installCask(using: brewData)
|
try await installCask(packageToInstall)
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|
@ -77,17 +78,16 @@ class InstallationProgressTracker: ObservableObject
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func installFormula(using _: BrewDataStorage) async throws -> [String]
|
private func installFormula(_ packageToInstall: BrewPackage) async throws -> [String]
|
||||||
{
|
{
|
||||||
let package: BrewPackage = packageBeingInstalled.package
|
|
||||||
var packageDependencies: [String] = .init()
|
var packageDependencies: [String] = .init()
|
||||||
/// For some reason, the line `fetching [package name]` appears twice during the matching process, and the first one is a dud. Ignore that first one.
|
/// For some reason, the line `fetching [package name]` appears twice during the matching process, and the first one is a dud. Ignore that first one.
|
||||||
var hasAlreadyMatchedLineAboutInstallingPackageItself: Bool = false
|
var hasAlreadyMatchedLineAboutInstallingPackageItself: Bool = false
|
||||||
var installOutput: [String] = .init()
|
var installOutput: [String] = .init()
|
||||||
|
|
||||||
AppConstants.shared.logger.info("Package \(package.name, privacy: .public) is Formula")
|
AppConstants.shared.logger.info("Package \(packageToInstall.name, privacy: .public) is Formula")
|
||||||
|
|
||||||
let (stream, process): (AsyncStream<StreamedTerminalOutput>, Process) = shell(AppConstants.shared.brewExecutablePath, ["install", package.name])
|
let (stream, process): (AsyncStream<StreamedTerminalOutput>, Process) = shell(AppConstants.shared.brewExecutablePath, ["install", packageToInstall.name])
|
||||||
installationProcess = process
|
installationProcess = process
|
||||||
for await output in stream
|
for await output in stream
|
||||||
{
|
{
|
||||||
|
|
@ -99,7 +99,7 @@ class InstallationProgressTracker: ObservableObject
|
||||||
|
|
||||||
if showRealTimeTerminalOutputs
|
if showRealTimeTerminalOutputs
|
||||||
{
|
{
|
||||||
packageBeingInstalled.realTimeTerminalOutput.append(RealTimeTerminalLine(line: outputLine))
|
realTimeTerminalOutput.append(RealTimeTerminalLine(line: outputLine))
|
||||||
}
|
}
|
||||||
|
|
||||||
AppConstants.shared.logger.info("Does the line contain an element from the array? \(outputLine.containsElementFromArray(packageDependencies), privacy: .public)")
|
AppConstants.shared.logger.info("Does the line contain an element from the array? \(outputLine.containsElementFromArray(packageDependencies), privacy: .public)")
|
||||||
|
|
@ -107,7 +107,7 @@ class InstallationProgressTracker: ObservableObject
|
||||||
if outputLine.contains("Fetching dependencies")
|
if outputLine.contains("Fetching dependencies")
|
||||||
{
|
{
|
||||||
// First, we have to get a list of all the dependencies
|
// First, we have to get a list of all the dependencies
|
||||||
var matchedDependencies: String = try outputLine.regexMatch("(?<=\(package.name): ).*?(.*)")
|
var matchedDependencies: String = try outputLine.regexMatch("(?<=\(packageToInstall.name): ).*?(.*)")
|
||||||
matchedDependencies = matchedDependencies.replacingOccurrences(of: " and", with: ",") // The last dependency is different, because it's preceded by "and" instead of "," so let's replace that "and" with "," so we can split it nicely
|
matchedDependencies = matchedDependencies.replacingOccurrences(of: " and", with: ",") // The last dependency is different, because it's preceded by "and" instead of "," so let's replace that "and" with "," so we can split it nicely
|
||||||
|
|
||||||
AppConstants.shared.logger.debug("Matched Dependencies: \(matchedDependencies, privacy: .auto)")
|
AppConstants.shared.logger.debug("Matched Dependencies: \(matchedDependencies, privacy: .auto)")
|
||||||
|
|
@ -120,43 +120,43 @@ class InstallationProgressTracker: ObservableObject
|
||||||
|
|
||||||
numberOfPackageDependencies = packageDependencies.count // Assign the number of dependencies to the tracker for the user to see
|
numberOfPackageDependencies = packageDependencies.count // Assign the number of dependencies to the tracker for the user to see
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = 1
|
installationProgress = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
else if outputLine.contains("Installing dependencies") || outputLine.contains("Installing \(package.name) dependency")
|
else if outputLine.contains("Installing dependencies") || outputLine.contains("Installing \(packageToInstall.name) dependency")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Will install dependencies!")
|
AppConstants.shared.logger.info("Will install dependencies!")
|
||||||
packageBeingInstalled.installationStage = .installingDependencies
|
installationStage = .installingDependencies
|
||||||
|
|
||||||
// Increment by 1 for each package that finished installing
|
// Increment by 1 for each package that finished installing
|
||||||
numberInLineOfPackageCurrentlyBeingInstalled = numberInLineOfPackageCurrentlyBeingInstalled + 1
|
numberInLineOfPackageCurrentlyBeingInstalled = numberInLineOfPackageCurrentlyBeingInstalled + 1
|
||||||
AppConstants.shared.logger.info("Installing dependency \(self.numberInLineOfPackageCurrentlyBeingInstalled) of \(packageDependencies.count)")
|
AppConstants.shared.logger.info("Installing dependency \(self.numberInLineOfPackageCurrentlyBeingInstalled) of \(packageDependencies.count)")
|
||||||
|
|
||||||
// TODO: Add a math formula for advancing the stepper
|
// TODO: Add a math formula for advancing the stepper
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + Double(Double(10) / (Double(3) * Double(numberOfPackageDependencies)))
|
installationProgress = installationProgress + Double(Double(10) / (Double(3) * Double(numberOfPackageDependencies)))
|
||||||
}
|
}
|
||||||
|
|
||||||
else if outputLine.contains("Already downloaded") || (outputLine.contains("Fetching") && outputLine.containsElementFromArray(packageDependencies))
|
else if outputLine.contains("Already downloaded") || (outputLine.contains("Fetching") && outputLine.containsElementFromArray(packageDependencies))
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Will fetch dependencies!")
|
AppConstants.shared.logger.info("Will fetch dependencies!")
|
||||||
packageBeingInstalled.installationStage = .fetchingDependencies
|
installationStage = .fetchingDependencies
|
||||||
|
|
||||||
numberInLineOfPackageCurrentlyBeingFetched = numberInLineOfPackageCurrentlyBeingFetched + 1
|
numberInLineOfPackageCurrentlyBeingFetched = numberInLineOfPackageCurrentlyBeingFetched + 1
|
||||||
|
|
||||||
AppConstants.shared.logger.info("Fetching dependency \(self.numberInLineOfPackageCurrentlyBeingFetched) of \(packageDependencies.count)")
|
AppConstants.shared.logger.info("Fetching dependency \(self.numberInLineOfPackageCurrentlyBeingFetched) of \(packageDependencies.count)")
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + Double(Double(10) / (Double(3) * (Double(numberOfPackageDependencies) * Double(5))))
|
installationProgress = installationProgress + Double(Double(10) / (Double(3) * (Double(numberOfPackageDependencies) * Double(5))))
|
||||||
}
|
}
|
||||||
|
|
||||||
else if outputLine.contains("Fetching \(package.name)") || outputLine.contains("Installing \(package.name)")
|
else if outputLine.contains("Fetching \(packageToInstall.name)") || outputLine.contains("Installing \(packageToInstall.name)")
|
||||||
{
|
{
|
||||||
if hasAlreadyMatchedLineAboutInstallingPackageItself
|
if hasAlreadyMatchedLineAboutInstallingPackageItself
|
||||||
{ /// Only the second line about the package being installed is valid
|
{ /// Only the second line about the package being installed is valid
|
||||||
AppConstants.shared.logger.info("Will install the package itself!")
|
AppConstants.shared.logger.info("Will install the package itself!")
|
||||||
packageBeingInstalled.installationStage = .installingPackage
|
installationStage = .installingPackage
|
||||||
|
|
||||||
// TODO: Add a math formula for advancing the stepper
|
// TODO: Add a math formula for advancing the stepper
|
||||||
packageBeingInstalled.packageInstallationProgress = Double(packageBeingInstalled.packageInstallationProgress) + Double((Double(10) - Double(packageBeingInstalled.packageInstallationProgress)) / Double(2))
|
installationProgress = Double(installationProgress) + Double((Double(10) - Double(installationProgress)) / Double(2))
|
||||||
|
|
||||||
AppConstants.shared.logger.info("Stepper value: \(Double(Double(10) / (Double(3) * Double(self.numberOfPackageDependencies))))")
|
AppConstants.shared.logger.info("Stepper value: \(Double(Double(10) / (Double(3) * Double(self.numberOfPackageDependencies))))")
|
||||||
}
|
}
|
||||||
|
|
@ -164,47 +164,45 @@ class InstallationProgressTracker: ObservableObject
|
||||||
{ /// When it appears for the first time, ignore it
|
{ /// When it appears for the first time, ignore it
|
||||||
AppConstants.shared.logger.info("Matched the dud line about the package itself being installed!")
|
AppConstants.shared.logger.info("Matched the dud line about the package itself being installed!")
|
||||||
hasAlreadyMatchedLineAboutInstallingPackageItself = true
|
hasAlreadyMatchedLineAboutInstallingPackageItself = true
|
||||||
packageBeingInstalled.packageInstallationProgress = Double(packageBeingInstalled.packageInstallationProgress) + Double((Double(10) - Double(packageBeingInstalled.packageInstallationProgress)) / Double(2))
|
installationProgress = Double(installationProgress) + Double((Double(10) - Double(installationProgress)) / Double(2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
installOutput.append(outputLine)
|
installOutput.append(outputLine)
|
||||||
|
|
||||||
AppConstants.shared.logger.debug("Current installation stage: \(self.packageBeingInstalled.installationStage.description, privacy: .public)")
|
AppConstants.shared.logger.debug("Current installation stage: \(self.installationStage.description, privacy: .public)")
|
||||||
|
|
||||||
case .standardError(let errorLine):
|
case .standardError(let errorLine):
|
||||||
AppConstants.shared.logger.error("Errored out: \(errorLine, privacy: .public)")
|
AppConstants.shared.logger.error("Errored out: \(errorLine, privacy: .public)")
|
||||||
|
|
||||||
if showRealTimeTerminalOutputs
|
if showRealTimeTerminalOutputs
|
||||||
{
|
{
|
||||||
packageBeingInstalled.realTimeTerminalOutput.append(RealTimeTerminalLine(line: errorLine))
|
realTimeTerminalOutput.append(RealTimeTerminalLine(line: errorLine))
|
||||||
}
|
}
|
||||||
|
|
||||||
if errorLine.contains("a password is required")
|
if errorLine.contains("a password is required")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.warning("Install requires sudo")
|
AppConstants.shared.logger.warning("Install requires sudo")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .requiresSudoPassword
|
installationStage = .requiresSudoPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = 10
|
installationProgress = 10
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .finished
|
installationStage = .finished
|
||||||
|
|
||||||
return installOutput
|
return installOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func installCask(using _: BrewDataStorage) async throws
|
func installCask(_ packageToInstall: BrewPackage) async throws
|
||||||
{
|
{
|
||||||
let package: BrewPackage = packageBeingInstalled.package
|
|
||||||
|
|
||||||
AppConstants.shared.logger.info("Package is Cask")
|
AppConstants.shared.logger.info("Package is Cask")
|
||||||
AppConstants.shared.logger.debug("Installing package \(package.name, privacy: .public)")
|
AppConstants.shared.logger.debug("Installing package \(packageToInstall.name, privacy: .public)")
|
||||||
|
|
||||||
let (stream, process): (AsyncStream<StreamedTerminalOutput>, Process) = shell(AppConstants.shared.brewExecutablePath, ["install", "--no-quarantine", package.name])
|
let (stream, process): (AsyncStream<StreamedTerminalOutput>, Process) = shell(AppConstants.shared.brewExecutablePath, ["install", "--no-quarantine", packageToInstall.name])
|
||||||
installationProcess = process
|
installationProcess = process
|
||||||
for await output in stream
|
for await output in stream
|
||||||
{
|
{
|
||||||
|
|
@ -215,56 +213,56 @@ class InstallationProgressTracker: ObservableObject
|
||||||
|
|
||||||
if showRealTimeTerminalOutputs
|
if showRealTimeTerminalOutputs
|
||||||
{
|
{
|
||||||
packageBeingInstalled.realTimeTerminalOutput.append(RealTimeTerminalLine(line: outputLine))
|
realTimeTerminalOutput.append(RealTimeTerminalLine(line: outputLine))
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputLine.contains("Downloading")
|
if outputLine.contains("Downloading")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Will download Cask")
|
AppConstants.shared.logger.info("Will download Cask")
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + 2
|
installationProgress = installationProgress + 2
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .downloadingCask
|
installationStage = .downloadingCask
|
||||||
}
|
}
|
||||||
else if outputLine.contains("Installing Cask")
|
else if outputLine.contains("Installing Cask")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Will install Cask")
|
AppConstants.shared.logger.info("Will install Cask")
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + 2
|
installationProgress = installationProgress + 2
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .installingCask
|
installationStage = .installingCask
|
||||||
}
|
}
|
||||||
else if outputLine.contains("Moving App")
|
else if outputLine.contains("Moving App")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Moving App")
|
AppConstants.shared.logger.info("Moving App")
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + 2
|
installationProgress = installationProgress + 2
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .movingCask
|
installationStage = .movingCask
|
||||||
}
|
}
|
||||||
else if outputLine.contains("Linking binary")
|
else if outputLine.contains("Linking binary")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Linking Binary")
|
AppConstants.shared.logger.info("Linking Binary")
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + 2
|
installationProgress = installationProgress + 2
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .linkingCaskBinary
|
installationStage = .linkingCaskBinary
|
||||||
}
|
}
|
||||||
else if outputLine.contains("Purging files")
|
else if outputLine.contains("Purging files")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Purging old version of cask \(package.name)")
|
AppConstants.shared.logger.info("Purging old version of cask \(packageToInstall.name)")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .installingCask
|
installationStage = .installingCask
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = packageBeingInstalled.packageInstallationProgress + 1
|
installationProgress = installationProgress + 1
|
||||||
}
|
}
|
||||||
else if outputLine.contains("was successfully installed")
|
else if outputLine.contains("was successfully installed")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.info("Finished installing app")
|
AppConstants.shared.logger.info("Finished installing app")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .finished
|
installationStage = .finished
|
||||||
|
|
||||||
packageBeingInstalled.packageInstallationProgress = 10
|
installationProgress = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
case .standardError(let errorLine):
|
case .standardError(let errorLine):
|
||||||
|
|
@ -272,26 +270,26 @@ class InstallationProgressTracker: ObservableObject
|
||||||
|
|
||||||
if showRealTimeTerminalOutputs
|
if showRealTimeTerminalOutputs
|
||||||
{
|
{
|
||||||
packageBeingInstalled.realTimeTerminalOutput.append(RealTimeTerminalLine(line: errorLine))
|
realTimeTerminalOutput.append(RealTimeTerminalLine(line: errorLine))
|
||||||
}
|
}
|
||||||
|
|
||||||
if errorLine.contains("a password is required")
|
if errorLine.contains("a password is required")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.warning("Install requires sudo")
|
AppConstants.shared.logger.warning("Install requires sudo")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .requiresSudoPassword
|
installationStage = .requiresSudoPassword
|
||||||
}
|
}
|
||||||
else if errorLine.contains("there is already an App at")
|
else if errorLine.contains("there is already an App at")
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.warning("The app already exists")
|
AppConstants.shared.logger.warning("The app already exists")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .binaryAlreadyExists
|
installationStage = .binaryAlreadyExists
|
||||||
}
|
}
|
||||||
else if errorLine.contains(/depends on hardware architecture being.+but you are running/)
|
else if errorLine.contains(/depends on hardware architecture being.+but you are running/)
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.warning("Package is wrong architecture")
|
AppConstants.shared.logger.warning("Package is wrong architecture")
|
||||||
|
|
||||||
packageBeingInstalled.installationStage = .wrongArchitecture
|
installationStage = .wrongArchitecture
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
// Created by David Bureš on 03.07.2022.
|
// Created by David Bureš on 03.07.2022.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import ButtonKit
|
||||||
import CorkNotifications
|
import CorkNotifications
|
||||||
import CorkShared
|
import CorkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ButtonKit
|
|
||||||
|
|
||||||
struct AddFormulaView: View
|
struct AddFormulaView: View
|
||||||
{
|
{
|
||||||
|
|
@ -39,10 +39,35 @@ struct AddFormulaView: View
|
||||||
{
|
{
|
||||||
[.ready, .presentingSearchResults].contains(packageInstallationProcessStep)
|
[.ready, .presentingSearchResults].contains(packageInstallationProcessStep)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDismissable: Bool
|
var isDismissable: Bool
|
||||||
{
|
{
|
||||||
[.ready, .presentingSearchResults, .fatalError, .anotherProcessAlreadyRunning, .binaryAlreadyExists, .requiresSudoPassword, .wrongArchitecture, .anotherProcessAlreadyRunning, .installationTerminatedUnexpectedly, .installing].contains(packageInstallationProcessStep)
|
if case .installing = packageInstallationProcessStep
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .binaryAlreadyExists = packageInstallationProcessStep
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .fatalError = packageInstallationProcessStep
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .wrongArchitecture = packageInstallationProcessStep
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .requiresSudoPassword = packageInstallationProcessStep
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.ready, .presentingSearchResults, .anotherProcessAlreadyRunning, .anotherProcessAlreadyRunning, .installationTerminatedUnexpectedly].contains(packageInstallationProcessStep)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sheetTitle: LocalizedStringKey
|
var sheetTitle: LocalizedStringKey
|
||||||
|
|
@ -109,32 +134,40 @@ struct AddFormulaView: View
|
||||||
installationProgressTracker: installationProgressTracker
|
installationProgressTracker: installationProgressTracker
|
||||||
)
|
)
|
||||||
|
|
||||||
case .installing:
|
case .installing(let packageToInstall):
|
||||||
InstallingPackageView(
|
InstallingPackageView(
|
||||||
installationProgressTracker: installationProgressTracker,
|
installationProgressTracker: installationProgressTracker,
|
||||||
|
packageToInstall: packageToInstall,
|
||||||
packageInstallationProcessStep: $packageInstallationProcessStep
|
packageInstallationProcessStep: $packageInstallationProcessStep
|
||||||
)
|
)
|
||||||
|
|
||||||
case .finished:
|
case .finished:
|
||||||
InstallationFinishedSuccessfullyView()
|
InstallationFinishedSuccessfullyView()
|
||||||
|
|
||||||
case .fatalError: /// This shows up when the function for executing the install action throws an error
|
case .fatalError(let packageThatWasGettingInstalled): /// This shows up when the function for executing the install action throws an error
|
||||||
InstallationFatalErrorView(installationProgressTracker: installationProgressTracker)
|
InstallationFatalErrorView(
|
||||||
|
installationProgressTracker: installationProgressTracker,
|
||||||
|
packageThatWasGettingInstalled: packageThatWasGettingInstalled
|
||||||
|
)
|
||||||
|
|
||||||
case .requiresSudoPassword:
|
case .requiresSudoPassword(let packageThatWasGettingInstalled):
|
||||||
SudoRequiredView(installationProgressTracker: installationProgressTracker)
|
SudoRequiredView(
|
||||||
|
packageThatWasGettingInstalled: packageThatWasGettingInstalled
|
||||||
|
)
|
||||||
|
|
||||||
case .wrongArchitecture:
|
case .wrongArchitecture(let packageThatWasGettingInstalled):
|
||||||
WrongArchitectureView(installationProgressTracker: installationProgressTracker)
|
WrongArchitectureView(
|
||||||
|
packageThatWasGettingInstalled: packageThatWasGettingInstalled
|
||||||
|
)
|
||||||
|
|
||||||
case .binaryAlreadyExists:
|
case .binaryAlreadyExists(let packageThatWasGettingInstalled):
|
||||||
BinaryAlreadyExistsView(installationProgressTracker: installationProgressTracker)
|
BinaryAlreadyExistsView(installationProgressTracker: installationProgressTracker, packageThatWasGettingInstalled: packageThatWasGettingInstalled)
|
||||||
|
|
||||||
case .anotherProcessAlreadyRunning:
|
case .anotherProcessAlreadyRunning:
|
||||||
AnotherProcessAlreadyRunningView()
|
AnotherProcessAlreadyRunningView()
|
||||||
|
|
||||||
case .installationTerminatedUnexpectedly:
|
case .installationTerminatedUnexpectedly:
|
||||||
InstallationTerminatedUnexpectedlyView(terminalOutputOfTheInstallation: installationProgressTracker.packageBeingInstalled.realTimeTerminalOutput)
|
InstallationTerminatedUnexpectedlyView(terminalOutputOfTheInstallation: installationProgressTracker.realTimeTerminalOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(sheetTitle)
|
.navigationTitle(sheetTitle)
|
||||||
|
|
@ -148,7 +181,7 @@ struct AddFormulaView: View
|
||||||
{
|
{
|
||||||
dismiss()
|
dismiss()
|
||||||
installationProgressTracker.cancel()
|
installationProgressTracker.cancel()
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
try await brewData.synchronizeInstalledPackages(cachedPackagesTracker: cachedDownloadsTracker)
|
try await brewData.synchronizeInstalledPackages(cachedPackagesTracker: cachedDownloadsTracker)
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,10 @@ struct InstallationInitialView: View
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
installationProgressTracker.packageBeingInstalled = PackageInProgressOfBeingInstalled(package: packageToInstall, installationStage: .ready, packageInstallationProgress: 0)
|
|
||||||
|
|
||||||
AppConstants.shared.logger.debug("Packages to install: \(installationProgressTracker.packageBeingInstalled.package.name, privacy: .public)")
|
AppConstants.shared.logger.debug("Packages to install: \(packageToInstall.name, privacy: .public)")
|
||||||
|
|
||||||
packageInstallationProcessStep = .installing
|
packageInstallationProcessStep = .installing(packageToInstall: packageToInstall)
|
||||||
|
|
||||||
} label: {
|
} label: {
|
||||||
Text("add-package.install.action")
|
Text("add-package.install.action")
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
// Created by David Bureš on 29.09.2023.
|
// Created by David Bureš on 29.09.2023.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import CorkShared
|
import CorkShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct InstallingPackageView: View
|
struct InstallingPackageView: View
|
||||||
{
|
{
|
||||||
|
|
@ -14,11 +14,13 @@ struct InstallingPackageView: View
|
||||||
|
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var brewData: BrewDataStorage
|
@EnvironmentObject var brewData: BrewDataStorage
|
||||||
|
|
||||||
@EnvironmentObject var cachedPackagesTracker: CachedPackagesTracker
|
@EnvironmentObject var cachedPackagesTracker: CachedPackagesTracker
|
||||||
|
|
||||||
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
||||||
|
|
||||||
|
let packageToInstall: BrewPackage
|
||||||
|
|
||||||
@Binding var packageInstallationProcessStep: PackageInstallationProcessSteps
|
@Binding var packageInstallationProcessStep: PackageInstallationProcessSteps
|
||||||
|
|
||||||
@State var isShowingRealTimeOutput: Bool = false
|
@State var isShowingRealTimeOutput: Bool = false
|
||||||
|
|
@ -27,13 +29,13 @@ struct InstallingPackageView: View
|
||||||
{
|
{
|
||||||
VStack(alignment: .leading)
|
VStack(alignment: .leading)
|
||||||
{
|
{
|
||||||
if installationProgressTracker.packageBeingInstalled.installationStage != .finished
|
if installationProgressTracker.installationStage != .finished
|
||||||
{
|
{
|
||||||
ProgressView(value: installationProgressTracker.packageBeingInstalled.packageInstallationProgress, total: 10)
|
ProgressView(value: installationProgressTracker.installationProgress, total: 10)
|
||||||
{
|
{
|
||||||
VStack(alignment: .leading)
|
VStack(alignment: .leading)
|
||||||
{
|
{
|
||||||
switch installationProgressTracker.packageBeingInstalled.installationStage
|
switch installationProgressTracker.installationStage
|
||||||
{
|
{
|
||||||
case .ready:
|
case .ready:
|
||||||
Text("add-package.install.ready")
|
Text("add-package.install.ready")
|
||||||
|
|
@ -56,36 +58,36 @@ struct InstallingPackageView: View
|
||||||
|
|
||||||
// CASKS
|
// CASKS
|
||||||
case .downloadingCask:
|
case .downloadingCask:
|
||||||
Text("add-package.install.downloading-cask-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.downloading-cask-\(packageToInstall.name)")
|
||||||
|
|
||||||
case .installingCask:
|
case .installingCask:
|
||||||
Text("add-package.install.installing-cask-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.installing-cask-\(packageToInstall.name)")
|
||||||
|
|
||||||
case .linkingCaskBinary:
|
case .linkingCaskBinary:
|
||||||
Text("add-package.install.linking-cask-binary")
|
Text("add-package.install.linking-cask-binary")
|
||||||
|
|
||||||
case .movingCask:
|
case .movingCask:
|
||||||
Text("add-package.install.moving-cask-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.moving-cask-\(packageToInstall.name)")
|
||||||
|
|
||||||
case .requiresSudoPassword:
|
case .requiresSudoPassword:
|
||||||
Text("add-package.install.requires-sudo-password-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.requires-sudo-password-\(packageToInstall.name)")
|
||||||
.onAppear
|
.onAppear
|
||||||
{
|
{
|
||||||
packageInstallationProcessStep = .requiresSudoPassword
|
packageInstallationProcessStep = .requiresSudoPassword(packageThatWasGettingInstalled: packageToInstall)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .wrongArchitecture:
|
case .wrongArchitecture:
|
||||||
Text("add-package.install.wrong-architecture.title")
|
Text("add-package.install.wrong-architecture.title")
|
||||||
.onAppear
|
.onAppear
|
||||||
{
|
{
|
||||||
packageInstallationProcessStep = .wrongArchitecture
|
packageInstallationProcessStep = .wrongArchitecture(packageThatWasGettingInstalled: packageToInstall)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .binaryAlreadyExists:
|
case .binaryAlreadyExists:
|
||||||
Text("add-package.install.binary-already-exists-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.binary-already-exists-\(packageToInstall.name)")
|
||||||
.onAppear
|
.onAppear
|
||||||
{
|
{
|
||||||
packageInstallationProcessStep = .binaryAlreadyExists
|
packageInstallationProcessStep = .binaryAlreadyExists(packageThatWasGettingInstalled: packageToInstall)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .terminatedUnexpectedly:
|
case .terminatedUnexpectedly:
|
||||||
|
|
@ -96,7 +98,7 @@ struct InstallingPackageView: View
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LiveTerminalOutputView(
|
LiveTerminalOutputView(
|
||||||
lineArray: $installationProgressTracker.packageBeingInstalled.realTimeTerminalOutput,
|
lineArray: $installationProgressTracker.realTimeTerminalOutput,
|
||||||
isRealTimeTerminalOutputExpanded: $isShowingRealTimeOutput
|
isRealTimeTerminalOutputExpanded: $isShowingRealTimeOutput
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -117,19 +119,19 @@ struct InstallingPackageView: View
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
let installationResult: TerminalOutput = try await installationProgressTracker.installPackage(
|
let installationResult: TerminalOutput = try await installationProgressTracker.installPackage(
|
||||||
using: brewData,
|
packageToInstall: packageToInstall, using: brewData,
|
||||||
cachedPackagesTracker: cachedPackagesTracker
|
cachedPackagesTracker: cachedPackagesTracker
|
||||||
)
|
)
|
||||||
|
|
||||||
AppConstants.shared.logger.debug("Installation result:\nStandard output: \(installationResult.standardOutput, privacy: .public)\nStandard error: \(installationResult.standardError, privacy: .public)")
|
AppConstants.shared.logger.debug("Installation result:\nStandard output: \(installationResult.standardOutput, privacy: .public)\nStandard error: \(installationResult.standardError, privacy: .public)")
|
||||||
|
|
||||||
/// Check if the package installation stag at the end of the install process was something unexpected. Normal package installations go through multiple steps, and the three listed below are not supposed to be the end state. This means that something went wrong during the installation
|
/// Check if the package installation stag at the end of the install process was something unexpected. Normal package installations go through multiple steps, and the three listed below are not supposed to be the end state. This means that something went wrong during the installation
|
||||||
let installationStage: PackageInstallationStage = installationProgressTracker.packageBeingInstalled.installationStage
|
let installationStage: PackageInstallationStage = installationProgressTracker.installationStage
|
||||||
if [.installingCask, .installingPackage, .ready].contains(installationStage)
|
if [.installingCask, .installingPackage, .ready].contains(installationStage)
|
||||||
{
|
{
|
||||||
AppConstants.shared.logger.warning("The installation process quit before it was supposed to")
|
AppConstants.shared.logger.warning("The installation process quit before it was supposed to")
|
||||||
|
|
||||||
installationProgressTracker.packageBeingInstalled.installationStage = .terminatedUnexpectedly
|
installationProgressTracker.installationStage = .terminatedUnexpectedly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch let fatalInstallationError
|
catch let fatalInstallationError
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import CorkShared
|
import CorkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ButtonKit
|
||||||
|
|
||||||
struct PresentingSearchResultsView: View
|
struct PresentingSearchResultsView: View
|
||||||
{
|
{
|
||||||
|
|
@ -149,12 +150,19 @@ struct PresentingSearchResultsView: View
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var startInstallProcessButton: some View
|
var startInstallProcessButton: some View
|
||||||
{
|
{
|
||||||
Button
|
// This has to be an AsyncButton so it shakes
|
||||||
|
AsyncButton
|
||||||
{
|
{
|
||||||
packageInstallationProcessStep = .installing
|
guard let packageToInstall = foundPackageSelection else
|
||||||
|
{
|
||||||
|
throw PackageInstallationInitializationError.couldNotStartInstallProcessWithPackage(package: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
packageInstallationProcessStep = .installing(packageToInstall: packageToInstall)
|
||||||
} label: {
|
} label: {
|
||||||
Text("add-package.install.action")
|
Text("add-package.install.action")
|
||||||
}
|
}
|
||||||
|
.throwableButtonStyle(.shake)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(foundPackageSelection == nil)
|
.disabled(foundPackageSelection == nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ struct BinaryAlreadyExistsView: View, Sendable
|
||||||
@EnvironmentObject var brewData: BrewDataStorage
|
@EnvironmentObject var brewData: BrewDataStorage
|
||||||
|
|
||||||
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
||||||
|
|
||||||
|
let packageThatWasGettingInstalled: BrewPackage
|
||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +26,7 @@ struct BinaryAlreadyExistsView: View, Sendable
|
||||||
VStack(alignment: .leading, spacing: 10)
|
VStack(alignment: .leading, spacing: 10)
|
||||||
{
|
{
|
||||||
HeadlineWithSubheadline(
|
HeadlineWithSubheadline(
|
||||||
headline: "add-package.install.binary-already-exists-\(installationProgressTracker.packageBeingInstalled.package.name)",
|
headline: "add-package.install.binary-already-exists-\(packageThatWasGettingInstalled.name)",
|
||||||
subheadline: "add-package.install.binary-already-exists.subheadline",
|
subheadline: "add-package.install.binary-already-exists.subheadline",
|
||||||
alignment: .leading
|
alignment: .leading
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ struct InstallationFatalErrorView: View
|
||||||
{
|
{
|
||||||
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
||||||
|
|
||||||
|
let packageThatWasGettingInstalled: BrewPackage
|
||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
{
|
{
|
||||||
ComplexWithIcon(systemName: "exclamationmark.triangle")
|
ComplexWithIcon(systemName: "exclamationmark.triangle")
|
||||||
{
|
{
|
||||||
HeadlineWithSubheadline(
|
HeadlineWithSubheadline(
|
||||||
headline: "add-package.fatal-error-\(installationProgressTracker.packageBeingInstalled.package.name)",
|
headline: "add-package.fatal-error-\(packageThatWasGettingInstalled.name)",
|
||||||
subheadline: "add-package.fatal-error.description",
|
subheadline: "add-package.fatal-error.description",
|
||||||
alignment: .leading
|
alignment: .leading
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ struct SudoRequiredView: View, Sendable
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var brewData: BrewDataStorage
|
@EnvironmentObject var brewData: BrewDataStorage
|
||||||
|
|
||||||
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
let packageThatWasGettingInstalled: BrewPackage
|
||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
{
|
{
|
||||||
|
|
@ -24,14 +24,14 @@ struct SudoRequiredView: View, Sendable
|
||||||
{
|
{
|
||||||
VStack(alignment: .leading, spacing: 10)
|
VStack(alignment: .leading, spacing: 10)
|
||||||
{
|
{
|
||||||
Text("add-package.install.requires-sudo-password-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add-package.install.requires-sudo-password-\(packageThatWasGettingInstalled.name)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
ManualInstallInstructions(installationProgressTracker: installationProgressTracker)
|
manualInstallInstructions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("add.package.install.requires-sudo-password.terminal-instructions-\(installationProgressTracker.packageBeingInstalled.package.name)")
|
Text("add.package.install.requires-sudo-password.terminal-instructions-\(packageThatWasGettingInstalled.name)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -51,19 +51,15 @@ struct SudoRequiredView: View, Sendable
|
||||||
}
|
}
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@ViewBuilder
|
||||||
private struct ManualInstallInstructions: View
|
var manualInstallInstructions: some View
|
||||||
{
|
|
||||||
let installationProgressTracker: InstallationProgressTracker
|
|
||||||
|
|
||||||
var manualInstallCommand: String
|
|
||||||
{
|
|
||||||
return "brew install \(installationProgressTracker.packageBeingInstalled.package.type == .cask ? "--cask" : "") \(installationProgressTracker.packageBeingInstalled.package.name)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View
|
|
||||||
{
|
{
|
||||||
|
var manualInstallCommand: String
|
||||||
|
{
|
||||||
|
return "brew install \(packageThatWasGettingInstalled.type == .cask ? "--cask" : "") \(packageThatWasGettingInstalled.name)"
|
||||||
|
}
|
||||||
|
|
||||||
VStack
|
VStack
|
||||||
{
|
{
|
||||||
Text("add-package.install.requires-sudo-password.description")
|
Text("add-package.install.requires-sudo-password.description")
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ struct WrongArchitectureView: View, Sendable
|
||||||
|
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var brewData: BrewDataStorage
|
@EnvironmentObject var brewData: BrewDataStorage
|
||||||
|
|
||||||
@ObservedObject var installationProgressTracker: InstallationProgressTracker
|
let packageThatWasGettingInstalled: BrewPackage
|
||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +24,7 @@ struct WrongArchitectureView: View, Sendable
|
||||||
{
|
{
|
||||||
HeadlineWithSubheadline(
|
HeadlineWithSubheadline(
|
||||||
headline: "add-package.install.wrong-architecture.title",
|
headline: "add-package.install.wrong-architecture.title",
|
||||||
subheadline: "add-package.install.wrong-architecture-\(installationProgressTracker.packageBeingInstalled.package.name).user-architecture-is-\(ProcessInfo().CPUArchitecture == .arm ? "Apple Silicon" : "Intel")",
|
subheadline: "add-package.install.wrong-architecture-\(packageThatWasGettingInstalled.name).user-architecture-is-\(ProcessInfo().CPUArchitecture == .arm ? "Apple Silicon" : "Intel")",
|
||||||
alignment: .leading
|
alignment: .leading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue