Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Selfhosted light #5

Merged
merged 7 commits into from
May 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
GoogleService-Info.plist
ntfy.xcodeproj/xcuserdata
ntfy.xcodeproj/project.xcworkspace/xcuserdata/
12 changes: 12 additions & 0 deletions ntfy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
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 */; };
94867145284058C60093C7A4 /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20E283326C500CDE4DD /* ApiService.swift */; };
948671472841B0B20093C7A4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671462841B0B20093C7A4 /* NotificationContent.swift */; };
948671482841B1430093C7A4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671462841B0B20093C7A4 /* NotificationContent.swift */; };
9486714A2841D0CE0093C7A4 /* ActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671492841D0CE0093C7A4 /* ActionExecutor.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 */; };
Expand Down Expand Up @@ -94,6 +98,8 @@
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>"; };
948671462841B0B20093C7A4 /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = "<group>"; };
948671492841D0CE0093C7A4 /* ActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionExecutor.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>"; };
Expand Down Expand Up @@ -219,6 +225,8 @@
94A3F7C928386B2100C48E79 /* Config.swift */,
9474F216283531A200CDE4DD /* Log.swift */,
94867142283EC9950093C7A4 /* Actions.swift */,
948671462841B0B20093C7A4 /* NotificationContent.swift */,
948671492841D0CE0093C7A4 /* ActionExecutor.swift */,
);
path = Utils;
sourceTree = "<group>";
Expand Down Expand Up @@ -344,6 +352,7 @@
buildActionMask = 2147483647;
files = (
02024E60283D7CBB0064224A /* Extensions.swift in Sources */,
948671472841B0B20093C7A4 /* NotificationContent.swift in Sources */,
9474F1F92830835400CDE4DD /* Store.swift in Sources */,
9474F212283327C200CDE4DD /* Helpers.swift in Sources */,
9474F217283531A300CDE4DD /* Log.swift in Sources */,
Expand All @@ -354,6 +363,7 @@
9474F1C3282F2AA700CDE4DD /* ContentView.swift in Sources */,
9474F20C283321C300CDE4DD /* Notification.swift in Sources */,
9474F1F22830825600CDE4DD /* SubscriptionListView.swift in Sources */,
9486714A2841D0CE0093C7A4 /* ActionExecutor.swift in Sources */,
9474F1FD2831311A00CDE4DD /* SubscriptionAddView.swift in Sources */,
9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */,
94CD196A283E666900973B93 /* EmojiManager.swift in Sources */,
Expand All @@ -374,9 +384,11 @@
9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */,
94CD196B283E666900973B93 /* EmojiManager.swift in Sources */,
94867144283ECD370093C7A4 /* Actions.swift in Sources */,
94867145284058C60093C7A4 /* ApiService.swift in Sources */,
9474F2052831D51500CDE4DD /* Store.swift in Sources */,
9474F2062831D73C00CDE4DD /* ntfy.xcdatamodeld in Sources */,
94A3F7CB28386B2100C48E79 /* Config.swift in Sources */,
948671482841B1430093C7A4 /* NotificationContent.swift in Sources */,
9474F2142834755E00CDE4DD /* Subscription.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
105 changes: 58 additions & 47 deletions ntfy/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import SafariServices
import UserNotifications
import Firebase
import FirebaseCore
import FirebaseMessaging
import CoreData

class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
let tag = "AppDelegate"
private let tag = "AppDelegate"
private let pollTopic = "~poll" // See ntfy server if ever changed

// Implements navigation from notifications, see https://stackoverflow.com/a/70731861/1440785
@Published var selectedBaseUrl: String? = nil
Expand All @@ -30,9 +32,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
// Set self as messaging delegate
Messaging.messaging().delegate = self

// Register to "~poll" topic
Messaging.messaging().subscribe(toTopic: pollTopic)

return true
}

/// Executed when a background notification arrives on the "~poll" topic. This is used to trigger polling of local topics.
/// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Log.d(tag, "Background notification received", userInfo)

// Exit out early if this message is not expected
let topic = userInfo["topic"] as? String ?? ""
if topic != pollTopic {
completionHandler(.noData)
return
}

// Poll and show new messages as notifications
let store = Store.shared
let subscriptionManager = SubscriptionManager(store: store)
store.getSubscriptions()?.forEach { subscription in
subscriptionManager.poll(subscription) { messages in
messages.forEach { message in
self.showNotification(subscription, message)
}
}
}
completionHandler(.newData)
}

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { data in String(format: "%02.2hhx", data) }.joined()
Messaging.messaging().apnsToken = deviceToken
Expand All @@ -42,6 +72,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
Log.e(tag, "Failed to register for remote notifications", error)
}

/// Create a local notification manually (as opposed to a remote notification being generated by Firebase). We need to make the
/// local notification look exactly like the remote one (same userInfo), so that when we tap it, the userNotificationCenter(didReceive) function
/// has the same information available.
private func showNotification(_ subscription: Subscription, _ message: Message) {
let content = UNMutableNotificationContent()
content.modify(message: message, baseUrl: subscription.baseUrl ?? "?")

let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */)
UNUserNotificationCenter.current().add(request) { (error) in
if let error = error {
Log.e(self.tag, "Unable to create notification", error)
}
}
}
}

extension AppDelegate: UNUserNotificationCenterDelegate {
Expand All @@ -63,64 +108,30 @@ 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)
guard let message = Message.from(userInfo: userInfo) else {
Log.w(tag, "Cannot convert userInfo to message", userInfo)
completionHandler()
return
}

let clickUrl = URL(string: userInfo["click"] as? String ?? "")
let topic = userInfo["topic"] as? String ?? ""
let actions = userInfo["actions"] as? String ?? "[]"
let action = findAction(id: actionId, actions: Actions.shared.parse(actions))
let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl
let action = message.actions?.first { $0.id == response.actionIdentifier }

