Compare commits
13 Commits
bdd4a4d755
...
ff11835333
Author | SHA1 | Date |
---|---|---|
Shadowfacts | ff11835333 | |
Shadowfacts | 9353bbb56c | |
Shadowfacts | edc887dd4c | |
Shadowfacts | 68dad77f81 | |
Shadowfacts | 840b83012a | |
Shadowfacts | e150856e91 | |
Shadowfacts | 42a3f6c880 | |
Shadowfacts | 7a47b09b39 | |
Shadowfacts | 241e6f7e3a | |
Shadowfacts | f02afaac26 | |
Shadowfacts | ec76754270 | |
Shadowfacts | d0bb197e8c | |
Shadowfacts | efd90bca3e |
|
@ -1,5 +1,9 @@
|
||||||
# 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
|
||||||
|
|
|
@ -13,7 +13,7 @@ class DisabledPushManager: _PushManager {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
var pushProxyRegistration: PushProxyRegistration? {
|
var proxyRegistration: PushProxyRegistration? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ class DisabledPushManager: _PushManager {
|
||||||
throw Disabled()
|
throw Disabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateIfNecessary() async {
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||||
|
|
|
@ -7,9 +7,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
#if canImport(Sentry)
|
|
||||||
import Sentry
|
|
||||||
#endif
|
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
|
||||||
|
@ -19,6 +16,9 @@ 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 pushProxyRegistration: PushProxyRegistration? { get }
|
var proxyRegistration: 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() async
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
|
||||||
|
|
||||||
func didRegisterForRemoteNotifications(deviceToken: Data)
|
func didRegisterForRemoteNotifications(deviceToken: Data)
|
||||||
func didFailToRegisterForRemoteNotifications(error: any Error)
|
func didFailToRegisterForRemoteNotifications(error: any Error)
|
||||||
|
|
|
@ -7,9 +7,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
#if canImport(Sentry)
|
|
||||||
import Sentry
|
|
||||||
#endif
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
class PushManagerImpl: _PushManager {
|
class PushManagerImpl: _PushManager {
|
||||||
|
@ -30,7 +27,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 pushProxyRegistration: PushProxyRegistration? {
|
private(set) var proxyRegistration: 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) {
|
||||||
|
@ -43,7 +40,7 @@ class PushManagerImpl: _PushManager {
|
||||||
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
|
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private(set) var pushSubscriptions: [PushSubscription] {
|
private(set) var subscriptions: [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:))
|
||||||
|
@ -61,7 +58,7 @@ class PushManagerImpl: _PushManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
|
func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
|
||||||
guard let pushProxyRegistration else {
|
guard let proxyRegistration else {
|
||||||
throw CreateSubscriptionError.notRegisteredWithProxy
|
throw CreateSubscriptionError.notRegisteredWithProxy
|
||||||
}
|
}
|
||||||
if let existing = pushSubscription(account: account) {
|
if let existing = pushSubscription(account: account) {
|
||||||
|
@ -77,22 +74,29 @@ class PushManagerImpl: _PushManager {
|
||||||
}
|
}
|
||||||
let subscription = PushSubscription(
|
let subscription = PushSubscription(
|
||||||
accountID: account.id,
|
accountID: account.id,
|
||||||
endpoint: pushProxyRegistration.endpoint,
|
endpoint: endpointURL(registration: proxyRegistration, accountID: account.id),
|
||||||
secretKey: key,
|
secretKey: key,
|
||||||
authSecret: authSecret,
|
authSecret: authSecret,
|
||||||
alerts: [],
|
alerts: [],
|
||||||
policy: .all
|
policy: .all
|
||||||
)
|
)
|
||||||
pushSubscriptions.append(subscription)
|
subscriptions.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) {
|
||||||
pushSubscriptions.removeAll { $0.accountID == account.id }
|
subscriptions.removeAll { $0.accountID == account.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
pushSubscriptions.first { $0.accountID == account.id }
|
subscriptions.first { $0.accountID == account.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func register(transactionID: UInt64) async throws -> PushProxyRegistration {
|
func register(transactionID: UInt64) async throws -> PushProxyRegistration {
|
||||||
|
@ -109,22 +113,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)
|
||||||
}
|
}
|
||||||
pushProxyRegistration = registration
|
proxyRegistration = registration
|
||||||
return registration
|
return registration
|
||||||
}
|
}
|
||||||
|
|
||||||
func unregister() async throws {
|
func unregister() async throws {
|
||||||
guard let pushProxyRegistration else {
|
guard let proxyRegistration else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||||
url.path = "/app/v1/registrations/\(pushProxyRegistration.id)"
|
url.path = "/app/v1/registrations/\(proxyRegistration.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.pushProxyRegistration = nil
|
self.proxyRegistration = 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)")
|
||||||
|
@ -133,26 +137,35 @@ class PushManagerImpl: _PushManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateIfNecessary() async {
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
guard let pushProxyRegistration else {
|
guard let proxyRegistration else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
PushManager.logger.debug("Push proxy registration: \(pushProxyRegistration.id, privacy: .public)")
|
PushManager.logger.debug("Push proxy registration: \(proxyRegistration.id, privacy: .public)")
|
||||||
do {
|
do {
|
||||||
let token = try await getDeviceToken().hexEncodedString()
|
let token = try await getDeviceToken().hexEncodedString()
|
||||||
guard token != pushProxyRegistration.deviceToken else {
|
guard token != proxyRegistration.deviceToken else {
|
||||||
// already up-to-date, nothing to do
|
// already up-to-date, nothing to do
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token)
|
let newRegistration = try await update(registration: proxyRegistration, deviceToken: token)
|
||||||
if pushProxyRegistration.endpoint != newRegistration.endpoint {
|
self.proxyRegistration = newRegistration
|
||||||
// TODO: update subscriptions if the endpoint's changed
|
if proxyRegistration.endpoint != newRegistration.endpoint {
|
||||||
|
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)")
|
||||||
#if canImport(Sentry)
|
PushManager.captureError?(error)
|
||||||
SentrySDK.capture(error: error)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,3 +314,25 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
public struct PushSubscription {
|
public struct PushSubscription {
|
||||||
let accountID: String
|
public let accountID: String
|
||||||
let endpoint: URL
|
public internal(set) var 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
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// TriStateToggle.swift
|
// AsyncToggle.swift
|
||||||
// Tusker
|
// TuskerComponents
|
||||||
//
|
//
|
||||||
// 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,26 +8,21 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TriStateToggle: View {
|
public struct AsyncToggle: 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 -> Void
|
let onChange: (Bool) async -> Bool
|
||||||
|
|
||||||
init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Void) {
|
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
||||||
self.titleKey = titleKey
|
self.titleKey = titleKey
|
||||||
self.labelHidden = labelHidden
|
self.labelHidden = labelHidden
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
public 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
|
||||||
|
@ -51,8 +46,12 @@ struct TriStateToggle: View {
|
||||||
} set: { newValue in
|
} set: { newValue in
|
||||||
mode = .loading
|
mode = .loading
|
||||||
Task {
|
Task {
|
||||||
await onChange(newValue)
|
let operationCompleted = await onChange(newValue)
|
||||||
mode = newValue ? .on : .off
|
if operationCompleted {
|
||||||
|
mode = newValue ? .on : .off
|
||||||
|
} else {
|
||||||
|
mode = newValue ? .off : .on
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
@ -64,7 +63,7 @@ struct TriStateToggle: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Mode {
|
public enum Mode {
|
||||||
case off
|
case off
|
||||||
case loading
|
case loading
|
||||||
case on
|
case on
|
||||||
|
@ -72,8 +71,9 @@ struct TriStateToggle: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@State var mode = TriStateToggle.Mode.on
|
@State var mode = AsyncToggle.Mode.on
|
||||||
return TriStateToggle("", mode: $mode) { _ in
|
return AsyncToggle("", mode: $mode) { _ in
|
||||||
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -13,8 +13,9 @@ 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 private(set) var username: String!
|
public let username: String!
|
||||||
public let accessToken: String
|
public internal(set) var 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?
|
||||||
|
@ -40,16 +41,19 @@ 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) {
|
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [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]) {
|
||||||
|
@ -67,6 +71,7 @@ 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
|
||||||
|
@ -83,6 +88,9 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -100,12 +108,4 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,9 @@ 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 {
|
||||||
|
@ -38,7 +40,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: String]] {
|
if let array = defaults.array(forKey: accountsKey) as? [[String: Any]] {
|
||||||
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
|
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
|
||||||
} else {
|
} else {
|
||||||
return []
|
return []
|
||||||
|
@ -101,12 +103,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) -> UserAccountInfo {
|
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String, scopes: [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)
|
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken, scopes: scopes)
|
||||||
accounts.append(info)
|
accounts.append(info)
|
||||||
self.accounts = accounts
|
self.accounts = accounts
|
||||||
return info
|
return info
|
||||||
|
@ -146,6 +148,16 @@ 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 {
|
||||||
|
|
|
@ -93,6 +93,8 @@
|
||||||
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 */; };
|
||||||
|
@ -133,7 +135,6 @@
|
||||||
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 */; };
|
||||||
|
@ -498,6 +499,8 @@
|
||||||
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>"; };
|
||||||
|
@ -536,7 +539,6 @@
|
||||||
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>"; };
|
||||||
|
@ -1203,7 +1205,6 @@
|
||||||
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>";
|
||||||
|
@ -1654,6 +1655,7 @@
|
||||||
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 */,
|
||||||
|
@ -1668,6 +1670,7 @@
|
||||||
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>";
|
||||||
|
@ -2064,6 +2067,7 @@
|
||||||
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 */,
|
||||||
|
@ -2153,7 +2157,6 @@
|
||||||
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 */,
|
||||||
|
@ -2232,6 +2235,7 @@
|
||||||
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 */,
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import PushNotifications
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class LogoutService {
|
class LogoutService {
|
||||||
|
@ -20,7 +22,12 @@ 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)
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 = [UserAccountInfo: MastodonController]()
|
static private(set) var all = [String: MastodonController]()
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
|
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
|
||||||
if let controller = all[account] {
|
if let controller = all[account.id] {
|
||||||
return controller
|
return controller
|
||||||
} else {
|
} else {
|
||||||
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
|
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
|
||||||
all[account] = controller
|
all[account.id] = controller
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func removeForAccount(_ account: UserAccountInfo) {
|
static func removeForAccount(_ account: UserAccountInfo) {
|
||||||
all.removeValue(forKey: account)
|
all.removeValue(forKey: account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
@ -84,9 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
BackgroundManager.shared.registerHandlers()
|
BackgroundManager.shared.registerHandlers()
|
||||||
|
|
||||||
Task {
|
initializePushManager()
|
||||||
await PushManager.shared.updateIfNecessary()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -182,6 +180,26 @@ 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:"))
|
||||||
|
|
|
@ -158,7 +158,14 @@ class OnboardingViewController: UINavigationController {
|
||||||
throw Error.gettingOwnAccount(error)
|
throw Error.gettingOwnAccount(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
|
let accountInfo = UserAccountsManager.shared.addAccount(
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,38 +184,19 @@ 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 {
|
||||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
do {
|
||||||
components.path = "/oauth/authorize"
|
let service = GetAuthorizationTokenService(instanceURL: instanceURL, clientID: clientID, presentationContextProvider: self)
|
||||||
components.queryItems = [
|
return try await service.run()
|
||||||
URLQueryItem(name: "client_id", value: clientID),
|
} catch let error as GetAuthorizationTokenService.Error {
|
||||||
URLQueryItem(name: "response_type", value: "code"),
|
switch error {
|
||||||
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
|
case .cancelled:
|
||||||
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
|
throw Error.cancelled
|
||||||
]
|
case .noAuthorizationCode:
|
||||||
let authorizeURL = components.url!
|
throw Error.noAuthorizationCode
|
||||||
|
}
|
||||||
return try await withCheckedThrowingContinuation({ continuation in
|
} catch {
|
||||||
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
|
throw Error.authenticationSessionError(error)
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,11 @@ 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 = TriStateToggle.Mode.off
|
@State private var isSetup = AsyncToggle.Mode.off
|
||||||
@State private var pushProxyRegistration: PushProxyRegistration?
|
@State private var pushProxyRegistration: PushProxyRegistration?
|
||||||
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
||||||
|
|
||||||
|
@ -32,7 +33,7 @@ struct NotificationsPrefsView: View {
|
||||||
|
|
||||||
private var enableSection: some View {
|
private var enableSection: some View {
|
||||||
Section {
|
Section {
|
||||||
TriStateToggle("Push Notifications", mode: $isSetup, onChange: isSetupChanged(newValue:))
|
AsyncToggle("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
|
||||||
|
@ -41,7 +42,7 @@ struct NotificationsPrefsView: View {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
}
|
}
|
||||||
.task { @MainActor in
|
.task { @MainActor in
|
||||||
pushProxyRegistration = PushManager.shared.pushProxyRegistration
|
pushProxyRegistration = PushManager.shared.proxyRegistration
|
||||||
isSetup = pushProxyRegistration != nil ? .on : .off
|
isSetup = pushProxyRegistration != nil ? .on : .off
|
||||||
if !UIApplication.shared.isRegisteredForRemoteNotifications {
|
if !UIApplication.shared.isRegisteredForRemoteNotifications {
|
||||||
_ = await registerForRemoteNotifications()
|
_ = await registerForRemoteNotifications()
|
||||||
|
@ -58,17 +59,11 @@ struct NotificationsPrefsView: View {
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSetupChanged(newValue: Bool) async {
|
private func isSetupChanged(newValue: Bool) async -> Bool {
|
||||||
if newValue {
|
if newValue {
|
||||||
let success = await startRegistration()
|
return await startRegistration()
|
||||||
if !success {
|
|
||||||
isSetup = .off
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let success = await unregister()
|
return await unregister()
|
||||||
if !success {
|
|
||||||
isSetup = .on
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,13 +10,15 @@ 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: TriStateToggle.Mode
|
@State private var mode: AsyncToggle.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) {
|
||||||
|
@ -32,7 +34,7 @@ struct PushInstanceSettingsView: View {
|
||||||
HStack {
|
HStack {
|
||||||
PrefsAccountView(account: account)
|
PrefsAccountView(account: account)
|
||||||
Spacer()
|
Spacer()
|
||||||
TriStateToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
|
AsyncToggle("\(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)
|
||||||
|
@ -42,15 +44,24 @@ 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 {
|
private func updateNotificationsEnabled(enabled: Bool) async -> Bool {
|
||||||
if enabled {
|
if enabled {
|
||||||
do {
|
do {
|
||||||
try await enableNotifications()
|
return 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 {
|
||||||
|
@ -58,24 +69,25 @@ 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 {
|
private func enableNotifications() async throws -> Bool {
|
||||||
|
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.run(req)
|
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
||||||
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)
|
||||||
|
@ -84,26 +96,25 @@ 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.run(req)
|
try await mastodonController.deletePushSubscription()
|
||||||
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 {
|
private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool {
|
||||||
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.run(req)
|
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -125,34 +136,6 @@ 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()
|
||||||
//}
|
//}
|
||||||
|
|
|
@ -9,11 +9,12 @@
|
||||||
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 -> Void
|
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let subscription {
|
if let subscription {
|
||||||
|
@ -28,10 +29,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 -> Void
|
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
|
||||||
@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 -> Void) {
|
init(account: UserAccountInfo, subscription: PushSubscription, updateSubscription: @escaping (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool) {
|
||||||
self.account = account
|
self.account = account
|
||||||
self.subscription = subscription
|
self.subscription = subscription
|
||||||
self.updateSubscription = updateSubscription
|
self.updateSubscription = updateSubscription
|
||||||
|
@ -39,9 +40,14 @@ private struct PushSubscriptionSettingsView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .prefsAvatar) {
|
VStack(alignment: .prefsAvatar) {
|
||||||
TriStateToggle("Mentions", mode: alertsBinding(for: .mention)) {
|
toggle("All", alert: [.mention, .favorite, .reblog, .follow, .followRequest, .poll, .update])
|
||||||
await onChange(alert: .mention, value: $0)
|
toggle("Mentions", alert: .mention)
|
||||||
}
|
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
|
||||||
|
@ -51,22 +57,25 @@ private struct PushSubscriptionSettingsView: View {
|
||||||
.padding(.leading, 38)
|
.padding(.leading, 38)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alertsBinding(for alert: PushSubscription.Alerts) -> Binding<TriStateToggle.Mode> {
|
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {
|
||||||
return Binding {
|
let binding: Binding<AsyncToggle.Mode> = 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 onChange(alert: PushSubscription.Alerts, value: Bool) async {
|
private func updateSubscription(alert: PushSubscription.Alerts, isOn: Bool) async -> Bool {
|
||||||
var newAlerts = subscription.alerts
|
var newAlerts = subscription.alerts
|
||||||
if value {
|
if isOn {
|
||||||
newAlerts.insert(alert)
|
newAlerts.insert(alert)
|
||||||
} else {
|
} else {
|
||||||
newAlerts.remove(alert)
|
newAlerts.remove(alert)
|
||||||
}
|
}
|
||||||
await updateSubscription(newAlerts, subscription.policy)
|
return await updateSubscription(newAlerts, subscription.policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,12 +9,19 @@
|
||||||
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)
|
||||||
|
@ -31,6 +38,8 @@ 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) {
|
||||||
|
@ -57,7 +66,7 @@ class PreferencesNavigationController: UINavigationController {
|
||||||
dismiss(animated: true) // dismisses instance selector
|
dismiss(animated: true) // dismisses instance selector
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func activateAccount(_ notification: Notification) {
|
@objc func activateAccount(_ notification: Foundation.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)
|
||||||
|
@ -93,6 +102,34 @@ 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 {
|
||||||
|
@ -111,3 +148,14 @@ 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")
|
||||||
|
}
|
||||||
|
|
|
@ -68,15 +68,22 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = 118
|
CURRENT_PROJECT_VERSION = 119
|
||||||
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
|
||||||
|
|
Loading…
Reference in New Issue