From 8e78277afccbdaabc3103cb74f66447ea730cd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Bures=CC=8C?= Date: Wed, 13 Nov 2024 22:52:35 +0100 Subject: [PATCH] ~ Rewriting of package loading function --- .../File Browser/Get Contents of Folder.swift | 108 ---------------- ... Package Was Installed Intentionally.swift | 121 ++++++++++++++++++ .../Helper Logic/Filter Symlinks.swift | 28 ++++ .../Get Package Type from URL.swift | 24 ++++ .../Load Up Installed Packages.swift | 112 +++++++++++++++- 5 files changed, 281 insertions(+), 112 deletions(-) create mode 100644 Cork/Logic/Package Loading/Helper Logic/Check if Package Was Installed Intentionally.swift create mode 100644 Cork/Logic/Package Loading/Helper Logic/Filter Symlinks.swift create mode 100644 Cork/Logic/Package Loading/Helper Logic/Get Package Type from URL.swift diff --git a/Cork/Logic/File System/File Browser/Get Contents of Folder.swift b/Cork/Logic/File System/File Browser/Get Contents of Folder.swift index c1d74bf8..f894f0b6 100644 --- a/Cork/Logic/File System/File Browser/Get Contents of Folder.swift +++ b/Cork/Logic/File System/File Browser/Get Contents of Folder.swift @@ -111,114 +111,6 @@ private extension URL return items } - /// This function checks whether the package was installed intentionally. - /// - For Formulae, this info gets read from the install receipt - /// - Casks are always instaled intentionally - /// - Parameter versionURLs: All available versions for this package. Some packages have multiple versions installed at a time (for example, the package `xz` might have versions 1.2 and 1.3 installed at once) - /// - Returns: Indication whether this package was installed intentionally or not - func checkIfPackageWasInstalledIntentionally(_ versionURLs: [URL]) async throws -> Bool - { - guard let localPackagePath = versionURLs.first - else - { - throw PackageLoadingError.failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-load-version-to-check-from-available-versions")) - } - - guard localPackagePath.lastPathComponent != "Cellar" - else - { - AppConstants.shared.logger.error("The last path component of the requested URL is the package container folder itself - perhaps a misconfigured package folder? Tried to load URL \(localPackagePath)") - - throw PackageLoadingError.failedWhileLoadingPackages(failureReason: String(localized: "error.package-loading.last-path-component-of-checked-package-url-is-folder-containing-packages-itself.formulae")) - } - - guard localPackagePath.lastPathComponent != "Caskroom" - else - { - AppConstants.shared.logger.error("The last path component of the requested URL is the package container folder itself - perhaps a misconfigured package folder? Tried to load URL \(localPackagePath)") - - throw PackageLoadingError.failedWhileLoadingPackages(failureReason: String(localized: "error.package-loading.last-path-component-of-checked-package-url-is-folder-containing-packages-itself.casks")) - } - - if path.contains("Cellar") - { - let localPackageInfoJSONPath: URL = localPackagePath.appendingPathComponent("INSTALL_RECEIPT.json", conformingTo: .json) - if FileManager.default.fileExists(atPath: localPackageInfoJSONPath.path) - { - struct InstallRecepitParser: Codable - { - let installedOnRequest: Bool - } - - let decoder: JSONDecoder = { - let decoder: JSONDecoder = .init() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - return decoder - }() - - do - { - let installReceiptContents: Data = try .init(contentsOf: localPackageInfoJSONPath) - - do - { - return try decoder.decode(InstallRecepitParser.self, from: installReceiptContents).installedOnRequest - } - catch let installReceiptParsingError - { - AppConstants.shared.logger.error("Failed to decode install receipt for package \(self.lastPathComponent, privacy: .public) with error \(installReceiptParsingError.localizedDescription, privacy: .public)") - - throw PackageLoadingError.failedWhileLoadingCertainPackage(self.lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-decode-installa-receipt-\(installReceiptParsingError.localizedDescription)")) - } - } - catch let installReceiptLoadingError - { - AppConstants.shared.logger.error("Failed to load contents of install receipt for package \(self.lastPathComponent, privacy: .public) with error \(installReceiptLoadingError.localizedDescription, privacy: .public)") - throw PackageLoadingError.failedWhileLoadingCertainPackage(self.lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-convert-contents-of-install-receipt-to-data-\(installReceiptLoadingError.localizedDescription)")) - } - } - else - { /// There's no install receipt for this package - silently fail and return that the packagw was not installed intentionally - // TODO: Add a setting like "Strictly check for errors" that would instead throw an error here - - AppConstants.shared.logger.error("There appears to be no install receipt for package \(localPackageInfoJSONPath.lastPathComponent, privacy: .public)") - - let shouldStrictlyCheckForHomebrewErrors: Bool = UserDefaults.standard.bool(forKey: "strictlyCheckForHomebrewErrors") - - if shouldStrictlyCheckForHomebrewErrors - { - throw PackageLoadingError.failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.missing-install-receipt")) - } - else - { - return false - } - } - } - else if path.contains("Caskroom") - { - return true - } - else - { - throw PackageLoadingError.failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.unexpected-folder-name")) - } - } - - /// Determine a package's type type from its URL - var packageType: PackageType - { - if path.contains("Cellar") - { - return .formula - } - else - { - return .cask - } - } - /// Get URLs to a package's versions var packageVersionURLs: [URL]? { diff --git a/Cork/Logic/Package Loading/Helper Logic/Check if Package Was Installed Intentionally.swift b/Cork/Logic/Package Loading/Helper Logic/Check if Package Was Installed Intentionally.swift new file mode 100644 index 00000000..5f05fb99 --- /dev/null +++ b/Cork/Logic/Package Loading/Helper Logic/Check if Package Was Installed Intentionally.swift @@ -0,0 +1,121 @@ +// +// Check if Package Was Installed Intentionally.swift +// Cork +// +// Created by David Bureš on 13.11.2024. +// + +import Foundation +import CorkShared + +enum IntentionalInstallationDiscoveryError: Error +{ + /// The function could not determine the most relevant version of the package to read the recepit from + case failedToDetermineMostRelevantVersion(packageURL: URL) + + /// The installation receipt is there, but cannot be read due to permission issues + case failedToReadInstallationRecepit(packageURL: URL) + + /// The installation receipt could be read, but not parsed + case failedToParseInstallationReceipt(packageURL: URL) + + /// The installation receipt is missing completely + case installationReceiptMissingCompletely(packageURL: URL) + + /// The provided `URL` has an unexpected form + case unexpectedFolderName(packageURL: URL) +} + +extension URL +{ + /// This function checks whether the package was installed intentionally. + /// - For Formulae, this info gets read from the install receipt + /// - Casks are always instaled intentionally + /// - Parameter versionURLs: All available versions for this package. Some packages have multiple versions installed at a time (for example, the package `xz` might have versions 1.2 and 1.3 installed at once) + /// - Returns: Indication whether this package was installed intentionally or not + func checkIfPackageWasInstalledIntentionally(versionURLs: [URL]) async throws(IntentionalInstallationDiscoveryError) -> Bool + { + + // TODO: Convert this so it uses the most recent version instead of a random one + guard let localPackagePath = versionURLs.first + else + { + throw .failedToDetermineMostRelevantVersion(packageURL: self) + + //throw .failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-load-version-to-check-from-available-versions")) + } + + if path.contains("Cellar") + { + let localPackageInfoJSONPath: URL = localPackagePath.appendingPathComponent("INSTALL_RECEIPT.json", conformingTo: .json) + if FileManager.default.fileExists(atPath: localPackageInfoJSONPath.path) + { + struct InstallRecepitParser: Codable + { + let installedOnRequest: Bool + } + + let decoder: JSONDecoder = { + let decoder: JSONDecoder = .init() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + return decoder + }() + + do + { + let installReceiptContents: Data = try .init(contentsOf: localPackageInfoJSONPath) + + do + { + return try decoder.decode(InstallRecepitParser.self, from: installReceiptContents).installedOnRequest + } + catch let installReceiptParsingError + { + AppConstants.shared.logger.error("Failed to decode install receipt for package \(self.lastPathComponent, privacy: .public) with error \(installReceiptParsingError.localizedDescription, privacy: .public)") + + throw IntentionalInstallationDiscoveryError.failedToParseInstallationReceipt(packageURL: self) + + //throw PackageLoadingError.failedWhileLoadingCertainPackage(self.lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-decode-installa-receipt-\(installReceiptParsingError.localizedDescription)")) + } + } + catch let installReceiptLoadingError + { + AppConstants.shared.logger.error("Failed to load contents of install receipt for package \(self.lastPathComponent, privacy: .public) with error \(installReceiptLoadingError.localizedDescription, privacy: .public)") + + throw .failedToReadInstallationRecepit(packageURL: self) + + //throw .failedWhileLoadingCertainPackage(self.lastPathComponent, self, failureReason: String(localized: "error.package-loading.could-not-convert-contents-of-install-receipt-to-data-\(installReceiptLoadingError.localizedDescription)")) + } + } + else + { /// There's no install receipt for this package - silently fail and return that the packagw was not installed intentionally + // TODO: Add a setting like "Strictly check for errors" that would instead throw an error here + + AppConstants.shared.logger.error("There appears to be no install receipt for package \(localPackageInfoJSONPath.lastPathComponent, privacy: .public)") + + let shouldStrictlyCheckForHomebrewErrors: Bool = UserDefaults.standard.bool(forKey: "strictlyCheckForHomebrewErrors") + + if shouldStrictlyCheckForHomebrewErrors + { + throw .installationReceiptMissingCompletely(packageURL: self) + + //throw .failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.missing-install-receipt")) + } + else + { + return false + } + } + } + else if path.contains("Caskroom") + { + return true + } + else + { + throw .unexpectedFolderName(packageURL: self) + //throw .failedWhileLoadingCertainPackage(lastPathComponent, self, failureReason: String(localized: "error.package-loading.unexpected-folder-name")) + } + } +} diff --git a/Cork/Logic/Package Loading/Helper Logic/Filter Symlinks.swift b/Cork/Logic/Package Loading/Helper Logic/Filter Symlinks.swift new file mode 100644 index 00000000..fc5ad0f2 --- /dev/null +++ b/Cork/Logic/Package Loading/Helper Logic/Filter Symlinks.swift @@ -0,0 +1,28 @@ +// +// Filter Symlinks.swift +// Cork +// +// Created by David Bureš on 13.11.2024. +// + +import Foundation + +extension [URL] +{ + /// Filter out all symlinks from an array of URLs + var withoutSymlinks: [URL] + { + return self.filter + { url in + /// If the existence of a symlink cannot be verified, be safe and return `false` + guard let isSymlink = url.isSymlink() + else + { + return false + } + + /// `isSymlink` is `true` for a symlink. Therefore, if we want to filter out symlinks, we have to return the opposite of `true`, which is `false` + return !isSymlink + } + } +} diff --git a/Cork/Logic/Package Loading/Helper Logic/Get Package Type from URL.swift b/Cork/Logic/Package Loading/Helper Logic/Get Package Type from URL.swift new file mode 100644 index 00000000..2d7a5981 --- /dev/null +++ b/Cork/Logic/Package Loading/Helper Logic/Get Package Type from URL.swift @@ -0,0 +1,24 @@ +// +// Get Package Type from URL.swift +// Cork +// +// Created by David Bureš on 13.11.2024. +// + +import Foundation + +extension URL +{ + /// Determine a package's type type from its URL + var packageType: PackageType + { + if self.pathComponents.contains("Cellar") + { + return .formula + } + else + { + return .cask + } + } +} diff --git a/Cork/Logic/Package Loading/Load Up Installed Packages.swift b/Cork/Logic/Package Loading/Load Up Installed Packages.swift index 37085fc7..66378fbc 100644 --- a/Cork/Logic/Package Loading/Load Up Installed Packages.swift +++ b/Cork/Logic/Package Loading/Load Up Installed Packages.swift @@ -29,7 +29,7 @@ extension BrewDataStorage /// Calculate how long loading took defer { - AppConstants.shared.logger.debug("Finished \(packageTypeToLoad.rawValue, privacy: .public). Took \(timeLoadingStarted.timeIntervalSince(.now), privacy: .public)") + AppConstants.shared.logger.debug("Finished \(packageTypeToLoad.rawValue, privacy: .public) loading task. Took \(timeLoadingStarted.timeIntervalSince(.now), privacy: .public)") } do @@ -79,11 +79,39 @@ private extension BrewDataStorage { do { + /// This gets URLs to all package folders in a folder. + /// `/opt/homebrew/Caskroom/microsoft-edge/` let urlsInParentFolder: [URL] = try getContentsOfFolder(targetFolder: packageTypeToLoad.parentFolder, options: [.skipsHiddenFiles]) - + AppConstants.shared.logger.debug("Loaded contents of folder: \(urlsInParentFolder)") - - return nil + + let packageLoader = await withTaskGroup(of: BrewPackage?.self, returning: Set.self) + { taskGroup in + for packageURL in urlsInParentFolder + { + guard taskGroup.addTaskUnlessCancelled(priority: .high, operation: { + try? await self.loadInstalledPackage(packageURL: packageURL) + }) + else + { + break + } + } + + var loadedPackages: Set = .init() + + for await loadedPackage in taskGroup + { + if let loadedPackage + { + loadedPackages.insert(loadedPackage) + } + } + + return loadedPackages + } + + return packageLoader } catch let parentFolderReadingError { @@ -92,4 +120,80 @@ private extension BrewDataStorage throw .couldNotReadContentsOfParentFolder(failureReason: parentFolderReadingError.localizedDescription) } } + + /// For a given `URL` to a package folder containing the various versions of the package, parse the package contained within + /// - Parameter packageURL: `URL` to the package parent folder + /// - Returns: A parsed package of the ``BrewPackage`` type + func loadInstalledPackage(packageURL: URL) async throws(PackageLoadingError) -> BrewPackage + { + /// Get the name of the package - at this stage, it is the last path component + let packageName: String = packageURL.lastPathComponent + + /// Check if we're not trying to read versions in the Cellar or Caskroom folder itself - this usually means Homebrew is broken + guard packageName != "Cellar", packageName != "Caskroom" + else + { + AppConstants.shared.logger.error("The last path component of the requested URL is the package container folder itself - perhaps a misconfigured package folder? Tried to load URL \(packageURL)") + + switch packageURL.packageType + { + case .formula: + throw PackageLoadingError.failedWhileLoadingPackages(failureReason: String(localized: "error.package-loading.last-path-component-of-checked-package-url-is-folder-containing-packages-itself.formulae")) + case .cask: + throw PackageLoadingError.failedWhileLoadingPackages(failureReason: String(localized: "error.package-loading.last-path-component-of-checked-package-url-is-folder-containing-packages-itself.casks")) + } + } + + /// Let's try to parse the package now + do + { + /// Gets URL to installed versions of a package provided as ``packageURL`` + /// `/opt/homebrew/Cellar/cmake/3.30.5`, `/opt/homebrew/Cellar/cmake/3.30.4` + let versionURLs: [URL] = try getContentsOfFolder(targetFolder: packageURL, options: [.skipsHiddenFiles]) + + /// Gets the name of the version, which at this stage is the last path component of the `versionURLs` URL + let versionNamesForPackage: [String] = versionURLs.map + { versionURL in + versionURL.lastPathComponent + } + + AppConstants.shared.logger.debug("Package \(packageURL.lastPathComponent) has these versions available: \(versionURLs.map { $0.absoluteString }.joined(separator: ", "))") + + do + { + let wasPackageInstalledIntentionally: Bool = try await packageURL.checkIfPackageWasInstalledIntentionally(versionURLs: versionURLs) + + return .init( + name: packageName, + type: packageURL.packageType, + installedOn: packageURL.creationDate, + versions: versionNamesForPackage, + installedIntentionally: wasPackageInstalledIntentionally, + sizeInBytes: packageURL.directorySize + ) + } + catch let intentionalInstallationDiscoveryError + { + switch intentionalInstallationDiscoveryError + { + case .failedToDetermineMostRelevantVersion(let packageURL): + throw PackageLoadingError.failedWhileLoadingCertainPackage(packageName, packageURL, failureReason: String(localized: "error.package-loading.could-not-load-version-to-check-from-available-versions")) + case .failedToReadInstallationRecepit(let packageURL): + throw PackageLoadingError.failedWhileLoadingCertainPackage(packageName, packageURL, failureReason: String(localized: "error.package-loading.could-not-convert-contents-of-install-receipt-to-data")) + case .failedToParseInstallationReceipt(let packageURL): + throw PackageLoadingError.failedWhileLoadingCertainPackage(packageName, packageURL, failureReason: String(localized: "error.package-loading.could-not-decode-installa-receipt")) + case .installationReceiptMissingCompletely(let packageURL): + throw PackageLoadingError.failedWhileLoadingCertainPackage(packageName, packageURL, failureReason: String(localized: "error.package-loading.missing-install-receipt")) + case .unexpectedFolderName(let packageURL): + throw PackageLoadingError.failedWhileLoadingCertainPackage(packageName, packageURL, failureReason: String(localized: "error.package-loading.unexpected-folder-name")) + } + } + } + catch let loadingError + { + AppConstants.shared.logger.error("Failed while loading package \(packageURL.lastPathComponent, privacy: .public): \(loadingError.localizedDescription)") + + throw .failedWhileLoadingCertainPackage(packageURL.lastPathComponent, packageURL, failureReason: loadingError.localizedDescription) + } + } }