// Show current topic
if topic != "" {
selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic)
if message.topic != "" {
selectedBaseUrl = topicUrl(baseUrl: baseUrl, topic: message.topic)
}

// Execute user action or click action (if any)
if let action = action {
handleAction(action)
} else if let clickUrl = clickUrl {
handleCustomClick(clickUrl)
ActionExecutor.execute(action)
} else if let click = message.click, click != "", let url = URL(string: click) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}

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 {
Expand Down
60 changes: 59 additions & 1 deletion ntfy/Persistence/Notification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,78 @@ extension Notification {
func nonEmojiTags() -> [String] {
return parseNonEmojiTags(tags)
}

func actionsList() -> [Action] {
return Actions.shared.parse(actions) ?? []
}
}

/// This is the "on the wire" message as it is received from the ntfy server
struct Message: Decodable {
var id: String
var time: Int64
var event: String
var topic: String
var message: String?
var title: String?
var priority: Int16?
var tags: [String]?
var actions: [Action]?
var click: String?
var pollId: String?

func toUserInfo() -> [AnyHashable: Any] {
// This should mimic the way that the ntfy server encodes a message.
// See server_firebase.go for more details.

return [
"id": id,
"time": String(time),
"event": event,
"topic": topic,
"message": message ?? "",
"title": title ?? "",
"priority": String(priority ?? 3),
"tags": tags?.joined(separator: ",") ?? "",
"actions": Actions.shared.encode(actions),
"click": click ?? "",
"poll_id": pollId ?? ""
]
}

static func from(userInfo: [AnyHashable: Any]) -> Message? {
guard let id = userInfo["id"] as? String,
let time = userInfo["time"] as? String,
let event = userInfo["event"] as? String,
let topic = userInfo["topic"] as? String,
let timeInt = Int64(time),
let message = userInfo["message"] as? String else {
Log.d(Store.tag, "Unknown or irrelevant message", userInfo)
return nil
}
let title = userInfo["title"] as? String
let priority = Int16(userInfo["priority"] as? String ?? "3") ?? 3
let tags = (userInfo["tags"] as? String ?? "").components(separatedBy: ",")
let actions = userInfo["actions"] as? String
let click = userInfo["click"] as? String
let pollId = userInfo["poll_id"] as? String
return Message(
id: id,
time: timeInt,
event: event,
topic: topic,
message: message,
title: title,
priority: priority,
tags: tags,
actions: Actions.shared.parse(actions),
click: click,
pollId: pollId
)
}
}

struct Action: Decodable {
struct Action: Encodable, Decodable, Identifiable {
var id: String
var action: String
var label: String
Expand Down
41 changes: 10 additions & 31 deletions ntfy/Persistence/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,39 +70,15 @@ class Store: ObservableObject {
return try? context.fetch(fetchRequest).first
}

func getSubscriptions() -> [Subscription]? {
return try? context.fetch(Subscription.fetchRequest())
}

func delete(subscription: Subscription) {
context.delete(subscription)
try? context.save()
}

func save(notificationFromUserInfo userInfo: [AnyHashable: Any]) {
guard let id = userInfo["id"] as? String,
let topic = userInfo["topic"] as? String,
let time = userInfo["time"] as? String,
let timeInt = Int64(time),
let message = userInfo["message"] as? String else {
Log.d(Store.tag, "Unknown or irrelevant message", userInfo)
return
}
let baseUrl = Config.appBaseUrl // Firebase messages all come from the main ntfy server
guard let subscription = getSubscription(baseUrl: baseUrl, topic: topic) else {
Log.d(Store.tag, "Subscription for topic \(topic) unknown")
return
}
let title = userInfo["title"] as? String ?? ""
let priority = Int16(userInfo["priority"] as? String ?? "3") ?? 3
let tags = (userInfo["tags"] as? String ?? "").components(separatedBy: ",")
let m = Message(
id: id,
time: timeInt,
message: message,
title: title,
priority: priority,
tags: tags
)
save(notificationFromMessage: m, withSubscription: subscription)
}

func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
do {
let notification = Notification(context: context)
Expand All @@ -112,6 +88,8 @@ class Store: ObservableObject {
notification.title = message.title ?? ""
notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
notification.tags = message.tags?.joined(separator: ",") ?? ""
notification.actions = Actions.shared.encode(message.actions)
notification.click = message.click ?? ""
subscription.addToNotifications(notification)
subscription.lastNotificationId = message.id
try context.save()
Expand Down Expand Up @@ -177,9 +155,10 @@ class Store: ObservableObject {
extension Store {
static let sampleData = [
"stats": [
Message(id: "1", time: 1653048956, message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers", priority: 4, tags: ["smile", "server123", "de"]),
Message(id: "2", time: 1653058956, message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: []),
Message(id: "3", time: 1643058956, message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil, priority: 5, tags: ["facepalm"])
// TODO: Message with action
Message(id: "1", time: 1653048956, event: "message", topic: "stats", message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers", priority: 4, tags: ["smile", "server123", "de"], actions: nil),
Message(id: "2", time: 1653058956, event: "message", topic: "stats", message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: [], actions: nil),
Message(id: "3", time: 1643058956, event: "message", topic: "stats", message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil, priority: 5, tags: ["facepalm"], actions: nil)
],
"backups": [],
"announcements": [],
Expand Down
6 changes: 5 additions & 1 deletion ntfy/Persistence/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ extension Subscription {
}

func topicName() -> String {
return topic ?? "<unknown>"
return topic ?? "?"
}

func urlHash() -> String {
return topicHash(baseUrl: baseUrl ?? "?", topic: topic ?? "?")
}

func notificationCount() -> Int {
Expand Down