diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 18e3d44..1aa7090 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -91,6 +91,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -186,6 +189,9 @@ 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 = ""; }; + 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 = ""; }; 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; }; @@ -358,15 +364,15 @@ path = Resources; sourceTree = ""; }; - 41483CCA29101C7200BB10C2 /* Cask Data */ = { + 41483CCA29101C7200BB10C2 /* Cask Models */ = { isa = PBXGroup; children = ( + 419256692D1F284100D9EF10 /* Cask */, 418F332528EC921D0023D76F /* CaskData.swift */, - 418F332328EC8BA10023D76F /* Cask.swift */, 4166EE7C28F73B2300CE305A /* BrewAnalytics.swift */, 4191392B29159B5C00F1D75D /* CaskDTO.swift */, ); - path = "Cask Data"; + path = "Cask Models"; sourceTree = ""; }; 41483CCB29101C7D00BB10C2 /* Categories */ = { @@ -389,7 +395,7 @@ 418F332228EC8B120023D76F /* Model */ = { isa = PBXGroup; children = ( - 41483CCA29101C7200BB10C2 /* Cask Data */, + 41483CCA29101C7200BB10C2 /* Cask Models */, 41483CCB29101C7D00BB10C2 /* Categories */, 4104D7442A8FC53200F84F9B /* Preferences */, ); @@ -543,6 +549,17 @@ path = "Alert Manager"; sourceTree = ""; }; + 419256692D1F284100D9EF10 /* Cask */ = { + isa = PBXGroup; + children = ( + 418F332328EC8BA10023D76F /* Cask.swift */, + 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */, + 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */, + 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */, + ); + path = Cask; + sourceTree = ""; + }; 4196C8F628F9CB4100EADDDA /* App Views */ = { isa = PBXGroup; children = ( @@ -701,6 +718,7 @@ 419256352D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift in Sources */, 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift in Sources */, 4192560F2D1CC09500D9EF10 /* DependencyError.swift in Sources */, + 4192566E2D1F293700D9EF10 /* Cask+LaunchApp.swift in Sources */, 419256592D1E0EA000D9EF10 /* DiscoverSectionView+OffsetKey.swift in Sources */, 4192565E2D1E155400D9EF10 /* UpdateView+UpdateAllButton.swift in Sources */, 419256572D1E0E5F00D9EF10 /* DiscoverSectionView+AppRow.swift in Sources */, @@ -727,10 +745,12 @@ 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 */, 4192560D2D1CA02C00D9EF10 /* ShellError.swift in Sources */, + 4192566B2D1F286B00D9EF10 /* Cask+BrewFunctions.swift in Sources */, 4192561A2D1DEB2100D9EF10 /* AppView+OpenAndManageView.swift in Sources */, 4192562C2D1DF20600D9EF10 /* SetupView+BrewInstall.swift in Sources */, 41062C952A3794EA00FD48EA /* BrewPaths.swift in Sources */, diff --git a/Applite/Model/Cask Data/Cask.swift b/Applite/Model/Cask Data/Cask.swift deleted file mode 100755 index 3fcbdbb..0000000 --- a/Applite/Model/Cask Data/Cask.swift +++ /dev/null @@ -1,334 +0,0 @@ -// -// Cask.swift -// Applite -// -// Created by Milán Várady on 2022. 10. 04.. -// - -import SwiftUI -import os - -/// Holds all essential data of a Homebrew cask and provides methods to run brew commands on it (e.g. install, uninstall, update) -final class Cask: Identifiable, Decodable, Hashable, ObservableObject { - /// Unique id of the class, this is the same name you would use to download the cask with brew - let id: String - /// Longer format cask name - let name: String - /// Short description - let description: String - let homepageURL: URL? - /// Number of downloads in the last 365 days - var downloadsIn365days: Int = 0 - /// Description of any caveats with the app - let caveats: String? - /// If true app has a .pkg installer - let pkgInstaller: Bool - - /// Cask progress state when installing, updating or uninstalling - public enum ProgressState: Equatable, Hashable { - case idle - case busy(withTask: String) - case downloading(percent: Double) - case success - case failed(output: String) - } - - @MainActor - @Published var isInstalled: Bool = false - - @MainActor - @Published var isOutdated: Bool = false - - /// Progress state of the cask when installing, updating or uninstalling - @MainActor - @Published public var progressState: ProgressState = .idle - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String(describing: Cask.self) - ) - - init(from decoder: Decoder) throws { - let rawData = try? CaskDTO(from: decoder) - - let homepage: String = rawData?.homepage ?? "https://brew.sh/" - - self.id = rawData?.token ?? "N/A" - self.name = rawData?.nameArray[0] ?? "N/A" - self.description = rawData?.desc ?? "N/A" - self.homepageURL = URL(string: homepage) - self.caveats = rawData?.caveats - self.pkgInstaller = rawData?.url.hasSuffix("pkg") ?? false - } - - init() { - self.id = "test" - self.name = "Test app" - self.description = "An application to test this application" - self.homepageURL = URL(string: "https://aerolite.dev/") - self.caveats = nil - self.pkgInstaller = false - } - - /// Installs the cask - /// - /// - Parameters: - /// - force: If `true` install will be run with the `--force` flag - /// - Returns: `Void` - func install(caskData: CaskData, force: Bool = false) async { - defer { - resetProgressState(caskData: caskData) - } - - Self.logger.info("Cask \"\(self.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 - await MainActor.run { - self.progressState = .busy(withTask: "") - caskData.busyCasks.insert(self) - } - - var completeOutput = "" - - // Run install command and stream output - do { - for try await line in Shell.stream(command) { - completeOutput += line - - await MainActor.run { - self.progressState = self.parseBrewInstall(output: line) - } - } - } catch { - Self.logger.error("Failed to install cask \(self.id).") - - // Capture output - let output = completeOutput - - await MainActor.run { - progressState = .failed(output: output) - caskData.busyCasks.remove(self) - } - - sendNotification(title: String(localized: "Failed to download \(self.name)"), reason: .failure) - } - - Self.logger.info("Successfully installed cask \(self.id)") - - sendNotification(title: String(localized: "\(self.name) successfully installed!"), reason: .success) - - await MainActor.run { - progressState = .success - self.isInstalled = true - } - - // Show success for 2 seconds - try? await Task.sleep(for: .seconds(2)) - } - - /// 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: "") - } - - /// 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) - } - - _ = await MainActor.run { - caskData.busyCasks.insert(self) - } - - let arguments: [String] = if zap { ["--zap", self.id] } else { [self.id] } - - await runBrewCommand( - command: "uninstall", - arguments: arguments, - taskDescription: "Uninstalling", - notificationSuccess: String(localized:"\(self.name) successfully uninstalled"), - notificationFailure: "Failed to uninstall \(self.name)", - onSuccess: { self.isInstalled = false } - ) - } - - /// Updates the cask - /// - Returns: Bool - Whether the task has failed or not - func update(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - _ = await MainActor.run { - caskData.busyCasks.insert(self) - } - - await runBrewCommand( - command: "upgrade", - arguments: [self.id], - taskDescription: "Updating", - notificationSuccess: String(localized: "\(self.name) successfully updated"), - notificationFailure: String(localized: "Failed to update \(self.name)"), - onSuccess: { - self.isOutdated = false - caskData.outdatedCasks.remove(self) - }) - } - - /// Updates the cask - /// - Returns: Bool - Whether the task has failed or not - func reinstall(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - _ = await MainActor.run { - caskData.busyCasks.insert(self) - } - - await runBrewCommand( - command: "reinstall", - arguments: [self.id], - taskDescription: "Reinstalling", - notificationSuccess: String(localized: "\(self.name) successfully reinstalled"), - notificationFailure: String(localized:"Failed to reinstall \(self.name)"), - onSuccess: { - caskData.busyCasks.remove(self) - } - ) - } - - /// Runs a shell command with the currently selected brew path - /// - /// - Parameters: - /// - command: Brew command to be run - /// - arguments: Command arguments - /// - taskDesctiption: Description showed under the progress indicator in the UI - /// - notificationSuccess: Notification message if succeeds - /// - notificationFailure: Notification message if fails - /// - onSuccess: Closure run if task succeeds - /// - Returns: Bool - Whether the the task has failed or not - private func runBrewCommand( - command: String, - arguments: [String], - taskDescription: String, - notificationSuccess: String, - notificationFailure: String, - onSuccess: (@MainActor () -> Void)? = nil - ) async { - await MainActor.run { - let localizedTaskDescription = String.LocalizationValue(stringLiteral: taskDescription) - self.progressState = .busy(withTask: String(localized: localizedTaskDescription)) - } - - let command = "HOMEBREW_NO_AUTO_UPDATE=1 \(BrewPaths.currentBrewExecutable) \(command) --cask \(arguments.joined(separator: " "))" - - var output: String = "" - - do { - output = try await Shell.runAsync(command) - } catch { - Self.logger.error("Failed to run brew command: \(error.localizedDescription)") - - sendNotification(title: notificationFailure, reason: .failure) - - await MainActor.run { self.progressState = .failed(output: error.localizedDescription) } - } - - if let onSuccess { - await MainActor.run { - onSuccess() - } - } - - // Log and Notify - Self.logger.notice("Successfully run brew command \"\(command)\" with arguments \"\(arguments)\", output: \(output)") - - sendNotification(title: notificationSuccess, reason: .success) - - // Show success for 2 seconds - await MainActor.run { self.progressState = .success } - try? await Task.sleep(for: .seconds(2)) - } - - public func launchApp() throws { - let appPath: String - - if self.pkgInstaller { - // Open PKG type app - var applicationsDirectory = "/Applications" - - // Appdir - if UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) { - applicationsDirectory = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) ?? "/Applications" - - // Remove trailing "/" - if applicationsDirectory.hasSuffix("/") { - applicationsDirectory.removeLast() - } - } - - appPath = "\"\(applicationsDirectory)/\(self.name).app\"" - } else { - // Open normal app - let brewDirectory = BrewPaths.currentBrewDirectory - - appPath = "\(brewDirectory.replacingOccurrences(of: " ", with: "\\ ") )/Caskroom/\(self.id)/*/*.app" - } - - try Shell.run("open \(appPath)") - } - - /// Resets progress state and removes self from ``CaskData.busyCasks`` - private func resetProgressState(caskData: CaskData) { - Task { - await MainActor.run { - // Only reset state if it's not failed - if case .failed(_) = self.progressState { - } else { - self.progressState = .idle - caskData.busyCasks.remove(self) - - // Filter busy casks to make sure - caskData.filterBusyCasks() - } - } - } - } - - static func == (lhs: Cask, rhs: Cask) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } -} diff --git a/Applite/Model/Cask Data/BrewAnalytics.swift b/Applite/Model/Cask Models/BrewAnalytics.swift similarity index 100% rename from Applite/Model/Cask Data/BrewAnalytics.swift rename to Applite/Model/Cask Models/BrewAnalytics.swift diff --git a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift new file mode 100644 index 0000000..de13fba --- /dev/null +++ b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift @@ -0,0 +1,264 @@ +// +// 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.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) { + 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.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.name)", + alertMessage: alertMessage + ) + return + } + + showSuccess( + logMessage: "Successfully installed cask \(self.id)", + alertTitle: "\(self.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.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.name)", + alertMessage: error.localizedDescription + ) + return + } + + showSuccess( + logMessage: "Successfully uninstalled \(self.id)", + alertTitle: "\(self.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.id]) + } catch { + showFailure( + error: error, + output: output, + alertTitle: "Failed to update \(self.name)", + alertMessage: error.localizedDescription + ) + return + } + + showSuccess( + logMessage: "Successfully updated \(self.id)", + alertTitle: "\(self.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.id]) + } catch { + showFailure( + error: error, + output: output, + alertTitle: "Failed to reinstall \(self.name)", + alertMessage: error.localizedDescription + ) + return + } + + showSuccess( + logMessage: "Successfully reinstalled \(self.id)", + alertTitle: "\(self.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)") + 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 + 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+LaunchApp.swift b/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift new file mode 100644 index 0000000..8c49f1b --- /dev/null +++ b/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift @@ -0,0 +1,38 @@ +// +// Cask+LaunchApp.swift +// Applite +// +// Created by Milán Várady on 2024.12.27. +// + +import Foundation + +extension Cask { + func launchApp() throws { + let appPath: String + + if self.pkgInstaller { + // Open PKG type app + var applicationsDirectory = "/Applications" + + // Appdir + if UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) { + applicationsDirectory = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) ?? "/Applications" + + // Remove trailing "/" + if applicationsDirectory.hasSuffix("/") { + applicationsDirectory.removeLast() + } + } + + appPath = "\"\(applicationsDirectory)/\(self.name).app\"" + } else { + // Open normal app + let brewDirectory = BrewPaths.currentBrewDirectory + + appPath = "\(brewDirectory.replacingOccurrences(of: " ", with: "\\ ") )/Caskroom/\(self.id)/*/*.app" + } + + try Shell.run("open \(appPath)") + } +} diff --git a/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift b/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift new file mode 100644 index 0000000..944abf5 --- /dev/null +++ b/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift @@ -0,0 +1,20 @@ +// +// Cask+ProtocolConformances.swift +// Applite +// +// Created by Milán Várady on 2024.12.27. +// + +import Foundation + +extension Cask { + // 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 new file mode 100755 index 0000000..3960d1c --- /dev/null +++ b/Applite/Model/Cask Models/Cask/Cask.swift @@ -0,0 +1,77 @@ +// +// Cask.swift +// Applite +// +// Created by Milán Várady on 2022. 10. 04.. +// + +import SwiftUI +import os + +/// Holds all essential data of a Homebrew cask and provides methods to run brew commands on it (e.g. install, uninstall, update) +@MainActor +final class Cask: Identifiable, Decodable, Hashable, ObservableObject { + // MARK: - Static properties + + /// Unique id of the class, this is the same name you would use to download the cask with brew + let id: String + /// Longer format cask name + let name: String + /// Short description + let description: String + let homepageURL: URL? + /// Number of downloads in the last 365 days + var downloadsIn365days: Int = 0 + /// Description of any caveats with the app + let caveats: String? + /// If true app has a .pkg installer + let pkgInstaller: Bool + + // 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() + + 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) + } + + // MARK: - Initializers + + nonisolated init(from decoder: Decoder) throws { + let rawData = try? CaskDTO(from: decoder) + + let homepage: String = rawData?.homepage ?? "https://brew.sh/" + + self.id = rawData?.token ?? "N/A" + self.name = rawData?.nameArray[0] ?? "N/A" + self.description = rawData?.desc ?? "N/A" + self.homepageURL = URL(string: homepage) + self.caveats = rawData?.caveats + self.pkgInstaller = rawData?.url.hasSuffix("pkg") ?? false + } + + init() { + self.id = "test" + self.name = "Test app" + self.description = "An application to test this application" + self.homepageURL = URL(string: "https://aerolite.dev/") + self.caveats = nil + self.pkgInstaller = false + } +} diff --git a/Applite/Model/Cask Data/CaskDTO.swift b/Applite/Model/Cask Models/CaskDTO.swift similarity index 100% rename from Applite/Model/Cask Data/CaskDTO.swift rename to Applite/Model/Cask Models/CaskDTO.swift diff --git a/Applite/Model/Cask Data/CaskData.swift b/Applite/Model/Cask Models/CaskData.swift similarity index 100% rename from Applite/Model/Cask Data/CaskData.swift rename to Applite/Model/Cask Models/CaskData.swift diff --git a/Applite/Utilities/Shell/Shell.swift b/Applite/Utilities/Shell/Shell.swift index 2d4b05f..d188913 100644 --- a/Applite/Utilities/Shell/Shell.swift +++ b/Applite/Utilities/Shell/Shell.swift @@ -47,6 +47,13 @@ public enum Shell { try run(command) } + /// Executes a brew command asynchronously + @discardableResult + static func runBrewCommand(_ brewCommand: String, arguments: [String]) async throws -> String { + let command = "brew \(brewCommand) --cask \(arguments.joined(separator: " "))" + return try await runAsync(command) + } + /// Executes a shell command and streams the output static func stream(_ command: String) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -110,8 +117,8 @@ public enum Shell { task.standardOutput = pipe task.standardError = pipe task.environment = environment - task.arguments = ["-l", "-c", command] task.executableURL = URL(fileURLWithPath: "/bin/zsh") + task.arguments = ["-l", "-c", command] task.standardInput = nil return (task, pipe) diff --git a/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift b/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift index 39e4e58..e5c009c 100755 --- a/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift +++ b/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift @@ -25,7 +25,7 @@ public func isBrewPathValid(path: String) -> Bool { if !path.hasSuffix("brew") && !path.hasSuffix("brew\"") { return false } - + // Check if Homebrew is returned when checking version guard let output = try? Shell.run("\(path) --version") else { return false diff --git a/Applite/Views/App Views/App View/AppView+ActionsView.swift b/Applite/Views/App Views/App View/AppView+ActionsView.swift index 20e341d..e987d4e 100644 --- a/Applite/Views/App Views/App View/AppView+ActionsView.swift +++ b/Applite/Views/App Views/App View/AppView+ActionsView.swift @@ -109,27 +109,6 @@ extension AppView { } .buttonStyle(.bordered) } - .onAppear { - // Alert for install errors - if output.contains("It seems there is already an App") { - failureAlertMessage = String(localized: "\(cask.name) is already installed. If you want to add it to \(Bundle.main.appName) click more options (chevron icon) and press Force Install.") - showingFailureAlert = true - } else if output.contains("Could not resolve host") { - failureAlertMessage = String(localized: "Couldn't download app. No internet connection, or host is unreachable.") - showingFailureAlert = true - } - } - .alert("Error", isPresented: $showingFailureAlert) { - Button("OK") { } - - Button("View Error") { - // Open new window with shell output - openWindow(value: output) - cask.progressState = .idle - } - } message: { - Text(failureAlertMessage) - } case .idle: EmptyView() diff --git a/Applite/Views/App Views/App View/AppView+IconAndDescriptionView.swift b/Applite/Views/App Views/App View/AppView+IconAndDescriptionView.swift index 9e2fe9f..d14b377 100644 --- a/Applite/Views/App Views/App View/AppView+IconAndDescriptionView.swift +++ b/Applite/Views/App Views/App View/AppView+IconAndDescriptionView.swift @@ -32,12 +32,5 @@ extension AppView { Spacer() } .contentShape(Rectangle()) - .alert("Broken Brew Path", isPresented: $showingBrewPathError) { - Button("OK", role: .cancel) { - showingBrewPathError = false - } - } message: { - Text(LocalizedStringKey(DependencyManager.brokenPathOrIstallMessage)) - } } } diff --git a/Applite/Views/App Views/App View/AppView+UninstallButton.swift b/Applite/Views/App Views/App View/AppView+UninstallButton.swift index 89856b3..9a4bfcc 100644 --- a/Applite/Views/App Views/App View/AppView+UninstallButton.swift +++ b/Applite/Views/App Views/App View/AppView+UninstallButton.swift @@ -17,9 +17,7 @@ extension AppView { var body: some View { Button { Task { - await MainActor.run { cask.progressState = .busy(withTask: "Uninstalling") } - - _ = await cask.uninstall(caskData: caskData) + await cask.uninstall(caskData: caskData) } } label: { Image(systemName: "trash.fill") diff --git a/Applite/Views/App Views/App View/AppView+UpdateButton.swift b/Applite/Views/App Views/App View/AppView+UpdateButton.swift index e7fc55e..50997ea 100644 --- a/Applite/Views/App Views/App View/AppView+UpdateButton.swift +++ b/Applite/Views/App Views/App View/AppView+UpdateButton.swift @@ -15,7 +15,6 @@ extension AppView { var body: some View { Button { Task { - await MainActor.run { cask.progressState = .busy(withTask: "Updating") } await cask.update(caskData: caskData) } } label: { diff --git a/Applite/Views/App Views/App View/AppView.swift b/Applite/Views/App Views/App View/AppView.swift index 546282a..49c336c 100755 --- a/Applite/Views/App Views/App View/AppView.swift +++ b/Applite/Views/App Views/App View/AppView.swift @@ -27,7 +27,6 @@ struct AppView: View { @EnvironmentObject var caskData: CaskData // Alerts - @State var showingBrewPathError = false @State var failureAlertMessage = "" @State var showingFailureAlert = false @@ -48,6 +47,7 @@ struct AppView: View { } .buttonStyle(.plain) .frame(width: Self.dimensions.width, height: Self.dimensions.height) + .alertManager(cask.alert) } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b7540d2..dbc83bd 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -376,6 +376,7 @@ } }, "%@ successfully installed!" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -410,6 +411,7 @@ } }, "%@ successfully reinstalled" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -444,6 +446,7 @@ } }, "%@ successfully uninstalled" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -478,6 +481,7 @@ } }, "%@ successfully updated" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2437,6 +2441,7 @@ } }, "Failed to download %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2471,6 +2476,7 @@ } }, "Failed to reinstall %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2505,6 +2511,7 @@ } }, "Failed to update %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -5555,6 +5562,7 @@ } }, "View Error" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : {