mirror of https://github.com/buresdv/Cork
403 lines
13 KiB
Swift
403 lines
13 KiB
Swift
//
|
|
// Brew Package.swift
|
|
// Cork
|
|
//
|
|
// Created by David Bureš - P on 28.10.2025.
|
|
//
|
|
|
|
import AppKit
|
|
import CorkShared
|
|
import DavidFoundation
|
|
import Foundation
|
|
import SwiftData
|
|
import Charts
|
|
import AppIntents
|
|
import SwiftUI
|
|
import CorkTerminalFunctions
|
|
|
|
/// A representation of the loaded ``BrewPackage``s
|
|
/// Includes packages that were loaded properly, along those whose loading failed
|
|
public typealias BrewPackages = Set<Result<BrewPackage, BrewPackage.PackageLoadingError>>
|
|
|
|
/// A representation of a Homebrew package
|
|
public struct BrewPackage: Identifiable, Equatable, Hashable, Codable, Sendable
|
|
{
|
|
public init(
|
|
name: String,
|
|
type: BrewPackage.PackageType,
|
|
isTagged: Bool? = nil,
|
|
isPinned: Bool? = nil,
|
|
installedOn: Date?,
|
|
versions: [String],
|
|
url: URL?,
|
|
installedIntentionally: Bool? = nil,
|
|
sizeInBytes: Int64?,
|
|
downloadCount: Int?
|
|
) {
|
|
self.id = .init()
|
|
self.name = name
|
|
self.type = type
|
|
self.isTagged = isTagged ?? false
|
|
self.isPinned = isPinned ?? false
|
|
self.installedOn = installedOn
|
|
self.versions = versions
|
|
self.url = url
|
|
self.installedIntentionally = self.type == .cask ? true : installedIntentionally ?? false // If the package is cask, it was installed intentionally. If it's a formula, check if an override was provided, and if not, set it to false
|
|
self.sizeInBytes = sizeInBytes
|
|
self.downloadCount = downloadCount
|
|
self.isBeingModified = false
|
|
}
|
|
|
|
public var id: UUID
|
|
public let name: String
|
|
|
|
let type: PackageType
|
|
var isTagged: Bool = false
|
|
|
|
public var isPinned: Bool
|
|
|
|
public let installedOn: Date?
|
|
public let versions: [String]
|
|
|
|
public let url: URL?
|
|
|
|
public var installedIntentionally: Bool
|
|
|
|
public let sizeInBytes: Int64?
|
|
|
|
/// Download count for top packages
|
|
public let downloadCount: Int?
|
|
|
|
var isBeingModified: Bool = false
|
|
|
|
func getFormattedVersions() -> String
|
|
{
|
|
return versions.formatted(.list(type: .and))
|
|
}
|
|
|
|
public enum PackageType: String, CustomStringConvertible, Plottable, AppEntity, Codable
|
|
{
|
|
case formula
|
|
case cask
|
|
|
|
/// User-readable description of the package type
|
|
public var description: String
|
|
{
|
|
switch self
|
|
{
|
|
case .formula:
|
|
return String(localized: "package-details.type.formula")
|
|
case .cask:
|
|
return String(localized: "package-details.type.cask")
|
|
}
|
|
}
|
|
|
|
/// Localization keys for description of the package type
|
|
public var localizableDescription: LocalizedStringKey
|
|
{
|
|
switch self
|
|
{
|
|
case .formula:
|
|
return "package-details.type.formula"
|
|
case .cask:
|
|
return "package-details.type.cask"
|
|
}
|
|
}
|
|
|
|
/// Parent folder for this package type
|
|
public var parentFolder: URL
|
|
{
|
|
switch self
|
|
{
|
|
case .formula:
|
|
return AppConstants.shared.brewCellarPath
|
|
case .cask:
|
|
return AppConstants.shared.brewCaskPath
|
|
}
|
|
}
|
|
|
|
/// Accessibility representation
|
|
public var accessibilityLabel: LocalizedStringKey
|
|
{
|
|
switch self
|
|
{
|
|
case .formula:
|
|
return "accessibility.label.package-type.formula"
|
|
case .cask:
|
|
return "accessibility.label.package-type.cask"
|
|
}
|
|
}
|
|
|
|
public static let typeDisplayRepresentation: TypeDisplayRepresentation = .init(name: "package-details.type")
|
|
|
|
public var displayRepresentation: DisplayRepresentation
|
|
{
|
|
switch self
|
|
{
|
|
case .formula:
|
|
DisplayRepresentation(title: "package-details.type.formula")
|
|
case .cask:
|
|
DisplayRepresentation(title: "package-details.type.cask")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The purpose of the tagged status change operation
|
|
public enum TaggedStatusChangePurpose: String
|
|
{
|
|
/// Only load and apply the tagged status to packages
|
|
///
|
|
/// For when the tagged packages are just being loaded and applied to the packages
|
|
case justLoading = "loading"
|
|
|
|
/// Change and persist the change.
|
|
///
|
|
/// For when the user initiates the change.
|
|
case actuallyChangingTheTaggedState = "actually changing the tagged state"
|
|
}
|
|
|
|
/// Change the tagged status of a package, and optionally persist that change in the database
|
|
///
|
|
/// - Parameter purpose: The purpose of this operation
|
|
@MainActor
|
|
public mutating func changeTaggedStatus(purpose: TaggedStatusChangePurpose)
|
|
{
|
|
|
|
let packageName: String = self.name
|
|
|
|
AppConstants.shared.logger.debug("Will change the tagged status of package \(packageName) for the purpose of \(purpose.rawValue)")
|
|
|
|
if purpose == .actuallyChangingTheTaggedState
|
|
{
|
|
let saveablePackageRepresentation: SavedTaggedPackage = .init(fullName: packageName)
|
|
|
|
if !isTagged
|
|
{
|
|
AppConstants.shared.logger.debug("Will add package representation \(saveablePackageRepresentation.fullName) to the persistence container")
|
|
|
|
saveablePackageRepresentation.saveSelfToDatabase()
|
|
}
|
|
else
|
|
{
|
|
AppConstants.shared.logger.debug("Will remove package \(saveablePackageRepresentation.fullName) from the persistence container")
|
|
|
|
saveablePackageRepresentation.deleteSelfFromDatabase()
|
|
}
|
|
}
|
|
|
|
isTagged.toggle()
|
|
}
|
|
|
|
public enum PinnedStatus
|
|
{
|
|
case pinned
|
|
case unpinned
|
|
}
|
|
|
|
/// Toggle pinned status of the package.
|
|
///
|
|
/// Optionally specify which status to change the package to.
|
|
///
|
|
/// This function only changes the pinned status in the UI. Use the function ``performPinnedStatusChangeAction(appState:brewPackagesTracker:)`` to trigger a pinned status change in Homebrew.
|
|
public mutating func changePinnedStatus(to status: PinnedStatus? = nil)
|
|
{
|
|
if let status
|
|
{
|
|
switch status {
|
|
case .pinned:
|
|
isPinned = true
|
|
case .unpinned:
|
|
isPinned = false
|
|
}
|
|
}
|
|
else
|
|
{
|
|
isPinned.toggle()
|
|
}
|
|
}
|
|
|
|
/// Perform a pinned status change in Homebrew.
|
|
///
|
|
/// For changing the pinned status of the package in the UI, use the function ``changePinnedStatus(to:)``
|
|
public func performPinnedStatusChangeAction(appState: AppState, brewPackagesTracker: BrewPackagesTracker) async
|
|
{
|
|
/// We need to get the number of packages that were pinned before the action, because if there's only one and it gets unpinned, the whole folder with pinned packages is deleted - therefore, there would be a bug where unpinning the last package would make it seem like the whole process failed
|
|
async let numberOfPinnedPackagesBeforePinChangeAction: Int = await brewPackagesTracker.successfullyLoadedFormulae.filter { $0.isPinned }.count
|
|
|
|
if self.isPinned
|
|
{
|
|
let pinResult: TerminalOutput = await shell(AppConstants.shared.brewExecutablePath, ["unpin", name])
|
|
|
|
if !pinResult.standardError.isEmpty
|
|
{
|
|
AppConstants.shared.logger.error("Error pinning: \(pinResult.standardError, privacy: .public)")
|
|
}
|
|
}
|
|
else
|
|
{
|
|
let unpinResult: TerminalOutput = await shell(AppConstants.shared.brewExecutablePath, ["pin", name])
|
|
if !unpinResult.standardError.isEmpty
|
|
{
|
|
AppConstants.shared.logger.error("Error unpinning: \(unpinResult.standardError, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
guard let pinnedPackagesPath: URL = AppConstants.shared.pinnedPackagesPath else
|
|
{
|
|
/// If there was only pinned package left, it got correctly unpinned, but then the folder was deleted, so this `guard` got tripped and made it seem like the proces failed, because the whole folder gets deleted after the last package gets unpinned
|
|
/// Therefore, in this case, we just say that there are no packages left to be pinned
|
|
/// We also have to capture this variable
|
|
let numberOfPinnedPackagesBeforePinChangeAction: Int = await numberOfPinnedPackagesBeforePinChangeAction
|
|
|
|
AppConstants.shared.logger.debug("Tripped condition for the pinned packages missing. Number of pinned packages before the pin change action: \(numberOfPinnedPackagesBeforePinChangeAction)")
|
|
|
|
if numberOfPinnedPackagesBeforePinChangeAction == 1
|
|
{
|
|
await brewPackagesTracker.applyPinnedStatus(namesOfPinnedPackages: .init())
|
|
|
|
return
|
|
}
|
|
else
|
|
{
|
|
await appState.showAlert(errorToShow: .couldNotAssociateAnyPackageWithProvidedPackageUUID)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
await brewPackagesTracker.applyPinnedStatus(namesOfPinnedPackages: brewPackagesTracker.getNamesOfPinnedPackages(atPinnedPackagesPath: pinnedPackagesPath))
|
|
}
|
|
|
|
public mutating func changeBeingModifiedStatus(to setState: Bool? = nil)
|
|
{
|
|
let packageName: String = self.name
|
|
|
|
AppConstants.shared.logger.debug("Will change the \"Being Modified\" status of package \(packageName)")
|
|
|
|
if let setState
|
|
{
|
|
self.isBeingModified = setState
|
|
}
|
|
else
|
|
{
|
|
isBeingModified.toggle()
|
|
}
|
|
}
|
|
|
|
func getSanitizedName() -> String
|
|
{
|
|
var packageNameWithoutTap: String
|
|
{ /// First, remove the tap name from the package name if it has it
|
|
if self.name.contains("/")
|
|
{ /// Check if the package name contains slashes (this would mean it includes the tap name)
|
|
if let sanitizedName = try? self.name.regexMatch("[^\\/]*$")
|
|
{
|
|
return sanitizedName
|
|
}
|
|
else
|
|
{
|
|
return self.name
|
|
}
|
|
}
|
|
else
|
|
{
|
|
return self.name
|
|
}
|
|
}
|
|
|
|
if packageNameWithoutTap.contains("@")
|
|
{ /// Only do the matching if the name contains @
|
|
if let sanitizedName = try? packageNameWithoutTap.regexMatch(".+?(?=@)")
|
|
{ /// Try to REGEX-match the name out of the raw name
|
|
return sanitizedName
|
|
}
|
|
else
|
|
{ /// If the REGEX matching fails, just show the entire name
|
|
return packageNameWithoutTap
|
|
}
|
|
}
|
|
else
|
|
{ /// If the name doesn't contain the @, don't do anything
|
|
return packageNameWithoutTap
|
|
}
|
|
}
|
|
|
|
/// Open the location of this package in Finder
|
|
public func revealInFinder() throws
|
|
{
|
|
enum FinderRevealError: LocalizedError
|
|
{
|
|
case couldNotFindPackageInParent
|
|
|
|
var errorDescription: String?
|
|
{
|
|
return String(localized: "error.finder-reveal.could-not-find-package-in-parent")
|
|
}
|
|
}
|
|
|
|
var packageURL: URL?
|
|
var packageLocationParent: URL
|
|
{
|
|
if type == .formula
|
|
{
|
|
return AppConstants.shared.brewCellarPath
|
|
}
|
|
else
|
|
{
|
|
return AppConstants.shared.brewCaskPath
|
|
}
|
|
}
|
|
|
|
do
|
|
{
|
|
let contentsOfParentFolder: [URL] = try FileManager.default.contentsOfDirectory(at: packageLocationParent, includingPropertiesForKeys: [.isDirectoryKey])
|
|
|
|
packageURL = contentsOfParentFolder.filter
|
|
{
|
|
$0.lastPathComponent.contains(name)
|
|
}.first
|
|
|
|
guard let packageURL
|
|
else
|
|
{
|
|
throw FinderRevealError.couldNotFindPackageInParent
|
|
}
|
|
|
|
packageURL.revealInFinder(.openParentDirectoryAndHighlightTarget)
|
|
}
|
|
catch let finderRevealError
|
|
{
|
|
AppConstants.shared.logger.error("Failed while revealing package: \(finderRevealError.localizedDescription)")
|
|
/// Play the error sound
|
|
NSSound(named: "ping")?.play()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert between ``MinimalHomebrewPackage`` and ``BrewPackage``
|
|
extension BrewPackage
|
|
{
|
|
init?(from minimalPackage: MinimalHomebrewPackage?)
|
|
{
|
|
guard let minimalPackage = minimalPackage else { return nil }
|
|
|
|
self.init(
|
|
name: minimalPackage.name,
|
|
type: minimalPackage.type,
|
|
installedOn: minimalPackage.installDate,
|
|
versions: [],
|
|
url: nil,
|
|
sizeInBytes: nil,
|
|
downloadCount: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
extension FormatStyle where Self == Date.FormatStyle
|
|
{
|
|
static var packageInstallationStyle: Self
|
|
{
|
|
dateTime.day().month(.wide).year().weekday(.wide).hour().minute()
|
|
}
|
|
}
|