From 53451bab2ce5a5c2177aeb4cf658b75375b3ea54 Mon Sep 17 00:00:00 2001 From: Kai Azim <68963405+MrKai77@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:28:51 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20#171=20Send=20notifications=20i?= =?UTF-8?q?nstead=20of=20alerts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 4 + Loop/AppDelegate.swift | 78 ++++++++++++++++++- .../UNNotification+Extensions.swift | 39 ++++++++++ Loop/Managers/IconManager.swift | 53 +++++++------ Loop/Settings/GeneralSettingsView.swift | 2 +- 5 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 Loop/Extensions/UNNotification+Extensions.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 651b1564..6c8c60a1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ A84497D22B3B88A6003D4CF3 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84497D12B3B88A6003D4CF3 /* Optional+Extensions.swift */; }; A848D8A72A8C2F3F00060834 /* LoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A848D8A62A8C2F3F00060834 /* LoopManager.swift */; }; A8504D2D2A85832F00C2EFDA /* SoftwareUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8504D2C2A85832F00C2EFDA /* SoftwareUpdater.swift */; }; + A859799B2B55FE94009FB067 /* UNNotification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A859799A2B55FE94009FB067 /* UNNotification+Extensions.swift */; }; A85B560E2AAAD62C00386ACE /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85B560D2AAAD62C00386ACE /* EventMonitor.swift */; }; A85CB5852ACFA5F700BF63E6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CB5842ACFA5F700BF63E6 /* AppDelegate.swift */; }; A85FEEBC2AF15CDE00354D79 /* KeybindCustomizationViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85FEEBB2AF15CDE00354D79 /* KeybindCustomizationViewItem.swift */; }; @@ -113,6 +114,7 @@ A84497D12B3B88A6003D4CF3 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; A848D8A62A8C2F3F00060834 /* LoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopManager.swift; sourceTree = ""; }; A8504D2C2A85832F00C2EFDA /* SoftwareUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdater.swift; sourceTree = ""; }; + A859799A2B55FE94009FB067 /* UNNotification+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Extensions.swift"; sourceTree = ""; }; A85B560D2AAAD62C00386ACE /* EventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; A85CB5842ACFA5F700BF63E6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A85FEEBB2AF15CDE00354D79 /* KeybindCustomizationViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindCustomizationViewItem.swift; sourceTree = ""; }; @@ -226,6 +228,7 @@ A8330AC62A3AC19500673C8D /* NSScreen+Extensions.swift */, A84497D12B3B88A6003D4CF3 /* Optional+Extensions.swift */, A8330ACE2A3AC1E900673C8D /* View+Extensions.swift */, + A859799A2B55FE94009FB067 /* UNNotification+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -502,6 +505,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A859799B2B55FE94009FB067 /* UNNotification+Extensions.swift in Sources */, A8EF1F09299C87DF00633440 /* IconManager.swift in Sources */, A84497D22B3B88A6003D4CF3 /* Optional+Extensions.swift in Sources */, A848D8A72A8C2F3F00060834 /* LoopManager.swift in Sources */, diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 6cb5c71e..92141972 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -7,8 +7,9 @@ import SwiftUI import Defaults +import UserNotifications -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { private let loopManager = LoopManager() private let windowDragManager = WindowDragManager() @@ -26,6 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Check & ask for accessibility access PermissionsManager.Accessibility.requestAccess() + UNUserNotificationCenter.current().delegate = self IconManager.refreshCurrentAppIcon() loopManager.startObservingKeys() @@ -78,4 +80,78 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.center() } } + + // ---------- + // MARK: - Notifications + // ---------- + + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == "setIconAction", + let icon = response.notification.request.content.userInfo["icon"] as? String { + IconManager.setAppIcon(to: icon) + } + + completionHandler() + } + + // Implementation is necessary to show notifications even when the app has focus! + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner]) + } + + static func requestNotificationAuthorization() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert] + ) { (accepted, _) in + if !accepted { + print("User Notification access denied.") + } + } + } + + private static func registerNotificationCategories() { + let setIconAction = UNNotificationAction( + identifier: "setIconAction", + title: "Set Current Icon", + options: .destructive + ) + let notificationCategory = UNNotificationCategory( + identifier: "icon_unlocked", + actions: [setIconAction], + intentIdentifiers: [] + ) + UNUserNotificationCenter.current().setNotificationCategories([notificationCategory]) + } + + static func sendNotification(_ content: UNMutableNotificationContent) { + let uuidString = UUID().uuidString + let request = UNNotificationRequest( + identifier: uuidString, + content: content, + trigger: nil + ) + + requestNotificationAuthorization() + registerNotificationCategories() + + UNUserNotificationCenter.current().add(request) + } + + static func sendNotification(_ title: String, _ body: String) { + let content = UNMutableNotificationContent() + + content.title = title + content.body = body + content.categoryIdentifier = UUID().uuidString + + AppDelegate.sendNotification(content) + } } diff --git a/Loop/Extensions/UNNotification+Extensions.swift b/Loop/Extensions/UNNotification+Extensions.swift new file mode 100644 index 00000000..73476687 --- /dev/null +++ b/Loop/Extensions/UNNotification+Extensions.swift @@ -0,0 +1,39 @@ +// +// UNNotification+Extensions.swift +// Loop +// +// Created by Kai Azim on 2024-01-15. +// + +import SwiftUI +import UserNotifications + +// Thanks https://stackoverflow.com/questions/45226847/unnotificationattachment-failing-to-attach-image +extension UNNotificationAttachment { + static func create(_ imgData: NSData) -> UNNotificationAttachment? { + let imageFileIdentifier = UUID().uuidString + ".jpeg" + + let fileManager = FileManager.default + let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString + let tmpSubFolderURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( + tmpSubFolderName, + isDirectory: true + ) + + do { + try fileManager.createDirectory(at: tmpSubFolderURL!, withIntermediateDirectories: true, attributes: nil) + let fileURL = tmpSubFolderURL?.appendingPathComponent(imageFileIdentifier) + try imgData.write(to: fileURL!, options: []) + let imageAttachment = try UNNotificationAttachment.init( + identifier: imageFileIdentifier, + url: fileURL!, + options: nil + ) + return imageAttachment + } catch let error { + print("error \(error)") + } + + return nil + } +} diff --git a/Loop/Managers/IconManager.swift b/Loop/Managers/IconManager.swift index d31eddab..1124d2c4 100644 --- a/Loop/Managers/IconManager.swift +++ b/Loop/Managers/IconManager.swift @@ -7,6 +7,7 @@ import SwiftUI import Defaults +import UserNotifications class IconManager { @@ -15,6 +16,15 @@ class IconManager { var iconName: String var unlockTime: Int var unlockMessage: String? + + func getName() -> String { + if let name = self.name { + return name + } else { + let prefix = "AppIcon-" + return iconName.replacingOccurrences(of: prefix, with: "") + } + } } private static let icons: [Icon] = [ @@ -44,11 +54,6 @@ class IconManager { ) ] - static func nameWithoutPrefix(name: String) -> String { - let prefix = "AppIcon-" - return name.replacingOccurrences(of: prefix, with: "") - } - static func returnUnlockedIcons() -> [Icon] { var returnValue: [Icon] = [] for icon in icons where icon.unlockTime <= Defaults[.timesLooped] { @@ -60,12 +65,13 @@ class IconManager { static func setAppIcon(to icon: Icon) { Defaults[.currentIcon] = icon.iconName self.refreshCurrentAppIcon() + print("Setting app icon to: \(icon.getName())") + } - let alert = NSAlert() - alert.messageText = "\(Bundle.main.appName)" - alert.informativeText = "Current icon is now \(icon.name ?? nameWithoutPrefix(name: icon.iconName))!" - alert.icon = NSImage(named: icon.iconName) - alert.runModal() + static func setAppIcon(to iconName: String) { + if let targetIcon = icons.first(where: { $0.iconName == iconName }) { + setAppIcon(to: targetIcon) + } } // This function is run at startup to set the current icon to the user's set icon. @@ -76,24 +82,25 @@ class IconManager { static func checkIfUnlockedNewIcon() { for icon in icons where icon.unlockTime == Defaults[.timesLooped] { - NSApp.setActivationPolicy(.regular) + let content = UNMutableNotificationContent() + + content.title = "Loop" - let alert = NSAlert() - alert.icon = NSImage(named: icon.iconName) if let message = icon.unlockMessage { - alert.messageText = message + content.body = message } else { - alert.messageText = "You've unlocked a new icon: \(nameWithoutPrefix(name: icon.iconName))!" + content.body = "You've unlocked a new icon: \(icon.getName())!" } - alert.informativeText = "Would you like to set this as \(Bundle.main.appName)'s new icon?" - alert.alertStyle = .informational - alert.addButton(withTitle: "Yes").keyEquivalent = "\r" - alert.addButton(withTitle: "No") - - let response = alert.runModal() - if response == NSApplication.ModalResponse.alertFirstButtonReturn { - setAppIcon(to: icon) + + if let data = NSImage(named: icon.iconName)?.tiffRepresentation, + let attachment = UNNotificationAttachment.create(NSData(data: data)) { + content.attachments = [attachment] + content.userInfo = ["icon": icon.iconName] } + + content.categoryIdentifier = "icon_unlocked" + + AppDelegate.sendNotification(content) } } diff --git a/Loop/Settings/GeneralSettingsView.swift b/Loop/Settings/GeneralSettingsView.swift index 010d8ab2..b401dc33 100644 --- a/Loop/Settings/GeneralSettingsView.swift +++ b/Loop/Settings/GeneralSettingsView.swift @@ -112,7 +112,7 @@ struct GeneralSettingsView: View { ForEach(IconManager.returnUnlockedIcons(), id: \.self) { icon in HStack { Image(nsImage: NSImage(named: icon.iconName)!) - Text(icon.name ?? IconManager.nameWithoutPrefix(name: icon.iconName)) + Text(icon.getName()) } .tag(icon.iconName) } From 3962c666ee567868523c023782673e13d83f2d61 Mon Sep 17 00:00:00 2001 From: Kai Azim <68963405+MrKai77@users.noreply.github.com> Date: Mon, 15 Jan 2024 18:02:58 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20#171=20Make=20notifications=20t?= =?UTF-8?q?oggleable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/AppDelegate.swift | 25 ++++++++++++++++++++- Loop/Extensions/Defaults+Extensions.swift | 1 + Loop/Managers/IconManager.swift | 2 ++ Loop/Settings/GeneralSettingsView.swift | 27 +++++++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 92141972..3870090f 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -29,6 +29,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele PermissionsManager.Accessibility.requestAccess() UNUserNotificationCenter.current().delegate = self + if !AppDelegate.areNotificationsEnabled() { + Defaults[.notificationWhenIconUnlocked] = false + } + IconManager.refreshCurrentAppIcon() loopManager.startObservingKeys() windowDragManager.addObservers() @@ -110,10 +114,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele static func requestNotificationAuthorization() { UNUserNotificationCenter.current().requestAuthorization( options: [.alert] - ) { (accepted, _) in + ) { (accepted, error) in if !accepted { print("User Notification access denied.") } + + if let error = error { + print(error) + } } } @@ -131,6 +139,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele UNUserNotificationCenter.current().setNotificationCategories([notificationCategory]) } + static func areNotificationsEnabled() -> Bool { + let group = DispatchGroup() + group.enter() + + var notificationsEnabled = false + + UNUserNotificationCenter.current().getNotificationSettings { notificationSettings in + notificationsEnabled = notificationSettings.authorizationStatus != UNAuthorizationStatus.denied + group.leave() + } + + group.wait() + return notificationsEnabled + } + static func sendNotification(_ content: UNMutableNotificationContent) { let uuidString = UUID().uuidString let request = UNNotificationRequest( diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index b726910c..20b31ec1 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -13,6 +13,7 @@ extension Defaults.Keys { static let launchAtLogin = Key("launchAtLogin", default: false) static let hideMenuBarIcon = Key("hideMenuBarIcon", default: false) static let currentIcon = Key("currentIcon", default: "AppIcon-Classic") + static let notificationWhenIconUnlocked = Key("notificationWhenIconUnlocked", default: true) static let timesLooped = Key("timesLooped", default: 0) static let windowSnapping = Key("windowSnapping", default: false) // BETA static let animateWindowResizes = Key("animateWindowResizes", default: false) // BETA diff --git a/Loop/Managers/IconManager.swift b/Loop/Managers/IconManager.swift index 1124d2c4..75560b74 100644 --- a/Loop/Managers/IconManager.swift +++ b/Loop/Managers/IconManager.swift @@ -81,6 +81,8 @@ class IconManager { } static func checkIfUnlockedNewIcon() { + guard Defaults[.notificationWhenIconUnlocked] else { return } + for icon in icons where icon.unlockTime == Defaults[.timesLooped] { let content = UNMutableNotificationContent() diff --git a/Loop/Settings/GeneralSettingsView.swift b/Loop/Settings/GeneralSettingsView.swift index b401dc33..e1cc15db 100644 --- a/Loop/Settings/GeneralSettingsView.swift +++ b/Loop/Settings/GeneralSettingsView.swift @@ -20,6 +20,7 @@ struct GeneralSettingsView: View { @Default(.useGradient) var useGradient @Default(.gradientColor) var gradientColor @Default(.currentIcon) var currentIcon + @Default(.notificationWhenIconUnlocked) var notificationWhenIconUnlocked @Default(.timesLooped) var timesLooped @Default(.animateWindowResizes) var animateWindowResizes @Default(.windowPadding) var windowPadding @@ -125,6 +126,32 @@ struct GeneralSettingsView: View { .onChange(of: self.currentIcon) { _ in IconManager.refreshCurrentAppIcon() } + + Toggle( + "Notify when new icons are unlocked", + isOn: Binding( + get: { + self.notificationWhenIconUnlocked + }, + set: { + if $0 { + AppDelegate.sendNotification( + "Loop", + "You will now be notified when you unlock a new icon." + ) + + self.notificationWhenIconUnlocked = AppDelegate.areNotificationsEnabled() + } else { + self.notificationWhenIconUnlocked = $0 + } + } + ) + ) + .onAppear { + if self.notificationWhenIconUnlocked { + self.notificationWhenIconUnlocked = AppDelegate.areNotificationsEnabled() + } + } } Section("Accent Color") { From faf0cc14feed4e8aaafc664b4349401de4a1b3ca Mon Sep 17 00:00:00 2001 From: Kai Azim <68963405+MrKai77@users.noreply.github.com> Date: Mon, 15 Jan 2024 18:19:09 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20#171=20Auto-request=20notificat?= =?UTF-8?q?ion=20permits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/AppDelegate.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 3870090f..ee00af6d 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -29,9 +29,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele PermissionsManager.Accessibility.requestAccess() UNUserNotificationCenter.current().delegate = self - if !AppDelegate.areNotificationsEnabled() { - Defaults[.notificationWhenIconUnlocked] = false - } + AppDelegate.requestNotificationAuthorization() IconManager.refreshCurrentAppIcon() loopManager.startObservingKeys()