Compare commits

..

No commits in common. "ff11835333ed1a3d13a541d2f91db436c2114d86" and "bdd4a4d75552e9dc490eb444e9e679c9ba1d6c21" have entirely different histories.

21 changed files with 182 additions and 426 deletions

View File

@ -1,9 +1,5 @@
# Changelog # Changelog
## 2024.1 (119)
Features/Improvements:
- Add Account Settings button to Preferences
## 2024.1 (118) ## 2024.1 (118)
Bugfixes: Bugfixes:
- Fix music not pausing/resuming when video playback starts - Fix music not pausing/resuming when video playback starts

View File

@ -13,7 +13,7 @@ class DisabledPushManager: _PushManager {
false false
} }
var proxyRegistration: PushProxyRegistration? { var pushProxyRegistration: PushProxyRegistration? {
nil nil
} }
@ -36,7 +36,7 @@ class DisabledPushManager: _PushManager {
throw Disabled() throw Disabled()
} }
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { func updateIfNecessary() async {
} }
func didRegisterForRemoteNotifications(deviceToken: Data) { func didRegisterForRemoteNotifications(deviceToken: Data) {

View File

@ -7,6 +7,9 @@
import Foundation import Foundation
import OSLog import OSLog
#if canImport(Sentry)
import Sentry
#endif
import Pachyderm import Pachyderm
import UserAccounts import UserAccounts
@ -16,9 +19,6 @@ public struct PushManager {
public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
@MainActor
public static var captureError: ((any Error) -> Void)?
private init() {} private init() {}
@MainActor @MainActor
@ -43,7 +43,7 @@ public struct PushManager {
@MainActor @MainActor
public protocol _PushManager { public protocol _PushManager {
var enabled: Bool { get } var enabled: Bool { get }
var proxyRegistration: PushProxyRegistration? { get } var pushProxyRegistration: PushProxyRegistration? { get }
func createSubscription(account: UserAccountInfo) throws -> PushSubscription func createSubscription(account: UserAccountInfo) throws -> PushSubscription
func removeSubscription(account: UserAccountInfo) func removeSubscription(account: UserAccountInfo)
@ -51,7 +51,7 @@ public protocol _PushManager {
func register(transactionID: UInt64) async throws -> PushProxyRegistration func register(transactionID: UInt64) async throws -> PushProxyRegistration
func unregister() async throws func unregister() async throws
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async func updateIfNecessary() async
func didRegisterForRemoteNotifications(deviceToken: Data) func didRegisterForRemoteNotifications(deviceToken: Data)
func didFailToRegisterForRemoteNotifications(error: any Error) func didFailToRegisterForRemoteNotifications(error: any Error)

View File

@ -7,6 +7,9 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
#if canImport(Sentry)
import Sentry
#endif
import CryptoKit import CryptoKit
class PushManagerImpl: _PushManager { class PushManagerImpl: _PushManager {
@ -27,7 +30,7 @@ class PushManagerImpl: _PushManager {
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>? private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
private(set) var proxyRegistration: PushProxyRegistration? { private(set) var pushProxyRegistration: PushProxyRegistration? {
get { get {
if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String], if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String],
let registration = PushProxyRegistration(defaultsDict: dict) { let registration = PushProxyRegistration(defaultsDict: dict) {
@ -40,7 +43,7 @@ class PushManagerImpl: _PushManager {
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration") defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
} }
} }
private(set) var subscriptions: [PushSubscription] { private(set) var pushSubscriptions: [PushSubscription] {
get { get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] { if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
return array.compactMap(PushSubscription.init(defaultsDict:)) return array.compactMap(PushSubscription.init(defaultsDict:))
@ -58,7 +61,7 @@ class PushManagerImpl: _PushManager {
} }
func createSubscription(account: UserAccountInfo) throws -> PushSubscription { func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
guard let proxyRegistration else { guard let pushProxyRegistration else {
throw CreateSubscriptionError.notRegisteredWithProxy throw CreateSubscriptionError.notRegisteredWithProxy
} }
if let existing = pushSubscription(account: account) { if let existing = pushSubscription(account: account) {
@ -74,29 +77,22 @@ class PushManagerImpl: _PushManager {
} }
let subscription = PushSubscription( let subscription = PushSubscription(
accountID: account.id, accountID: account.id,
endpoint: endpointURL(registration: proxyRegistration, accountID: account.id), endpoint: pushProxyRegistration.endpoint,
secretKey: key, secretKey: key,
authSecret: authSecret, authSecret: authSecret,
alerts: [], alerts: [],
policy: .all policy: .all
) )
subscriptions.append(subscription) pushSubscriptions.append(subscription)
return subscription return subscription
} }
private func endpointURL(registration: PushProxyRegistration, accountID: String) -> URL {
var endpoint = URLComponents(url: registration.endpoint, resolvingAgainstBaseURL: false)!
endpoint.queryItems = endpoint.queryItems ?? []
endpoint.queryItems!.append(URLQueryItem(name: "ctx", value: accountID))
return endpoint.url!
}
func removeSubscription(account: UserAccountInfo) { func removeSubscription(account: UserAccountInfo) {
subscriptions.removeAll { $0.accountID == account.id } pushSubscriptions.removeAll { $0.accountID == account.id }
} }
func pushSubscription(account: UserAccountInfo) -> PushSubscription? { func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
subscriptions.first { $0.accountID == account.id } pushSubscriptions.first { $0.accountID == account.id }
} }
func register(transactionID: UInt64) async throws -> PushProxyRegistration { func register(transactionID: UInt64) async throws -> PushProxyRegistration {
@ -113,22 +109,22 @@ class PushManagerImpl: _PushManager {
PushManager.logger.error("Proxy registration failed: \(String(describing: error))") PushManager.logger.error("Proxy registration failed: \(String(describing: error))")
throw PushRegistrationError.registeringWithProxy(error) throw PushRegistrationError.registeringWithProxy(error)
} }
proxyRegistration = registration pushProxyRegistration = registration
return registration return registration
} }
func unregister() async throws { func unregister() async throws {
guard let proxyRegistration else { guard let pushProxyRegistration else {
return return
} }
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations/\(proxyRegistration.id)" url.path = "/app/v1/registrations/\(pushProxyRegistration.id)"
var request = URLRequest(url: url.url!) var request = URLRequest(url: url.url!)
request.httpMethod = "DELETE" request.httpMethod = "DELETE"
let (data, resp) = try await URLSession.shared.data(for: request) let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode let status = (resp as! HTTPURLResponse).statusCode
if (200...299).contains(status) { if (200...299).contains(status) {
self.proxyRegistration = nil self.pushProxyRegistration = nil
PushManager.logger.debug("Unregistered from proxy") PushManager.logger.debug("Unregistered from proxy")
} else { } else {
PushManager.logger.error("Unregistering: unexpected status \(status)") PushManager.logger.error("Unregistering: unexpected status \(status)")
@ -137,35 +133,26 @@ class PushManagerImpl: _PushManager {
} }
} }
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { func updateIfNecessary() async {
guard let proxyRegistration else { guard let pushProxyRegistration else {
return return
} }
PushManager.logger.debug("Push proxy registration: \(proxyRegistration.id, privacy: .public)") PushManager.logger.debug("Push proxy registration: \(pushProxyRegistration.id, privacy: .public)")
do { do {
let token = try await getDeviceToken().hexEncodedString() let token = try await getDeviceToken().hexEncodedString()
guard token != proxyRegistration.deviceToken else { guard token != pushProxyRegistration.deviceToken else {
// already up-to-date, nothing to do // already up-to-date, nothing to do
return return
} }
let newRegistration = try await update(registration: proxyRegistration, deviceToken: token) let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token)
self.proxyRegistration = newRegistration if pushProxyRegistration.endpoint != newRegistration.endpoint {
if proxyRegistration.endpoint != newRegistration.endpoint { // TODO: update subscriptions if the endpoint's changed
self.subscriptions = await AsyncSequenceAdaptor(wrapping: self.subscriptions).map {
var copy = $0
copy.endpoint = await self.endpointURL(registration: newRegistration, accountID: $0.accountID)
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
} }
} catch { } catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)") PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error) #if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
} }
} }
@ -314,25 +301,3 @@ private extension Data {
} }
} }
} }
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
typealias Element = S.Element
let base: S
init(wrapping base: S) {
self.base = base
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(base: base.makeIterator())
}
struct AsyncIterator: AsyncIteratorProtocol {
var base: S.Iterator
mutating func next() async -> Element? {
base.next()
}
}
}

View File

@ -9,8 +9,8 @@ import Foundation
import CryptoKit import CryptoKit
public struct PushSubscription { public struct PushSubscription {
public let accountID: String let accountID: String
public internal(set) var endpoint: URL let endpoint: URL
public let secretKey: P256.KeyAgreement.PrivateKey public let secretKey: P256.KeyAgreement.PrivateKey
public let authSecret: Data public let authSecret: Data
public var alerts: Alerts public var alerts: Alerts

View File

@ -13,9 +13,8 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
public let instanceURL: URL public let instanceURL: URL
public let clientID: String public let clientID: String
public let clientSecret: String public let clientSecret: String
public let username: String! public private(set) var username: String!
public internal(set) var accessToken: String public let accessToken: String
public internal(set) var scopes: [String]?
// Sort of hack to be able to access these from the share extension. // Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String? public internal(set) var serverDefaultLanguage: String?
@ -41,19 +40,16 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.clientID = clientID self.clientID = clientID
self.clientSecret = clientSecret self.clientSecret = clientSecret
self.username = nil
self.accessToken = accessToken self.accessToken = accessToken
self.scopes = nil
} }
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [String]) { init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username) self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.clientID = clientID self.clientID = clientID
self.clientSecret = clientSecret self.clientSecret = clientSecret
self.username = username self.username = username
self.accessToken = accessToken self.accessToken = accessToken
self.scopes = scopes
} }
init?(userDefaultsDict dict: [String: Any]) { init?(userDefaultsDict dict: [String: Any]) {
@ -71,7 +67,6 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
self.clientSecret = secret self.clientSecret = secret
self.username = dict["username"] as? String self.username = dict["username"] as? String
self.accessToken = accessToken self.accessToken = accessToken
self.scopes = dict["scopes"] as? [String]
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
@ -88,9 +83,6 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
if let username { if let username {
dict["username"] = username dict["username"] = username
} }
if let scopes {
dict["scopes"] = scopes
}
if let serverDefaultLanguage { if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage dict["serverDefaultLanguage"] = serverDefaultLanguage
} }
@ -108,4 +100,12 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
// slashes are not allowed in the persistent store coordinator name // slashes are not allowed in the persistent store coordinator name
id.replacingOccurrences(of: "/", with: "_") id.replacingOccurrences(of: "/", with: "_")
} }
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
return lhs.id == rhs.id
}
} }

