diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 7bd3596..3c2c0ec 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -48,7 +48,7 @@ 418E9EF42AACD9C000046A58 /* CircularProgress in Frameworks */ = {isa = PBXBuildFile; productRef = 418E9EF32AACD9C000046A58 /* CircularProgress */; }; 418F331C28EB3D540023D76F /* AppGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F331B28EB3D540023D76F /* AppGridView.swift */; }; 418F332428EC8BA10023D76F /* Cask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332328EC8BA10023D76F /* Cask.swift */; }; - 418F332628EC921D0023D76F /* CaskData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332528EC921D0023D76F /* CaskData.swift */; }; + 418F332628EC921D0023D76F /* CaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332528EC921D0023D76F /* CaskManager.swift */; }; 4191392C29159B5C00F1D75D /* CaskDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4191392B29159B5C00F1D75D /* CaskDTO.swift */; }; 419256062D1C546D00D9EF10 /* askpass.js in Resources */ = {isa = PBXBuildFile; fileRef = 419256052D1C546D00D9EF10 /* askpass.js */; }; 419256082D1C734600D9EF10 /* Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256072D1C734600D9EF10 /* Shell.swift */; }; @@ -91,14 +91,19 @@ 419256622D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256612D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift */; }; 419256642D1E164600D9EF10 /* UpdateView+ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256632D1E164600D9EF10 /* UpdateView+ToolbarItems.swift */; }; 419256682D1E18D100D9EF10 /* AlertManagerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256672D1E18D100D9EF10 /* AlertManagerViewModifier.swift */; }; - 4192566B2D1F286B00D9EF10 /* Cask+BrewFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */; }; + 4192566B2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */; }; 4192566E2D1F293700D9EF10 /* Cask+LaunchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */; }; - 419256702D1F299E00D9EF10 /* Cask+ProtocolConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */; }; 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256822D22055200D9EF10 /* CaskInfo.swift */; }; 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256892D22D67400D9EF10 /* SidebarItem.swift */; }; 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */; }; 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */; }; 419256912D23F93E00D9EF10 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256902D23F93E00D9EF10 /* Card.swift */; }; + 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256932D24255000D9EF10 /* CaskLoadError.swift */; }; + 419256962D2430C500D9EF10 /* CaskManager+LoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */; }; + 419256982D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */; }; + 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569A2D24335900D9EF10 /* CaskTaskError.swift */; }; + 4192569D2D2433E200D9EF10 /* CaskProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */; }; + 419256A12D25ACC300D9EF10 /* CategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -151,7 +156,7 @@ 4189CE40293C980E009C836D /* BigButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigButtonStyle.swift; sourceTree = ""; }; 418F331B28EB3D540023D76F /* AppGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGridView.swift; sourceTree = ""; }; 418F332328EC8BA10023D76F /* Cask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cask.swift; sourceTree = ""; }; - 418F332528EC921D0023D76F /* CaskData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskData.swift; sourceTree = ""; }; + 418F332528EC921D0023D76F /* CaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskManager.swift; sourceTree = ""; }; 4191392B29159B5C00F1D75D /* CaskDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskDTO.swift; sourceTree = ""; }; 419256052D1C546D00D9EF10 /* askpass.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = askpass.js; sourceTree = ""; }; 419256072D1C734600D9EF10 /* Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shell.swift; sourceTree = ""; }; @@ -194,14 +199,19 @@ 419256612D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpdateView+UpdateUnavailable.swift"; sourceTree = ""; }; 419256632D1E164600D9EF10 /* UpdateView+ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpdateView+ToolbarItems.swift"; sourceTree = ""; }; 419256672D1E18D100D9EF10 /* AlertManagerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagerViewModifier.swift; sourceTree = ""; }; - 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+BrewFunctions.swift"; sourceTree = ""; }; + 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+BrewFunctions.swift"; sourceTree = ""; }; 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+LaunchApp.swift"; sourceTree = ""; }; - 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+ProtocolConformances.swift"; sourceTree = ""; }; 419256822D22055200D9EF10 /* CaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskInfo.swift; sourceTree = ""; }; 419256892D22D67400D9EF10 /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationView.swift; sourceTree = ""; }; 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtension.swift; sourceTree = ""; }; 419256902D23F93E00D9EF10 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; + 419256932D24255000D9EF10 /* CaskLoadError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskLoadError.swift; sourceTree = ""; }; + 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+LoadData.swift"; sourceTree = ""; }; + 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+RefreshOutdated.swift"; sourceTree = ""; }; + 4192569A2D24335900D9EF10 /* CaskTaskError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskTaskError.swift; sourceTree = ""; }; + 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskProgressState.swift; sourceTree = ""; }; + 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryViewModel.swift; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -379,8 +389,8 @@ 41483CCA29101C7200BB10C2 /* Cask Models */ = { isa = PBXGroup; children = ( + 419256922D24253800D9EF10 /* Cask Manager */, 419256692D1F284100D9EF10 /* Cask */, - 418F332528EC921D0023D76F /* CaskData.swift */, 419256822D22055200D9EF10 /* CaskInfo.swift */, 4191392B29159B5C00F1D75D /* CaskDTO.swift */, 4166EE7C28F73B2300CE305A /* BrewAnalytics.swift */, @@ -392,6 +402,7 @@ isa = PBXGroup; children = ( 41483CCC29101C9900BB10C2 /* Category.swift */, + 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */, ); path = Categories; sourceTree = ""; @@ -567,13 +578,25 @@ isa = PBXGroup; children = ( 418F332328EC8BA10023D76F /* Cask.swift */, - 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */, 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */, - 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */, ); path = Cask; sourceTree = ""; }; + 419256922D24253800D9EF10 /* Cask Manager */ = { + isa = PBXGroup; + children = ( + 418F332528EC921D0023D76F /* CaskManager.swift */, + 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */, + 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */, + 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */, + 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */, + 419256932D24255000D9EF10 /* CaskLoadError.swift */, + 4192569A2D24335900D9EF10 /* CaskTaskError.swift */, + ); + path = "Cask Manager"; + sourceTree = ""; + }; 4196C8F628F9CB4100EADDDA /* App Views */ = { isa = PBXGroup; children = ( @@ -741,12 +764,14 @@ 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */, 419506A42964A27F00FE5802 /* SetupView.swift in Sources */, 4192561E2D1DEBE700D9EF10 /* AppView+UninstallButton.swift in Sources */, + 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */, 41524B99295E352200D0046A /* SettingsView.swift in Sources */, 415563A02A9265CE00AE2F2E /* CaskExportType.swift in Sources */, 41524B9E295FA36E00D0046A /* DebounceObject.swift in Sources */, 419256292D1DF1CF00D9EF10 /* SetupView+BrewPathSelection.swift in Sources */, 415563A22A98BB2500AE2F2E /* ErrorWindowView.swift in Sources */, 4196C8FE28F9E13600EADDDA /* UpdateView.swift in Sources */, + 419256982D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift in Sources */, 41062C992A3A263F00FD48EA /* UninstallSelfView.swift in Sources */, 419256372D1DF34200D9EF10 /* SettingsView+ProxySettings.swift in Sources */, 414074F528DF53E80073EB22 /* AppliteApp.swift in Sources */, @@ -762,23 +787,25 @@ 419256272D1DF1AC00D9EF10 /* SetupView+BrewTypeSelection.swift in Sources */, 4192560A2D1C9FF800D9EF10 /* StringExtension.swift in Sources */, 4191392C29159B5C00F1D75D /* CaskDTO.swift in Sources */, - 419256702D1F299E00D9EF10 /* Cask+ProtocolConformances.swift in Sources */, 413E60C22BBFF98A00978F6A /* AppIconView.swift in Sources */, 418989AD2A33A5C4004AC23B /* BrewManagementView.swift in Sources */, 418989B22A35D651004AC23B /* isBrewPathValid.swift in Sources */, + 419256A12D25ACC300D9EF10 /* CategoryViewModel.swift in Sources */, 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */, 4192560D2D1CA02C00D9EF10 /* ShellError.swift in Sources */, - 4192566B2D1F286B00D9EF10 /* Cask+BrewFunctions.swift in Sources */, + 4192566B2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift in Sources */, 4192561A2D1DEB2100D9EF10 /* AppView+OpenAndManageView.swift in Sources */, 4192562C2D1DF20600D9EF10 /* SetupView+BrewInstall.swift in Sources */, 41062C952A3794EA00FD48EA /* BrewPaths.swift in Sources */, 4192565B2D1E0ECF00D9EF10 /* DiscoverSectionView+Placeholder.swift in Sources */, 41483CCD29101C9900BB10C2 /* Category.swift in Sources */, + 4192569D2D2433E200D9EF10 /* CaskProgressState.swift in Sources */, 418F331C28EB3D540023D76F /* AppGridView.swift in Sources */, 419256332D1DF2E000D9EF10 /* SettingsView+BrewPath.swift in Sources */, + 419256962D2430C500D9EF10 /* CaskManager+LoadData.swift in Sources */, 419256552D1E0E2500D9EF10 /* DiscoverSectionView+CategoryHeader.swift in Sources */, 41062C972A3A20F900FD48EA /* UninstallSelf.swift in Sources */, - 418F332628EC921D0023D76F /* CaskData.swift in Sources */, + 418F332628EC921D0023D76F /* CaskManager.swift in Sources */, 4178CF922A8689AF0037F270 /* ExportCasks.swift in Sources */, 4192562E2D1DF22500D9EF10 /* SetupView+AllSet.swift in Sources */, 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */, @@ -793,6 +820,7 @@ 419256082D1C734600D9EF10 /* Shell.swift in Sources */, 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */, 419256162D1DEA0A00D9EF10 /* AppView+IconAndDescriptionView.swift in Sources */, + 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1027,7 +1055,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.11.0; + minimumVersion = 8.1.0; }; }; 4189CE372937CD41009C836D /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */ = { diff --git a/Applite/AppliteApp.swift b/Applite/AppliteApp.swift index ccd944b..0c22743 100755 --- a/Applite/AppliteApp.swift +++ b/Applite/AppliteApp.swift @@ -12,7 +12,7 @@ import Kingfisher @main struct AppliteApp: App { - @StateObject var caskData = CaskData() + @StateObject var caskManager = CaskManager() @AppStorage(Preferences.colorSchemePreference.rawValue) var colorSchemePreference: ColorSchemePreference = .system @AppStorage(Preferences.setupComplete.rawValue) var setupComplete: Bool = false @@ -42,7 +42,7 @@ struct AppliteApp: App { WindowGroup { if setupComplete { ContentView() - .environmentObject(caskData) + .environmentObject(caskManager) .frame(minWidth: 970, minHeight: 520) .preferredColorScheme(selectedColorScheme) } else { diff --git a/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift b/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift new file mode 100644 index 0000000..dce7813 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift @@ -0,0 +1,31 @@ +// +// CaskLoadError.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +enum CaskLoadError: LocalizedError { + case failedToLoadCategoryJSON + case failedToLoadFromCache + + var errorDescription: String? { + switch self { + case .failedToLoadCategoryJSON: + return "Failed to load categories" + case .failedToLoadFromCache: + return "Failed to load app catalog from cache" + } + } + + var failureReason: String? { + switch self { + case .failedToLoadCategoryJSON: + return "Couldn't load category JSON file" + case .failedToLoadFromCache: + return "The file doesn't exist or couldn't be read" + } + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift new file mode 100644 index 0000000..0fe143b --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift @@ -0,0 +1,282 @@ +// +// Cask+BrewFunctions.swift +// Applite +// +// Created by Milán Várady on 2024.12.27. +// + +import Foundation + +extension CaskManager { + /// Installs the cask + /// + /// - Parameters: + /// - caskManager: ``CaskData`` object passed in by the view + /// - force: If `true` install will be run with the `--force` flag + func install(_ cask: Cask, force: Bool = false) { + runTask(for: cask) { + Self.logger.info("Cask \"\(cask.id)\" installation started") + + // Appdir argument + let appdirOn = UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) + let appdirPath = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) + let appdirArgument = "--appdir=\"\(appdirPath ?? "/Applications")\"" + + // Install command + var arguments = [cask.id] + if force { arguments.append("--force") } + if appdirOn { arguments.append(appdirArgument) } + + let command = "\(BrewPaths.currentBrewExecutable) install --cask \(arguments.joined(separator: " "))" + + // Setup progress + cask.progressState = .busy(withTask: "") + + /// Holds the complete output of the install process + var completeOutput = "" + + // Run install command and stream output + do { + for try await line in Shell.stream(command, pty: true) { + completeOutput += line + + let newProgress = self.parseBrewInstall(output: line) + cask.progressState = newProgress + } + } catch { + let alertMessage = switch completeOutput { + // Already installed + case _ where completeOutput.contains("It seems there is already an App"): + String(localized: "\(cask.info.name) is already installed. If you want to add it to \(Bundle.main.appName) click more options (chevron icon) and press Force Install.") + // Network error + case _ where completeOutput.contains("Could not resolve host"): + String(localized: "Couldn't download app. No internet connection, or host is unreachable.") + default: + error.localizedDescription + } + + await self.showFailure( + for: cask, + error: error, + output: completeOutput, + alertTitle: "Failed to install \(cask.info.name)", + alertMessage: alertMessage + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully installed cask \(cask.id)", + alertTitle: "\(cask.info.name) successfully installed!" + ) + + // Update state + cask.isInstalled = true + self.installedCasks.insert(cask) + } + } + + /// Uninstalls the cask + /// - Parameters: + /// - caskManager: ``CaskData`` object + /// - zap: If true the app will be uninstalled completely using the brew --zap flag + func uninstall(_ cask: Cask, zap: Bool = false) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Uninstalling") + + var arguments: [String] = [cask.info.id] + + // Add -- zap argument + if zap { + arguments.append("--zap") + } + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("uninstall", arguments: arguments) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to uninstall \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully uninstalled \(cask.info.id)", + alertTitle: "\(cask.info.name) successfully uninstalled" + ) + + // Update state + cask.isInstalled = false + self.installedCasks.remove(cask) + } + } + + /// Updates the cask + func update(_ cask: Cask) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Updating") + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("upgrade", arguments: [cask.info.id]) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to update \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully updated \(cask.id)", + alertTitle: "\(cask.info.name) successfully updated" + ) + + // Update state + self.outdatedCasks.remove(cask) + } + } + + /// Reinstalls the cask + func reinstall(_ cask: Cask) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Reinstalling") + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("reinstall", arguments: [cask.info.id]) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to reinstall \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully reinstalled \(cask.info.id)", + alertTitle: "\(cask.info.name) successfully reinstalled" + ) + } + } + + /// Update all outdated casks + func updateAll(_ casks: [Cask]) { + for cask in casks { + self.update(cask) + } + } + + // MARK: - Helper functions + + /// Starts a brew task and appends it to active tasks + private func runTask(for cask: Cask, _ operation: @escaping () async -> Void) { + let task = Task { + defer { + self.activeTasks.removeAll { + $0.cask == cask + } + } + + // Make sure if brew path is valid + guard await BrewPaths.isSelectedBrewPathValid() else { + Self.logger.error("Couln't start brew operation because brew path is invalid") + alert.show(title: "Brew path is invalid", message: DependencyManager.brokenPathOrIstallMessage) + return + } + + await operation() + } + + self.activeTasks.append((cask: cask, task: task)) + } + + /// Parses the shell output when installing a cask + private func parseBrewInstall(output: String) -> CaskProgressState { + if output.contains("Downloading") { + return .busy(withTask: "") + } else if output.contains("#") { + let regex = /#+\s+(\d+\.\d+)%/ + + if let result = output.firstMatch(of: regex) { + return .downloading(percent: (Double(result.1) ?? 0) / 100) + } + } + else if output.contains("Installing") || output.contains("Moving") || output.contains("Linking") { + return .busy(withTask: String(localized: "Installing")) + } + else if output.contains("successfully installed") { + return .success + } + + return .busy(withTask: "") + } + + /// Register successful task + /// + /// - Logs success + /// - Sends notification + /// - Sets progress state to success for 2 seconds + private func showSuccess( + for cask: Cask, + logMessage: String, + alertTitle: String, + alertMessage: String = "" + ) async { + Self.logger.info("\(logMessage)") + + // Show success for 2 seconds + cask.progressState = .success + try? await Task.sleep(for: .seconds(2)) + cask.progressState = .idle + + await sendNotification(title: alertTitle, body: alertMessage, reason: .success) + } + + /// Register failed task + /// + /// - Logs error + /// - Shows alert and notification + /// - Sets progress state to failed + private func showFailure( + for cask: Cask, + error: Error, + output: String, + alertTitle: String, + alertMessage: String, + notificationTitle: String? = nil, + notificationMessage: String = "" + ) async { + // Log error + Self.logger.error("\(alertTitle)\nError: \(error.localizedDescription)\nOutput: \(output)") + + // Alert + alert.show(title: alertTitle, message: alertMessage) + + // Send notification + let notificationTitle = notificationTitle ?? alertTitle + + // Set progress state to failed + cask.progressState = .failed(output: output) + + await sendNotification(title: notificationTitle, body: notificationMessage, reason: .failure) + } +} diff --git a/Applite/Model/Cask Models/CaskData.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift old mode 100755 new mode 100644 similarity index 56% rename from Applite/Model/Cask Models/CaskData.swift rename to Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift index f9df9dc..d3ef339 --- a/Applite/Model/Cask Models/CaskData.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift @@ -1,45 +1,25 @@ // -// CaskData.swift +// CaskManager+LoadData.swift // Applite // -// Created by Milán Várady on 2022. 10. 04.. +// Created by Milán Várady on 2024.12.31. // import Foundation -import OSLog - -/// A dictionary that has cask id's as keys and number of downloads as the values -typealias BrewAnalyticsDictionary = [String: Int] - -/// Gathers data from Homebrew API and local sources and combines them concurrently into ``Cask`` objects -@MainActor -final class CaskData: ObservableObject { - @Published var casks: [Cask] = [] - @Published var busyCasks: Set = [] - @Published var outdatedCasks: Set = [] - + +extension CaskManager { + // URLs private static let cacheDirectory = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) - + private static let caskCacheURL = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) .appendingPathComponent("cask.json", conformingTo: .json) - + private static let analyicsCacheURL = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) .appendingPathComponent("caskAnalytics.json", conformingTo: .json) - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String(describing: CaskData.self) - ) - - typealias CaskCategoryDict = [String: [Cask]] - typealias CoupledCaskCategoryDict = [String: [[Cask]]] - - var casksByCategory: CaskCategoryDict = [:] - var casksByCategoryCoupled: CoupledCaskCategoryDict = [:] - + /// Gathers all necessary information and combines them to a list of ``Cask`` objects /// - Returns: Void func loadData() async throws -> Void { @@ -49,36 +29,36 @@ final class CaskData: ObservableObject { func loadCaskInfo() async throws -> [CaskInfo] { // Get json data from api guard let casksURL = URL(string: "https://formulae.brew.sh/api/cask.json") else { return [] } - - let caskData: Data + + let caskManager: Data let sessionConfiguration = NetworkProxyManager.getURLSessionConfiguration() let urlSession = URLSession(configuration: sessionConfiguration) do { - (caskData, _) = try await urlSession.data(from: casksURL) + (caskManager, _) = try await urlSession.data(from: casksURL) } catch { await Self.logger.error("Couldn't get cask data from brew API. Error: \(error.localizedDescription)") // Try to load from cache await Self.logger.notice("Attempting to load cask data from cache") - caskData = try await loadDataFromCache(dataURL: Self.caskCacheURL) + caskManager = try await loadDataFromCache(dataURL: Self.caskCacheURL) } - + // Chache json file - await cacheData(data: caskData, to: Self.caskCacheURL) - + await cacheData(data: caskManager, to: Self.caskCacheURL) + // Decode static cask data - return try JSONDecoder().decode([CaskInfo].self, from: caskData) + return try JSONDecoder().decode([CaskInfo].self, from: caskManager) } - + /// Gets cask analytics information from the Homebrew API and decodes it into a dictionary /// - Returns: A Cask ID to download count dictionary @Sendable func loadAnalyticsData() async throws -> BrewAnalyticsDictionary { // Get json data from api guard let analyticsURL = URL(string: "https://formulae.brew.sh/api/analytics/cask-install/365d.json") else { return [:] } - + let analyticsData: Data let sessionConfiguration = NetworkProxyManager.getURLSessionConfiguration() @@ -88,28 +68,28 @@ final class CaskData: ObservableObject { (analyticsData, _) = try await urlSession.data(from: analyticsURL) } catch { await Self.logger.error("Couldn't get analytics data from brew API. Error: \(error.localizedDescription)") - + // Try to load from cache await Self.logger.notice("Attempting to load analytics data from cache") analyticsData = try await loadDataFromCache(dataURL: Self.analyicsCacheURL) } - + // Chache json file await cacheData(data: analyticsData, to: Self.analyicsCacheURL) - + let analyticsDecoded: BrewAnalytics - + // Decode data analyticsDecoded = try JSONDecoder().decode(BrewAnalytics.self, from: analyticsData) - + // Convert analytics to a cask ID to download count dictionary let analyticsDict: BrewAnalyticsDictionary = Dictionary(uniqueKeysWithValues: analyticsDecoded.items.map { ($0.cask, Int($0.count.replacingOccurrences(of: ",", with: "")) ?? 0) }) - + return analyticsDict } - + /// Gets the list of installed casks /// - Returns: A list of Cask ID's @Sendable @@ -119,23 +99,12 @@ final class CaskData: ObservableObject { if output.isEmpty { await Self.logger.notice("No installed casks were found") } - - return output - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") - } - - /// Gets the list of outdated casks - /// - Returns: A list of Cask ID's - @Sendable - func getOutdatedCasks() async throws -> [String] { - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) outdated --cask -q") return output .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: "\n") } - + /// Saves ``Data`` objects to cache /// /// - Parameters: @@ -147,7 +116,7 @@ final class CaskData: ObservableObject { // Create cache directory if doesn't exists do { var isDirectory: ObjCBool = true - + if await !FileManager.default.fileExists(atPath: Self.cacheDirectory.path, isDirectory: &isDirectory) { await Self.logger.warning("Cache directory doesn't exists, attempting to create it") try await FileManager.default.createDirectory(at: Self.cacheDirectory, withIntermediateDirectories: false) @@ -156,7 +125,7 @@ final class CaskData: ObservableObject { await Self.logger.error("Cound't create cache directory") return } - + // Save data to cache do { try data.write(to: filePath) @@ -164,92 +133,86 @@ final class CaskData: ObservableObject { await Self.logger.error("Couldn't write data to cache") } } - + /// Loads data from cache /// - Returns: A ``Data`` object @Sendable func loadDataFromCache(dataURL: URL) async throws -> Data { - let data = try Data(contentsOf: dataURL) - - return data - } - - /// Filters casks into a category to casks dictionary - /// - Returns: A tuple of two dictionaries, the first is just a category to casks dict, the second is the same but chunked into two for the discover view - func fillCategoryDicts() -> (CaskCategoryDict, CoupledCaskCategoryDict) { - var categoryDict: CaskCategoryDict = [:] - - for category in categories { - // Filter casks - let filteredCasks = casks.filter { - category.casks.contains($0.info.id) - } - - // Sort by number of downloads - categoryDict[category.id] = filteredCasks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) - } - - var coupledCategoryDict: CoupledCaskCategoryDict = [:] - - for (categoryID, cask) in categoryDict { - let chunkedCasks = cask.chunked(into: 2) - - coupledCategoryDict[categoryID] = chunkedCasks + do { + return try Data(contentsOf: dataURL) + } catch { + throw CaskLoadError.failedToLoadFromCache } - - return (categoryDict, coupledCategoryDict) } - + + func loadCategoryJSONAsync() async throws -> [Category] { + try loadCategoryJSON() + } + // Get data components concurrently + async let categories = loadCategoryJSONAsync() async let caskInfo = loadCaskInfo() async let analyticsDict = loadAnalyticsData() async let installedCasks = getInstalledCasks() - async let outdatedCaskIDs = getOutdatedCasks() - var casks: [Cask] = [] + // Set casks reseve capacity for better performance + await self.casks.reserveCapacity(try caskInfo.count) + + // Casks by category + var categoryDict: [CategoryId: [Cask]] = [:] for caskInfo in try await caskInfo { + let isInstalled = try await installedCasks.contains(caskInfo.id) + let cask = Cask( info: caskInfo, downloadsIn365days: try await analyticsDict[caskInfo.id] ?? 0, - isInstalled: try await installedCasks.contains(caskInfo.id), - isOutdated: try await outdatedCaskIDs.contains(caskInfo.id) + isInstalled: isInstalled ) - casks.append(cask) + casks[cask.id] = cask + + if isInstalled { + self.installedCasks.insert(cask) + } + + // Add to category if needed + for category in try await categories { + // Add to category + if category.casks.contains(cask.id) { + if let casksInCategory = categoryDict[category.id] { + categoryDict[category.id] = casksInCategory + [cask] + } else { + categoryDict[category.id] = [cask] + } + } + } } - self.casks = casks + Self.logger.info("Compiling categories") - Self.logger.info("Cask data loaded successfully!") - - // Create category dicts - (casksByCategory, casksByCategoryCoupled) = fillCategoryDicts() - } + var categoryViewModels: [CategoryViewModel] = [] + + // Make category view models + for category in try await categories { + if let casksInCategory = categoryDict[category.id] { + let casks = casksInCategory.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) + let chunkedCasks = casks.chunked(into: 2) - func refreshOutdatedApps(greedy: Bool = false) async throws -> Void { - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) outdated --cask \(greedy ? "-g" : "") -q") - - let outdatedCaskIDs = output - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) // Remove empty strings - .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) // Trim whitespace - - for i in self.casks.indices { - if outdatedCaskIDs.contains(self.casks[i].info.id) && self.casks[i].isInstalled { - self.casks[i].isOutdated = true - outdatedCasks.insert(casks[i]) + categoryViewModels.append( + CategoryViewModel( + name: category.id, + sfSymbol: category.sfSymbol, + casks: casks, + casksCoupled: chunkedCasks) + ) } } - - Self.logger.info("Outdated apps refreshed") - } - - /// Filters busy casks - func filterBusyCasks() { - self.busyCasks = self.busyCasks.filter { - $0.progressState != .idle - } + + self.categories = categoryViewModels + + Self.logger.info("Cask data loaded successfully!") + + try await self.refreshOutdated() } } diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift new file mode 100644 index 0000000..c26e744 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift @@ -0,0 +1,36 @@ +// +// CaskManager+RefreshOutdated.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +extension CaskManager { + func refreshOutdated(greedy: Bool = false) async throws -> Void { + var arguments: [String] = ["-q"] + + if greedy { + arguments.append("-g") + } + + let output = try await Shell.runBrewCommand("outdated", arguments: arguments) + + let outdatedCaskIDs = output + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + .filter({ !$0.isEmpty }) // Remove empty strings + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) // Trim whitespace + + self.outdatedCasks.removeAll() + + for caskID in outdatedCaskIDs { + if let cask = self.casks[caskID] { + self.outdatedCasks.insert(cask) + } + } + + Self.logger.info("Outdated apps refreshed") + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager.swift new file mode 100755 index 0000000..8e392ea --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager.swift @@ -0,0 +1,61 @@ +// +// CaskManager.swift +// Applite +// +// Created by Milán Várady on 2022. 10. 04.. +// + +import Foundation +import OSLog + +typealias CaskId = String +typealias BrewAnalyticsDictionary = [CaskId: Int] +typealias BrewTask = (cask: Cask, task: Task) + +/// Holds all cask data and provides methods to take actions on them (e.g. install, update) +@MainActor +final class CaskManager: ObservableObject { + /// Cask view models + @Published var casks: [CaskId: Cask] = [:] + /// All currently running brew tasks + @Published var activeTasks: [BrewTask] = [] + @Published var installedCasks: Set = [] + @Published var outdatedCasks: Set = [] + + @Published var alert = AlertManager() + + // Precompiled cask category dicts + var categories: [CategoryViewModel] = [] + + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: CaskManager.self) + ) + + init() { + // Load categories at init so the view can display them + do { + let categories = try loadCategoryJSON() + let categoryViewModels = categories.map { + CategoryViewModel(name: $0.id, sfSymbol: $0.sfSymbol, casks: [], casksCoupled: []) + } + + self.categories = categoryViewModels + } catch { + self.alert.show(title: "Couldn't load categories") + Self.logger.error("Failed to load categories: \(error.localizedDescription)") + } + } + + func loadCategoryJSON() throws -> [Category] { + let decoder = JSONDecoder() + guard let url = Bundle.main.url(forResource: "categories", withExtension: "json") else { + throw CaskLoadError.failedToLoadCategoryJSON + } + + let data = try Data(contentsOf: url) + let categories = try decoder.decode([Category].self, from: data) + + return categories + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift b/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift new file mode 100644 index 0000000..024cba3 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift @@ -0,0 +1,17 @@ +// +// CaskProgressState.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +/// Cask progress state when installing, updating or uninstalling +enum CaskProgressState: Equatable, Hashable { + case idle + case busy(withTask: String) + case downloading(percent: Double) + case success + case failed(output: String) +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift b/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift new file mode 100644 index 0000000..c239ffb --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift @@ -0,0 +1,26 @@ +// +// CaskTaskError.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +enum CaskTaskError: LocalizedError { + case failedToUpdateProgress + + var errorDescription: String? { + switch self { + case .failedToUpdateProgress: + return "Failed to update progress state" + } + } + + var failureReason: String? { + switch self { + case .failedToUpdateProgress: + return "The cask isn't present" + } + } +} diff --git a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift deleted file mode 100644 index 407d6ce..0000000 --- a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// Cask+BrewFunctions.swift -// Applite -// -// Created by Milán Várady on 2024.12.27. -// - -import Foundation - -extension Cask { - /// Installs the cask - /// - /// - Parameters: - /// - caskData: ``CaskData`` object passed in by the view - /// - force: If `true` install will be run with the `--force` flag - func install(caskData: CaskData, force: Bool = false) async { - defer { - resetProgressState(caskData: caskData) - } - - Self.logger.info("Cask \"\(self.info.id)\" installation started") - - // Appdir argument - let appdirOn = UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) - let appdirPath = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) - let appdirArgument = "--appdir=\"\(appdirPath ?? "/Applications")\"" - - // Install command - let command = "\(BrewPaths.currentBrewExecutable) install --cask \(force ? "--force" : "") \(self.id) \(appdirOn ? appdirArgument : "")" - - // Setup progress - self.progressState = .busy(withTask: "") - caskData.busyCasks.insert(self) - - /// Holds the complete output of the install process - var completeOutput = "" - - // Run install command and stream output - do { - for try await line in Shell.stream(command, pty: true) { - completeOutput += line - self.progressState = self.parseBrewInstall(output: line) - } - } catch { - let alertMessage = switch completeOutput { - // Already installed - case _ where completeOutput.contains("It seems there is already an App"): - String(localized: "\(self.info.name) is already installed. If you want to add it to \(Bundle.main.appName) click more options (chevron icon) and press Force Install.") - // Network error - case _ where completeOutput.contains("Could not resolve host"): - String(localized: "Couldn't download app. No internet connection, or host is unreachable.") - default: - error.localizedDescription - } - - showFailure( - error: error, - output: completeOutput, - alertTitle: "Failed to install \(self.info.name)", - alertMessage: alertMessage - ) - return - } - - showSuccess( - logMessage: "Successfully installed cask \(self.id)", - alertTitle: "\(self.info.name) successfully installed!" - ) - - // Update state - self.isInstalled = true - } - - /// Uninstalls the cask - /// - Parameters: - /// - caskData: ``CaskData`` object - /// - zap: If true the app will be uninstalled completely using the brew --zap flag - func uninstall(caskData: CaskData, zap: Bool = false) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Uninstalling") - caskData.busyCasks.insert(self) - - var arguments: [String] = [self.info.id] - - // Add -- zap argument - if zap { - arguments.append("--zap") - } - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: arguments) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to uninstall \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully uninstalled \(self.info.id)", - alertTitle: "\(self.info.name) successfully uninstalled" - ) - - // Update state - self.isInstalled = false - } - - /// Updates the cask - func update(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Updating") - caskData.busyCasks.insert(self) - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: [self.info.id]) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to update \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully updated \(self.id)", - alertTitle: "\(self.info.name) successfully updated" - ) - - // Update state - caskData.outdatedCasks.remove(self) - } - - /// Reinstalls the cask - func reinstall(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Reinstalling") - caskData.busyCasks.insert(self) - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: [self.info.id]) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to reinstall \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully reinstalled \(self.info.id)", - alertTitle: "\(self.info.name) successfully reinstalled" - ) - } - - // MARK: - Helper functions - - /// Parses the shell output when installing a cask - private func parseBrewInstall(output: String) -> ProgressState { - if output.contains("Downloading") { - return .busy(withTask: "") - } else if output.contains("#") { - let regex = /#+\s+(\d+\.\d+)%/ - - if let result = output.firstMatch(of: regex) { - return .downloading(percent: (Double(result.1) ?? 0) / 100) - } - } - else if output.contains("Installing") || output.contains("Moving") || output.contains("Linking") { - return .busy(withTask: String(localized: "Installing")) - } - else if output.contains("successfully installed") { - return .success - } - - return .busy(withTask: "") - } - - /// Register successful task - /// - /// - Logs success - /// - Sends notification - /// - Sets progress state to success for 2 seconds - private func showSuccess( - logMessage: String, - alertTitle: String, - alertMessage: String = "" - ) { - Self.logger.info("\(logMessage)") - - Task { - await sendNotification(title: alertTitle, body: alertMessage, reason: .success) - } - - // Show success for 2 seconds - progressState = .success - Task { - try? await Task.sleep(for: .seconds(2)) - progressState = .idle - } - } - - /// Register failed task - /// - /// - Logs error - /// - Shows alert and notification - /// - Sets progress state to failed - private func showFailure( - error: Error, - output: String, - alertTitle: String, - alertMessage: String, - notificationTitle: String? = nil, - notificationMessage: String = "" - ) { - // Log error - Self.logger.error("\(alertTitle)\nError: \(error.localizedDescription)\nOutput: \(output)") - - // Alert - alert.show(title: alertTitle, message: alertMessage) - - // Send notification - let notificationTitle = notificationTitle ?? alertTitle - - Task { - await sendNotification(title: notificationTitle, body: notificationMessage, reason: .failure) - } - - // Set progress state to failed - progressState = .failed(output: output) - } - - /// Resets progress state and removes self from ``CaskData.busyCasks`` - private func resetProgressState(caskData: CaskData) { - Task { - caskData.busyCasks.remove(self) - - // Reset state unless it's not succes or failed - switch self.progressState { - case .success: - break - case .failed(_): - break - default: - self.progressState = .idle - } - } - } -} diff --git a/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift b/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift deleted file mode 100644 index d894c97..0000000 --- a/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Cask+ProtocolConformances.swift -// Applite -// -// Created by Milán Várady on 2024.12.27. -// - -import Foundation - -extension Cask { - nonisolated var id: String { - self.info.id - } - - // Equatable - nonisolated static func == (lhs: Cask, rhs: Cask) -> Bool { - lhs.id == rhs.id - } - - // Hashable - nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } -} diff --git a/Applite/Model/Cask Models/Cask/Cask.swift b/Applite/Model/Cask Models/Cask/Cask.swift index 6a1077a..55e4fa7 100755 --- a/Applite/Model/Cask Models/Cask/Cask.swift +++ b/Applite/Model/Cask Models/Cask/Cask.swift @@ -17,35 +17,21 @@ final class Cask: ObservableObject, Identifiable, Hashable { /// Number of downloads in the last 365 days let downloadsIn365days: Int - // MARK: - Published properties - + // MARK: Published properties @Published var isInstalled: Bool = false - @Published var isOutdated: Bool = false /// Progress state of the cask when installing, updating or uninstalling - @Published var progressState: ProgressState = .idle - - @Published var alert = AlertManager() + @Published var progressState: CaskProgressState = .idle static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: Cask.self) ) - /// Cask progress state when installing, updating or uninstalling - enum ProgressState: Equatable, Hashable { - case idle - case busy(withTask: String) - case downloading(percent: Double) - case success - case failed(output: String) - } - - required init(info: CaskInfo, downloadsIn365days: Int, isInstalled: Bool = false, isOutdated: Bool = false) { + required init(info: CaskInfo, downloadsIn365days: Int, isInstalled: Bool = false) { self.info = info self.downloadsIn365days = downloadsIn365days self.isInstalled = isInstalled - self.isOutdated = isOutdated } static let dummy = Cask(info: CaskInfo( @@ -56,4 +42,20 @@ final class Cask: ObservableObject, Identifiable, Hashable { caveats: nil, pkgInstaller: false ), downloadsIn365days: 100) + + // MARK: - Protocols + + nonisolated var id: String { + self.info.id + } + + // Equatable + nonisolated static func == (lhs: Cask, rhs: Cask) -> Bool { + lhs.id == rhs.id + } + + // Hashable + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } } diff --git a/Applite/Model/Categories/Category.swift b/Applite/Model/Categories/Category.swift index 90041b8..9d5e8f8 100755 --- a/Applite/Model/Categories/Category.swift +++ b/Applite/Model/Categories/Category.swift @@ -7,29 +7,14 @@ import Foundation -/// Holds the app categories -let categories: [Category] = loadLocalJson(fileName: "categories") +typealias CategoryId = String /// App category object struct Category: Decodable, Identifiable { /// Category id let id: String /// List of cask ids - let casks: [String] + let casks: [CaskId] /// SF Symbol of the category let sfSymbol: String } - -/// Loads a json from resources -fileprivate func loadLocalJson(fileName: String) -> [Category] { - let decoder = JSONDecoder() - guard - let url = Bundle.main.url(forResource: fileName, withExtension: "json"), - let data = try? Data(contentsOf: url), - let categories = try? decoder.decode([Category].self, from: data) - else { - return [] - } - - return categories -} diff --git a/Applite/Model/Categories/CategoryViewModel.swift b/Applite/Model/Categories/CategoryViewModel.swift new file mode 100644 index 0000000..df3fef1 --- /dev/null +++ b/Applite/Model/Categories/CategoryViewModel.swift @@ -0,0 +1,17 @@ +// +// CategoryViewModel.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import Foundation + +struct CategoryViewModel: Identifiable, Equatable, Hashable { + let name: String + let sfSymbol: String + let casks: [Cask] + let casksCoupled: [[Cask]] + + var id: String { name } +} diff --git a/Applite/Model/SidebarItem.swift b/Applite/Model/SidebarItem.swift index cb35728..9d37ef0 100644 --- a/Applite/Model/SidebarItem.swift +++ b/Applite/Model/SidebarItem.swift @@ -14,5 +14,5 @@ enum SidebarItem: Equatable, Hashable { case activeTasks case appMigration case brew - case appCategory(categoryId: String) + case appCategory(category: CategoryViewModel) } diff --git a/Applite/Utilities/Import Export/ExportCasks.swift b/Applite/Utilities/Import Export/ExportCasks.swift index 7262031..ae24351 100644 --- a/Applite/Utilities/Import Export/ExportCasks.swift +++ b/Applite/Utilities/Import Export/ExportCasks.swift @@ -64,14 +64,16 @@ enum CaskToFileManager { return casks } - static func installImportedCasks(casks: [String], caskData: CaskData) async { - let casksToInstall: [Cask] = await caskData.casks.filter({ casks.contains($0.info.id) }) - + static func installImportedCasks(caskIds: [CaskId], caskManager: CaskManager) async { await withTaskGroup(of: Void.self) { group in - for cask in casksToInstall { + for caskId in caskIds { + guard let cask = await caskManager.casks[caskId] else { + continue + } + group.addTask { if await !cask.isInstalled { - await cask.install(caskData: caskData) + await caskManager.install(cask) } } } diff --git a/Applite/Views/App Views/App View/AppView+DownloadButton.swift b/Applite/Views/App Views/App View/AppView+DownloadButton.swift index ca96c5b..b058114 100644 --- a/Applite/Views/App Views/App View/AppView+DownloadButton.swift +++ b/Applite/Views/App Views/App View/AppView+DownloadButton.swift @@ -12,7 +12,7 @@ extension AppView { struct DownloadButton: View { @ObservedObject var cask: Cask - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager // Alerts @State var showingPopover = false @@ -31,7 +31,7 @@ extension AppView { return } - download() + caskManager.install(cask) } label: { Image(systemName: "arrow.down.to.line.circle\(buttonFill ? ".fill" : "")") .font(.system(size: 22)) @@ -46,7 +46,7 @@ extension AppView { } .alert("App caveats", isPresented: $showingCaveats) { Button("Download Anyway") { - download() + caskManager.install(cask) } Button("Cancel", role: .cancel) { } @@ -90,23 +90,11 @@ extension AppView { } .confirmationDialog("Are you sure you want to force install \(cask.info.name)? This will override any current installation!", isPresented: $showingForceInstallConfirmation) { Button("Yes") { - download(force: true) + caskManager.install(cask) } Button("Cancel", role: .cancel) { } } } - - private func download(force: Bool = false) { - Task { @MainActor in - // Check if brew path is valid - guard await BrewPaths.isSelectedBrewPathValid() else { - showingBrewError = true - return - } - - await cask.install(caskData: caskData, force: force) - } - } } } diff --git a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift index 9935d76..525bdb9 100644 --- a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift +++ b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift @@ -13,7 +13,7 @@ extension AppView { @StateObject var cask: Cask let deleteButton: Bool - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var showAppNotFoundAlert = false @State var showPopover = false @@ -52,9 +52,7 @@ extension AppView { VStack(alignment: .leading, spacing: 6) { // Reinstall button Button { - Task { - await cask.reinstall(caskData: caskData) - } + caskManager.reinstall(cask) } label: { Label("Reinstall", systemImage: "arrow.2.squarepath") } @@ -62,7 +60,7 @@ extension AppView { // Uninstall button Button(role: .destructive) { Task { - await cask.uninstall(caskData: caskData) + caskManager.uninstall(cask) } } label: { Label("Uninstall", systemImage: "trash") @@ -72,7 +70,7 @@ extension AppView { // Uninstall completely button Button(role: .destructive) { Task { - await cask.uninstall(caskData: caskData, zap: true) + caskManager.uninstall(cask, zap: true) } } label: { Label("Uninstall Completely", systemImage: "trash.fill") diff --git a/Applite/Views/App Views/App View/AppView+UninstallButton.swift b/Applite/Views/App Views/App View/AppView+UninstallButton.swift index 9a4bfcc..cdc7d91 100644 --- a/Applite/Views/App Views/App View/AppView+UninstallButton.swift +++ b/Applite/Views/App Views/App View/AppView+UninstallButton.swift @@ -10,15 +10,13 @@ import SwiftUI extension AppView { struct UninstallButton: View { @StateObject var cask: Cask - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var showingError = false var body: some View { Button { - Task { - await cask.uninstall(caskData: caskData) - } + caskManager.uninstall(cask) } label: { Image(systemName: "trash.fill") .font(.system(size: 20)) diff --git a/Applite/Views/App Views/App View/AppView+UpdateButton.swift b/Applite/Views/App Views/App View/AppView+UpdateButton.swift index 50997ea..29e6d75 100644 --- a/Applite/Views/App Views/App View/AppView+UpdateButton.swift +++ b/Applite/Views/App Views/App View/AppView+UpdateButton.swift @@ -9,14 +9,12 @@ import SwiftUI extension AppView { struct UpdateButton: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @StateObject var cask: Cask var body: some View { Button { - Task { - await cask.update(caskData: caskData) - } + caskManager.update(cask) } label: { Image(systemName: "arrow.uturn.down.circle.fill") .font(.system(size: 20)) diff --git a/Applite/Views/App Views/App View/AppView.swift b/Applite/Views/App Views/App View/AppView.swift index 4398b68..4c5a2ff 100755 --- a/Applite/Views/App Views/App View/AppView.swift +++ b/Applite/Views/App Views/App View/AppView.swift @@ -24,7 +24,7 @@ struct AppView: View { @Environment(\.openWindow) var openWindow - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager // Alerts @State var failureAlertMessage = "" @@ -47,7 +47,7 @@ struct AppView: View { } .buttonStyle(.plain) .frame(width: Self.dimensions.width, height: Self.dimensions.height) - .alertManager(cask.alert) + .alertManager(caskManager.alert) } } diff --git a/Applite/Views/App Views/AppGridView.swift b/Applite/Views/App Views/AppGridView.swift index 9fa10a0..e8e7d33 100755 --- a/Applite/Views/App Views/AppGridView.swift +++ b/Applite/Views/App Views/AppGridView.swift @@ -36,9 +36,6 @@ struct AppGridView: View { } } -struct AppGridView_Previews: PreviewProvider { - static var previews: some View { - AppGridView(casks: Array(CaskData().casks[0...10]), appRole: .installAndManage) - .frame(width: 660, height: 500) - } +#Preview { + AppGridView(casks: Array(repeating: .dummy, count: 8), appRole: .installAndManage) } diff --git a/Applite/Views/Content View/ContentView+DetailView.swift b/Applite/Views/Content View/ContentView+DetailView.swift index 7f021c3..e9c623a 100644 --- a/Applite/Views/Content View/ContentView+DetailView.swift +++ b/Applite/Views/Content View/ContentView+DetailView.swift @@ -44,12 +44,8 @@ extension ContentView { case .appMigration: AppMigrationView() - case .appCategory(let categoryId): - if let category = categories.first(where: { $0.id == categoryId }) { - CategoryView(category: category) - } else { - Text("No Selection") - } + case .appCategory(let category): + CategoryView(category: category) case .brew: BrewManagementView(modifyingBrew: $modifyingBrew) diff --git a/Applite/Views/Content View/ContentView+LoadCasks.swift b/Applite/Views/Content View/ContentView+LoadCasks.swift index d8bef85..4e8d51f 100644 --- a/Applite/Views/Content View/ContentView+LoadCasks.swift +++ b/Applite/Views/Content View/ContentView+LoadCasks.swift @@ -27,7 +27,7 @@ extension ContentView { } do { - try await caskData.loadData() + try await caskManager.loadData() brokenInstall = false } catch { loadAlert.show(title: "Couldn't load app catalog", message: error.localizedDescription) diff --git a/Applite/Views/Content View/ContentView+SidebarViews.swift b/Applite/Views/Content View/ContentView+SidebarViews.swift index 90b0580..d8e38c2 100644 --- a/Applite/Views/Content View/ContentView+SidebarViews.swift +++ b/Applite/Views/Content View/ContentView+SidebarViews.swift @@ -16,23 +16,23 @@ extension ContentView { .tag(SidebarItem.home) Label("Updates", systemImage: "arrow.clockwise.circle.fill") - .badge(caskData.outdatedCasks.count) + .badge(caskManager.outdatedCasks.count) .tag(SidebarItem.updates) Label("Installed", systemImage: "externaldrive.fill.badge.checkmark") .tag(SidebarItem.installed) Label("Active Tasks", systemImage: "gearshape.arrow.triangle.2.circlepath") - .badge(caskData.busyCasks.count) + .badge(caskManager.activeTasks.count) .tag(SidebarItem.activeTasks) Label("App Migration", systemImage: "square.and.arrow.up.on.square") .tag(SidebarItem.appMigration) Section("Categories") { - ForEach(categories) { category in - Label(LocalizedStringKey(category.id), systemImage: category.sfSymbol) - .tag(SidebarItem.appCategory(categoryId: category.id)) + ForEach(caskManager.categories) { category in + Label(LocalizedStringKey(category.name), systemImage: category.sfSymbol) + .tag(SidebarItem.appCategory(category: category)) } } diff --git a/Applite/Views/Content View/ContentView.swift b/Applite/Views/Content View/ContentView.swift index 29cc1e1..63abef1 100755 --- a/Applite/Views/Content View/ContentView.swift +++ b/Applite/Views/Content View/ContentView.swift @@ -9,7 +9,7 @@ import SwiftUI import os struct ContentView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager /// Currently selected tab in the sidebar @State var selection: SidebarItem = .home @@ -27,7 +27,7 @@ struct ContentView: View { @State var modifyingBrew = false let logger = Logger() - + var body: some View { NavigationSplitView { sidebarViews diff --git a/Applite/Views/Detail Views/ActiveTasksView.swift b/Applite/Views/Detail Views/ActiveTasksView.swift index c4c7f93..0cd9b28 100644 --- a/Applite/Views/Detail Views/ActiveTasksView.swift +++ b/Applite/Views/Detail Views/ActiveTasksView.swift @@ -8,25 +8,22 @@ import SwiftUI struct ActiveTasksView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager var body: some View { ScrollView { VStack { - if caskData.busyCasks.isEmpty { + if caskManager.activeTasks.isEmpty { Text("No Active Tasks") .font(.title) } else { - AppGridView(casks: Array(caskData.busyCasks), appRole: .update) + AppGridView(casks: caskManager.activeTasks.map { $0.cask }, appRole: .update) } Spacer() } .padding() } - .onAppear { - caskData.filterBusyCasks() - } } } diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift index daab4bf..a9f6ae6 100755 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ b/Applite/Views/Detail Views/BrewManagementView.swift @@ -258,7 +258,7 @@ struct BrewManagementView: View { } struct ExportView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State private var fileExporterPresented = false @State private var fileImporterPresented = false @@ -344,7 +344,7 @@ struct BrewManagementView: View { func installImported(casks: [String]) { Task { - await CaskToFileManager.installImportedCasks(casks: casks, caskData: caskData) + await CaskToFileManager.installImportedCasks(caskIds: casks, caskManager: caskManager) } } } diff --git a/Applite/Views/Detail Views/CategoryView.swift b/Applite/Views/Detail Views/CategoryView.swift index de0c90e..66bac8f 100755 --- a/Applite/Views/Detail Views/CategoryView.swift +++ b/Applite/Views/Detail Views/CategoryView.swift @@ -9,14 +9,13 @@ import SwiftUI /// Detail view used in the category section struct CategoryView: View { - let category: Category - @EnvironmentObject var caskData: CaskData + let category: CategoryViewModel var body: some View { VStack(alignment: .leading) { // Category name Group { - Text(LocalizedStringKey(category.id)) + Text(category.name) .font(.appliteMediumTitle) .padding(.bottom, -20) @@ -26,15 +25,20 @@ struct CategoryView: View { // Apps ScrollView { - AppGridView(casks: caskData.casksByCategory[category.id] ?? [], appRole: .installAndManage) + AppGridView(casks: category.casks, appRole: .installAndManage) } } } } -struct CategoryView_Previews: PreviewProvider { - static var previews: some View { - CategoryView(category: categories[0]) - } +#Preview { + CategoryView(category: + .init( + name: "Test", + sfSymbol: "star", + casks: Array(repeating: .dummy, count: 8), + casksCoupled: [Array(repeating: .dummy, count: 8)] + ) + ) } diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift index ca11711..7cb4c1c 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift @@ -21,8 +21,7 @@ extension DiscoverSectionView { // App row appRow - - .coordinateSpace(name: "\(category.id)Scroll") + .coordinateSpace(name: "\(category.id)Scroll") // Forward button scrollButton( @@ -38,8 +37,8 @@ extension DiscoverSectionView { private var appRow: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { - if caskData.casksByCategoryCoupled[category.id]?.count ?? 0 > 0 { - ForEach(Array((caskData.casksByCategoryCoupled[category.id]?.enumerated())!), id: \.offset) { index, casks in + if category.casksCoupled.count > 0 { + ForEach(Array(category.casksCoupled.enumerated()), id: \.offset) { index, casks in VStack { ForEach(casks) { cask in AppView(cask: cask, role: .installAndManage) diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift index 2593baa..3eba857 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift @@ -15,11 +15,11 @@ extension DiscoverSectionView { Image(systemName: category.sfSymbol) .font(.system(size: fontSize)) - Text(LocalizedStringKey(category.id)) + Text(LocalizedStringKey(category.name)) .font(.system(size: fontSize, weight: .bold)) Button("See All") { - navigationSelection = .appCategory(categoryId: category.id) + navigationSelection = .appCategory(category: category) } .buttonStyle(.plain) .foregroundColor(.blue) diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift index 40620be..50b685a 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift @@ -8,10 +8,10 @@ import SwiftUI struct DiscoverSectionView: View { - let category: Category + let category: CategoryViewModel @Binding var navigationSelection: SidebarItem - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var scrollOffset: CGFloat = 0 diff --git a/Applite/Views/Detail Views/Discover/DiscoverView.swift b/Applite/Views/Detail Views/Discover/DiscoverView.swift index 162c241..7cd1072 100755 --- a/Applite/Views/Detail Views/Discover/DiscoverView.swift +++ b/Applite/Views/Detail Views/Discover/DiscoverView.swift @@ -10,6 +10,7 @@ import Shimmer /// Shows apps in categories struct DiscoverView: View { + @EnvironmentObject var caskManager: CaskManager @Binding var navigationSelection: SidebarItem @State var currentPage: Float = 0 @@ -19,7 +20,7 @@ struct DiscoverView: View { .font(.appliteLargeTitle) .padding(.bottom) - ForEach(categories) { category in + ForEach(caskManager.categories) { category in DiscoverSectionView(category: category, navigationSelection: $navigationSelection) Divider() @@ -33,6 +34,6 @@ struct DiscoverView: View { struct DiscoverView_Previews: PreviewProvider { static var previews: some View { DiscoverView(navigationSelection: .constant(.home)) - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/Download/DownloadView+Search.swift b/Applite/Views/Detail Views/Download/DownloadView+Search.swift index 086fb36..e6812f4 100644 --- a/Applite/Views/Detail Views/Download/DownloadView+Search.swift +++ b/Applite/Views/Detail Views/Download/DownloadView+Search.swift @@ -15,13 +15,13 @@ extension DownloadView { /// - searchText: Search query /// - Returns: List of filtered casks func fuzzyFilter(casks: [Cask], searchText: String) -> [Cask] { - var casks = casks + var filteredCasks = casks if searchText.isEmpty { - casks = caskData.casks + filteredCasks = casks } else { // A score of 0 means a perfect match, a score of one matches everything - casks = caskData.casks.filter { + filteredCasks = casks.filter { ($0.info.name.lowercased().contains(searchText.lowercased()) || $0.info.description.lowercased().contains(searchText.lowercased())) || (fuseSearch.search(searchText.lowercased(), in: $0.info.name.lowercased())?.score ?? 1) < 0.25 || (fuseSearch.search(searchText.lowercased(), in: $0.info.description.lowercased())?.score ?? 1) < 0.25 @@ -30,19 +30,19 @@ extension DownloadView { // Filters if sortBy == .mostDownloaded { - casks = casks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) + filteredCasks = casks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) } if hideUnpopularApps { - casks = casks.filter { + filteredCasks = casks.filter { $0.downloadsIn365days > 500 } } - return casks + return filteredCasks } func search() { - self.searchResults = fuzzyFilter(casks: caskData.casks, searchText: searchText) + self.searchResults = fuzzyFilter(casks: Array(caskManager.casks.values), searchText: searchText) } } diff --git a/Applite/Views/Detail Views/Download/DownloadView.swift b/Applite/Views/Detail Views/Download/DownloadView.swift index cb67156..1138101 100755 --- a/Applite/Views/Detail Views/Download/DownloadView.swift +++ b/Applite/Views/Detail Views/Download/DownloadView.swift @@ -13,7 +13,7 @@ struct DownloadView: View { @Binding var navigationSelection: SidebarItem @Binding var searchText: String - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchResults: [Cask] = [] @@ -48,7 +48,7 @@ struct DownloadView: View { } .onChange(of: searchText) { newSearchText in // Filter apps - searchResults = fuzzyFilter(casks: caskData.casks, searchText: newSearchText) + searchResults = fuzzyFilter(casks: Array(caskManager.casks.values), searchText: newSearchText) } .onChange(of: sortBy) { _newValue in // Refilter if sorting options change @@ -68,6 +68,6 @@ struct DownloadView: View { struct DownloadView_Previews: PreviewProvider { static var previews: some View { DownloadView(navigationSelection: .constant(.home), searchText: .constant("")) - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/InstalledView.swift b/Applite/Views/Detail Views/InstalledView.swift index cf933cb..0b38a58 100755 --- a/Applite/Views/Detail Views/InstalledView.swift +++ b/Applite/Views/Detail Views/InstalledView.swift @@ -10,7 +10,7 @@ import Fuse /// Shows installed apps, where the user can open and uninstall them struct InstalledView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchText = "" let fuseSearch = Fuse() @@ -23,25 +23,28 @@ struct InstalledView: View { } } .searchable(text: $searchText) + .id("InstalledView") } - + // Filter installed casks var casks: [Cask] { - var filteredCasks = caskData.casks.filter { $0.isInstalled } - + var installedCasks = caskManager.installedCasks + if !$searchText.wrappedValue.isEmpty { - filteredCasks = filteredCasks.filter { + installedCasks = installedCasks.filter { (fuseSearch.search(searchText, in: $0.info.name)?.score ?? 1) < 0.4 } } - - return filteredCasks + + let installedCasksAlphabetical = installedCasks.sorted { $0.info.name < $1.info.name } + + return installedCasksAlphabetical } } struct InstalledView_Previews: PreviewProvider { static var previews: some View { InstalledView() - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift b/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift index 77e5f25..44bd359 100644 --- a/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift +++ b/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift @@ -32,7 +32,7 @@ extension UpdateView { Button("Show All") { Task { do { - try await caskData.refreshOutdatedApps(greedy: true) + try await caskManager.refreshOutdated(greedy: true) } catch { loadAlert.show(title: "Failed to load updates", message: error.localizedDescription) } @@ -51,7 +51,7 @@ extension UpdateView { refreshing = true do { - try await caskData.refreshOutdatedApps(greedy: true) + try await caskManager.refreshOutdated(greedy: true) } catch { loadAlert.show(title: "Failed to refresh updates", message: error.localizedDescription) } diff --git a/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift b/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift index 69ed9e2..4ab46e9 100644 --- a/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift +++ b/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift @@ -15,27 +15,11 @@ extension UpdateView { withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { updateAllButtonRotation = 360.0 } - - Task { - await withTaskGroup(of: Void.self) { group in - for cask in casks { - group.addTask { - await cask.update(caskData: caskData) - } - } - } - - await MainActor.run { - withAnimation(.linear(duration: 0.2)) { - updateAllButtonRotation = 0.0 - } - } - - updateAllFinished = true - } + + caskManager.updateAll(casks) } label: { HStack { - Image(systemName: updateAllFinished ? "checkmark" : "arrow.2.circlepath") + Image(systemName: "arrow.2.circlepath") .rotationEffect(.degrees(updateAllButtonRotation)) Text("Update All") diff --git a/Applite/Views/Detail Views/Update/UpdateView.swift b/Applite/Views/Detail Views/Update/UpdateView.swift index a3f8207..c366e6b 100755 --- a/Applite/Views/Detail Views/Update/UpdateView.swift +++ b/Applite/Views/Detail Views/Update/UpdateView.swift @@ -10,12 +10,11 @@ import Fuse /// Update section struct UpdateView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchText = "" @State var refreshing = false @State var isUpdatingAll = false - @State var updateAllFinished = false @State var updateAllButtonRotation = 0.0 @State var showingGreedyUpdateConfirm = false @@ -23,15 +22,16 @@ struct UpdateView: View { // Filter outdated casks var casks: [Cask] { - var filteredCasks = caskData.casks.filter { $0.isOutdated } - + var outdatedCasks = caskManager.outdatedCasks if !$searchText.wrappedValue.isEmpty { - filteredCasks = filteredCasks.filter { + outdatedCasks = outdatedCasks.filter { (fuseSearch.search(searchText, in: $0.info.name)?.score ?? 1) < 0.4 } } - - return filteredCasks + + let outdatedCasksAphabetical = Array(outdatedCasks).sorted { $0.info.name < $1.info.name } + + return outdatedCasksAphabetical } let fuseSearch = Fuse() @@ -39,7 +39,7 @@ struct UpdateView: View { var body: some View { ScrollView { // App grid - AppGridView(casks: Array(caskData.outdatedCasks), appRole: .update) + AppGridView(casks: Array(caskManager.outdatedCasks), appRole: .update) .padding() if casks.count > 1 { @@ -59,7 +59,7 @@ struct UpdateView: View { struct UpdateView_Previews: PreviewProvider { static var previews: some View { UpdateView() - .environmentObject(CaskData()) + .environmentObject(CaskManager()) .frame(width: 500, height: 400) } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4425642..7a6584a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3492,6 +3492,7 @@ } }, "No Selection" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : {