diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 0b11c85..c3b5568 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -829,7 +829,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1400; - LastUpgradeCheck = 1630; + LastUpgradeCheck = 1631; TargetAttributes = { 414074F028DF53E80073EB22 = { CreatedOnToolsVersion = 14.0; @@ -1025,6 +1025,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -1090,6 +1091,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -1151,12 +1153,13 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Applite/AppliteDebug.entitlements; CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 19; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Applite/Preview Content\""; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1173,6 +1176,7 @@ MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = dev.aerolite.Applite; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; diff --git a/Applite.xcodeproj/xcshareddata/xcschemes/Applite.xcscheme b/Applite.xcodeproj/xcshareddata/xcschemes/Applite.xcscheme index 0ada141..8e09c9f 100755 --- a/Applite.xcodeproj/xcshareddata/xcschemes/Applite.xcscheme +++ b/Applite.xcodeproj/xcshareddata/xcschemes/Applite.xcscheme @@ -1,6 +1,6 @@ [Package] { + Self.logger.info("Fetching installed Homebrew packages") + + do { + let output = try await Shell.runBrewCommand(["list", "--formula", "--versions"]) + return parseInstalledPackages(output) + } catch { + Self.logger.error("Failed to get installed packages: \(error.localizedDescription)") + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + func getOutdatedPackages() async throws -> [Package] { + Self.logger.info("Fetching outdated Homebrew packages") + + do { + let output = try await Shell.runBrewCommand(["outdated", "--formula"]) + return parseOutdatedPackages(output) + } catch { + Self.logger.error("Failed to get outdated packages: \(error.localizedDescription)") + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + func searchPackages(_ query: String) async throws -> [Package] { + Self.logger.info("Searching for packages with query: \(query)") + + guard !query.isEmpty else { + return [] + } + + do { + let output = try await Shell.runBrewCommand(["search", "--desc", query]) + return parseSearchResultsWithDescriptions(output, query: query) + } catch { + Self.logger.error("Failed to search packages: \(error.localizedDescription)") + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + func getPackageInfo(_ packageId: String) async throws -> Package { + Self.logger.info("Getting info for package: \(packageId)") + + do { + let output = try await Shell.runBrewCommand(["info", "--json", packageId]) + return try parsePackageInfo(output, packageId: packageId) + } catch { + Self.logger.error("Failed to get package info for \(packageId): \(error.localizedDescription)") + throw PackageManagerError.packageNotFound(packageId) + } + } + + // MARK: - Parsing Methods + + private func parseInstalledPackages(_ output: String) -> [Package] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + return lines.compactMap { line in + let components = line.components(separatedBy: " ") + guard let packageName = components.first else { return nil } + + let version = components.count > 1 ? components[1] : nil + + return GenericPackage( + id: packageName, + name: packageName, + version: version, + manager: .homebrew, + isInstalled: true + ) + } + } + + private func parseOutdatedPackages(_ output: String) -> [Package] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + return lines.compactMap { line in + // Format: package (current_version) < latest_version + let components = line.components(separatedBy: " ") + guard let packageName = components.first else { return nil } + + // Extract versions if present + var currentVersion: String? + var latestVersion: String? + + if let currentVersionMatch = line.range(of: #"\([^)]+\)"#, options: .regularExpression) { + currentVersion = String(line[currentVersionMatch]) + .trimmingCharacters(in: CharacterSet(charactersIn: "()")) + } + + if let latestVersionIndex = components.lastIndex(of: "<"), + latestVersionIndex + 1 < components.count { + latestVersion = components[latestVersionIndex + 1] + } + + return GenericPackage( + id: packageName, + name: packageName, + version: currentVersion, + manager: .homebrew, + isInstalled: true, + isOutdated: true, + latestVersion: latestVersion + ) + } + } + + private func parseSearchResults(_ output: String, query: String) -> [Package] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .filter { !$0.hasPrefix("==>") } // Remove section headers + + return lines.compactMap { line in + let packageName = line.trimmingCharacters(in: .whitespaces) + guard !packageName.isEmpty else { return nil } + + return GenericPackage( + id: packageName, + name: packageName, + manager: .homebrew, + isInstalled: false + ) + } + } + + private func parseSearchResultsWithDescriptions(_ output: String, query: String) -> [Package] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .filter { !$0.hasPrefix("==>") } // Remove section headers + .filter { !$0.contains("Warning:") } // Remove warnings + .filter { $0.contains(":") } // Only lines with descriptions + + return lines.compactMap { line in + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + guard !trimmedLine.isEmpty else { return nil } + + // Format: "packagename: description" + let components = trimmedLine.components(separatedBy: ": ") + guard components.count >= 2 else { return nil } + + let packageName = components[0].trimmingCharacters(in: .whitespaces) + let description = components.dropFirst().joined(separator: ": ").trimmingCharacters(in: .whitespaces) + + guard !packageName.isEmpty && !description.isEmpty else { return nil } + + return GenericPackage( + id: packageName, + name: packageName, + description: description, + manager: .homebrew, + isInstalled: false + ) + } + } + + private func parsePackageInfo(_ jsonOutput: String, packageId: String) throws -> Package { + guard let data = jsonOutput.data(using: .utf8) else { + throw PackageManagerError.parseError("Invalid JSON data") + } + + do { + if let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let packageInfo = jsonArray.first { + + let name = packageInfo["name"] as? String ?? packageId + let description = packageInfo["desc"] as? String + let homepage = packageInfo["homepage"] as? String + let version = packageInfo["versions"] as? [String: String] + let currentVersion = version?["stable"] + + // Check if package is installed + let installedVersions = packageInfo["installed"] as? [[String: Any]] + let isInstalled = !(installedVersions?.isEmpty ?? true) + + // Get dependencies + let dependencies = packageInfo["dependencies"] as? [String] ?? [] + + return GenericPackage( + id: packageId, + name: name, + version: currentVersion, + description: description, + manager: .homebrew, + isInstalled: isInstalled, + homepage: homepage, + dependencies: dependencies + ) + } + } catch { + throw PackageManagerError.parseError("Failed to parse package info JSON: \(error.localizedDescription)") + } + + throw PackageManagerError.packageNotFound(packageId) + } +} \ No newline at end of file diff --git a/Applite/Model/Package Manager/PackageManager.swift b/Applite/Model/Package Manager/PackageManager.swift new file mode 100644 index 0000000..24f0d2a --- /dev/null +++ b/Applite/Model/Package Manager/PackageManager.swift @@ -0,0 +1,200 @@ +// +// PackageManager.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import Foundation +import SwiftUI +import OSLog + +// MARK: - Package Manager Protocol + +/// Protocol defining the interface for package managers +protocol PackageManagerProtocol: AnyObject { + /// The type of packages this manager handles + associatedtype Package + + /// The name of this package manager + var name: String { get } + + /// Whether this package manager is available on the system + var isAvailable: Bool { get async } + + /// Install a package + func install(_ package: Package) async throws + + /// Uninstall a package + func uninstall(_ package: Package) async throws + + /// Update a package + func update(_ package: Package) async throws + + /// Update all packages managed by this package manager + func updateAll() async throws + + /// Get list of installed packages + func getInstalledPackages() async throws -> [Package] + + /// Get list of outdated packages + func getOutdatedPackages() async throws -> [Package] + + /// Search for packages + func searchPackages(_ query: String) async throws -> [Package] + + /// Get package information + func getPackageInfo(_ packageId: String) async throws -> Package +} + +// MARK: - Package Manager Types + +/// Enum representing different package manager types +enum PackageManagerType: String, CaseIterable, Identifiable, Codable { + case homebrew = "homebrew" + case macports = "macports" + case npm = "npm" + case pip = "pip" + case gem = "gem" + case cargo = "cargo" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .homebrew: return "Homebrew" + case .macports: return "MacPorts" + case .npm: return "npm" + case .pip: return "pip" + case .gem: return "RubyGems" + case .cargo: return "Cargo" + } + } + + var iconName: String { + switch self { + case .homebrew: return "mug" + case .macports: return "port" + case .npm: return "cube.box" + case .pip: return "snake" + case .gem: return "diamond" + case .cargo: return "shippingbox" + } + } +} + +// MARK: - Generic Package Model + +/// Generic package model that can represent packages from different managers +struct GenericPackage: Identifiable, Codable, Hashable { + let id: String + let name: String + let version: String? + let description: String? + let manager: PackageManagerType + let isInstalled: Bool + let isOutdated: Bool + let latestVersion: String? + let homepage: String? + let installSize: String? + let dependencies: [String] + + init( + id: String, + name: String, + version: String? = nil, + description: String? = nil, + manager: PackageManagerType, + isInstalled: Bool = false, + isOutdated: Bool = false, + latestVersion: String? = nil, + homepage: String? = nil, + installSize: String? = nil, + dependencies: [String] = [] + ) { + self.id = id + self.name = name + self.version = version + self.description = description + self.manager = manager + self.isInstalled = isInstalled + self.isOutdated = isOutdated + self.latestVersion = latestVersion + self.homepage = homepage + self.installSize = installSize + self.dependencies = dependencies + } +} + +// MARK: - Package Manager Errors + +enum PackageManagerError: LocalizedError { + case managerNotAvailable(String) + case packageNotFound(String) + case installationFailed(String, String) + case uninstallationFailed(String, String) + case updateFailed(String, String) + case commandExecutionFailed(String) + case parseError(String) + + var errorDescription: String? { + switch self { + case .managerNotAvailable(let manager): + return "Package manager '\(manager)' is not available" + case .packageNotFound(let package): + return "Package '\(package)' not found" + case .installationFailed(let package, let error): + return "Failed to install '\(package)': \(error)" + case .uninstallationFailed(let package, let error): + return "Failed to uninstall '\(package)': \(error)" + case .updateFailed(let package, let error): + return "Failed to update '\(package)': \(error)" + case .commandExecutionFailed(let command): + return "Failed to execute command: \(command)" + case .parseError(let details): + return "Failed to parse output: \(details)" + } + } +} + +// MARK: - Package Operation Progress + +/// Represents the progress state of a package operation +enum PackageOperationState: Equatable { + case idle + case installing + case uninstalling + case updating + case downloading(progress: Double) + case success + case failed(error: String) + + var isActive: Bool { + switch self { + case .idle, .success, .failed: + return false + default: + return true + } + } + + var localizedDescription: String { + switch self { + case .idle: + return "" + case .installing: + return String(localized: "Installing", comment: "Package installing state") + case .uninstalling: + return String(localized: "Uninstalling", comment: "Package uninstalling state") + case .updating: + return String(localized: "Updating", comment: "Package updating state") + case .downloading(let progress): + return String(localized: "Downloading \(Int(progress * 100))%", comment: "Package downloading state") + case .success: + return String(localized: "Success", comment: "Package operation success state") + case .failed(let error): + return String(localized: "Failed: \(error)", comment: "Package operation failed state") + } + } +} \ No newline at end of file diff --git a/Applite/Model/Package Manager/PackageManagerCoordinator.swift b/Applite/Model/Package Manager/PackageManagerCoordinator.swift new file mode 100644 index 0000000..7505baa --- /dev/null +++ b/Applite/Model/Package Manager/PackageManagerCoordinator.swift @@ -0,0 +1,351 @@ +// +// PackageManagerCoordinator.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import Foundation +import SwiftUI +import OSLog + +/// Coordinates multiple package managers and provides a unified interface +@MainActor +final class PackageManagerCoordinator: ObservableObject { + + // MARK: - Published Properties + + @Published var availableManagers: [PackageManagerType] = [] + @Published var allPackages: [GenericPackage] = [] + @Published var installedPackages: [GenericPackage] = [] + @Published var outdatedPackages: [GenericPackage] = [] + @Published var isLoading = false + @Published var isUpdating = false + @Published var operationStates: [String: PackageOperationState] = [:] + + // MARK: - Private Properties + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: PackageManagerCoordinator.self) + ) + + private var managers: [PackageManagerType: AnyPackageManager] = [:] + + // MARK: - Initialization + + init() { + setupManagers() + Task { + await detectAvailableManagers() + await loadInstalledPackages() + } + } + + private func setupManagers() { + // Initialize Homebrew manager + let brewManager = BrewPackageManager() + managers[.homebrew] = AnyPackageManager(brewManager) + + // TODO: Add other package managers as needed + // managers[.npm] = AnyPackageManager(NPMPackageManager()) + // managers[.pip] = AnyPackageManager(PipPackageManager()) + } + + // MARK: - Manager Detection + + func detectAvailableManagers() async { + logger.info("Detecting available package managers") + + var available: [PackageManagerType] = [] + + for (type, manager) in managers { + if await manager.isAvailable { + available.append(type) + logger.info("Package manager available: \(type.displayName)") + } + } + + availableManagers = available + } + + // MARK: - Package Loading + + func loadInstalledPackages() async { + guard !isLoading else { return } + + isLoading = true + logger.info("Loading installed packages from all managers") + + var allInstalled: [GenericPackage] = [] + + for managerType in availableManagers { + guard let manager = managers[managerType] else { continue } + + do { + let packages = try await manager.getInstalledPackages() + allInstalled.append(contentsOf: packages) + logger.info("Loaded \(packages.count) packages from \(managerType.displayName)") + } catch { + logger.error("Failed to load packages from \(managerType.displayName): \(error.localizedDescription)") + } + } + + installedPackages = allInstalled + isLoading = false + } + + func loadOutdatedPackages() async { + logger.info("Loading outdated packages from all managers") + + var allOutdated: [GenericPackage] = [] + + for managerType in availableManagers { + guard let manager = managers[managerType] else { continue } + + do { + let packages = try await manager.getOutdatedPackages() + allOutdated.append(contentsOf: packages) + logger.info("Found \(packages.count) outdated packages from \(managerType.displayName)") + } catch { + logger.error("Failed to get outdated packages from \(managerType.displayName): \(error.localizedDescription)") + } + } + + outdatedPackages = allOutdated + } + + func searchPackages(_ query: String, in managerTypes: [PackageManagerType] = []) async -> [GenericPackage] { + guard !query.isEmpty else { return [] } + + let searchManagers = managerTypes.isEmpty ? availableManagers : managerTypes + var results: [GenericPackage] = [] + + for managerType in searchManagers { + guard let manager = managers[managerType] else { continue } + + do { + let packages = try await manager.searchPackages(query) + results.append(contentsOf: packages) + } catch { + logger.error("Failed to search in \(managerType.displayName): \(error.localizedDescription)") + } + } + + return results + } + + // MARK: - Package Operations + + func installPackage(_ package: GenericPackage) async { + guard let manager = managers[package.manager] else { + logger.error("Manager not available for package: \(package.id)") + return + } + + setOperationState(.installing, for: package.id) + + do { + try await manager.install(package) + setOperationState(.success, for: package.id) + + // Update installed packages + await loadInstalledPackages() + + logger.info("Successfully installed package: \(package.id)") + } catch { + logger.error("Failed to install package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + // Reset state after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + func uninstallPackage(_ package: GenericPackage) async { + guard let manager = managers[package.manager] else { + logger.error("Manager not available for package: \(package.id)") + return + } + + setOperationState(.uninstalling, for: package.id) + + do { + try await manager.uninstall(package) + setOperationState(.success, for: package.id) + + // Update installed packages + await loadInstalledPackages() + + logger.info("Successfully uninstalled package: \(package.id)") + } catch { + logger.error("Failed to uninstall package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + // Reset state after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + func updatePackage(_ package: GenericPackage) async { + guard let manager = managers[package.manager] else { + logger.error("Manager not available for package: \(package.id)") + return + } + + setOperationState(.updating, for: package.id) + + do { + try await manager.update(package) + setOperationState(.success, for: package.id) + + // Update packages + await loadInstalledPackages() + await loadOutdatedPackages() + + logger.info("Successfully updated package: \(package.id)") + } catch { + logger.error("Failed to update package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + // Reset state after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + func updateAllPackages(for managerType: PackageManagerType? = nil) async { + guard !isUpdating else { return } + + isUpdating = true + logger.info("Starting update all packages operation") + + let managersToUpdate = managerType.map { [$0] } ?? availableManagers + + for type in managersToUpdate { + guard let manager = managers[type] else { continue } + + do { + try await manager.updateAll() + logger.info("Successfully updated all packages for \(type.displayName)") + } catch { + logger.error("Failed to update all packages for \(type.displayName): \(error.localizedDescription)") + } + } + + // Refresh package lists + await loadInstalledPackages() + await loadOutdatedPackages() + + isUpdating = false + logger.info("Completed update all packages operation") + } + + // MARK: - Package Information + + func getPackageInfo(_ packageId: String, manager: PackageManagerType) async -> GenericPackage? { + guard let packageManager = managers[manager] else { return nil } + + do { + return try await packageManager.getPackageInfo(packageId) + } catch { + logger.error("Failed to get package info for \(packageId): \(error.localizedDescription)") + return nil + } + } + + // MARK: - Operation State Management + + private func setOperationState(_ state: PackageOperationState, for packageId: String) { + operationStates[packageId] = state + } + + func getOperationState(for packageId: String) -> PackageOperationState { + return operationStates[packageId] ?? .idle + } + + // MARK: - Utility Methods + + func getPackagesByManager(_ managerType: PackageManagerType) -> [GenericPackage] { + return installedPackages.filter { $0.manager == managerType } + } + + func getOutdatedPackagesByManager(_ managerType: PackageManagerType) -> [GenericPackage] { + return outdatedPackages.filter { $0.manager == managerType } + } + + func refreshAllData() async { + await detectAvailableManagers() + await loadInstalledPackages() + await loadOutdatedPackages() + } +} + +// MARK: - Type Erasing Wrapper + +/// Type-erasing wrapper for package managers +private class AnyPackageManager { + private let _install: (GenericPackage) async throws -> Void + private let _uninstall: (GenericPackage) async throws -> Void + private let _update: (GenericPackage) async throws -> Void + private let _updateAll: () async throws -> Void + private let _getInstalledPackages: () async throws -> [GenericPackage] + private let _getOutdatedPackages: () async throws -> [GenericPackage] + private let _searchPackages: (String) async throws -> [GenericPackage] + private let _getPackageInfo: (String) async throws -> GenericPackage + private let _isAvailable: () async -> Bool + + init(_ manager: T) where T.Package == GenericPackage { + _install = manager.install + _uninstall = manager.uninstall + _update = manager.update + _updateAll = manager.updateAll + _getInstalledPackages = manager.getInstalledPackages + _getOutdatedPackages = manager.getOutdatedPackages + _searchPackages = manager.searchPackages + _getPackageInfo = manager.getPackageInfo + _isAvailable = { await manager.isAvailable } + } + + var isAvailable: Bool { + get async { await _isAvailable() } + } + + func install(_ package: GenericPackage) async throws { + try await _install(package) + } + + func uninstall(_ package: GenericPackage) async throws { + try await _uninstall(package) + } + + func update(_ package: GenericPackage) async throws { + try await _update(package) + } + + func updateAll() async throws { + try await _updateAll() + } + + func getInstalledPackages() async throws -> [GenericPackage] { + try await _getInstalledPackages() + } + + func getOutdatedPackages() async throws -> [GenericPackage] { + try await _getOutdatedPackages() + } + + func searchPackages(_ query: String) async throws -> [GenericPackage] { + try await _searchPackages(query) + } + + func getPackageInfo(_ packageId: String) async throws -> GenericPackage { + try await _getPackageInfo(packageId) + } +} \ No newline at end of file diff --git a/Applite/Model/Package Manager/PackageUpdater.swift b/Applite/Model/Package Manager/PackageUpdater.swift new file mode 100644 index 0000000..18e1ea1 --- /dev/null +++ b/Applite/Model/Package Manager/PackageUpdater.swift @@ -0,0 +1,332 @@ +// +// PackageUpdater.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT + +import Foundation +import SwiftUI +import OSLog +import UserNotifications + +/// Manages automatic package updates and notifications +@MainActor +final class PackageUpdater: ObservableObject { + + // MARK: - Published Properties + + @Published var isAutoUpdateEnabled: Bool { + didSet { + UserDefaults.standard.set(isAutoUpdateEnabled, forKey: "PackageAutoUpdateEnabled") + if isAutoUpdateEnabled { + scheduleAutoUpdate() + } else { + cancelAutoUpdate() + } + } + } + + @Published var autoUpdateFrequency: UpdateFrequency { + didSet { + UserDefaults.standard.set(autoUpdateFrequency.rawValue, forKey: "PackageAutoUpdateFrequency") + if isAutoUpdateEnabled { + scheduleAutoUpdate() + } + } + } + + @Published var lastUpdateCheck: Date? { + didSet { + if let date = lastUpdateCheck { + UserDefaults.standard.set(date, forKey: "PackageLastUpdateCheck") + } + } + } + + @Published var updateResults: [UpdateResult] = [] + @Published var isUpdating = false + + // MARK: - Private Properties + + private let coordinator: PackageManagerCoordinator + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: PackageUpdater.self) + ) + + private var updateTimer: Timer? + private let notificationCenter = UNUserNotificationCenter.current() + + // MARK: - Initialization + + init(coordinator: PackageManagerCoordinator) { + self.coordinator = coordinator + + // Load preferences + self.isAutoUpdateEnabled = UserDefaults.standard.bool(forKey: "PackageAutoUpdateEnabled") + self.autoUpdateFrequency = UpdateFrequency(rawValue: UserDefaults.standard.string(forKey: "PackageAutoUpdateFrequency") ?? UpdateFrequency.daily.rawValue) ?? .daily + self.lastUpdateCheck = UserDefaults.standard.object(forKey: "PackageLastUpdateCheck") as? Date + + // Setup notification permissions + Task { + await requestNotificationPermission() + } + + // Schedule auto update if enabled + if isAutoUpdateEnabled { + scheduleAutoUpdate() + } + } + + deinit { + cancelAutoUpdate() + } + + // MARK: - Auto Update Management + + private func scheduleAutoUpdate() { + cancelAutoUpdate() + + let interval = autoUpdateFrequency.timeInterval + updateTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.performAutoUpdate() + } + } + + logger.info("Auto update scheduled with frequency: \(autoUpdateFrequency.rawValue)") + } + + private func cancelAutoUpdate() { + updateTimer?.invalidate() + updateTimer = nil + logger.info("Auto update cancelled") + } + + private func performAutoUpdate() async { + guard !isUpdating else { return } + + logger.info("Performing automatic package update") + + // Check for outdated packages + await coordinator.loadOutdatedPackages() + + guard !coordinator.outdatedPackages.isEmpty else { + logger.info("No packages to update") + lastUpdateCheck = Date() + return + } + + // Send notification about available updates + await sendUpdateAvailableNotification(count: coordinator.outdatedPackages.count) + + // Perform updates + await updateAllPackages() + + lastUpdateCheck = Date() + } + + // MARK: - Manual Update Operations + + func checkForUpdates() async { + logger.info("Manual check for updates") + await coordinator.loadOutdatedPackages() + lastUpdateCheck = Date() + } + + func updateAllPackages() async { + guard !isUpdating else { return } + + isUpdating = true + updateResults = [] + + logger.info("Starting update all packages") + + let outdatedPackages = coordinator.outdatedPackages + guard !outdatedPackages.isEmpty else { + isUpdating = false + return + } + + var results: [UpdateResult] = [] + + for package in outdatedPackages { + let result = await updatePackage(package) + results.append(result) + } + + updateResults = results + isUpdating = false + + // Send completion notification + let successCount = results.filter { $0.success }.count + let failureCount = results.count - successCount + + await sendUpdateCompletionNotification( + successCount: successCount, + failureCount: failureCount + ) + + // Refresh data + await coordinator.refreshAllData() + + logger.info("Completed update all packages: \(successCount) successful, \(failureCount) failed") + } + + func updatePackagesFor(manager: PackageManagerType) async { + guard !isUpdating else { return } + + isUpdating = true + updateResults = [] + + logger.info("Updating packages for manager: \(manager.displayName)") + + let packages = coordinator.getOutdatedPackagesByManager(manager) + var results: [UpdateResult] = [] + + for package in packages { + let result = await updatePackage(package) + results.append(result) + } + + updateResults = results + isUpdating = false + + await coordinator.refreshAllData() + + logger.info("Completed updates for \(manager.displayName)") + } + + private func updatePackage(_ package: GenericPackage) async -> UpdateResult { + let startTime = Date() + + do { + await coordinator.updatePackage(package) + + let duration = Date().timeIntervalSince(startTime) + return UpdateResult( + package: package, + success: true, + duration: duration, + error: nil + ) + } catch { + let duration = Date().timeIntervalSince(startTime) + return UpdateResult( + package: package, + success: false, + duration: duration, + error: error.localizedDescription + ) + } + } + + // MARK: - Notification Management + + private func requestNotificationPermission() async { + do { + let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + if granted { + logger.info("Notification permission granted") + } else { + logger.warning("Notification permission denied") + } + } catch { + logger.error("Failed to request notification permission: \(error.localizedDescription)") + } + } + + private func sendUpdateAvailableNotification(count: Int) async { + let content = UNMutableNotificationContent() + content.title = String(localized: "Package Updates Available", comment: "Notification title for available updates") + content.body = String(localized: "\(count) packages have updates available", comment: "Notification body for available updates") + content.sound = .default + + let request = UNNotificationRequest( + identifier: "package-updates-available", + content: content, + trigger: nil + ) + + do { + try await notificationCenter.add(request) + logger.info("Sent update available notification") + } catch { + logger.error("Failed to send update notification: \(error.localizedDescription)") + } + } + + private func sendUpdateCompletionNotification(successCount: Int, failureCount: Int) async { + let content = UNMutableNotificationContent() + + if failureCount == 0 { + content.title = String(localized: "Package Updates Complete", comment: "Notification title for successful updates") + content.body = String(localized: "Successfully updated \(successCount) packages", comment: "Notification body for successful updates") + } else { + content.title = String(localized: "Package Updates Complete", comment: "Notification title for mixed update results") + content.body = String(localized: "\(successCount) successful, \(failureCount) failed", comment: "Notification body for mixed update results") + } + + content.sound = .default + + let request = UNNotificationRequest( + identifier: "package-updates-complete", + content: content, + trigger: nil + ) + + do { + try await notificationCenter.add(request) + logger.info("Sent update completion notification") + } catch { + logger.error("Failed to send completion notification: \(error.localizedDescription)") + } + } +} + +// MARK: - Supporting Types + +enum UpdateFrequency: String, CaseIterable, Identifiable { + case hourly = "hourly" + case daily = "daily" + case weekly = "weekly" + case monthly = "monthly" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .hourly: return String(localized: "Every Hour", comment: "Hourly update frequency") + case .daily: return String(localized: "Daily", comment: "Daily update frequency") + case .weekly: return String(localized: "Weekly", comment: "Weekly update frequency") + case .monthly: return String(localized: "Monthly", comment: "Monthly update frequency") + } + } + + var timeInterval: TimeInterval { + switch self { + case .hourly: return 3600 + case .daily: return 86400 + case .weekly: return 604800 + case .monthly: return 2628000 + } + } +} + +struct UpdateResult: Identifiable { + let id = UUID() + let package: GenericPackage + let success: Bool + let duration: TimeInterval + let error: String? + let timestamp = Date() + + var formattedDuration: String { + if duration < 60 { + return String(format: "%.1fs", duration) + } else { + return String(format: "%.1fm", duration / 60) + } + } +} \ No newline at end of file diff --git a/Applite/Model/SidebarItem.swift b/Applite/Model/SidebarItem.swift index 0d4a749..75d0bd7 100644 --- a/Applite/Model/SidebarItem.swift +++ b/Applite/Model/SidebarItem.swift @@ -3,6 +3,8 @@ // Applite // // Created by Milán Várady on 2024.12.30. +// MODIFIED by Subham mahesh EVERY MODIFICATION MADE BY SUBHAM MAHESH LICENSE UNDER THE MIT + // import Foundation @@ -13,6 +15,7 @@ enum SidebarItem: Equatable, Hashable { case installed case activeTasks case appMigration + case packageManager case brew case appCategory(category: CategoryViewModel) case tap(tap: TapViewModel) diff --git a/Applite/Views/Content View/ContentView+DetailView.swift b/Applite/Views/Content View/ContentView+DetailView.swift index 8fdc1be..26e5a65 100644 --- a/Applite/Views/Content View/ContentView+DetailView.swift +++ b/Applite/Views/Content View/ContentView+DetailView.swift @@ -3,61 +3,830 @@ // Applite // // Created by Milán Várady on 2024.12.26. + //MODIFIED by Subham mahesh EVERY MODIFICATION MADE BY SUBHAM MAHESH LICENSE UNDER THE MIT + // import SwiftUI import ButtonKit +import Foundation extension ContentView { @ViewBuilder var detailView: some View { switch selection { case .home: - if !brokenInstall { - HomeView( - navigationSelection: $selection, - searchText: $searchInput, - showSearchResults: $showSearchResults, - caskCollection: caskManager.allCasks - ) - } else { - brokenInstallView - } + HomeView( + navigationSelection: $selection, + searchText: $searchInput, + showSearchResults: $showSearchResults, + caskCollection: caskManager.allCasks + ) - case .updates: - UpdateView(caskCollection: caskManager.outdatedCasks) - case .installed: InstalledView(caskCollection: caskManager.installedCasks) + case .updates: + UpdateView(caskCollection: caskManager.outdatedCasks) + case .activeTasks: ActiveTasksView() + case .brew: + BrewManagementView(modifyingBrew: $modifyingBrew) + case .appMigration: AppMigrationView() + case .packageManager: + PackageManagerView() + case .appCategory(let category): CategoryView(category: category) - + case .tap(let tap): TapView(tap: tap) + } + } +} + +// MARK: - Package Manager Implementation + +struct PackageManagerView: View { + @State private var searchText = "" + @State private var isSearching = false + @State private var searchResults: [PackageInfo] = [] + @State private var installedPackages: [PackageInfo] = [] + @State private var isLoading = false + @State private var selectedTab = 0 + @State private var selectedPackage: PackageInfo? + @State private var operationStates: [String: PackageOperationStatus] = [:] + @State private var searchTask: Task? + + var body: some View { + VStack(spacing: 0) { + // Header with Tab Buttons + VStack(spacing: 16) { + HStack { + Text(String(localized: "Package Manager", comment: "Package manager title")) + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + } + + // Custom Tab Buttons + HStack(spacing: 12) { + // Installed Tab Button + Button(action: { selectedTab = 0 }) { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + Text(String(localized: "Installed", comment: "Installed tab")) + .font(.headline) + .fontWeight(.medium) + + if !installedPackages.isEmpty { + Text("\(installedPackages.count)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(selectedTab == 0 ? Color.green.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundColor(selectedTab == 0 ? .green : .primary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectedTab == 0 ? Color.green : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(PlainButtonStyle()) + + // Search Tab Button + Button(action: { selectedTab = 1 }) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16)) + Text(String(localized: "Search", comment: "Search tab")) + .font(.headline) + .fontWeight(.medium) + + if !searchResults.isEmpty && !searchText.isEmpty { + Text("\(searchResults.count)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(selectedTab == 1 ? Color.blue.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundColor(selectedTab == 1 ? .blue : .primary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectedTab == 1 ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + + // Loading indicator + if isLoading || isSearching { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.8) + Text(isLoading ? String(localized: "Loading...", comment: "Loading indicator") : String(localized: "Searching...", comment: "Search in progress")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .padding() - case .brew: - BrewManagementView(modifyingBrew: $modifyingBrew) + Divider() + + // Content based on selected tab + if selectedTab == 0 { + // Installed Packages Content + if installedPackages.isEmpty && !isLoading { + VStack(spacing: 16) { + Image(systemName: "cube.box") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(String(localized: "No packages installed", comment: "No packages message")) + .font(.headline) + .foregroundColor(.secondary) + + Text(String(localized: "Install packages using the Search tab", comment: "Install packages instruction")) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + + Button(action: { selectedTab = 1 }) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + Text(String(localized: "Search Packages", comment: "Search packages button")) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.blue) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(PlainButtonStyle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + PackageListView( + packages: installedPackages, + isLoading: isLoading, + selectedPackage: $selectedPackage, + operationStates: $operationStates + ) + } + } else { + // Search Content + VStack(spacing: 16) { + // Search Bar + HStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField(String(localized: "Search packages...", comment: "Search placeholder"), text: $searchText) + .textFieldStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if !searchText.isEmpty { + Button(action: { + searchText = "" + searchResults = [] + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal) + + // Search Results + if searchText.isEmpty { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(String(localized: "Search for packages", comment: "Search empty state title")) + .font(.headline) + .foregroundColor(.secondary) + + Text(String(localized: "Enter a package name to search the Homebrew catalog", comment: "Search empty state description")) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if searchResults.isEmpty && !isSearching { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(.orange) + + Text(String(localized: "No packages found", comment: "No search results")) + .font(.headline) + .foregroundColor(.secondary) + + Text(String(localized: "Try a different search term", comment: "Search suggestion")) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + PackageListView( + packages: searchResults, + isLoading: isSearching, + selectedPackage: $selectedPackage, + operationStates: $operationStates + ) + } + } + } + } + .task { + await loadInstalledPackages() + } + .onChange(of: searchText) { newValue in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(for: .milliseconds(500)) + + if !Task.isCancelled && !newValue.isEmpty { + await performSearch(query: newValue) + } else if newValue.isEmpty { + searchResults = [] + } + } + } + .sheet(item: $selectedPackage) { package in + PackageDetailView( + package: package, + operationStates: $operationStates + ) + } + } + + private func loadInstalledPackages() async { + isLoading = true + + do { + let output = try await Shell.runBrewCommand(["list", "--formula", "--versions"]) + let packages = output.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .compactMap { line -> PackageInfo? in + let components = line.components(separatedBy: " ") + guard let name = components.first else { return nil } + let version = components.count > 1 ? components[1] : nil + return PackageInfo(name: name, version: version, isInstalled: true) + } + .sorted { $0.name < $1.name } + + installedPackages = packages + } catch { + installedPackages = [] + } + + isLoading = false + } + + private func performSearch(query: String) async { + guard !query.isEmpty else { + searchResults = [] + return + } + + isSearching = true + + do { + let output = try await Shell.runBrewCommand(["search", query]) + let installedSet = Set(installedPackages.map { $0.name }) + let packages = output.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("==>") } + .prefix(50) + .map { packageName in + let isInstalled = installedSet.contains(packageName) + // If package is installed, find its version from installedPackages + let version = isInstalled ? installedPackages.first(where: { $0.name == packageName })?.version : nil + return PackageInfo(name: packageName, version: version, isInstalled: isInstalled) + } + + searchResults = Array(packages) + } catch { + searchResults = [] } + + isSearching = false } +} + +// MARK: - Supporting Views - private var brokenInstallView: some View { - VStack(alignment: .center) { - Text(DependencyManager.brokenPathOrIstallMessage) +struct PackageListView: View { + let packages: [PackageInfo] + let isLoading: Bool + @Binding var selectedPackage: PackageInfo? + @Binding var operationStates: [String: PackageOperationStatus] + + var body: some View { + if isLoading { + VStack { + ProgressView() + Text(String(localized: "Loading packages...", comment: "Loading packages")) + .foregroundColor(.secondary) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(packages, id: \.name) { package in + PackageRowView( + package: package, + selectedPackage: $selectedPackage, + operationStates: $operationStates + ) + + if package != packages.last { + Divider() + } + } + } + } + } + } +} - AsyncButton { - await loadCasks() - } label: { - Label("Retry load", systemImage: "arrow.clockwise.circle") +struct PackageRowView: View { + @State private var package: PackageInfo + @Binding var selectedPackage: PackageInfo? + @Binding var operationStates: [String: PackageOperationStatus] + + init(package: PackageInfo, selectedPackage: Binding, operationStates: Binding<[String: PackageOperationStatus]>) { + self._package = State(initialValue: package) + self._selectedPackage = selectedPackage + self._operationStates = operationStates + } + + var body: some View { + HStack(spacing: 16) { + // Status Icon - Larger + Image(systemName: package.isInstalled ? "checkmark.circle.fill" : "circle") + .foregroundColor(package.isInstalled ? .green : .secondary) + .font(.system(size: 24)) + + // Package Information - More detailed + VStack(alignment: .leading, spacing: 6) { + // Package Name + Text(package.name) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + // Version and Status Row + HStack(spacing: 12) { + if let version = package.version { + HStack(spacing: 4) { + Image(systemName: "number") + .foregroundColor(.blue) + .font(.caption2) + Text("v\(version)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.blue) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + // Installation status badge + HStack(spacing: 4) { + Image(systemName: package.isInstalled ? "checkmark.circle.fill" : "circle.dashed") + .foregroundColor(package.isInstalled ? .green : .orange) + .font(.caption2) + Text(package.isInstalled ? String(localized: "Installed", comment: "Installed badge") : String(localized: "Available", comment: "Available badge")) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(package.isInstalled ? .green : .orange) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background((package.isInstalled ? Color.green : Color.orange).opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + Spacer() + } + + + // Dependencies info (if available) + if !package.dependencies.isEmpty { + HStack(spacing: 4) { + Image(systemName: "link") + .foregroundColor(.purple) + .font(.caption2) + Text("\(package.dependencies.count) dependencies") + .font(.caption) + .foregroundColor(.purple) + } + .padding(.top, 2) + } + } + + Spacer() + + HStack(spacing: 8) { + if let operationState = operationStates[package.name], operationState.isActive { + HStack(spacing: 6) { + // Progress indicator based on operation type + switch operationState { + case .installing(let progress), .uninstalling(let progress): + PackageCircularProgressView(progress: progress) + case .installed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.system(size: 14)) + case .uninstalled: + Image(systemName: "minus.circle.fill") + .foregroundColor(.orange) + .font(.system(size: 14)) + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + .font(.system(size: 14)) + default: + ProgressView() + .scaleEffect(0.5) + } + + Text(operationState.displayText) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + .frame(minWidth: 80, alignment: .leading) + } else { + Button(action: { selectedPackage = package }) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + } + .buttonStyle(.borderless) + .help("Package information") + + if package.isInstalled { + Button(action: { uninstallPackage() }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .help("Uninstall package") + } else { + Button(action: { installPackage() }) { + Image(systemName: "arrow.down.circle") + .foregroundColor(.green) + } + .buttonStyle(.borderless) + .help("Install package") + } + } } - .controlSize(.large) } - .frame(maxWidth: 600) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(minHeight: 80) + .background(Color.primary.opacity(0.02)) + .contentShape(Rectangle()) + } + + private func installPackage() { + operationStates[package.name] = .installing(progress: 0.0) + + Task { + do { + // Simulate progress updates during installation + await updateProgress(for: package.name, isInstalling: true) + + _ = try await Shell.runBrewCommand(["install", package.name]) + + _ = await MainActor.run { + operationStates[package.name] = .installed + // Update package state immediately + package.isInstalled = true + } + + // Show success state briefly, then clear + try await Task.sleep(nanoseconds: 2_000_000_000) + _ = await MainActor.run { + operationStates.removeValue(forKey: package.name) + } + } catch { + _ = await MainActor.run { + operationStates[package.name] = .failed(error: "Install failed") + } + + try? await Task.sleep(nanoseconds: 3_000_000_000) + _ = await MainActor.run { + operationStates.removeValue(forKey: package.name) + } + } + } + } + + private func uninstallPackage() { + operationStates[package.name] = .uninstalling(progress: 0.0) + + Task { + do { + // Simulate progress updates during uninstallation + await updateProgress(for: package.name, isInstalling: false) + + _ = try await Shell.runBrewCommand(["uninstall", package.name]) + + _ = await MainActor.run { + operationStates[package.name] = .uninstalled + // Update package state immediately + package.isInstalled = false + } + + // Show success state briefly, then clear + try await Task.sleep(nanoseconds: 2_000_000_000) + _ = await MainActor.run { + operationStates.removeValue(forKey: package.name) + } + } catch { + _ = await MainActor.run { + operationStates[package.name] = .failed(error: "Uninstall failed") + } + + try? await Task.sleep(nanoseconds: 3_000_000_000) + _ = await MainActor.run { + operationStates.removeValue(forKey: package.name) + } + } + } + } + + private func updateProgress(for packageName: String, isInstalling: Bool) async { + let steps = 10 + for i in 1...steps { + let progress = Double(i) / Double(steps) + _ = await MainActor.run { + if isInstalling { + operationStates[packageName] = .installing(progress: progress) + } else { + operationStates[packageName] = .uninstalling(progress: progress) + } + } + // Faster progress updates for more responsive feel + try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between updates + } + } +} + +struct PackageDetailView: View { + let package: PackageInfo + @Binding var operationStates: [String: PackageOperationStatus] + @Environment(\.dismiss) private var dismiss + @State private var detailedInfo: PackageInfo? + @State private var isLoadingDetails = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack { + Image(systemName: package.isInstalled ? "checkmark.circle.fill" : "circle") + .foregroundColor(package.isInstalled ? .green : .secondary) + .font(.title) + + VStack(alignment: .leading) { + Text(package.name) + .font(.title2) + .fontWeight(.bold) + + if let version = package.version { + Text("Version: \(version)") + .foregroundColor(.secondary) + } + } + + Spacer() + + Button(String(localized: "Close", comment: "Close button")) { + dismiss() + } + } + .padding() + + if isLoadingDetails { + VStack { + ProgressView() + Text(String(localized: "Loading details...", comment: "Loading details")) + .foregroundColor(.secondary) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let info = detailedInfo { + if let description = info.description, !description.isEmpty { + DetailRow(label: "Description", value: description) + } + + if let homepage = info.homepage, !homepage.isEmpty { + DetailRow(label: "Homepage", value: homepage) + } + + if !info.dependencies.isEmpty { + DetailRow( + label: "Dependencies", + value: info.dependencies.joined(separator: ", ") + ) + } + } + } + .padding() + } + } + } + .frame(width: 600, height: 400) + .task { + await loadDetailedInfo() + } + } + + private func loadDetailedInfo() async { + isLoadingDetails = true + + do { + let output = try await Shell.runBrewCommand(["info", "--json", package.name]) + + // Parse JSON response (simplified) + if let data = output.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let packageInfo = json.first { + + let description = packageInfo["desc"] as? String + let homepage = packageInfo["homepage"] as? String + let dependencies = packageInfo["dependencies"] as? [String] ?? [] + + let detailed = PackageInfo( + name: package.name, + description: description, + version: package.version, + isInstalled: package.isInstalled, + homepage: homepage, + dependencies: dependencies + ) + + detailedInfo = detailed + } + } catch { + // Handle error silently for now + } + + isLoadingDetails = false + } +} + +struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .foregroundColor(.secondary) + + Spacer() + + Text(value) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Progress Views + +struct PackageCircularProgressView: View { + let progress: Double + + private var displayText: String { + let percentage = Int(progress * 100) + if percentage >= 100 { + return "✓" // Checkmark for 100% + } else { + return "\(percentage)%" + } + } + + var body: some View { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.3), lineWidth: 1.5) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + Text(displayText) + .font(.system(size: progress >= 1.0 ? 8 : 5, weight: .bold)) + .foregroundColor(progress >= 1.0 ? .green : .primary) + } + .frame(width: 18, height: 18) + } +} + +// MARK: - Supporting Types + +struct PackageInfo: Identifiable, Equatable { + let id = UUID() + let name: String + let description: String? + let version: String? + var isInstalled: Bool + let homepage: String? + let dependencies: [String] + + init(name: String, description: String? = nil, version: String? = nil, isInstalled: Bool = false, homepage: String? = nil, dependencies: [String] = []) { + self.name = name + self.description = description + self.version = version + self.isInstalled = isInstalled + self.homepage = homepage + self.dependencies = dependencies + } +} + +enum PackageOperationStatus { + case idle + case installing(progress: Double) + case uninstalling(progress: Double) + case installed + case uninstalled + case failed(error: String) + + var displayText: String { + switch self { + case .idle: + return "" + case .installing(let progress): + return String(localized: "Installing", comment: "Installing status") + " \(Int(progress * 100))%" + case .uninstalling(let progress): + return String(localized: "Uninstalling", comment: "Uninstalling status") + " \(Int(progress * 100))%" + case .installed: + return String(localized: "Installed", comment: "Installed status") + case .uninstalled: + return String(localized: "Uninstalled", comment: "Uninstalled status") + case .failed(let error): + return error.contains("install") ? String(localized: "Install Failed", comment: "Install failed status") : String(localized: "Uninstall Failed", comment: "Uninstall failed status") + } + } + + var isActive: Bool { + switch self { + case .idle: + return false + case .installing, .uninstalling: + return true + case .installed, .uninstalled, .failed: + return true + } } } diff --git a/Applite/Views/Content View/ContentView+SidebarViews.swift b/Applite/Views/Content View/ContentView+SidebarViews.swift index a7330cb..6d1f820 100644 --- a/Applite/Views/Content View/ContentView+SidebarViews.swift +++ b/Applite/Views/Content View/ContentView+SidebarViews.swift @@ -3,6 +3,7 @@ // Applite // // Created by Milán Várady on 2024.12.26. + //MODIFIED by Subham mahesh EVERY MODIFICATION MADE BY SUBHAM MAHESH LICENSE UNDER THE MIT // import SwiftUI @@ -27,6 +28,9 @@ extension ContentView { Label("App Migration", systemImage: "square.and.arrow.up.on.square") .tag(SidebarItem.appMigration) + + Label("Package Manager", systemImage: "cube.box.fill") + .tag(SidebarItem.packageManager) Section("Categories") { ForEach(caskManager.categories) { category in diff --git a/Applite/Views/Package Manager/PackageDetailView.swift b/Applite/Views/Package Manager/PackageDetailView.swift new file mode 100644 index 0000000..4ac3158 --- /dev/null +++ b/Applite/Views/Package Manager/PackageDetailView.swift @@ -0,0 +1,413 @@ +// +// PackageDetailView.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import SwiftUI + +struct PackageDetailView: View { + let package: GenericPackage + @ObservedObject var coordinator: PackageManagerCoordinator + + @Environment(\.dismiss) private var dismiss + @State private var detailedPackage: GenericPackage? + @State private var isLoadingDetails = false + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header + PackageHeaderView(package: displayPackage) + + // Actions + PackageActionsView(package: displayPackage, coordinator: coordinator) + + // Information sections + VStack(alignment: .leading, spacing: 16) { + if let description = displayPackage.description { + PackageInfoSection(title: "Description", content: description) + } + + PackageDetailsSection(package: displayPackage) + + if !displayPackage.dependencies.isEmpty { + PackageDependenciesSection(dependencies: displayPackage.dependencies) + } + + PackageLinksSection(package: displayPackage) + } + + Spacer() + } + .padding() + } + .navigationTitle(package.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .frame(width: 600, height: 700) + .task { + await loadDetailedPackageInfo() + } + } + + private var displayPackage: GenericPackage { + detailedPackage ?? package + } + + private func loadDetailedPackageInfo() { + guard !isLoadingDetails else { return } + + isLoadingDetails = true + + Task { + if let detailed = await coordinator.getPackageInfo(package.id, manager: package.manager) { + await MainActor.run { + detailedPackage = detailed + } + } + + await MainActor.run { + isLoadingDetails = false + } + } + } +} + +struct PackageHeaderView: View { + let package: GenericPackage + + var body: some View { + HStack(spacing: 16) { + // Package manager icon + Image(systemName: package.manager.iconName) + .font(.system(size: 48)) + .foregroundColor(.accentColor) + .frame(width: 64, height: 64) + .background(Color.accentColor.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 4) { + Text(package.name) + .font(.title) + .fontWeight(.bold) + + HStack(spacing: 12) { + // Version badge + if let version = package.version { + Text("v\(version)") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // Status badge + StatusBadge(package: package) + + // Manager badge + Text(package.manager.displayName) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.2)) + .foregroundColor(.secondary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + Spacer() + } + } +} + +struct StatusBadge: View { + let package: GenericPackage + + var body: some View { + if package.isInstalled { + if package.isOutdated { + Text("Update Available") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange.opacity(0.2)) + .foregroundColor(.orange) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Text("Installed") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green.opacity(0.2)) + .foregroundColor(.green) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } else { + Text("Not Installed") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .foregroundColor(.gray) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } +} + +struct PackageActionsView: View { + let package: GenericPackage + @ObservedObject var coordinator: PackageManagerCoordinator + + var body: some View { + let operationState = coordinator.getOperationState(for: package.id) + + HStack(spacing: 12) { + if operationState.isActive { + // Progress view + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + + Text(operationState.localizedDescription) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + // Action buttons + if package.isInstalled { + if package.isOutdated { + Button(action: updatePackage) { + HStack { + Image(systemName: "arrow.up.circle.fill") + Text("Update to \(package.latestVersion ?? "latest")") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + Button(action: uninstallPackage) { + HStack { + Image(systemName: "trash") + Text("Uninstall") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .foregroundColor(.red) + } else { + Button(action: installPackage) { + HStack { + Image(systemName: "arrow.down.circle.fill") + Text("Install") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } + } + } + + private func installPackage() { + Task { + await coordinator.installPackage(package) + } + } + + private func uninstallPackage() { + Task { + await coordinator.uninstallPackage(package) + } + } + + private func updatePackage() { + Task { + await coordinator.updatePackage(package) + } + } +} + +struct PackageInfoSection: View { + let title: String + let content: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + .foregroundColor(.primary) + + Text(content) + .font(.body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +struct PackageDetailsSection: View { + let package: GenericPackage + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Details") + .font(.headline) + .foregroundColor(.primary) + + VStack(spacing: 8) { + DetailRow(label: "Package ID", value: package.id) + DetailRow(label: "Manager", value: package.manager.displayName) + + if let version = package.version { + DetailRow(label: "Version", value: version) + } + + if let latestVersion = package.latestVersion, package.isOutdated { + DetailRow(label: "Latest Version", value: latestVersion) + } + + if let installSize = package.installSize { + DetailRow(label: "Install Size", value: installSize) + } + } + } + } +} + +struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .foregroundColor(.secondary) + + Spacer() + + Text(value) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +struct PackageDependenciesSection: View { + let dependencies: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Dependencies (\(dependencies.count))") + .font(.headline) + .foregroundColor(.primary) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 8) { + ForEach(dependencies, id: \.self) { dependency in + Text(dependency) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } + } +} + +struct PackageLinksSection: View { + let package: GenericPackage + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Links") + .font(.headline) + .foregroundColor(.primary) + + VStack(spacing: 8) { + if let homepage = package.homepage, let url = URL(string: homepage) { + LinkRow(title: "Homepage", url: url, icon: "house") + } + + // Add more links as available + } + } + } +} + +struct LinkRow: View { + let title: String + let url: URL + let icon: String + + var body: some View { + Button(action: { NSWorkspace.shared.open(url) }) { + HStack { + Image(systemName: icon) + .foregroundColor(.blue) + + Text(title) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "arrow.up.right") + .foregroundColor(.secondary) + .font(.caption) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } +} + +#Preview { + let coordinator = PackageManagerCoordinator() + let samplePackage = GenericPackage( + id: "git", + name: "Git", + version: "2.42.0", + description: "Git is a free and open source distributed version control system designed to handle everything from small to very large projects with speed and efficiency.", + manager: .homebrew, + isInstalled: true, + isOutdated: true, + latestVersion: "2.42.1", + homepage: "https://git-scm.com", + installSize: "45.2 MB", + dependencies: ["gettext", "pcre2"] + ) + + return PackageDetailView(package: samplePackage, coordinator: coordinator) +} \ No newline at end of file diff --git a/Applite/Views/Package Manager/PackageListView.swift b/Applite/Views/Package Manager/PackageListView.swift new file mode 100644 index 0000000..3592f1e --- /dev/null +++ b/Applite/Views/Package Manager/PackageListView.swift @@ -0,0 +1,298 @@ +// +// PackageListView.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import SwiftUI + +struct PackageListView: View { + let packages: [GenericPackage] + @ObservedObject var coordinator: PackageManagerCoordinator + let emptyMessage: String + + @State private var selectedPackage: GenericPackage? + @State private var sortOption: SortOption = .name + @State private var sortOrder: SortOrder = .ascending + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Sort controls + HStack { + Text("\(sortedPackages.count) packages") + .foregroundColor(.secondary) + + Spacer() + + Menu { + Picker("Sort by", selection: $sortOption) { + ForEach(SortOption.allCases) { option in + Label(option.displayName, systemImage: option.iconName) + .tag(option) + } + } + + Divider() + + Picker("Order", selection: $sortOrder) { + Label("Ascending", systemImage: "arrow.up").tag(SortOrder.ascending) + Label("Descending", systemImage: "arrow.down").tag(SortOrder.descending) + } + } label: { + HStack(spacing: 4) { + Image(systemName: sortOption.iconName) + Image(systemName: sortOrder == .ascending ? "arrow.up" : "arrow.down") + } + .font(.caption) + .foregroundColor(.secondary) + } + .menuStyle(BorderlessButtonMenuStyle()) + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + // Package list + if sortedPackages.isEmpty { + VStack(spacing: 16) { + Image(systemName: "cube.box") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(emptyMessage) + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(sortedPackages, selection: $selectedPackage) { package in + PackageRowView( + package: package, + coordinator: coordinator + ) + .tag(package) + } + .listStyle(PlainListStyle()) + } + } + .sheet(item: $selectedPackage) { package in + PackageDetailView(package: package, coordinator: coordinator) + } + } + + private var sortedPackages: [GenericPackage] { + packages.sorted { lhs, rhs in + let ascending = sortOrder == .ascending + + switch sortOption { + case .name: + return ascending ? lhs.name < rhs.name : lhs.name > rhs.name + case .manager: + let lhsManager = lhs.manager.displayName + let rhsManager = rhs.manager.displayName + return ascending ? lhsManager < rhsManager : lhsManager > rhsManager + case .version: + let lhsVersion = lhs.version ?? "" + let rhsVersion = rhs.version ?? "" + return ascending ? lhsVersion < rhsVersion : lhsVersion > rhsVersion + case .installSize: + let lhsSize = lhs.installSize ?? "" + let rhsSize = rhs.installSize ?? "" + return ascending ? lhsSize < rhsSize : lhsSize > rhsSize + } + } + } +} + +struct PackageRowView: View { + let package: GenericPackage + @ObservedObject var coordinator: PackageManagerCoordinator + + @State private var showingActions = false + + var body: some View { + HStack(spacing: 12) { + // Package manager icon + Image(systemName: package.manager.iconName) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 24) + + // Package info + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(package.name) + .font(.headline) + .lineLimit(1) + + if package.isOutdated { + Image(systemName: "arrow.up.circle.fill") + .foregroundColor(.orange) + .font(.caption) + } + } + + HStack(spacing: 8) { + if let version = package.version { + Text("v\(version)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + Text(package.manager.displayName) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + + if let description = package.description { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + // Operation state or actions + PackageActionView(package: package, coordinator: coordinator) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } +} + +struct PackageActionView: View { + let package: GenericPackage + @ObservedObject var coordinator: PackageManagerCoordinator + + var body: some View { + let operationState = coordinator.getOperationState(for: package.id) + + HStack(spacing: 8) { + if operationState.isActive { + // Show progress + HStack(spacing: 4) { + SmallProgressView() + Text(operationState.localizedDescription) + .font(.caption) + .foregroundColor(.secondary) + } + } else { + // Show action buttons + if package.isInstalled { + if package.isOutdated { + Button(action: updatePackage) { + Image(systemName: "arrow.up.circle") + .foregroundColor(.orange) + } + .help("Update package") + } + + Button(action: uninstallPackage) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .help("Uninstall package") + } else { + Button(action: installPackage) { + Image(systemName: "arrow.down.circle") + .foregroundColor(.blue) + } + .help("Install package") + } + } + } + } + + private func installPackage() { + Task { + await coordinator.installPackage(package) + } + } + + private func uninstallPackage() { + Task { + await coordinator.uninstallPackage(package) + } + } + + private func updatePackage() { + Task { + await coordinator.updatePackage(package) + } + } +} + +// MARK: - Supporting Types + +enum SortOption: String, CaseIterable, Identifiable { + case name = "name" + case manager = "manager" + case version = "version" + case installSize = "installSize" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .name: return "Name" + case .manager: return "Manager" + case .version: return "Version" + case .installSize: return "Size" + } + } + + var iconName: String { + switch self { + case .name: return "textformat" + case .manager: return "cube.box" + case .version: return "number" + case .installSize: return "externaldrive" + } + } +} + +enum SortOrder { + case ascending + case descending +} + +#Preview { + let coordinator = PackageManagerCoordinator() + let samplePackages = [ + GenericPackage( + id: "git", + name: "Git", + version: "2.42.0", + description: "Distributed version control system", + manager: .homebrew, + isInstalled: true + ), + GenericPackage( + id: "node", + name: "Node.js", + version: "18.17.0", + description: "JavaScript runtime", + manager: .homebrew, + isInstalled: true, + isOutdated: true, + latestVersion: "20.5.0" + ) + ] + + return PackageListView( + packages: samplePackages, + coordinator: coordinator, + emptyMessage: "No packages found" + ) + .frame(height: 400) +} \ No newline at end of file diff --git a/Applite/Views/Package Manager/PackageManagerIntegrated.swift b/Applite/Views/Package Manager/PackageManagerIntegrated.swift new file mode 100644 index 0000000..83b7d1d --- /dev/null +++ b/Applite/Views/Package Manager/PackageManagerIntegrated.swift @@ -0,0 +1,961 @@ +// +// PackageManagerIntegrated.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import Foundation +import SwiftUI +import OSLog +import UserNotifications + +// MARK: - Package Manager Types + +enum PackageManagerType: String, CaseIterable, Identifiable, Codable { + case homebrew = "homebrew" + case macports = "macports" + case npm = "npm" + case pip = "pip" + case gem = "gem" + case cargo = "cargo" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .homebrew: return "Homebrew" + case .macports: return "MacPorts" + case .npm: return "npm" + case .pip: return "pip" + case .gem: return "RubyGems" + case .cargo: return "Cargo" + } + } + + var iconName: String { + switch self { + case .homebrew: return "mug" + case .macports: return "port" + case .npm: return "cube.box" + case .pip: return "snake" + case .gem: return "diamond" + case .cargo: return "shippingbox" + } + } +} + +// MARK: - Generic Package Model + +struct GenericPackage: Identifiable, Codable, Hashable { + let id: String + let name: String + let version: String? + let description: String? + let manager: PackageManagerType + let isInstalled: Bool + let isOutdated: Bool + let latestVersion: String? + let homepage: String? + let installSize: String? + let dependencies: [String] + + init( + id: String, + name: String, + version: String? = nil, + description: String? = nil, + manager: PackageManagerType, + isInstalled: Bool = false, + isOutdated: Bool = false, + latestVersion: String? = nil, + homepage: String? = nil, + installSize: String? = nil, + dependencies: [String] = [] + ) { + self.id = id + self.name = name + self.version = version + self.description = description + self.manager = manager + self.isInstalled = isInstalled + self.isOutdated = isOutdated + self.latestVersion = latestVersion + self.homepage = homepage + self.installSize = installSize + self.dependencies = dependencies + } +} + +// MARK: - Package Operation State + +enum PackageOperationState: Equatable { + case idle + case installing + case uninstalling + case updating + case downloading(progress: Double) + case success + case failed(error: String) + + var isActive: Bool { + switch self { + case .idle, .success, .failed: + return false + default: + return true + } + } + + var localizedDescription: String { + switch self { + case .idle: + return "" + case .installing: + return String(localized: "Installing", comment: "Package installing state") + case .uninstalling: + return String(localized: "Uninstalling", comment: "Package uninstalling state") + case .updating: + return String(localized: "Updating", comment: "Package updating state") + case .downloading(let progress): + return String(localized: "Downloading \(Int(progress * 100))%", comment: "Package downloading state") + case .success: + return String(localized: "Success", comment: "Package operation success state") + case .failed(let error): + return String(localized: "Failed: \(error)", comment: "Package operation failed state") + } + } +} + +// MARK: - Package Manager Errors + +enum PackageManagerError: LocalizedError { + case managerNotAvailable(String) + case packageNotFound(String) + case installationFailed(String, String) + case uninstallationFailed(String, String) + case updateFailed(String, String) + case commandExecutionFailed(String) + case parseError(String) + + var errorDescription: String? { + switch self { + case .managerNotAvailable(let manager): + return "Package manager '\(manager)' is not available" + case .packageNotFound(let package): + return "Package '\(package)' not found" + case .installationFailed(let package, let error): + return "Failed to install '\(package)': \(error)" + case .uninstallationFailed(let package, let error): + return "Failed to uninstall '\(package)': \(error)" + case .updateFailed(let package, let error): + return "Failed to update '\(package)': \(error)" + case .commandExecutionFailed(let command): + return "Failed to execute command: \(command)" + case .parseError(let details): + return "Failed to parse output: \(details)" + } + } +} + +// MARK: - Brew Package Manager + +@MainActor +class BrewPackageManager: ObservableObject { + let name = "Homebrew" + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: BrewPackageManager.self) + ) + + var isAvailable: Bool { + get async { + do { + _ = try await Shell.runBrewCommand(["--version"]) + return true + } catch { + return false + } + } + } + + func install(_ package: GenericPackage) async throws { + Self.logger.info("Installing package: \(package.id)") + let arguments = ["install", package.id] + + do { + _ = try await Shell.runBrewCommand(arguments) + Self.logger.info("Successfully installed package: \(package.id)") + } catch { + Self.logger.error("Failed to install package \(package.id): \(error.localizedDescription)") + throw PackageManagerError.installationFailed(package.id, error.localizedDescription) + } + } + + func uninstall(_ package: GenericPackage) async throws { + Self.logger.info("Uninstalling package: \(package.id)") + let arguments = ["uninstall", package.id] + + do { + _ = try await Shell.runBrewCommand(arguments) + Self.logger.info("Successfully uninstalled package: \(package.id)") + } catch { + Self.logger.error("Failed to uninstall package \(package.id): \(error.localizedDescription)") + throw PackageManagerError.uninstallationFailed(package.id, error.localizedDescription) + } + } + + func update(_ package: GenericPackage) async throws { + Self.logger.info("Updating package: \(package.id)") + let arguments = ["upgrade", package.id] + + do { + _ = try await Shell.runBrewCommand(arguments) + Self.logger.info("Successfully updated package: \(package.id)") + } catch { + Self.logger.error("Failed to update package \(package.id): \(error.localizedDescription)") + throw PackageManagerError.updateFailed(package.id, error.localizedDescription) + } + } + + func getInstalledPackages() async throws -> [GenericPackage] { + do { + let output = try await Shell.runBrewCommand(["list", "--formula", "--versions"]) + return parseInstalledPackages(output) + } catch { + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + func getOutdatedPackages() async throws -> [GenericPackage] { + do { + let output = try await Shell.runBrewCommand(["outdated", "--formula"]) + return parseOutdatedPackages(output) + } catch { + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + func searchPackages(_ query: String) async throws -> [GenericPackage] { + guard !query.isEmpty else { return [] } + + do { + let output = try await Shell.runBrewCommand(["search", query]) + return parseSearchResults(output) + } catch { + throw PackageManagerError.commandExecutionFailed(error.localizedDescription) + } + } + + private func parseInstalledPackages(_ output: String) -> [GenericPackage] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + return lines.compactMap { line in + let components = line.components(separatedBy: " ") + guard let packageName = components.first else { return nil } + let version = components.count > 1 ? components[1] : nil + + return GenericPackage( + id: packageName, + name: packageName, + version: version, + manager: .homebrew, + isInstalled: true + ) + } + } + + private func parseOutdatedPackages(_ output: String) -> [GenericPackage] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + return lines.compactMap { line in + let components = line.components(separatedBy: " ") + guard let packageName = components.first else { return nil } + + return GenericPackage( + id: packageName, + name: packageName, + manager: .homebrew, + isInstalled: true, + isOutdated: true + ) + } + } + + private func parseSearchResults(_ output: String) -> [GenericPackage] { + let lines = output.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .filter { !$0.hasPrefix("==>") } + + return lines.compactMap { line in + let packageName = line.trimmingCharacters(in: .whitespaces) + guard !packageName.isEmpty else { return nil } + + return GenericPackage( + id: packageName, + name: packageName, + manager: .homebrew, + isInstalled: false + ) + } + } +} + +// MARK: - Package Manager Coordinator + +@MainActor +final class SimplePackageManagerCoordinator: ObservableObject { + + @Published var installedPackages: [GenericPackage] = [] + @Published var outdatedPackages: [GenericPackage] = [] + @Published var isLoading = false + @Published var operationStates: [String: PackageOperationState] = [:] + + private let brewManager = BrewPackageManager() + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: SimplePackageManagerCoordinator.self) + ) + + init() { + Task { + await loadInstalledPackages() + } + } + + func loadInstalledPackages() async { + guard !isLoading else { return } + + isLoading = true + logger.info("Loading installed packages") + + do { + let packages = try await brewManager.getInstalledPackages() + installedPackages = packages + logger.info("Loaded \(packages.count) packages") + } catch { + logger.error("Failed to load packages: \(error.localizedDescription)") + } + + isLoading = false + } + + func loadOutdatedPackages() async { + logger.info("Loading outdated packages") + + do { + let packages = try await brewManager.getOutdatedPackages() + outdatedPackages = packages + logger.info("Found \(packages.count) outdated packages") + } catch { + logger.error("Failed to get outdated packages: \(error.localizedDescription)") + } + } + + func searchPackages(_ query: String) async -> [GenericPackage] { + guard !query.isEmpty else { return [] } + + do { + return try await brewManager.searchPackages(query) + } catch { + logger.error("Failed to search packages: \(error.localizedDescription)") + return [] + } + } + + func installPackage(_ package: GenericPackage) async { + setOperationState(.installing, for: package.id) + + do { + try await brewManager.install(package) + setOperationState(.success, for: package.id) + await loadInstalledPackages() + logger.info("Successfully installed package: \(package.id)") + } catch { + logger.error("Failed to install package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + func uninstallPackage(_ package: GenericPackage) async { + setOperationState(.uninstalling, for: package.id) + + do { + try await brewManager.uninstall(package) + setOperationState(.success, for: package.id) + await loadInstalledPackages() + logger.info("Successfully uninstalled package: \(package.id)") + } catch { + logger.error("Failed to uninstall package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + func updatePackage(_ package: GenericPackage) async { + setOperationState(.updating, for: package.id) + + do { + try await brewManager.update(package) + setOperationState(.success, for: package.id) + await loadInstalledPackages() + await loadOutdatedPackages() + logger.info("Successfully updated package: \(package.id)") + } catch { + logger.error("Failed to update package \(package.id): \(error.localizedDescription)") + setOperationState(.failed(error: error.localizedDescription), for: package.id) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.setOperationState(.idle, for: package.id) + } + } + + private func setOperationState(_ state: PackageOperationState, for packageId: String) { + operationStates[packageId] = state + } + + func getOperationState(for packageId: String) -> PackageOperationState { + return operationStates[packageId] ?? .idle + } + + func refreshAllData() async { + await loadInstalledPackages() + await loadOutdatedPackages() + } +} + +// MARK: - Package Manager View + +struct PackageManagerIntegratedView: View { + @StateObject private var coordinator = SimplePackageManagerCoordinator() + @State private var searchText = "" + @State private var selectedTab = 0 + @State private var searchResults: [GenericPackage] = [] + @State private var isSearching = false + @State private var searchTask: Task? + + var body: some View { + VStack(spacing: 0) { + // Tab selector + Picker("View", selection: $selectedTab) { + Text("Installed").tag(0) + if !coordinator.outdatedPackages.isEmpty { + Text("Updates (\(coordinator.outdatedPackages.count))").tag(1) + } + Text("Search").tag(2) + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + // Content + TabView(selection: $selectedTab) { + // Installed packages + PackageListTab( + packages: filteredInstalledPackages, + coordinator: coordinator, + emptyMessage: "No packages installed" + ) + .tag(0) + + // Outdated packages + if !coordinator.outdatedPackages.isEmpty { + PackageListTab( + packages: coordinator.outdatedPackages, + coordinator: coordinator, + emptyMessage: "All packages up to date" + ) + .tag(1) + } + + // Search + SearchTab( + searchText: $searchText, + searchResults: $searchResults, + isSearching: $isSearching, + coordinator: coordinator + ) + .tag(2) + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + } + .navigationTitle("Package Manager") + .searchable(text: $searchText, prompt: "Search packages...") + .onChange(of: searchText) { newValue in + if selectedTab != 2 && !newValue.isEmpty { + selectedTab = 2 + } + } + .task { + await coordinator.refreshAllData() + } + } + + private var filteredInstalledPackages: [GenericPackage] { + if searchText.isEmpty { + return coordinator.installedPackages + } + + return coordinator.installedPackages.filter { package in + package.name.localizedCaseInsensitiveContains(searchText) || + package.description?.localizedCaseInsensitiveContains(searchText) == true + } + } +} + +// MARK: - Supporting Views + +struct PackageListTab: View { + let packages: [GenericPackage] + @ObservedObject var coordinator: SimplePackageManagerCoordinator + let emptyMessage: String + + var body: some View { + VStack(spacing: 0) { + if coordinator.isLoading { + ProgressView("Loading packages...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if packages.isEmpty { + VStack(spacing: 16) { + Image(systemName: "cube.box") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(emptyMessage) + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(packages) { package in + PackageRowView(package: package, coordinator: coordinator) + } + .listStyle(PlainListStyle()) + } + } + } +} + +struct SearchTab: View { + @Binding var searchText: String + @Binding var searchResults: [GenericPackage] + @Binding var isSearching: Bool + @ObservedObject var coordinator: SimplePackageManagerCoordinator + @State private var searchTask: Task? + + var body: some View { + VStack(spacing: 0) { + if searchText.isEmpty { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("Search for packages") + .font(.headline) + .foregroundColor(.secondary) + + Text("Enter a package name to find new packages to install") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(alignment: .leading, spacing: 0) { + HStack { + if isSearching { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Searching...") + } + } else { + Text("Found \(searchResults.count) packages") + } + Spacer() + } + .padding() + + Divider() + + if searchResults.isEmpty && !isSearching { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("No packages found") + .font(.headline) + .foregroundColor(.secondary) + + Text("Try a different search term") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(searchResults) { package in + PackageRowView(package: package, coordinator: coordinator) + } + .listStyle(PlainListStyle()) + } + } + } + } + .onChange(of: searchText) { newValue in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(for: .milliseconds(500)) + + if !Task.isCancelled && !newValue.isEmpty { + await performSearch(query: newValue) + } else if newValue.isEmpty { + await MainActor.run { + searchResults = [] + } + } + } + } + } + + @MainActor + private func performSearch(query: String) async { + guard !query.isEmpty else { + searchResults = [] + return + } + + isSearching = true + let results = await coordinator.searchPackages(query) + searchResults = results + isSearching = false + } +} + +struct PackageRowView: View { + let package: GenericPackage + @ObservedObject var coordinator: SimplePackageManagerCoordinator + @State private var showingDetail = false + + var body: some View { + HStack(spacing: 12) { + Button(action: { showingDetail = true }) { + Image(systemName: package.manager.iconName) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 24) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(package.name) + .font(.headline) + .lineLimit(1) + + if package.isOutdated { + Image(systemName: "arrow.up.circle.fill") + .foregroundColor(.orange) + .font(.caption) + } + } + + HStack(spacing: 8) { + if let version = package.version { + Text("v\(version)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + Text(package.manager.displayName) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + + if let description = package.description { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + PackageActionView(package: package, coordinator: coordinator) + } + .padding(.vertical, 4) + .sheet(isPresented: $showingDetail) { + SimplePackageDetailView(package: package) + } + } +} + +struct PackageActionView: View { + let package: GenericPackage + @ObservedObject var coordinator: SimplePackageManagerCoordinator + + var body: some View { + let operationState = coordinator.getOperationState(for: package.id) + + HStack(spacing: 8) { + if operationState.isActive { + HStack(spacing: 4) { + ProgressView() + .scaleEffect(0.6) + Text(operationState.localizedDescription) + .font(.caption) + .foregroundColor(.secondary) + } + } else { + if package.isInstalled { + if package.isOutdated { + Button(action: updatePackage) { + Image(systemName: "arrow.up.circle") + .foregroundColor(.orange) + } + .help("Update package") + } + + Button(action: uninstallPackage) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .help("Uninstall package") + } else { + Button(action: installPackage) { + Image(systemName: "arrow.down.circle") + .foregroundColor(.blue) + } + .help("Install package") + } + } + } + } + + private func installPackage() { + Task { + await coordinator.installPackage(package) + } + } + + private func uninstallPackage() { + Task { + await coordinator.uninstallPackage(package) + } + } + + private func updatePackage() { + Task { + await coordinator.updatePackage(package) + } + } +} + +struct SimplePackageDetailView: View { + let package: GenericPackage + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack(spacing: 16) { + Image(systemName: package.manager.iconName) + .font(.system(size: 48)) + .foregroundColor(.accentColor) + .frame(width: 64, height: 64) + .background(Color.accentColor.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 4) { + Text(package.name) + .font(.title) + .fontWeight(.bold) + + HStack(spacing: 12) { + if let version = package.version { + Text("v\(version)") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + if package.isInstalled { + Text("Installed") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green.opacity(0.2)) + .foregroundColor(.green) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Text("Not Installed") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .foregroundColor(.gray) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + Text(package.manager.displayName) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.2)) + .foregroundColor(.secondary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + Spacer() + } + + // Description + if let description = package.description { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(.headline) + .foregroundColor(.primary) + + Text(description) + .font(.body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(.headline) + .foregroundColor(.primary) + + Text("No description available.") + .font(.body) + .foregroundColor(.secondary) + .italic() + } + } + + // Package Details + VStack(alignment: .leading, spacing: 8) { + Text("Details") + .font(.headline) + .foregroundColor(.primary) + + VStack(spacing: 8) { + HStack { + Text("Package ID") + .foregroundColor(.secondary) + Spacer() + Text(package.id) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + HStack { + Text("Manager") + .foregroundColor(.secondary) + Spacer() + Text(package.manager.displayName) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + if let version = package.version { + HStack { + Text("Version") + .foregroundColor(.secondary) + Spacer() + Text(version) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + // Homepage Link + if let homepage = package.homepage, let url = URL(string: homepage) { + VStack(alignment: .leading, spacing: 8) { + Text("Links") + .font(.headline) + .foregroundColor(.primary) + + Button(action: { NSWorkspace.shared.open(url) }) { + HStack { + Image(systemName: "house") + .foregroundColor(.blue) + + Text("Homepage") + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "arrow.up.right") + .foregroundColor(.secondary) + .font(.caption) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + } + + Spacer() + } + .padding() + } + .navigationTitle(package.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .frame(width: 500, height: 600) + } +} + +#Preview { + PackageManagerIntegratedView() +} \ No newline at end of file diff --git a/Applite/Views/Package Manager/PackageManagerSettingsView.swift b/Applite/Views/Package Manager/PackageManagerSettingsView.swift new file mode 100644 index 0000000..991e1db --- /dev/null +++ b/Applite/Views/Package Manager/PackageManagerSettingsView.swift @@ -0,0 +1,233 @@ +// +// PackageManagerSettingsView.swift +// Applite +// +// Created by Subham mahesh +// licensed under the MIT +// + +import SwiftUI + +struct PackageManagerSettingsView: View { + @ObservedObject var updater: PackageUpdater + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 24) { + // Auto Update Settings + VStack(alignment: .leading, spacing: 16) { + Text("Automatic Updates") + .font(.title2) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 12) { + Toggle("Enable automatic updates", isOn: $updater.isAutoUpdateEnabled) + + if updater.isAutoUpdateEnabled { + VStack(alignment: .leading, spacing: 8) { + Text("Update Frequency") + .font(.headline) + + Picker("Frequency", selection: $updater.autoUpdateFrequency) { + ForEach(UpdateFrequency.allCases) { frequency in + Text(frequency.displayName) + .tag(frequency) + } + } + .pickerStyle(SegmentedPickerStyle()) + + Text("Applite will automatically check for and install package updates.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.leading) + } + } + } + .padding() + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Update History + VStack(alignment: .leading, spacing: 16) { + Text("Update History") + .font(.title2) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 8) { + if let lastCheck = updater.lastUpdateCheck { + Text("Last checked: \(lastCheck, style: .relative) ago") + .foregroundColor(.secondary) + } else { + Text("Never checked for updates") + .foregroundColor(.secondary) + } + + if !updater.updateResults.isEmpty { + RecentUpdatesView(results: updater.updateResults) + } + } + } + .padding() + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Notifications + VStack(alignment: .leading, spacing: 16) { + Text("Notifications") + .font(.title2) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 8) { + Text("Applite will send notifications about package updates and installation results.") + .foregroundColor(.secondary) + + Button("Open System Preferences") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.borderless) + } + } + .padding() + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Spacer() + } + .padding() + .navigationTitle("Package Manager Settings") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .frame(width: 600, height: 500) + } +} + +struct RecentUpdatesView: View { + let results: [UpdateResult] + + private var recentResults: [UpdateResult] { + Array(results.suffix(5)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Updates") + .font(.headline) + + if recentResults.isEmpty { + Text("No recent updates") + .foregroundColor(.secondary) + .italic() + } else { + VStack(spacing: 6) { + ForEach(recentResults) { result in + UpdateResultRow(result: result) + } + } + } + } + } +} + +struct UpdateResultRow: View { + let result: UpdateResult + + var body: some View { + HStack(spacing: 8) { + // Status icon + Image(systemName: result.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(result.success ? .green : .red) + .font(.caption) + + // Package info + VStack(alignment: .leading, spacing: 2) { + Text(result.package.name) + .font(.caption) + .fontWeight(.medium) + + HStack(spacing: 4) { + Text(result.timestamp, style: .time) + .font(.caption2) + .foregroundColor(.secondary) + + Text("•") + .font(.caption2) + .foregroundColor(.secondary) + + Text(result.formattedDuration) + .font(.caption2) + .foregroundColor(.secondary) + + if let error = result.error { + Text("•") + .font(.caption2) + .foregroundColor(.secondary) + + Text(error) + .font(.caption2) + .foregroundColor(.red) + .lineLimit(1) + } + } + } + + Spacer() + + // Manager badge + Text(result.package.manager.displayName) + .font(.caption2) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +#Preview { + let coordinator = PackageManagerCoordinator() + let updater = PackageUpdater(coordinator: coordinator) + + // Add some sample update results + updater.updateResults = [ + UpdateResult( + package: GenericPackage( + id: "git", + name: "Git", + version: "2.42.0", + manager: .homebrew, + isInstalled: true + ), + success: true, + duration: 15.3, + error: nil + ), + UpdateResult( + package: GenericPackage( + id: "node", + name: "Node.js", + version: "18.17.0", + manager: .homebrew, + isInstalled: true + ), + success: false, + duration: 5.2, + error: "Permission denied" + ) + ] + + return PackageManagerSettingsView(updater: updater) +} \ No newline at end of file diff --git a/Applite/Views/Package Manager/PackageManagerView.swift b/Applite/Views/Package Manager/PackageManagerView.swift new file mode 100644 index 0000000..5deef02 --- /dev/null +++ b/Applite/Views/Package Manager/PackageManagerView.swift @@ -0,0 +1,254 @@ +// +// PackageManagerView.swift +// Applite +// +// Created by Subham mahesh + licensed under the MIT +// + +import SwiftUI + +struct PackageManagerView: View { + @StateObject private var coordinator = PackageManagerCoordinator() + @StateObject private var updater: PackageUpdater + @State private var selectedManager: PackageManagerType? + @State private var searchText = "" + @State private var showingSettings = false + + init() { + let coordinator = PackageManagerCoordinator() + self._coordinator = StateObject(wrappedValue: coordinator) + self._updater = StateObject(wrappedValue: PackageUpdater(coordinator: coordinator)) + } + + var body: some View { + NavigationSplitView { + // Sidebar + PackageManagerSidebar( + coordinator: coordinator, + selectedManager: $selectedManager + ) + } detail: { + // Main content + PackageManagerDetailView( + coordinator: coordinator, + updater: updater, + selectedManager: selectedManager, + searchText: $searchText + ) + } + .navigationTitle("Package Managers") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { showingSettings = true }) { + Image(systemName: "gearshape") + } + + Button(action: refreshAll) { + Image(systemName: "arrow.clockwise") + } + .disabled(coordinator.isLoading) + + if !coordinator.outdatedPackages.isEmpty { + Button(action: updateAll) { + Image(systemName: "square.and.arrow.down") + } + .disabled(updater.isUpdating) + } + } + } + .searchable(text: $searchText, prompt: "Search packages...") + .sheet(isPresented: $showingSettings) { + PackageManagerSettingsView(updater: updater) + } + .task { + await coordinator.refreshAllData() + } + } + + private func refreshAll() { + Task { + await coordinator.refreshAllData() + } + } + + private func updateAll() { + Task { + await updater.updateAllPackages() + } + } +} + +struct PackageManagerSidebar: View { + @ObservedObject var coordinator: PackageManagerCoordinator + @Binding var selectedManager: PackageManagerType? + + var body: some View { + List(selection: $selectedManager) { + Section("Overview") { + NavigationLink(value: nil as PackageManagerType?) { + Label("All Packages", systemImage: "square.grid.2x2") + } + .tag(nil as PackageManagerType?) + + if !coordinator.outdatedPackages.isEmpty { + NavigationLink(value: PackageManagerType.homebrew) { + Label("Updates Available", systemImage: "arrow.down.circle") + .badge(coordinator.outdatedPackages.count) + } + } + } + + Section("Package Managers") { + ForEach(coordinator.availableManagers) { manager in + NavigationLink(value: manager) { + HStack { + Image(systemName: manager.iconName) + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(manager.displayName) + .font(.headline) + + let installedCount = coordinator.getPackagesByManager(manager).count + let outdatedCount = coordinator.getOutdatedPackagesByManager(manager).count + + Text("\(installedCount) installed" + (outdatedCount > 0 ? ", \(outdatedCount) outdated" : "")) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + .tag(manager) + } + } + } + .navigationTitle("Packages") + .refreshable { + await coordinator.refreshAllData() + } + } +} + +struct PackageManagerDetailView: View { + @ObservedObject var coordinator: PackageManagerCoordinator + @ObservedObject var updater: PackageUpdater + let selectedManager: PackageManagerType? + @Binding var searchText: String + + @State private var selectedTab = 0 + + var body: some View { + VStack(spacing: 0) { + // Tab selector + Picker("View", selection: $selectedTab) { + Text("Installed").tag(0) + if !filteredOutdatedPackages.isEmpty { + Text("Updates").tag(1) + } + Text("Search").tag(2) + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + // Content + TabView(selection: $selectedTab) { + // Installed packages + PackageListView( + packages: filteredInstalledPackages, + coordinator: coordinator, + emptyMessage: "No installed packages" + ) + .tag(0) + + // Outdated packages + if !filteredOutdatedPackages.isEmpty { + VStack(spacing: 16) { + // Update all button + HStack { + Spacer() + + Button(action: updateSelectedPackages) { + HStack { + Image(systemName: "arrow.down.circle.fill") + Text("Update All") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .disabled(updater.isUpdating) + } + .padding(.horizontal) + + PackageListView( + packages: filteredOutdatedPackages, + coordinator: coordinator, + emptyMessage: "No packages to update" + ) + } + .tag(1) + } + + // Search + PackageSearchView( + coordinator: coordinator, + searchText: $searchText, + selectedManager: selectedManager + ) + .tag(2) + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + } + .navigationTitle(selectedManager?.displayName ?? "All Packages") + .overlay { + if coordinator.isLoading { + ProgressView("Loading packages...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.controlBackgroundColor).opacity(0.8)) + } + } + } + + private var filteredInstalledPackages: [GenericPackage] { + let packages = selectedManager.map { coordinator.getPackagesByManager($0) } ?? coordinator.installedPackages + + if searchText.isEmpty { + return packages + } + + return packages.filter { package in + package.name.localizedCaseInsensitiveContains(searchText) || + package.description?.localizedCaseInsensitiveContains(searchText) == true + } + } + + private var filteredOutdatedPackages: [GenericPackage] { + let packages = selectedManager.map { coordinator.getOutdatedPackagesByManager($0) } ?? coordinator.outdatedPackages + + if searchText.isEmpty { + return packages + } + + return packages.filter { package in + package.name.localizedCaseInsensitiveContains(searchText) || + package.description?.localizedCaseInsensitiveContains(searchText) == true + } + } + + private func updateSelectedPackages() { + Task { + if let manager = selectedManager { + await updater.updatePackagesFor(manager: manager) + } else { + await updater.updateAllPackages() + } + } + } +} + +#Preview { + PackageManagerView() +} \ No newline at end of file diff --git a/Applite/Views/Package Manager/PackageSearchView.swift b/Applite/Views/Package Manager/PackageSearchView.swift new file mode 100644 index 0000000..c28a87d --- /dev/null +++ b/Applite/Views/Package Manager/PackageSearchView.swift @@ -0,0 +1,232 @@ +// +// PackageSearchView.swift +// Applite +// +// Created by Subham mahesh + licensed under the MIT +// + +import SwiftUI + +struct PackageSearchView: View { + @ObservedObject var coordinator: PackageManagerCoordinator + @Binding var searchText: String + let selectedManager: PackageManagerType? + + @State private var searchResults: [GenericPackage] = [] + @State private var isSearching = false + @State private var searchTask: Task? + + var body: some View { + VStack(spacing: 0) { + if searchText.isEmpty { + // Empty search state + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("Search for packages") + .font(.headline) + .foregroundColor(.secondary) + + Text("Enter a package name or description to find new packages to install") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Search results + VStack(alignment: .leading, spacing: 0) { + // Search status + HStack { + if isSearching { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Searching...") + } + } else { + Text("Found \(searchResults.count) packages") + } + + Spacer() + + if !searchResults.isEmpty { + // Manager filter + Menu { + Button("All Managers") { + // Search in all managers + performSearch() + } + + Divider() + + ForEach(coordinator.availableManagers) { manager in + Button(manager.displayName) { + performSearch(in: [manager]) + } + } + } label: { + HStack(spacing: 4) { + Text("Filter") + Image(systemName: "chevron.down") + } + .font(.caption) + .foregroundColor(.secondary) + } + .menuStyle(BorderlessButtonMenuStyle()) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + // Results list + if searchResults.isEmpty && !isSearching { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("No packages found") + .font(.headline) + .foregroundColor(.secondary) + + Text("Try a different search term or check your spelling") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(searchResults) { package in + SearchResultRow(package: package, coordinator: coordinator) + } + .listStyle(PlainListStyle()) + } + } + } + } + .onChange(of: searchText) { newValue in + // Debounce search + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(for: .milliseconds(500)) + + if !Task.isCancelled && !newValue.isEmpty { + await performSearch() + } else if newValue.isEmpty { + await MainActor.run { + searchResults = [] + } + } + } + } + } + + @MainActor + private func performSearch(in managers: [PackageManagerType] = []) { + guard !searchText.isEmpty else { + searchResults = [] + return + } + + isSearching = true + + Task { + let managersToSearch = managers.isEmpty ? (selectedManager.map { [$0] } ?? []) : managers + let results = await coordinator.searchPackages(searchText, in: managersToSearch) + + await MainActor.run { + searchResults = results + isSearching = false + } + } + } +} + +struct SearchResultRow: View { + let package: GenericPackage + @ObservedObject var coordinator: PackageManagerCoordinator + + @State private var showingDetail = false + + var body: some View { + HStack(spacing: 12) { + // Package manager icon - clickable to show details + Button(action: { showingDetail = true }) { + Image(systemName: package.manager.iconName) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 24) + } + .buttonStyle(.plain) + + // Package info + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(package.name) + .font(.headline) + .lineLimit(1) + + if package.isInstalled { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + } + } + + HStack(spacing: 8) { + Text(package.manager.displayName) + .font(.caption) + .foregroundColor(.secondary) + + if let version = package.version { + Text("v\(version)") + .font(.caption) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + } + + if let description = package.description { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + // Actions + VStack(spacing: 4) { + PackageActionView(package: package, coordinator: coordinator) + + Button(action: { showingDetail = true }) { + Text("Info") + .font(.caption) + } + .buttonStyle(.borderless) + } + } + .padding(.vertical, 4) + .sheet(isPresented: $showingDetail) { + PackageDetailView(package: package, coordinator: coordinator) + } + } +} + +#Preview { + let coordinator = PackageManagerCoordinator() + + return PackageSearchView( + coordinator: coordinator, + searchText: .constant("git"), + selectedManager: nil + ) +} \ No newline at end of file diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 8203024..63357db 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -322,6 +322,9 @@ } } }, + "**Warning**: This will run the Homebrew uninstaller and remove Homebrew from all known locations and all packages installed. Administrator privileges may be required." : { + "comment" : "Uninstall Homebrew warning" + }, "/path/to/brew" : { "comment" : "Example brew path", "localizations" : { @@ -566,6 +569,12 @@ } } } + }, + "%lld" : { + + }, + "%lld dependencies" : { + }, "3 days" : { "comment" : "App catalog frequency option", @@ -1617,6 +1626,41 @@ } } }, + "Available" : { + "comment" : "Available badge", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disponible" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elérhető" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用可能" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "可用" + } + } + } + }, "Best match" : { "comment" : "Sorting option", "localizations" : { @@ -2109,6 +2153,9 @@ } } }, + "Close" : { + "comment" : "Close button" + }, "Color Scheme:" : { "comment" : "Color scheme setting title", "localizations" : { @@ -2961,6 +3008,9 @@ } } }, + "Enter a package name to search the Homebrew catalog" : { + "comment" : "Search empty state description" + }, "Error" : { "comment" : "Cask action failed (e.g. installation failed)", "localizations" : { @@ -4152,6 +4202,47 @@ } } }, + "Install Failed" : { + "comment" : "Install failed status", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation échouée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telepítés sikertelen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール失敗" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装失败" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "安裝失敗" + } + } + } + }, + "Install package" : { + + }, + "Install packages using the Search tab" : { + "comment" : "Install packages instruction" + }, "Install Separate Brew" : { "comment" : "Brew Management button", "localizations" : { @@ -4258,7 +4349,7 @@ } }, "Installed" : { - "comment" : "Brew dependency installed badge text", + "comment" : "Brew dependency installed badge text\nInstalled badge\nInstalled status\nInstalled tab", "localizations" : { "fr" : { "stringUnit" : { @@ -4293,7 +4384,7 @@ } }, "Installing" : { - "comment" : "Install progress text", + "comment" : "Install progress text\nInstalling status", "localizations" : { "fr" : { "stringUnit" : { @@ -4362,6 +4453,42 @@ } } }, + "Installing..." : { + "comment" : "Installing status", + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation..." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telepítés..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール中..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在安装..." + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在安裝..." + } + } + } + }, "Intel Mac" : { "comment" : "Brew path option", "localizations" : { @@ -4433,6 +4560,15 @@ } } }, + "Loading details..." : { + "comment" : "Loading details" + }, + "Loading packages..." : { + "comment" : "Loading packages" + }, + "Loading..." : { + "comment" : "Loading indicator" + }, "Maintenance" : { "comment" : "App category", "extractionState" : "manual", @@ -4788,6 +4924,42 @@ } } }, + "No description available" : { + "comment" : "No description placeholder", + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune description disponible" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs elérhető leírás" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "説明がありません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用描述" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可用描述" + } + } + } + }, "No homepage found" : { "comment" : "No homepage found alert", "localizations" : { @@ -4823,6 +4995,12 @@ } } }, + "No packages found" : { + "comment" : "No search results" + }, + "No packages installed" : { + "comment" : "No packages message" + }, "No Quarantine" : { "comment" : "Brew no quarantine flag toggle title", "localizations" : { @@ -5070,6 +5248,42 @@ } } }, + "Operation timed out" : { + "comment" : "Timeout error message", + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'opération a expiré" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A művelet időtúllépés miatt megszakadt" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作がタイムアウトしました" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作超时" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作逾時" + } + } + } + }, "Other Flags" : { "comment" : "Brew settings command line flags section title", "localizations" : { @@ -5105,6 +5319,12 @@ } } }, + "Package information" : { + + }, + "Package Manager" : { + "comment" : "Package manager title" + }, "Password Managers" : { "comment" : "App category", "extractionState" : "manual", @@ -5388,7 +5608,7 @@ } }, "Refresh" : { - "comment" : "Update refresh button", + "comment" : "Refresh button", "localizations" : { "fr" : { "stringUnit" : { @@ -5705,6 +5925,7 @@ }, "Retry load" : { "comment" : "Load failed alert button", + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -5738,6 +5959,18 @@ } } }, + "Search" : { + "comment" : "Search tab" + }, + "Search for packages" : { + "comment" : "Search empty state title" + }, + "Search Packages" : { + "comment" : "Search packages button" + }, + "Search packages..." : { + "comment" : "Search placeholder" + }, "Search Sorting Options" : { "localizations" : { "fr" : { @@ -5772,6 +6005,9 @@ } } }, + "Searching..." : { + "comment" : "Search in progress" + }, "See All" : { "comment" : "See all apps in category button", "localizations" : { @@ -6438,6 +6674,9 @@ } } }, + "Try a different search term" : { + "comment" : "Search suggestion" + }, "Turn off few downloads filter" : { "comment" : "Filter disable button", "localizations" : { @@ -6648,6 +6887,82 @@ } } }, + "Uninstall Failed" : { + "comment" : "Uninstall failed status", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstallation échouée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eltávolítás sikertelen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アンインストール失敗" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "卸载失败" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "解除安裝失敗" + } + } + } + }, + "Uninstall Homebrew" : { + + }, + "Uninstall package" : { + + }, + "Uninstalled" : { + "comment" : "Uninstalled status", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstallé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eltávolítva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アンインストール済み" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已卸载" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "已解除安裝" + } + } + } + }, "Uninstalling" : { "comment" : "Progress text", "extractionState" : "manual", @@ -6684,6 +6999,42 @@ } } }, + "Uninstalling..." : { + "comment" : "Uninstalling status", + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstallation..." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eltávolítás..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アンインストール中..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在卸载..." + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在解除安裝..." + } + } + } + }, "Update" : { "comment" : "Update Applite button", "localizations" : { @@ -7071,6 +7422,9 @@ } } } + }, + "v%@" : { + }, "Value" : { "comment" : "Cask info window property value", @@ -7106,6 +7460,9 @@ } } } + }, + "Version: %@" : { + }, "Virtualization" : { "comment" : "App category", @@ -7418,7 +7775,7 @@ "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "系統將提示您安裝 Xcode 命令列工具。 請選擇“安裝”,因為此應用程式需要它才能運行。" + "value" : "系統將提示您安裝 Xcode 命令列工具。 請選擇\"安裝\",因為此應用程式需要它才能運行。" } } }