From 094bb6f0cc2ac47190fb8244e4a3579b3694725a Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 28 Apr 2021 21:50:44 -0500 Subject: [PATCH 1/3] 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() + } + } +} From 11e8fdecf80340da39929fd9a79afb7e1f5f0b50 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Thu, 29 Apr 2021 17:04:38 -0500 Subject: [PATCH 2/3] Adds a settings button that loads MacOS Notification when user denies access --- Xcodes/Frontend/Preferences/NotificationsView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Xcodes/Frontend/Preferences/NotificationsView.swift b/Xcodes/Frontend/Preferences/NotificationsView.swift index fe1bbf8..257ab2b 100644 --- a/Xcodes/Frontend/Preferences/NotificationsView.swift +++ b/Xcodes/Frontend/Preferences/NotificationsView.swift @@ -11,8 +11,11 @@ struct NotificationsView: View { 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.") + Text("⚠️ Access Denied ⚠️\n\nPlease open your Notification Settings and select Xcodes if you wish to allow access.") .fixedSize(horizontal: false, vertical: true) + Button("Notification Settings", action: { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!) + }) default: Button("Enable Notifications", action: { From 31ffac80d3e7db65356bfb6369ca06b34e34ab59 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Sun, 2 May 2021 10:02:30 -0500 Subject: [PATCH 3/3] PR updates --- .../xcshareddata/swiftpm/Package.resolved | 88 +++++++++++++++++++ Xcodes/Backend/NotificationManager.swift | 2 +- .../Preferences/NotificationsView.swift | 3 +- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..c92db7b --- /dev/null +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,88 @@ +{ + "object": { + "pins": [ + { + "package": "CombineExpectations", + "repositoryURL": "https://github.com/groue/CombineExpectations", + "state": { + "branch": null, + "revision": "989a92221899929ab8347a5878aa2b16db8b81ca", + "version": "0.6.0" + } + }, + { + "package": "XcodeReleases", + "repositoryURL": "https://github.com/xcodereleases/data", + "state": { + "branch": null, + "revision": "b47228c688b608e34b3b84079ab6052a24c7a981", + "version": null + } + }, + { + "package": "ErrorHandling", + "repositoryURL": "https://github.com/RobotsAndPencils/ErrorHandling", + "state": { + "branch": null, + "revision": "7be837fcb515447c0776805c3288fb7d5181ec68", + "version": "0.1.0" + } + }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", + "state": { + "branch": null, + "revision": "8d33ffd6f74b3bcfc99af759d4204c6395a3f918", + "version": "3.2.1" + } + }, + { + "package": "LegibleError", + "repositoryURL": "https://github.com/mxcl/LegibleError", + "state": { + "branch": null, + "revision": "909e9bab3ded97350b28a5ab41dd745dd8aa9710", + "version": "1.0.4" + } + }, + { + "package": "Path.swift", + "repositoryURL": "https://github.com/mxcl/Path.swift", + "state": { + "branch": null, + "revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5", + "version": "0.16.3" + } + }, + { + "package": "Sparkle", + "repositoryURL": "https://github.com/sparkle-project/Sparkle/", + "state": { + "branch": null, + "revision": "891afd44c7075e699924ed9b81d8dc94a5111dfd", + "version": "1.24.0-spm" + } + }, + { + "package": "SwiftSoup", + "repositoryURL": "https://github.com/scinfu/SwiftSoup", + "state": { + "branch": null, + "revision": "aeb5b4249c273d1783a5299e05be1b26e061ea81", + "version": "2.0.0" + } + }, + { + "package": "Version", + "repositoryURL": "https://github.com/mxcl/Version", + "state": { + "branch": null, + "revision": "087c91fedc110f9f833b14ef4c32745dabca8913", + "version": "1.0.3" + } + } + ] + }, + "version": 1 +} diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift index 59512f9..cb37a5a 100644 --- a/Xcodes/Backend/NotificationManager.swift +++ b/Xcodes/Backend/NotificationManager.swift @@ -24,7 +24,7 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt case .newVersionAvailable: return "New version is available" case .finishedInstalling: - return "Finished Installing" + return "Finished installing" } } } diff --git a/Xcodes/Frontend/Preferences/NotificationsView.swift b/Xcodes/Frontend/Preferences/NotificationsView.swift index 257ab2b..9b7297b 100644 --- a/Xcodes/Frontend/Preferences/NotificationsView.swift +++ b/Xcodes/Frontend/Preferences/NotificationsView.swift @@ -20,8 +20,7 @@ struct NotificationsView: View { default: Button("Enable Notifications", action: { Current.notificationManager.requestAccess() - } - ) + }) } } .frame(maxWidth: .infinity, alignment: .leading)