Philipp Heckel 2 лет назад
Родитель
Сommit
72386665f9

+ 12 - 4
ntfy.xcodeproj/project.pbxproj

@@ -32,6 +32,8 @@
 		9474F2142834755E00CDE4DD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FE28316ACE00CDE4DD /* Subscription.swift */; };
 		9474F2152834758700CDE4DD /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F211283327C200CDE4DD /* Helpers.swift */; };
 		9474F217283531A300CDE4DD /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F216283531A200CDE4DD /* Log.swift */; };
+		94867143283EC9960093C7A4 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94867142283EC9950093C7A4 /* Actions.swift */; };
+		94867144283ECD370093C7A4 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94867142283EC9950093C7A4 /* Actions.swift */; };
 		94A3F7C8283734D900C48E79 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C7283734D900C48E79 /* SubscriptionManager.swift */; };
 		94A3F7CA28386B2100C48E79 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C928386B2100C48E79 /* Config.swift */; };
 		94A3F7CB28386B2100C48E79 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C928386B2100C48E79 /* Config.swift */; };
@@ -91,6 +93,7 @@
 		9474F20E283326C500CDE4DD /* ApiService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = "<group>"; };
 		9474F211283327C200CDE4DD /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
 		9474F216283531A200CDE4DD /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
+		94867142283EC9950093C7A4 /* Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = "<group>"; };
 		94A3F7C7283734D900C48E79 /* SubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = "<group>"; };
 		94A3F7C928386B2100C48E79 /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
 		94CD1965283E662900973B93 /* emojis.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emojis.json; sourceTree = "<group>"; };
@@ -215,6 +218,7 @@
 				9474F211283327C200CDE4DD /* Helpers.swift */,
 				94A3F7C928386B2100C48E79 /* Config.swift */,
 				9474F216283531A200CDE4DD /* Log.swift */,
+				94867142283EC9950093C7A4 /* Actions.swift */,
 			);
 			path = Utils;
 			sourceTree = "<group>";
@@ -354,6 +358,7 @@
 				9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */,
 				94CD196A283E666900973B93 /* EmojiManager.swift in Sources */,
 				9474F1C1282F2AA700CDE4DD /* AppMain.swift in Sources */,
+				94867143283EC9960093C7A4 /* Actions.swift in Sources */,
 				9474F20F283326C500CDE4DD /* ApiService.swift in Sources */,
 				9474F1F72830830700CDE4DD /* ntfy.xcdatamodeld in Sources */,
 			);
@@ -368,6 +373,7 @@
 				9474F2152834758700CDE4DD /* Helpers.swift in Sources */,
 				9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */,
 				94CD196B283E666900973B93 /* EmojiManager.swift in Sources */,
+				94867144283ECD370093C7A4 /* Actions.swift in Sources */,
 				9474F2052831D51500CDE4DD /* Store.swift in Sources */,
 				9474F2062831D73C00CDE4DD /* ntfy.xcdatamodeld in Sources */,
 				94A3F7CB28386B2100C48E79 /* Config.swift in Sources */,