View File

@ -25,9 +25,7 @@ public class UserAccountsManager: ObservableObject {
clientID: "client_id", clientID: "client_id",
clientSecret: "client_secret", clientSecret: "client_secret",
username: "admin", username: "admin",
accessToken: "access_token", accessToken: "access_token")
scopes: []
)
] ]
} }
} else { } else {
@ -40,7 +38,7 @@ public class UserAccountsManager: ObservableObject {
private let accountsKey = "accounts" private let accountsKey = "accounts"
public private(set) var accounts: [UserAccountInfo] { public private(set) var accounts: [UserAccountInfo] {
get { get {
if let array = defaults.array(forKey: accountsKey) as? [[String: Any]] { if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
return array.compactMap(UserAccountInfo.init(userDefaultsDict:)) return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
} else { } else {
return [] return []
@ -103,12 +101,12 @@ public class UserAccountsManager: ObservableObject {
return !accounts.isEmpty return !accounts.isEmpty
} }
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String, scopes: [String]) -> UserAccountInfo { public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
var accounts = self.accounts var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index) accounts.remove(at: index)
} }
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken, scopes: scopes) let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
accounts.append(info) accounts.append(info)
self.accounts = accounts self.accounts = accounts
return info return info
@ -148,16 +146,6 @@ public class UserAccountsManager: ObservableObject {
accounts[index] = account accounts[index] = account
} }
public func updateAccessToken(_ account: UserAccountInfo, token: String, scopes: [String]) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.accessToken = token
account.scopes = scopes
accounts[index] = account
}
} }
public extension Notification.Name { public extension Notification.Name {

View File

@ -93,8 +93,6 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; }; D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; };
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; };
D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; }; D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; };
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
@ -135,6 +133,7 @@
D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6552366289870790048A653 /* ScreenCorners */; }; D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6552366289870790048A653 /* ScreenCorners */; };
D659F35E2953A212002D944A /* TTTKit in Frameworks */ = {isa = PBXBuildFile; productRef = D659F35D2953A212002D944A /* TTTKit */; }; D659F35E2953A212002D944A /* TTTKit in Frameworks */ = {isa = PBXBuildFile; productRef = D659F35D2953A212002D944A /* TTTKit */; };
D659F36229541065002D944A /* TTTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D659F36129541065002D944A /* TTTView.swift */; }; D659F36229541065002D944A /* TTTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D659F36129541065002D944A /* TTTView.swift */; };
D65A261B2BC3928A005EB5D8 /* TriStateToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */; };
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */; }; D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */; };
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B532971F71D00DABDFB /* EditedReport.swift */; }; D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B532971F71D00DABDFB /* EditedReport.swift */; };
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B552971F98300DABDFB /* ReportView.swift */; }; D65B4B562971F98300DABDFB /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B552971F98300DABDFB /* ReportView.swift */; };
@ -499,8 +498,6 @@
D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = "<group>"; }; D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = "<group>"; };
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = "<group>"; }; D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = "<group>"; };
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; };
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = "<group>"; };
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAuthorizationTokenService.swift; sourceTree = "<group>"; };
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; }; D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; };
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
@ -539,6 +536,7 @@
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; }; D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; };
D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriStateToggle.swift; sourceTree = "<group>"; };
D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushInstanceSettingsView.swift; sourceTree = "<group>"; }; D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushInstanceSettingsView.swift; sourceTree = "<group>"; };
D65A26242BC39A02005EB5D8 /* PushNotifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PushNotifications; sourceTree = "<group>"; }; D65A26242BC39A02005EB5D8 /* PushNotifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PushNotifications; sourceTree = "<group>"; };
D65B4B532971F71D00DABDFB /* EditedReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedReport.swift; sourceTree = "<group>"; }; D65B4B532971F71D00DABDFB /* EditedReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedReport.swift; sourceTree = "<group>"; };
@ -1205,6 +1203,7 @@
D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */, D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */,
D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */, D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */,
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */, D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */,
D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */,
); );
path = Notifications; path = Notifications;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1655,7 +1654,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6F953EF21251A2900CF0F2B /* MastodonController.swift */, D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */,
@ -1670,7 +1668,6 @@
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */, D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */, D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */, D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
); );
path = API; path = API;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2067,7 +2064,6 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */, D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
@ -2157,6 +2153,7 @@
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D65A261B2BC3928A005EB5D8 /* TriStateToggle.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
@ -2235,7 +2232,6 @@
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,

View File

@ -1,63 +0,0 @@
//
// GetAuthorizationTokenService.swift
// Tusker
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import AuthenticationServices
class GetAuthorizationTokenService {
let instanceURL: URL
let clientID: String
let presentationContextProvider: ASWebAuthenticationPresentationContextProviding
init(instanceURL: URL, clientID: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding) {
self.instanceURL = instanceURL
self.clientID = clientID
self.presentationContextProvider = presentationContextProvider
}
@MainActor
func run() async throws -> String {
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/oauth/authorize"
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
]
let authorizeURL = components.url!
return try await withCheckedThrowingContinuation({ continuation in
let authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
if let error = error {
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
continuation.resume(throwing: Error.cancelled)
} else {
continuation.resume(throwing: error)
}
} else if let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let code = item.value {
continuation.resume(returning: code)
} else {
continuation.resume(throwing: Error.noAuthorizationCode)
}
})
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
authenticationSession.prefersEphemeralWebBrowserSession = true
authenticationSession.presentationContextProvider = presentationContextProvider
authenticationSession.start()
})
}
enum Error: Swift.Error {
case cancelled
case noAuthorizationCode
}
}

View File

@ -8,8 +8,6 @@
import Foundation import Foundation
import UserAccounts import UserAccounts
import PushNotifications
import Pachyderm
@MainActor @MainActor
class LogoutService { class LogoutService {
@ -22,12 +20,7 @@ class LogoutService {
} }
func run() { func run() {
let accountInfo = self.accountInfo
Task.detached { Task.detached {
if await PushManager.shared.pushSubscription(account: accountInfo) != nil {
_ = try? await self.mastodonController.run(Pachyderm.PushSubscription.delete())
await PushManager.shared.removeSubscription(account: accountInfo)
}
try? await self.mastodonController.client.revokeAccessToken() try? await self.mastodonController.client.revokeAccessToken()
} }
MastodonController.removeForAccount(accountInfo) MastodonController.removeForAccount(accountInfo)

View File

@ -1,71 +0,0 @@
//
// MastodonController+Push.swift
// Tusker
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import PushNotifications
import CryptoKit
extension MastodonController {
func createPushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
let req = Pachyderm.PushSubscription.create(
endpoint: subscription.endpoint,
// mastodon docs just say "Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve."
// other apps use SecKeyCopyExternalRepresentation which is documented to use X9.63 for elliptic curve keys
// and that seems to work
publicKey: subscription.secretKey.publicKey.x963Representation,
authSecret: subscription.authSecret,
alerts: .init(subscription.alerts),
policy: .init(subscription.policy)
)
return try await run(req).0
}
func updatePushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
// when updating anything other than the alerts/policy, we need to go through the create route
return try await createPushSubscription(subscription: subscription)
}
func updatePushSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async throws -> Pachyderm.PushSubscription {
let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy))
return try await run(req).0
}
func deletePushSubscription() async throws {
let req = Pachyderm.PushSubscription.delete()
_ = try await run(req)
}
}
private extension Pachyderm.PushSubscription.Alerts {
init(_ alerts: PushNotifications.PushSubscription.Alerts) {
self.init(
mention: alerts.contains(.mention),
status: alerts.contains(.status),
reblog: alerts.contains(.reblog),
follow: alerts.contains(.follow),
followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll),
update: alerts.contains(.update)
)
}
}
private extension Pachyderm.PushSubscription.Policy {
init(_ policy: PushNotifications.PushSubscription.Policy) {
switch policy {
case .all:
self = .all
case .followers:
self = .followers
case .followed:
self = .followed
}
}
}

View File

@ -24,22 +24,22 @@ final class MastodonController: ObservableObject, Sendable {
static let oauthScopes = [Scope.read, .write, .follow, .push] static let oauthScopes = [Scope.read, .write, .follow, .push]
@MainActor @MainActor
static private(set) var all = [String: MastodonController]() static private(set) var all = [UserAccountInfo: MastodonController]()
@MainActor @MainActor
static func getForAccount(_ account: UserAccountInfo) -> MastodonController { static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account.id] { if let controller = all[account] {
return controller return controller
} else { } else {
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account) let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
all[account.id] = controller all[account] = controller
return controller return controller
} }
} }
@MainActor @MainActor
static func removeForAccount(_ account: UserAccountInfo) { static func removeForAccount(_ account: UserAccountInfo) {
all.removeValue(forKey: account.id) all.removeValue(forKey: account)
} }
@MainActor @MainActor

View File

@ -84,7 +84,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
BackgroundManager.shared.registerHandlers() BackgroundManager.shared.registerHandlers()
initializePushManager() Task {
await PushManager.shared.updateIfNecessary()
}
return true return true
} }
@ -180,26 +182,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error) PushManager.shared.didFailToRegisterForRemoteNotifications(error: error)
} }
private func initializePushManager() {
Task {
PushManager.captureError = { SentrySDK.capture(error: $0) }
await PushManager.shared.updateIfNecessary(updateSubscription: {
guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else {
return false
}
let mastodonController = MastodonController.getForAccount(account)
do {
let result = try await mastodonController.updatePushSubscription(subscription: $0)
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
return true
} catch {
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
return false
}
})
}
}
#if !os(visionOS) #if !os(visionOS)
private func swizzleStatusBar() { private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:")) let selector = Selector(("handleTapAction:"))

View File

@ -158,14 +158,7 @@ class OnboardingViewController: UINavigationController {
throw Error.gettingOwnAccount(error) throw Error.gettingOwnAccount(error)
} }
let accountInfo = UserAccountsManager.shared.addAccount( let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
instanceURL: instanceURL,
clientID: clientID,
clientSecret: clientSecret,
username: ownAccount.username,
accessToken: accessToken,
scopes: MastodonController.oauthScopes.map(\.rawValue)
)
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
} }
@ -184,19 +177,38 @@ class OnboardingViewController: UINavigationController {
@MainActor @MainActor
private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String { private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String {
do { var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
let service = GetAuthorizationTokenService(instanceURL: instanceURL, clientID: clientID, presentationContextProvider: self) components.path = "/oauth/authorize"
return try await service.run() components.queryItems = [
} catch let error as GetAuthorizationTokenService.Error { URLQueryItem(name: "client_id", value: clientID),
switch error { URLQueryItem(name: "response_type", value: "code"),
case .cancelled: URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
throw Error.cancelled URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
case .noAuthorizationCode: ]
throw Error.noAuthorizationCode let authorizeURL = components.url!
}
} catch { return try await withCheckedThrowingContinuation({ continuation in
throw Error.authenticationSessionError(error) self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
} if let error = error {
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
continuation.resume(throwing: Error.cancelled)
} else {
continuation.resume(throwing: Error.authenticationSessionError(error))
}
} else if let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let code = item.value {
continuation.resume(returning: code)
} else {
continuation.resume(throwing: Error.noAuthorizationCode)
}
})
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
self.authenticationSession!.presentationContextProvider = self
self.authenticationSession!.start()
})
} }
} }

View File

@ -10,11 +10,10 @@ import SwiftUI
import UserNotifications import UserNotifications
import UserAccounts import UserAccounts
import PushNotifications import PushNotifications
import TuskerComponents
struct NotificationsPrefsView: View { struct NotificationsPrefsView: View {
@State private var error: NotificationsSetupError? @State private var error: NotificationsSetupError?
@State private var isSetup = AsyncToggle.Mode.off @State private var isSetup = TriStateToggle.Mode.off
@State private var pushProxyRegistration: PushProxyRegistration? @State private var pushProxyRegistration: PushProxyRegistration?
@ObservedObject private var userAccounts = UserAccountsManager.shared @ObservedObject private var userAccounts = UserAccountsManager.shared
@ -33,7 +32,7 @@ struct NotificationsPrefsView: View {
private var enableSection: some View { private var enableSection: some View {
Section { Section {
AsyncToggle("Push Notifications", mode: $isSetup, onChange: isSetupChanged(newValue:)) TriStateToggle("Push Notifications", mode: $isSetup, onChange: isSetupChanged(newValue:))
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
.alertWithData("An Error Occurred", data: $error) { error in .alertWithData("An Error Occurred", data: $error) { error in
@ -42,7 +41,7 @@ struct NotificationsPrefsView: View {
Text(error.localizedDescription) Text(error.localizedDescription)
} }
.task { @MainActor in .task { @MainActor in
pushProxyRegistration = PushManager.shared.proxyRegistration pushProxyRegistration = PushManager.shared.pushProxyRegistration
isSetup = pushProxyRegistration != nil ? .on : .off isSetup = pushProxyRegistration != nil ? .on : .off
if !UIApplication.shared.isRegisteredForRemoteNotifications { if !UIApplication.shared.isRegisteredForRemoteNotifications {
_ = await registerForRemoteNotifications() _ = await registerForRemoteNotifications()
@ -59,11 +58,17 @@ struct NotificationsPrefsView: View {
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
private func isSetupChanged(newValue: Bool) async -> Bool { private func isSetupChanged(newValue: Bool) async {
if newValue { if newValue {
return await startRegistration() let success = await startRegistration()
if !success {
isSetup = .off
}
} else { } else {
return await unregister() let success = await unregister()
if !success {
isSetup = .on
}
} }
} }

View File

@ -10,15 +10,13 @@ import SwiftUI
import UserAccounts import UserAccounts
import Pachyderm import Pachyderm
import PushNotifications import PushNotifications
import TuskerComponents
struct PushInstanceSettingsView: View { struct PushInstanceSettingsView: View {
let account: UserAccountInfo let account: UserAccountInfo
let pushProxyRegistration: PushProxyRegistration let pushProxyRegistration: PushProxyRegistration
@State private var mode: AsyncToggle.Mode @State private var mode: TriStateToggle.Mode
@State private var error: Error? @State private var error: Error?
@State private var subscription: PushNotifications.PushSubscription? @State private var subscription: PushNotifications.PushSubscription?
@State private var showReLoginRequiredAlert = false
@MainActor @MainActor
init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) { init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) {
@ -34,7 +32,7 @@ struct PushInstanceSettingsView: View {
HStack { HStack {
PrefsAccountView(account: account) PrefsAccountView(account: account)
Spacer() Spacer()
AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:)) TriStateToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
.labelsHidden() .labelsHidden()
} }
PushSubscriptionView(account: account, subscription: subscription, updateSubscription: updateSubscription) PushSubscriptionView(account: account, subscription: subscription, updateSubscription: updateSubscription)
@ -44,24 +42,15 @@ struct PushInstanceSettingsView: View {
} message: { data in } message: { data in
Text(data.localizedDescription) Text(data.localizedDescription)
} }
.alert("Re-Login Required", isPresented: $showReLoginRequiredAlert) {
Button("Cancel", role: .cancel) {}
Button("Login") {
NotificationCenter.default.post(name: .reLogInRequired, object: account)
}
} message: {
Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.")
}
} }
private func updateNotificationsEnabled(enabled: Bool) async -> Bool { private func updateNotificationsEnabled(enabled: Bool) async {
if enabled { if enabled {
do { do {
return try await enableNotifications() try await enableNotifications()
} catch { } catch {
PushManager.logger.error("Error creating instance subscription: \(String(describing: error))") PushManager.logger.error("Error creating instance subscription: \(String(describing: error))")
self.error = .enabling(error) self.error = .enabling(error)
return false
} }
} else { } else {
do { do {
@ -69,25 +58,24 @@ struct PushInstanceSettingsView: View {
} catch { } catch {
PushManager.logger.error("Error removing instance subscription: \(String(describing: error))") PushManager.logger.error("Error removing instance subscription: \(String(describing: error))")
self.error = .disabling(error) self.error = .disabling(error)
return false
} }
} }
return true
} }
private func enableNotifications() async throws -> Bool { private func enableNotifications() async throws {
guard account.scopes?.contains(Scope.push.rawValue) == true else {
showReLoginRequiredAlert = true
return false
}
let subscription = try await PushManager.shared.createSubscription(account: account) let subscription = try await PushManager.shared.createSubscription(account: account)
let req = Pachyderm.PushSubscription.create(
endpoint: pushProxyRegistration.endpoint,
publicKey: subscription.secretKey.publicKey.rawRepresentation,
authSecret: subscription.authSecret,
alerts: .init(subscription.alerts),
policy: .init(subscription.policy)
)
let mastodonController = await MastodonController.getForAccount(account) let mastodonController = await MastodonController.getForAccount(account)
do { do {
let result = try await mastodonController.createPushSubscription(subscription: subscription) let (result, _) = try await mastodonController.run(req)
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)") PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
self.subscription = subscription self.subscription = subscription
return true
} catch { } catch {
// if creation failed, remove the subscription locally as well // if creation failed, remove the subscription locally as well
await PushManager.shared.removeSubscription(account: account) await PushManager.shared.removeSubscription(account: account)
@ -96,25 +84,26 @@ struct PushInstanceSettingsView: View {
} }
private func disableNotifications() async throws { private func disableNotifications() async throws {
let req = Pachyderm.PushSubscription.delete()
let mastodonController = await MastodonController.getForAccount(account) let mastodonController = await MastodonController.getForAccount(account)
try await mastodonController.deletePushSubscription() _ = try await mastodonController.run(req)
await PushManager.shared.removeSubscription(account: account) await PushManager.shared.removeSubscription(account: account)
subscription = nil subscription = nil
PushManager.logger.debug("Push subscription removed on \(account.instanceURL)") PushManager.logger.debug("Push subscription removed on \(account.instanceURL)")
} }
private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool { private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async {
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy))
let mastodonController = await MastodonController.getForAccount(account) let mastodonController = await MastodonController.getForAccount(account)
do { do {
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy) let (result, _) = try await mastodonController.run(req)
PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)") PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
subscription?.alerts = alerts subscription?.alerts = alerts
subscription?.policy = policy subscription?.policy = policy
return true
} catch { } catch {
PushManager.logger.error("Error updating subscription: \(String(describing: error))") PushManager.logger.error("Error updating subscription: \(String(describing: error))")
self.error = .updating(error) self.error = .updating(error)
return false
} }
} }
} }
@ -136,6 +125,34 @@ private enum Error: LocalizedError {
} }
} }
private extension Pachyderm.PushSubscription.Alerts {
init(_ alerts: PushNotifications.PushSubscription.Alerts) {
self.init(
mention: alerts.contains(.mention),
status: alerts.contains(.status),
reblog: alerts.contains(.reblog),
follow: alerts.contains(.follow),
followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll),
update: alerts.contains(.update)
)
}
}
private extension Pachyderm.PushSubscription.Policy {
init(_ policy: PushNotifications.PushSubscription.Policy) {
switch policy {
case .all:
self = .all
case .followers:
self = .followers
case .followed:
self = .followed
}
}
}
//#Preview { //#Preview {
// PushInstanceSettingsView() // PushInstanceSettingsView()
//} //}

View File

@ -9,12 +9,11 @@
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import PushNotifications import PushNotifications
import TuskerComponents
struct PushSubscriptionView: View { struct PushSubscriptionView: View {
let account: UserAccountInfo let account: UserAccountInfo
let subscription: PushSubscription? let subscription: PushSubscription?
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Void
var body: some View { var body: some View {
if let subscription { if let subscription {
@ -29,10 +28,10 @@ struct PushSubscriptionView: View {
private struct PushSubscriptionSettingsView: View { private struct PushSubscriptionSettingsView: View {
let account: UserAccountInfo let account: UserAccountInfo
let subscription: PushSubscription let subscription: PushSubscription
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Void
@State private var isLoading: [PushSubscription.Alerts: Bool] = [:] @State private var isLoading: [PushSubscription.Alerts: Bool] = [:]
init(account: UserAccountInfo, subscription: PushSubscription, updateSubscription: @escaping (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool) { init(account: UserAccountInfo, subscription: PushSubscription, updateSubscription: @escaping (PushSubscription.Alerts, PushSubscription.Policy) async -> Void) {
self.account = account self.account = account
self.subscription = subscription self.subscription = subscription
self.updateSubscription = updateSubscription self.updateSubscription = updateSubscription
@ -40,14 +39,9 @@ private struct PushSubscriptionSettingsView: View {
var body: some View { var body: some View {
VStack(alignment: .prefsAvatar) { VStack(alignment: .prefsAvatar) {
toggle("All", alert: [.mention, .favorite, .reblog, .follow, .followRequest, .poll, .update]) TriStateToggle("Mentions", mode: alertsBinding(for: .mention)) {
toggle("Mentions", alert: .mention) await onChange(alert: .mention, value: $0)
toggle("Favorites", alert: .favorite) }
toggle("Reblogs", alert: .reblog)
toggle("Follows", alert: [.follow, .followRequest])
toggle("Polls", alert: .poll)
toggle("Edits", alert: .update)
// status notifications not supported until we can enable/disable them in the app
} }
// this is the default value of the alignment guide, but this modifier is loading bearing // this is the default value of the alignment guide, but this modifier is loading bearing
.alignmentGuide(.prefsAvatar, computeValue: { dimension in .alignmentGuide(.prefsAvatar, computeValue: { dimension in
@ -57,25 +51,22 @@ private struct PushSubscriptionSettingsView: View {
.padding(.leading, 38) .padding(.leading, 38)
} }
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View { private func alertsBinding(for alert: PushSubscription.Alerts) -> Binding<TriStateToggle.Mode> {
let binding: Binding<AsyncToggle.Mode> = Binding { return Binding {
isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off
} set: { newValue in } set: { newValue in
isLoading[alert] = newValue == .loading isLoading[alert] = newValue == .loading
} }
return AsyncToggle(titleKey, mode: binding) {
return await updateSubscription(alert: alert, isOn: $0)
}
} }
private func updateSubscription(alert: PushSubscription.Alerts, isOn: Bool) async -> Bool { private func onChange(alert: PushSubscription.Alerts, value: Bool) async {
var newAlerts = subscription.alerts var newAlerts = subscription.alerts
if isOn { if value {
newAlerts.insert(alert) newAlerts.insert(alert)
} else { } else {
newAlerts.remove(alert) newAlerts.remove(alert)
} }
return await updateSubscription(newAlerts, subscription.policy) await updateSubscription(newAlerts, subscription.policy)
} }
} }

View File

@ -1,6 +1,6 @@
// //
// AsyncToggle.swift // TriStateToggle.swift
// TuskerComponents // Tusker
// //
// Created by Shadowfacts on 4/7/24. // Created by Shadowfacts on 4/7/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
@ -8,21 +8,26 @@
import SwiftUI import SwiftUI
public struct AsyncToggle: View { struct TriStateToggle: View {
let titleKey: LocalizedStringKey let titleKey: LocalizedStringKey
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent") @available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool let labelHidden: Bool
@Binding var mode: Mode @Binding var mode: Mode
let onChange: (Bool) async -> Bool let onChange: (Bool) async -> Void
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) { init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Void) {
self.titleKey = titleKey self.titleKey = titleKey
self.labelHidden = labelHidden self.labelHidden = labelHidden
self._mode = mode self._mode = mode
self.onChange = onChange self.onChange = onChange
} }
public var body: some View { var body: some View {
content
}
@ViewBuilder
private var content: some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
LabeledContent(titleKey) { LabeledContent(titleKey) {
toggleOrSpinner toggleOrSpinner
@ -46,12 +51,8 @@ public struct AsyncToggle: View {
} set: { newValue in } set: { newValue in
mode = .loading mode = .loading
Task { Task {
let operationCompleted = await onChange(newValue) await onChange(newValue)
if operationCompleted { mode = newValue ? .on : .off
mode = newValue ? .on : .off
} else {
mode = newValue ? .off : .on
}
} }
}) })
.labelsHidden() .labelsHidden()
@ -63,7 +64,7 @@ public struct AsyncToggle: View {
} }
} }
public enum Mode { enum Mode {
case off case off
case loading case loading
case on case on
@ -71,9 +72,8 @@ public struct AsyncToggle: View {
} }
#Preview { #Preview {
@State var mode = AsyncToggle.Mode.on @State var mode = TriStateToggle.Mode.on
return AsyncToggle("", mode: $mode) { _ in return TriStateToggle("", mode: $mode) { _ in
try! await Task.sleep(nanoseconds: NSEC_PER_SEC) try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
return true
} }
} }

View File

@ -9,19 +9,12 @@
import UIKit import UIKit
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import SafariServices
import AuthenticationServices
import Pachyderm
class PreferencesNavigationController: UINavigationController { class PreferencesNavigationController: UINavigationController {
private let mastodonController: MastodonController
private var isSwitchingAccounts = false private var isSwitchingAccounts = false
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let view = PreferencesView(mastodonController: mastodonController) let view = PreferencesView(mastodonController: mastodonController)
let hostingController = UIHostingController(rootView: view) let hostingController = UIHostingController(rootView: view)
super.init(rootViewController: hostingController) super.init(rootViewController: hostingController)
@ -38,8 +31,6 @@ class PreferencesNavigationController: UINavigationController {
NotificationCenter.default.addObserver(self, selector: #selector(showAddAccount), name: .addAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(showAddAccount), name: .addAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(activateAccount(_:)), name: .activateAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(activateAccount(_:)), name: .activateAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userLoggedOut), name: .userLoggedOut, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userLoggedOut), name: .userLoggedOut, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(showMastodonSettings), name: .showMastodonSettings, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reLogInToAccount), name: .reLogInRequired, object: nil)
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
@ -66,7 +57,7 @@ class PreferencesNavigationController: UINavigationController {
dismiss(animated: true) // dismisses instance selector dismiss(animated: true) // dismisses instance selector
} }
@objc func activateAccount(_ notification: Foundation.Notification) { @objc func activateAccount(_ notification: Notification) {
// TODO: this is a temporary measure // TODO: this is a temporary measure
// when switching accounts shortly after adding a new one, there is an old instance of PreferncesNavigationController still around // when switching accounts shortly after adding a new one, there is an old instance of PreferncesNavigationController still around
// which tries to handle the notification but is unable to because it no longer is in a window (and therefore doesn't have a scene delegate) // which tries to handle the notification but is unable to because it no longer is in a window (and therefore doesn't have a scene delegate)
@ -102,34 +93,6 @@ class PreferencesNavigationController: UINavigationController {
} }
} }
@objc private func showMastodonSettings() {
var components = URLComponents(url: mastodonController.accountInfo!.instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/auth/edit"
let vc = SFSafariViewController(url: components.url!)
present(vc, animated: true)
}
@objc private func reLogInToAccount(_ notification: Foundation.Notification) {
guard let account = notification.object as? UserAccountInfo else {
return
}
Task {
do {
let service = GetAuthorizationTokenService(instanceURL: account.instanceURL, clientID: account.clientID, presentationContextProvider: self)
let code = try await service.run()
let mastodonController = MastodonController.getForAccount(account)
let token = try await mastodonController.authorize(authorizationCode: code)
UserAccountsManager.shared.updateAccessToken(account, token: token, scopes: MastodonController.oauthScopes.map(\.rawValue))
// try to revoke the old token
try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken, clientID: account.clientID, clientSecret: account.clientSecret).revokeAccessToken()
} catch {
let alert = UIAlertController(title: "Error Updating Permissions", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
} }
extension PreferencesNavigationController: OnboardingViewControllerDelegate { extension PreferencesNavigationController: OnboardingViewControllerDelegate {
@ -148,14 +111,3 @@ extension PreferencesNavigationController: OnboardingViewControllerDelegate {
} }
} }
} }
extension PreferencesNavigationController: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
view.window!
}
}
extension Foundation.Notification.Name {
static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings")
static let reLogInRequired = Notification.Name("Tusker.reLogInRequired")
}

View File

@ -68,22 +68,15 @@ struct PreferencesView: View {
Button(action: { Button(action: {
NotificationCenter.default.post(name: .addAccount, object: nil) NotificationCenter.default.post(name: .addAccount, object: nil)
}) { }) {
Text("Add Account") Text("Add Account...")
} }
Button(action: { Button(action: {
self.showingLogoutConfirmation = true self.showingLogoutConfirmation = true
}) { }) {
Text("Logout from Current…") Text("Logout from current")
}.alert(isPresented: $showingLogoutConfirmation) { }.alert(isPresented: $showingLogoutConfirmation) {
Alert(title: Text("Are you sure you want to logout?"), message: nil, primaryButton: .destructive(Text("Logout"), action: self.logoutPressed), secondaryButton: .cancel()) Alert(title: Text("Are you sure you want to logout?"), message: nil, primaryButton: .destructive(Text("Logout"), action: self.logoutPressed), secondaryButton: .cancel())
} }
Button {
NotificationCenter.default.post(name: .showMastodonSettings, object: nil)
} label: {
Text("Account Settings")
}
} header: { } header: {
Text("Accounts") Text("Accounts")
} }

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.1 MARKETING_VERSION = 2024.1
CURRENT_PROJECT_VERSION = 119 CURRENT_PROJECT_VERSION = 118
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev