diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index ec171d99..a139ded4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -121,6 +121,8 @@ D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; }; D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; }; D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; }; + D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */; }; + D64B967F2BC1D447002C8990 /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B967E2BC1D447002C8990 /* PushManager.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; @@ -521,6 +523,8 @@ D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = ""; }; + D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPrefsView.swift; sourceTree = ""; }; + D64B967E2BC1D447002C8990 /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; }; @@ -1119,6 +1123,7 @@ 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */, + D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */, D60089172981FEA4005B4D00 /* Tip Jar */, D68A76EF2953910A001DA1B3 /* About */, ); @@ -1181,6 +1186,14 @@ path = Toast; sourceTree = ""; }; + D64B967D2BC1D43A002C8990 /* Push */ = { + isa = PBXGroup; + children = ( + D64B967E2BC1D447002C8990 /* PushManager.swift */, + ); + path = Push; + sourceTree = ""; + }; D65A37F221472F300087646E /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1518,6 +1531,7 @@ D6E57FA525C26FAB00341037 /* Localizable.stringsdict */, D61959D2241E846D00A37B8E /* Models */, D663626021360A9600C9CBA2 /* Preferences */, + D64B967D2BC1D43A002C8990 /* Push */, D63CC70A2910AAC6000E19DE /* Scenes */, D641C780213DD7C4004B4513 /* Screens */, D62D241E217AA46B005076CC /* Shortcuts */, @@ -2124,6 +2138,7 @@ D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, + D64B967F2BC1D447002C8990 /* PushManager.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */, @@ -2205,6 +2220,7 @@ D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, + D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 51b060c0..d53d231f 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -83,12 +83,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundManager.shared.registerHandlers() + Task { + await PushManager.shared.updateIfNecessary() + } + return true } #if canImport(Sentry) private func configureSentry() { - guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String, + guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any], + let dsn = info["SentryDSN"] as? String, !dsn.isEmpty else { return } @@ -168,6 +173,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + PushManager.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + PushManager.shared.didFailToRegisterForRemoteNotifications(error: error) + } + #if !os(visionOS) private func swizzleStatusBar() { let selector = Selector(("handleTapAction:")) diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 2f03cd02..587a8344 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -49,20 +49,14 @@ NSAppTransportSecurity NSExceptionDomains - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - + NSCameraUsageDescription Post photos and videos from the camera. NSMicrophoneUsageDescription Post videos from the camera. NSPhotoLibraryAddUsageDescription - Save photos directly from other people's posts. + Save photos directly from other people's posts. NSPhotoLibraryUsageDescription Post photos from the photo library. NSUserActivityTypes @@ -106,8 +100,15 @@ - SentryDSN - $(SENTRY_DSN) + TuskerInfo + + PushProxyHost + $(TUSKER_PUSH_PROXY_HOST) + PushProxyScheme + $(TUSKER_PUSH_PROXY_SCHEME) + SentryDSN + $(SENTRY_DSN) + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/Tusker/Push/PushManager.swift b/Tusker/Push/PushManager.swift new file mode 100644 index 00000000..f41f7fbf --- /dev/null +++ b/Tusker/Push/PushManager.swift @@ -0,0 +1,324 @@ +// +// PushManager.swift +// Tusker +// +// Created by Shadowfacts on 4/6/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import OSLog +#if canImport(Sentry) +import Sentry +#endif + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") + +@MainActor +struct PushManager { + static let shared = createPushManager() + + private init() {} + + private static func createPushManager() -> any _PushManager { + guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any], + let scheme = info["PushProxyScheme"] as? String, + let host = info["PushProxyHost"] as? String, + !scheme.isEmpty, + !host.isEmpty else { + logger.debug("Missing proxy info, push disabled") + return DisabledPushManager() + } + var endpoint = URLComponents() + endpoint.scheme = scheme + endpoint.host = host + let url = endpoint.url! + logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)") + return PushManagerImpl(endpoint: url) + } +} + +@MainActor +protocol _PushManager { + var enabled: Bool { get } + var pushProxyRegistration: PushProxyRegistration? { get } + + func register(transactionID: UInt64) async throws -> PushProxyRegistration + func unregister() async throws + func updateIfNecessary() async + + func didRegisterForRemoteNotifications(deviceToken: Data) + func didFailToRegisterForRemoteNotifications(error: any Error) +} + +private class DisabledPushManager: _PushManager { + var enabled: Bool { + false + } + + var pushProxyRegistration: PushProxyRegistration? { + nil + } + + func register(transactionID: UInt64) async throws -> PushProxyRegistration { + throw Disabled() + } + + func unregister() async throws { + throw Disabled() + } + + func updateIfNecessary() async { + } + + func didRegisterForRemoteNotifications(deviceToken: Data) { + } + func didFailToRegisterForRemoteNotifications(error: any Error) { + } + + struct Disabled: LocalizedError { + var errorDescription: String? { + "Push notifications disabled" + } + } +} + +private class PushManagerImpl: _PushManager { + private let endpoint: URL + + var enabled: Bool { + true + } + + private var apnsEnvironment: String { + #if DEBUG + "development" + #else + "release" + #endif + } + + private var remoteNotificationsRegistrationContinuation: CheckedContinuation? + + private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! + private(set) var pushProxyRegistration: PushProxyRegistration? { + get { + if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String], + let registration = PushProxyRegistration(defaultsDict: dict) { + return registration + } else { + return nil + } + } + set { + defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration") + } + } + + init(endpoint: URL) { + self.endpoint = endpoint + } + + func register(transactionID: UInt64) async throws -> PushProxyRegistration { + guard remoteNotificationsRegistrationContinuation == nil else { + throw PushRegistrationError.alreadyRegistering + } + let deviceToken = try await getDeviceToken() + logger.debug("Got device token: \(deviceToken.hexEncodedString())") + let registration: PushProxyRegistration + do { + registration = try await register(deviceToken: deviceToken) + logger.debug("Got endpoint: \(registration.endpoint)") + } catch { + logger.error("Proxy registration failed: \(String(describing: error))") + throw PushRegistrationError.registeringWithProxy(error) + } + pushProxyRegistration = registration + return registration + } + + func unregister() async throws { + guard let pushProxyRegistration else { + return + } + var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + url.path = "/app/v1/registrations/\(pushProxyRegistration.id)" + var request = URLRequest(url: url.url!) + request.httpMethod = "DELETE" + let (_, resp) = try await URLSession.shared.data(for: request) + let status = (resp as! HTTPURLResponse).statusCode + if !(200...299).contains(status) { + logger.error("Unregistering: unexpected status \(status)") + } + } + + func updateIfNecessary() async { + guard let pushProxyRegistration else { + return + } + logger.debug("Push proxy registration: \(pushProxyRegistration.id, privacy: .public)") + do { + let token = try await getDeviceToken() + + let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token) + if pushProxyRegistration.endpoint != newRegistration.endpoint { + // TODO: update subscriptions if the endpoint's changed + } + } catch { + logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)") + #if canImport(Sentry) + SentrySDK.capture(error: error) + #endif + } + } + + private func getDeviceToken() async throws -> Data { + defer { + remoteNotificationsRegistrationContinuation = nil + } + return try await withCheckedThrowingContinuation { continuation in + remoteNotificationsRegistrationContinuation = continuation + UIApplication.shared.registerForRemoteNotifications() + } + } + + func didRegisterForRemoteNotifications(deviceToken: Data) { + remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken) + } + + func didFailToRegisterForRemoteNotifications(error: any Error) { + remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error)) + } + + private func register(deviceToken: Data) async throws -> PushProxyRegistration { + var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + url.path = "/app/v1/registrations" + var request = URLRequest(url: url.url!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(transactionID: "TODO", environment: apnsEnvironment, deviceToken: deviceToken.hexEncodedString(), pushVersion: 1)) + let (data, resp) = try await URLSession.shared.data(for: request) + let status = (resp as! HTTPURLResponse).statusCode + guard (200...299).contains(status) else { + logger.error("Registering: unexpected status \(status)") + let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: []) + throw error + } + return try JSONDecoder().decode(PushProxyRegistration.self, from: data) + } + + private func update(registration: PushProxyRegistration, deviceToken: Data) async throws -> PushProxyRegistration { + var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + url.path = "/app/v1/registrations/\(registration.id)" + var request = URLRequest(url: url.url!) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken.hexEncodedString(), pushVersion: 1)) + let (data, resp) = try await URLSession.shared.data(for: request) + let status = (resp as! HTTPURLResponse).statusCode + guard (200...299).contains(status) else { + logger.error("Updating: unexpected status \(status)") + let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: []) + throw error + } + return try JSONDecoder().decode(PushProxyRegistration.self, from: data) + } +} + +enum PushRegistrationError: LocalizedError { + case alreadyRegistering + case registeringForRemoteNotifications(any Error) + case registeringWithProxy(any Error) + + var errorDescription: String? { + switch self { + case .alreadyRegistering: + "Already registering" + case .registeringForRemoteNotifications(let error): + "Remote notifications: \(error.localizedDescription)" + case .registeringWithProxy(let error): + "Proxy: \(error.localizedDescription)" + } + } +} + +struct ProxyRegistrationError: LocalizedError, Decodable { + let error: String + let fields: [Field]? + + var errorDescription: String? { + if let fields, + !fields.isEmpty { + error + ": " + fields.map { "\($0.key): \($0.reason)" }.joined(separator: ", ") + } else { + error + } + } + + struct Field: Decodable { + let key: String + let reason: String + } +} + +private struct PushRegistrationParams: Encodable { + let transactionID: String + let environment: String + let deviceToken: String + let pushVersion: Int + + enum CodingKeys: String, CodingKey { + case transactionID = "transaction_id" + case environment + case deviceToken = "device_token" + case pushVersion = "push_version" + } +} + +private struct PushUpdateParams: Encodable { + let environment: String + let deviceToken: String + let pushVersion: Int + + enum CodingKeys: String, CodingKey { + case environment + case deviceToken = "device_token" + case pushVersion = "push_version" + } +} + +struct PushProxyRegistration: Decodable { + let id: String + let endpoint: URL + + fileprivate var defaultsDict: [String: String] { + [ + "id": id, + "endpoint": endpoint.absoluteString + ] + } + + fileprivate init?(defaultsDict: [String: String]) { + guard let id = defaultsDict["id"], + let endpoint = defaultsDict["endpoint"], + let endpointURL = URL(string: endpoint) else { + return nil + } + self.id = id + self.endpoint = endpointURL + } +} + +private extension Data { + func hexEncodedString() -> String { + String(unsafeUninitializedCapacity: count * 2) { buffer in + let chars = Array("0123456789ABCDEF".utf8) + for (i, x) in enumerated() { + let (upper, lower) = x.quotientAndRemainder(dividingBy: 16) + buffer[i * 2] = chars[Int(upper)] + buffer[i * 2 + 1] = chars[Int(lower)] + } + return count * 2 + } + } +} diff --git a/Tusker/Screens/Preferences/NotificationsPrefsView.swift b/Tusker/Screens/Preferences/NotificationsPrefsView.swift new file mode 100644 index 00000000..4e56697e --- /dev/null +++ b/Tusker/Screens/Preferences/NotificationsPrefsView.swift @@ -0,0 +1,129 @@ +// +// NotificationsPrefsView.swift +// Tusker +// +// Created by Shadowfacts on 4/6/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UserNotifications + +struct NotificationsPrefsView: View { + @State private var error: NotificationsSetupError? + @State private var isSetup = false + @State private var working = false + @State private var pushProxyRegistration: PushProxyRegistration? + + var body: some View { + List { + enableSection + } + .listStyle(.insetGrouped) + .appGroupedListBackground(container: PreferencesNavigationController.self) + .navigationTitle("Notifications") + } + + private var enableSection: some View { + Section { + HStack { + Text("Push Notifications") + + Spacer() + + if working { + ProgressView() + } else { + Toggle("Push Notifications Enabled", isOn: Binding(get: { + isSetup + }, set: { newValue in + isSetup = newValue + isSetupChanged(newValue: newValue) + })) + .labelsHidden() + } + } + } + .alertWithData("An Error Occurred", data: $error) { error in + Button("OK") {} + } message: { error in + Text(error.localizedDescription) + } + .task { @MainActor in + pushProxyRegistration = PushManager.shared.pushProxyRegistration + isSetup = pushProxyRegistration != nil + if !UIApplication.shared.isRegisteredForRemoteNotifications { + _ = await registerForRemoteNotifications() + } + } + } + + private func isSetupChanged(newValue: Bool) { + working = true + Task { + defer { + working = false + } + let success = if newValue { + await startRegistration() + } else { + await unregister() + } + if !success { + isSetup = !newValue + } + } + } + + private func startRegistration() async -> Bool { + let authorized: Bool + do { + authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) + } catch { + self.error = .requestingAuthorization(error) + return false + } + guard authorized else { + return false + } + return await registerForRemoteNotifications() + } + + private func registerForRemoteNotifications() async -> Bool { + do { + pushProxyRegistration = try await PushManager.shared.register(transactionID: 0) + return true + } catch { + self.error = .registering(error) + return false + } + } + + private func unregister() async -> Bool { + do { + try await PushManager.shared.unregister() + pushProxyRegistration = nil + return true + } catch { + self.error = .unregistering(error) + return false + } + } +} + +private enum NotificationsSetupError: LocalizedError { + case requestingAuthorization(any Error) + case registering(any Error) + case unregistering(any Error) + + var errorDescription: String? { + switch self { + case .requestingAuthorization(let error): + "Notifications authorization request failed: \(error.localizedDescription)" + case .registering(let error): + "Registration failed: \(error.localizedDescription)" + case .unregistering(let error): + "Deactivation failed: \(error.localizedDescription)" + } + } +} diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 6739d89e..e3ec54f3 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -22,6 +22,7 @@ struct PreferencesView: View { var body: some View { List { accountsSection + notificationsSection preferencesSection aboutSection } @@ -95,6 +96,15 @@ struct PreferencesView: View { .appGroupedListRowBackground() } + private var notificationsSection: some View { + Section { + NavigationLink(destination: NotificationsPrefsView()) { + Text("Notifications") + } + } + .appGroupedListRowBackground() + } + private var preferencesSection: some View { Section { NavigationLink(destination: AppearancePrefsView()) { diff --git a/Tusker/Tusker.entitlements b/Tusker/Tusker.entitlements index be4cea35..0466cf80 100644 --- a/Tusker/Tusker.entitlements +++ b/Tusker/Tusker.entitlements @@ -18,6 +18,8 @@ com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.developer.usernotifications.communication + com.apple.security.app-sandbox com.apple.security.application-groups