From 094bb6f0cc2ac47190fb8244e4a3579b3694725a Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 28 Apr 2021 21:50:44 -0500 Subject: [PATCH] Adds MacOS notifications --- Xcodes.xcodeproj/project.pbxproj | 8 ++ Xcodes/Backend/AppState+Install.swift | 4 + Xcodes/Backend/AppState.swift | 4 + Xcodes/Backend/Environment.swift | 6 ++ Xcodes/Backend/NotificationManager.swift | 100 ++++++++++++++++++ .../Preferences/GeneralPreferencePane.swift | 7 ++ .../Preferences/NotificationsView.swift | 35 ++++++ 7 files changed, 164 insertions(+) create mode 100644 Xcodes/Backend/NotificationManager.swift create mode 100644 Xcodes/Frontend/Preferences/NotificationsView.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 39e22a7..0217953 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -99,7 +99,9 @@ CAFE4ABC25B7D54B0064FE51 /* UpdatesPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE4ABB25B7D54B0064FE51 /* UpdatesPreferencePane.swift */; }; CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */; }; E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87DD6EA25D053FA00D86808 /* Progress+.swift */; }; + E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89342F925EDCC17007CF557 /* NotificationManager.swift */; }; E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8977EA225C11E1500835F80 /* PreferencesView.swift */; }; + E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DA461025FAF7FB002E85EF /* NotificationsView.swift */; }; E8E98A9025D8631800EC89A0 /* InstallationStepRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBC3FF259AC17F00E2A3D8 /* InstallationStepRowView.swift */; }; E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E98A9525D863D700EC89A0 /* InstallationStepDetailView.swift */; }; /* End PBXBuildFile section */ @@ -262,7 +264,9 @@ CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListViewRow.swift; sourceTree = ""; }; CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RingProgressViewStyle.swift; sourceTree = ""; }; E87DD6EA25D053FA00D86808 /* Progress+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+.swift"; sourceTree = ""; }; + E89342F925EDCC17007CF557 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; E8977EA225C11E1500835F80 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + E8DA461025FAF7FB002E85EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; E8E98A9525D863D700EC89A0 /* InstallationStepDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStepDetailView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -425,6 +429,7 @@ CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */, CA42DD6D25AEA8B200BC0B0C /* Logger.swift */, + E89342F925EDCC17007CF557 /* NotificationManager.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, @@ -546,6 +551,7 @@ CAFE4AAB25B7D2C70064FE51 /* GeneralPreferencePane.swift */, CAFE4ABB25B7D54B0064FE51 /* UpdatesPreferencePane.swift */, E8977EA225C11E1500835F80 /* PreferencesView.swift */, + E8DA461025FAF7FB002E85EF /* NotificationsView.swift */, ); path = Preferences; sourceTree = ""; @@ -788,6 +794,7 @@ CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */, + E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, @@ -814,6 +821,7 @@ CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, 63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */, + E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */, CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */, diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index d3642c1..4b68976 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -41,6 +41,7 @@ extension AppState { private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher { Logger.appState.info("Using \(downloader) downloader") + return getXcodeArchive(installationType, downloader: downloader) .flatMap { xcode, url -> AnyPublisher in self.installArchivedXcode(xcode, at: url) @@ -442,6 +443,9 @@ extension AppState { DispatchQueue.main.async { guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } self.allXcodes[index].installState = .installing(step) + + let xcode = self.allXcodes[index] + Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal) } } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6e4d622..6ea8b2c 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -16,6 +16,9 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { + if newValue.count > availableXcodes.count && availableXcodes.count != 0 { + Current.notificationManager.scheduleNotification(title: "New Xcode versions", body: "New Xcode versions are available to download.", category: .normal) + } updateAllXcodes( availableXcodes: newValue, installedXcodes: Current.files.installedXcodes(Path.root/"Applications"), @@ -289,6 +292,7 @@ class AppState: ObservableObject { func install(id: Xcode.ID) { guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return } + installationPublishers[id] = signInIfNeeded() .flatMap { [unowned self] in // signInIfNeeded might finish before the user actually authenticates if UI is involved. diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 1093aa4..6d8af24 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -19,6 +19,7 @@ public struct Environment { public var defaults = Defaults() public var date: () -> Date = Date.init public var helper = Helper() + public var notificationManager = NotificationManager() } public var Current = Environment() @@ -228,6 +229,11 @@ public struct Defaults { public func removeObject(forKey key: String) { removeObject(key) } + + public var get: (String) -> Any? = { UserDefaults.standard.value(forKey: $0) } + public func get(forKey key: String) -> Any? { + get(key) + } } private let helperClient = HelperClient() diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift new file mode 100644 index 0000000..59512f9 --- /dev/null +++ b/Xcodes/Backend/NotificationManager.swift @@ -0,0 +1,100 @@ +import Foundation +import os.log +import UserNotifications + +/// Representation of the 3 states of the Notifications permission prompt which may either have not been shown, or was shown and denied or accepted +/// Unknown is value to indicate that we have not yet determined the status and should not be used other than as a default value before determining the actual status +public enum NotificationPermissionPromptStatus: Int { + case unknown, notShown, shownAndDenied, shownAndAccepted +} + +public enum XcodesNotificationCategory: String { + case normal + case error +} + +public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomStringConvertible { + case newVersionAvailable + case finishedInstalling + + public var id: Self { self } + + public var description: String { + switch self { + case .newVersionAvailable: + return "New version is available" + case .finishedInstalling: + return "Finished Installing" + } + } +} + +public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + private let notificationCenter = UNUserNotificationCenter.current() + + @Published var notificationStatus = NotificationPermissionPromptStatus.unknown + + public override init() { + super.init() + loadNotificationStatus() + + notificationCenter.delegate = self + } + + public func loadNotificationStatus() { + UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { [weak self] (settings) in + let status = NotificationManager.systemPromptStatusFromSettings(settings) + self?.notificationStatus = status + }) + } + + private class func systemPromptStatusFromSettings(_ settings: UNNotificationSettings) -> NotificationPermissionPromptStatus { + switch settings.authorizationStatus { + case .notDetermined: + return .notShown + case .authorized, .provisional: + return .shownAndAccepted + case .denied: + return .shownAndDenied + @unknown default: + return .unknown + } + } + + public func requestAccess() { + + notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + DispatchQueue.main.async { + if let error = error { + // Handle the error here. + Logger.appState.error("Error requesting notification accesss: \(error.legibleLocalizedDescription)") + } else { + Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") + } + self?.loadNotificationStatus() + } + } + } + + func scheduleNotification(title: String?, body: String, category: XcodesNotificationCategory) { + + let content = UNMutableNotificationContent() + if let title = title { + content.title = title + } + content.body = body + content.sound = .default + content.categoryIdentifier = category.rawValue + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.3, repeats: false) + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + notificationCenter.add(request) + } + + // MARK: UNUserNotificationCenterDelegate + + public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler( [.banner, .badge, .sound]) + } +} + diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index a251bfd..06f8529 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -28,6 +28,13 @@ struct GeneralPreferencePane: View { SignInCredentialsView(isPresented: $appState.presentingSignInAlert) .environmentObject(appState) } + + Divider() + + GroupBox(label: Text("Notifications")) { + NotificationsView().environmentObject(appState) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) } .frame(width: 400) } diff --git a/Xcodes/Frontend/Preferences/NotificationsView.swift b/Xcodes/Frontend/Preferences/NotificationsView.swift new file mode 100644 index 0000000..fe1bbf8 --- /dev/null +++ b/Xcodes/Frontend/Preferences/NotificationsView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct NotificationsView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(alignment: .leading) { + + switch Current.notificationManager.notificationStatus { + case .shownAndAccepted: + Text("Access Granted. You will receive notifications from Xcodes.") + .fixedSize(horizontal: false, vertical: true) + case .shownAndDenied: + Text("⚠️ Access Denied ⚠️\n\nPlease open your Notification Settings if you wish to allow access.") + .fixedSize(horizontal: false, vertical: true) + + default: + Button("Enable Notifications", action: { + Current.notificationManager.requestAccess() + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + +} + +struct NotificationsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NotificationsView() + } + } +}