@@ -516,6 +522,7 @@
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = ntfy/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = ntfy;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -549,6 +556,7 @@
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = ntfy/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = ntfy;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -573,7 +581,7 @@
 			buildSettings = {
 				CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 2;
 				DEVELOPMENT_TEAM = YXQ4AMS4B4;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = ntfyNSE/Info.plist;
@@ -585,7 +593,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = io.heckel.ntfy.ntfyNSE;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -600,7 +608,7 @@
 			buildSettings = {
 				CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 2;
 				DEVELOPMENT_TEAM = YXQ4AMS4B4;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = ntfyNSE/Info.plist;
@@ -612,7 +620,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = io.heckel.ntfy.ntfyNSE;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;

+ 44 - 3
ntfy/App/AppDelegate.swift

@@ -63,18 +63,59 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
         withCompletionHandler completionHandler: @escaping () -> Void
     ) {
         let userInfo = response.notification.request.content.userInfo
+        let actionId = response.actionIdentifier
+
         Log.d(tag, "Notification received via userNotificationCenter(didReceive)", userInfo)
         
         let clickUrl = URL(string: userInfo["click"] as? String ?? "")
         let topic = userInfo["topic"] as? String ?? ""
-        if let clickUrl = clickUrl {
-            UIApplication.shared.open(clickUrl, options: [:], completionHandler: nil)
+        let action = findAction(id: actionId, actions: Actions.shared.parse(userInfo["actions"] as? String ?? "[]"))
+
+        if let action = action {
+            handleAction(action)
+        } else if let clickUrl = clickUrl {
+            handleCustomClick(clickUrl)
         } else if topic != "" {
-            selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic)
+            handleDefaultClick(topic: topic)
         }
     
         completionHandler()
     }
+    
+    private func findAction(id: String, actions: [Action]?) -> Action? {
+        guard let actions = actions else { return nil }
+        return actions.first { $0.id == id }
+    }
+    
+    private func handleAction(_ action: Action) {
+        Log.d(tag, "Executing user action", action)
+        switch action.action {
+        case "view":
+            if let url = URL(string: action.url ?? "") {
+                openUrl(url)
+            } else {
+                Log.w(tag, "Unable to parse action URL", action)
+            }
+        case "http":
+            Actions.shared.http(action)
+        default:
+            Log.w(tag, "Action \(action.action) not supported", action)
+        }
+    }
+    
+    private func handleCustomClick(_ url: URL) {
+        openUrl(url)
+    }
+    
+    private func handleDefaultClick(topic: String) {
+        Log.d(tag, "Selecting topic \(topic)")
+        selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic)
+    }
+    
+    private func openUrl(_ url: URL) {
+        Log.d(tag, "Opening URL \(url)")
+        UIApplication.shared.open(url, options: [:], completionHandler: nil)
+    }
 }
 
 extension AppDelegate: MessagingDelegate {

+ 5 - 0
ntfy/Info.plist

@@ -2,6 +2,11 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>NSAppTransportSecurity</key>
+	<dict>
+		<key>NSAllowsArbitraryLoads</key>
+		<true/>
+	</dict>
 	<key>AppBaseURL</key>
 	<string>$(APP_BASE_URL)</string>
 	<key>UIApplicationSceneManifest</key>

+ 12 - 0
ntfy/Persistence/Notification.swift

@@ -68,4 +68,16 @@ struct Message: Decodable {
     var title: String?
     var priority: Int16?
     var tags: [String]?
+    var actions: [Action]?
+}
+
+struct Action: Decodable {
+    var id: String
+    var action: String
+    var label: String
+    var url: String?
+    var method: String?
+    var headers: [String: String]?
+    var body: String?
+    var clear: Bool?
 }

+ 47 - 0
ntfy/Utils/Actions.swift

@@ -0,0 +1,47 @@
+import Foundation
+
+struct Actions {
+    static let shared = Actions()
+    private let tag = "Actions"
+    
+    func parse(_ actions: String?) -> [Action]? {
+        guard let actions = actions,
+              let data = actions.data(using: .utf8) else { return nil }
+        do {
+            return try JSONDecoder().decode([Action].self, from: data)
+                .filter { supportedActions.contains($0.action) }
+        } catch {
+            Log.e(tag, "Unable to parse actions: \(error.localizedDescription)", error)
+            return nil
+        }
+    }
+    
+    func http(_ action: Action) {
+        guard let actionUrl = action.url, let url = URL(string: actionUrl) else {
+            Log.w(tag, "Unable to execute HTTP action, no or invalid URL", action)
+            return
+        }
+        let method = action.method ?? "POST" // POST is the default!!
+        let body = action.body ?? ""
+
+        Log.d(tag, "Performing HTTP \(method) \(url)")
+        
+        var request = URLRequest(url: url)
+        request.httpMethod = method
+        action.headers?.forEach { key, value in
+            request.setValue(value, forHTTPHeaderField: key)
+        }
+        if !["GET", "HEAD"].contains(method) {
+            request.httpBody = body.data(using: .utf8)
+        }
+        URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                Log.e(self.tag, "Error performing HTTP \(method)", error!)
+                return
+            }
+            Log.d(self.tag, "HTTP \(method) succeeded", response)
+        }.resume()
+    }
+}
+
+

+ 3 - 0
ntfy/Utils/Helpers.swift

@@ -1,5 +1,8 @@
 import Foundation
 
+let helperFnTag = "Helpers"
+let supportedActions = ["view", "http"]
+
 func topicUrl(baseUrl: String, topic: String) -> String {
     return "\(baseUrl)/\(topic)"
 }

+ 5 - 0
ntfyNSE/Info.plist

@@ -2,6 +2,11 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>NSAppTransportSecurity</key>
+	<dict>
+		<key>NSAllowsArbitraryLoads</key>
+		<true/>
+	</dict>
 	<key>AppBaseURL</key>
 	<string>$(APP_BASE_URL)</string>
 	<key>NSExtension</key>

+ 19 - 0
ntfyNSE/NotificationService.swift

@@ -8,6 +8,7 @@ import CoreData
 /// select Debug -> Attach to Process by PID or Name, and select the extension. Don't forget to set a breakpoint, or you're not gonna have a good time.
 class NotificationService: UNNotificationServiceExtension {
     private let tag = "NotificationService"
+    private let actionsCategory = "ntfyActions" // It seems ok to re-use the same category
     
     var contentHandler: ((UNNotificationContent) -> Void)?
     var bestAttemptContent: UNMutableNotificationContent?
@@ -26,6 +27,7 @@ class NotificationService: UNNotificationServiceExtension {
             let title = userInfo["title"] as? String
             let priority = userInfo["priority"] as? String ?? "3"
             let tags = userInfo["tags"] as? String
+            let actions = userInfo["actions"] as? String ?? "[]"
 
             // Set notification title to short URL if there is no title. The title is always set
             // by the server, but it may be empty.
@@ -42,7 +44,24 @@ class NotificationService: UNNotificationServiceExtension {
                     bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body
                 }
             }
+            
+            // Add custom actions
+            //
+            // We re-define the categories every time here, which is weird, but it works. When tapped, the action sets the
+            // actionIdentifier in the application(didReceive) callback. This logic is handled in the AppDelegate. This approach
+            // is described in a comment in https://stackoverflow.com/questions/30103867/changing-action-titles-in-interactive-notifications-at-run-time#comment122812568_30107065
+            //
+            // We also must set the .foreground flag, which brings the notification to the foreground and avoids an error about
+            // permissions. This is described in https://stackoverflow.com/a/44580916/1440785
+            if let actions = Actions.shared.parse(actions), !actions.isEmpty {
+                bestAttemptContent.categoryIdentifier = actionsCategory
 
+                let center = UNUserNotificationCenter.current()
+                let notificationActions = actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: [.foreground]) } //
+                let category = UNNotificationCategory(identifier: actionsCategory, actions: notificationActions, intentIdentifiers: [])
+                center.setNotificationCategories([category])
+            }
+                        
             // Play a sound, and group by topic
             bestAttemptContent.sound = .default
             bestAttemptContent.threadIdentifier = topic