forked from shadowfacts/Tusker
Push proxy registration
This commit is contained in:
parent
c5226f6374
commit
3efa017942
|
@ -121,6 +121,8 @@
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
|
||||||
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.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 */; };
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
||||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
||||||
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.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 = "<group>"; };
|
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
|
||||||
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
|
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
|
||||||
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
|
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
|
||||||
|
D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPrefsView.swift; sourceTree = "<group>"; };
|
||||||
|
D64B967E2BC1D447002C8990 /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = "<group>"; };
|
||||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
||||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1119,6 +1123,7 @@
|
||||||
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
||||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
||||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
||||||
|
D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */,
|
||||||
D60089172981FEA4005B4D00 /* Tip Jar */,
|
D60089172981FEA4005B4D00 /* Tip Jar */,
|
||||||
D68A76EF2953910A001DA1B3 /* About */,
|
D68A76EF2953910A001DA1B3 /* About */,
|
||||||
);
|
);
|
||||||
|
@ -1181,6 +1186,14 @@
|
||||||
path = Toast;
|
path = Toast;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D64B967D2BC1D43A002C8990 /* Push */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D64B967E2BC1D447002C8990 /* PushManager.swift */,
|
||||||
|
);
|
||||||
|
path = Push;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D65A37F221472F300087646E /* Frameworks */ = {
|
D65A37F221472F300087646E /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1518,6 +1531,7 @@
|
||||||
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
|
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
|
||||||
D61959D2241E846D00A37B8E /* Models */,
|
D61959D2241E846D00A37B8E /* Models */,
|
||||||
D663626021360A9600C9CBA2 /* Preferences */,
|
D663626021360A9600C9CBA2 /* Preferences */,
|
||||||
|
D64B967D2BC1D43A002C8990 /* Push */,
|
||||||
D63CC70A2910AAC6000E19DE /* Scenes */,
|
D63CC70A2910AAC6000E19DE /* Scenes */,
|
||||||
D641C780213DD7C4004B4513 /* Screens */,
|
D641C780213DD7C4004B4513 /* Screens */,
|
||||||
D62D241E217AA46B005076CC /* Shortcuts */,
|
D62D241E217AA46B005076CC /* Shortcuts */,
|
||||||
|
@ -2124,6 +2138,7 @@
|
||||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
|
D64B967F2BC1D447002C8990 /* PushManager.swift in Sources */,
|
||||||
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
|
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
|
||||||
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
|
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
|
||||||
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */,
|
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */,
|
||||||
|
@ -2205,6 +2220,7 @@
|
||||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||||
|
D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */,
|
||||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||||
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
|
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
|
||||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||||
|
|
|
@ -83,12 +83,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
BackgroundManager.shared.registerHandlers()
|
BackgroundManager.shared.registerHandlers()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await PushManager.shared.updateIfNecessary()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(Sentry)
|
#if canImport(Sentry)
|
||||||
private func configureSentry() {
|
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 {
|
!dsn.isEmpty else {
|
||||||
return
|
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)
|
#if !os(visionOS)
|
||||||
private func swizzleStatusBar() {
|
private func swizzleStatusBar() {
|
||||||
let selector = Selector(("handleTapAction:"))
|
let selector = Selector(("handleTapAction:"))
|
||||||
|
|
|
@ -49,20 +49,14 @@
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionDomains</key>
|
<key>NSExceptionDomains</key>
|
||||||
<dict>
|
<dict/>
|
||||||
<key>localhost</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Post photos and videos from the camera.</string>
|
<string>Post photos and videos from the camera.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Post videos from the camera.</string>
|
<string>Post videos from the camera.</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Save photos directly from other people's posts.</string>
|
<string>Save photos directly from other people's posts.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Post photos from the photo library.</string>
|
<string>Post photos from the photo library.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
|
@ -106,8 +100,15 @@
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SentryDSN</key>
|
<key>TuskerInfo</key>
|
||||||
<string>$(SENTRY_DSN)</string>
|
<dict>
|
||||||
|
<key>PushProxyHost</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||||
|
<key>PushProxyScheme</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
|
||||||
|
<key>SentryDSN</key>
|
||||||
|
<string>$(SENTRY_DSN)</string>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|
|
@ -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<Data, any Error>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ struct PreferencesView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
accountsSection
|
accountsSection
|
||||||
|
notificationsSection
|
||||||
preferencesSection
|
preferencesSection
|
||||||
aboutSection
|
aboutSection
|
||||||
}
|
}
|
||||||
|
@ -95,6 +96,15 @@ struct PreferencesView: View {
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var notificationsSection: some View {
|
||||||
|
Section {
|
||||||
|
NavigationLink(destination: NotificationsPrefsView()) {
|
||||||
|
Text("Notifications")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appGroupedListRowBackground()
|
||||||
|
}
|
||||||
|
|
||||||
private var preferencesSection: some View {
|
private var preferencesSection: some View {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(destination: AppearancePrefsView()) {
|
NavigationLink(destination: AppearancePrefsView()) {
|
||||||
|
|
|
@ -18,6 +18,8 @@
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.developer.usernotifications.communication</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
|
Loading…
Reference in New Issue