Compare commits
32 Commits
ec76754270
...
7825ccbb3d
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 7825ccbb3d | |
Shadowfacts | f87da10a29 | |
Shadowfacts | 1eec70449d | |
Shadowfacts | 19ca930ee8 | |
Shadowfacts | 2e31d34e9d | |
Shadowfacts | 8a339ec171 | |
Shadowfacts | c7d79422bd | |
Shadowfacts | baf96a8b06 | |
Shadowfacts | bc516a6326 | |
Shadowfacts | 1cd6af1236 | |
Shadowfacts | 9f6910ba73 | |
Shadowfacts | 9cf4975bfd | |
Shadowfacts | ee992bc0bf | |
Shadowfacts | ff8a83ca2d | |
Shadowfacts | 4c957b86ae | |
Shadowfacts | ff11835333 | |
Shadowfacts | 9353bbb56c | |
Shadowfacts | edc887dd4c | |
Shadowfacts | 68dad77f81 | |
Shadowfacts | 840b83012a | |
Shadowfacts | e150856e91 | |
Shadowfacts | 42a3f6c880 | |
Shadowfacts | 7a47b09b39 | |
Shadowfacts | 241e6f7e3a | |
Shadowfacts | f02afaac26 | |
Shadowfacts | bdd4a4d755 | |
Shadowfacts | 94c1eb2c81 | |
Shadowfacts | b03991ae1d | |
Shadowfacts | f98589b419 | |
Shadowfacts | 9fad2a882a | |
Shadowfacts | 3efa017942 | |
Shadowfacts | c5226f6374 |
|
@ -1,5 +1,8 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2024.2 (120)
|
||||||
|
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||||
|
|
||||||
## 2024.1 (119)
|
## 2024.1 (119)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Add Account Settings button to Preferences
|
- Add Account Settings button to Preferences
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>TuskerInfo</key>
|
||||||
|
<dict>
|
||||||
|
<key>PushProxyHost</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||||
|
<key>PushProxyScheme</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
|
||||||
|
<key>SentryDSN</key>
|
||||||
|
<string>$(SENTRY_DSN)</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.usernotifications.service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,354 @@
|
||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// NotificationExtension
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import UserAccounts
|
||||||
|
import PushNotifications
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
import Pachyderm
|
||||||
|
import Intents
|
||||||
|
import HTMLStreamer
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
||||||
|
|
||||||
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
|
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
|
||||||
|
|
||||||
|
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
|
||||||
|
|
||||||
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
|
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||||
|
logger.error("Couldn't get mutable content")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard request.content.userInfo["v"] as? Int == 1,
|
||||||
|
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
|
||||||
|
let account = UserAccountsManager.shared.getAccount(id: accountID),
|
||||||
|
let subscription = getSubscription(account: account),
|
||||||
|
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
|
||||||
|
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
|
||||||
|
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
|
||||||
|
logger.error("Missing info from push notification")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let withoutPadding = body.dropFirst(2)
|
||||||
|
|
||||||
|
let notification: PushNotification
|
||||||
|
do {
|
||||||
|
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to decode push payload: \(String(describing: error))")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableContent.title = notification.title
|
||||||
|
mutableContent.body = notification.body
|
||||||
|
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||||
|
mutableContent.userInfo["accountID"] = accountID
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
|
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||||
|
if !Task.isCancelled {
|
||||||
|
contentHandler(pendingRequest?.0 ?? mutableContent)
|
||||||
|
pendingRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingRequest = (mutableContent, contentHandler, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func serviceExtensionTimeWillExpire() {
|
||||||
|
if let pendingRequest {
|
||||||
|
logger.debug("Expiring with pending request")
|
||||||
|
pendingRequest.2.cancel()
|
||||||
|
pendingRequest.1(pendingRequest.0)
|
||||||
|
self.pendingRequest = nil
|
||||||
|
} else {
|
||||||
|
logger.debug("Expiring without pending request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
|
||||||
|
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
|
||||||
|
let notification: Pachyderm.Notification
|
||||||
|
do {
|
||||||
|
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
|
||||||
|
} catch {
|
||||||
|
logger.error("Error fetching notification: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let kindStr: String?
|
||||||
|
switch notification.kind {
|
||||||
|
case .reblog:
|
||||||
|
kindStr = "🔁 Reblogged"
|
||||||
|
case .favourite:
|
||||||
|
kindStr = "⭐️ Favorited"
|
||||||
|
case .follow:
|
||||||
|
kindStr = "👤 Followed by @\(notification.account.acct)"
|
||||||
|
case .followRequest:
|
||||||
|
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
|
||||||
|
case .poll:
|
||||||
|
kindStr = "📊 Poll finished"
|
||||||
|
case .update:
|
||||||
|
kindStr = "✏️ Edited"
|
||||||
|
default:
|
||||||
|
kindStr = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationContent: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||||
|
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||||
|
notificationContent = nil
|
||||||
|
} else {
|
||||||
|
notificationContent = push.body
|
||||||
|
}
|
||||||
|
|
||||||
|
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
|
||||||
|
|
||||||
|
let attachmentDataTask: Task<URL?, Never>?
|
||||||
|
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||||
|
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||||
|
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||||
|
let attachment = notification.status?.attachments.first {
|
||||||
|
let url = attachment.previewURL ?? attachment.url
|
||||||
|
attachmentDataTask = Task {
|
||||||
|
do {
|
||||||
|
let data = try await URLSession.shared.data(from: url).0
|
||||||
|
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
|
||||||
|
try data.write(to: localAttachmentURL)
|
||||||
|
return localAttachmentURL
|
||||||
|
} catch {
|
||||||
|
logger.error("Error setting notification attachments: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentDataTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationIdentifier: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
if let context = status.pleromaExtras?.context {
|
||||||
|
conversationIdentifier = "context:\(context)"
|
||||||
|
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
|
||||||
|
conversationIdentifier = "status:\(status.id)"
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let account: Account?
|
||||||
|
switch notification.kind {
|
||||||
|
case .mention, .status:
|
||||||
|
account = notification.status?.account
|
||||||
|
default:
|
||||||
|
account = notification.account
|
||||||
|
}
|
||||||
|
let sender: INPerson?
|
||||||
|
if let account {
|
||||||
|
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
|
||||||
|
let image: INImage?
|
||||||
|
if let avatar = account.avatar,
|
||||||
|
let (data, resp) = try? await URLSession.shared.data(from: avatar),
|
||||||
|
let code = (resp as? HTTPURLResponse)?.statusCode,
|
||||||
|
(200...299).contains(code) {
|
||||||
|
image = INImage(imageData: data)
|
||||||
|
} else {
|
||||||
|
image = nil
|
||||||
|
}
|
||||||
|
sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: account.displayName,
|
||||||
|
image: image,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: account.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sender = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: notificationContent,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: conversationIdentifier,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await interaction.donate()
|
||||||
|
} catch {
|
||||||
|
logger.error("Error donating interaction: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedContent: UNMutableNotificationContent
|
||||||
|
do {
|
||||||
|
let newContent = try content.updating(from: intent)
|
||||||
|
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
|
||||||
|
pendingRequest?.0 = newMutableContent
|
||||||
|
updatedContent = newMutableContent
|
||||||
|
} else {
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Error updating notification from intent: \(String(describing: error))")
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if let localAttachmentURL = await attachmentDataTask?.value,
|
||||||
|
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
|
||||||
|
updatedContent.attachments = [
|
||||||
|
attachment
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
|
||||||
|
MainActor.runUnsafely {
|
||||||
|
PushManager.shared.pushSubscription(account: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
|
||||||
|
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
|
||||||
|
|
||||||
|
var context = Data()
|
||||||
|
context.append(0)
|
||||||
|
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
|
||||||
|
let clientPublicKeyLength = UInt16(clientPublicKey.count)
|
||||||
|
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
|
||||||
|
context.append(UInt8(clientPublicKeyLength & 0xFF))
|
||||||
|
context.append(clientPublicKey)
|
||||||
|
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
|
||||||
|
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
|
||||||
|
context.append(UInt8(serverPublicKeyLength & 0xFF))
|
||||||
|
context.append(serverPublicKeyData)
|
||||||
|
|
||||||
|
func info(encoding: String) -> Data {
|
||||||
|
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
|
||||||
|
info.append(context)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedSecret: SharedSecret
|
||||||
|
do {
|
||||||
|
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
|
||||||
|
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
|
||||||
|
} catch {
|
||||||
|
logger.error("Error getting shared secret: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
|
||||||
|
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
|
||||||
|
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
|
||||||
|
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
|
||||||
|
let nonceInfo = info(encoding: "nonce")
|
||||||
|
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||||
|
|
||||||
|
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
|
||||||
|
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
|
||||||
|
data.append(encryptedBody)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
|
||||||
|
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
|
||||||
|
return decrypted
|
||||||
|
} catch {
|
||||||
|
logger.error("Error decrypting push: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MainActor {
|
||||||
|
@_unavailableFromAsync
|
||||||
|
@available(macOS, obsoleted: 14.0)
|
||||||
|
@available(iOS, obsoleted: 17.0)
|
||||||
|
@available(watchOS, obsoleted: 10.0)
|
||||||
|
@available(tvOS, obsoleted: 17.0)
|
||||||
|
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||||
|
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||||
|
return try MainActor.assumeIsolated(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
return try withoutActuallyEscaping(body) { fn in
|
||||||
|
try unsafeBitCast(fn, to: (() throws -> T).self)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeBase64URL(_ s: String) -> Data? {
|
||||||
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
if str.count % 4 != 0 {
|
||||||
|
str.append(String(repeating: "=", count: 4 - str.count % 4))
|
||||||
|
}
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copied from HTMLConverter.Callbacks, blergh
|
||||||
|
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||||
|
static func makeURL(string: String) -> URL? {
|
||||||
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
|
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||||
|
// so, if available, use the system parser which doesn't require another round trip.
|
||||||
|
if #available(iOS 16.0, macOS 13.0, *),
|
||||||
|
let url = try? URL.ParseStrategy().parse(string) {
|
||||||
|
url
|
||||||
|
} else if let web = WebURL(string),
|
||||||
|
let url = URL(web) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
URL(string: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
|
guard name == "span" else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
let clazz = attributes.attributeValue(for: "class")
|
||||||
|
if clazz == "invisible" {
|
||||||
|
return .skip
|
||||||
|
} else if clazz == "ellipsis" {
|
||||||
|
return .append("…")
|
||||||
|
} else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -341,6 +341,10 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
public static func getNotification(id: String) -> Request<Notification> {
|
||||||
|
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"types" => allowedTypes.map { $0.rawValue }
|
"types" => allowedTypes.map { $0.rawValue }
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// PushNotification.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public struct PushNotification: Decodable {
|
||||||
|
public let accessToken: String
|
||||||
|
public let preferredLocale: String
|
||||||
|
public let notificationID: String
|
||||||
|
public let notificationType: Notification.Kind
|
||||||
|
public let icon: WebURL
|
||||||
|
public let title: String
|
||||||
|
public let body: String
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.accessToken = try container.decode(String.self, forKey: .accessToken)
|
||||||
|
self.preferredLocale = try container.decode(String.self, forKey: .preferredLocale)
|
||||||
|
// this should be a string, but mastodon encodes it as a json number
|
||||||
|
if let s = try? container.decode(String.self, forKey: .notificationID) {
|
||||||
|
self.notificationID = s
|
||||||
|
} else {
|
||||||
|
let i = try container.decode(Int.self, forKey: .notificationID)
|
||||||
|
self.notificationID = i.description
|
||||||
|
}
|
||||||
|
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
|
||||||
|
self.icon = try container.decode(WebURL.self, forKey: .icon)
|
||||||
|
self.title = try container.decode(String.self, forKey: .title)
|
||||||
|
self.body = try container.decode(String.self, forKey: .body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case preferredLocale = "preferred_locale"
|
||||||
|
case notificationID = "notification_id"
|
||||||
|
case notificationType = "notification_type"
|
||||||
|
case icon
|
||||||
|
case title
|
||||||
|
case body
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,13 +12,108 @@ public struct PushSubscription: Decodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let endpoint: URL
|
public let endpoint: URL
|
||||||
public let serverKey: String
|
public let serverKey: String
|
||||||
// TODO: WTF is this?
|
public let alerts: Alerts
|
||||||
// public let alerts
|
public let policy: Policy
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
// id is documented as being a string, but mastodon returns a json number
|
||||||
|
if let s = try? container.decode(String.self, forKey: .id) {
|
||||||
|
self.id = s
|
||||||
|
} else {
|
||||||
|
let i = try container.decode(Int.self, forKey: .id)
|
||||||
|
self.id = i.description
|
||||||
|
}
|
||||||
|
self.endpoint = try container.decode(URL.self, forKey: .endpoint)
|
||||||
|
self.serverKey = try container.decode(String.self, forKey: .serverKey)
|
||||||
|
self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts)
|
||||||
|
self.policy = try container.decode(PushSubscription.Policy.self, forKey: .policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
||||||
|
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
|
||||||
|
"subscription[endpoint]" => endpoint.absoluteString,
|
||||||
|
"subscription[keys][p256dh]" => publicKey.base64EncodedString(),
|
||||||
|
"subscription[keys][auth]" => authSecret.base64EncodedString(),
|
||||||
|
"data[alerts][mention]" => alerts.mention,
|
||||||
|
"data[alerts][status]" => alerts.status,
|
||||||
|
"data[alerts][reblog]" => alerts.reblog,
|
||||||
|
"data[alerts][follow]" => alerts.follow,
|
||||||
|
"data[alerts][follow_request]" => alerts.followRequest,
|
||||||
|
"data[alerts][favourite]" => alerts.favourite,
|
||||||
|
"data[alerts][poll]" => alerts.poll,
|
||||||
|
"data[alerts][update]" => alerts.update,
|
||||||
|
"data[policy]" => policy.rawValue,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func update(alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
||||||
|
return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([
|
||||||
|
"data[alerts][mention]" => alerts.mention,
|
||||||
|
"data[alerts][status]" => alerts.status,
|
||||||
|
"data[alerts][reblog]" => alerts.reblog,
|
||||||
|
"data[alerts][follow]" => alerts.follow,
|
||||||
|
"data[alerts][follow_request]" => alerts.followRequest,
|
||||||
|
"data[alerts][favourite]" => alerts.favourite,
|
||||||
|
"data[alerts][poll]" => alerts.poll,
|
||||||
|
"data[alerts][update]" => alerts.update,
|
||||||
|
"data[policy]" => policy.rawValue,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func delete() -> Request<Empty> {
|
||||||
|
return Request(method: .delete, path: "/api/v1/push/subscription")
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case endpoint
|
case endpoint
|
||||||
case serverKey = "server_key"
|
case serverKey = "server_key"
|
||||||
// case alerts
|
case alerts
|
||||||
|
case policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PushSubscription {
|
||||||
|
public struct Alerts: Decodable, Sendable {
|
||||||
|
public let mention: Bool
|
||||||
|
public let status: Bool
|
||||||
|
public let reblog: Bool
|
||||||
|
public let follow: Bool
|
||||||
|
public let followRequest: Bool
|
||||||
|
public let favourite: Bool
|
||||||
|
public let poll: Bool
|
||||||
|
public let update: Bool
|
||||||
|
|
||||||
|
public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
|
||||||
|
self.mention = mention
|
||||||
|
self.status = status
|
||||||
|
self.reblog = reblog
|
||||||
|
self.follow = follow
|
||||||
|
self.followRequest = followRequest
|
||||||
|
self.favourite = favourite
|
||||||
|
self.poll = poll
|
||||||
|
self.update = update
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case mention
|
||||||
|
case status
|
||||||
|
case reblog
|
||||||
|
case follow
|
||||||
|
case followRequest = "follow_request"
|
||||||
|
case favourite
|
||||||
|
case poll
|
||||||
|
case update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PushSubscription {
|
||||||
|
public enum Policy: String, Decodable, Sendable {
|
||||||
|
case all
|
||||||
|
case followed
|
||||||
|
case followers
|
||||||
|
case none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ public enum Scope: String, Sendable {
|
||||||
case read
|
case read
|
||||||
case write
|
case write
|
||||||
case follow
|
case follow
|
||||||
|
case push
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Scope {
|
extension Array where Element == Scope {
|
||||||
|
|
|
@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
public let editedAt: Date?
|
public let editedAt: Date?
|
||||||
|
|
||||||
|
public let pleromaExtras: PleromaExtras?
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
|
@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
|
|
||||||
|
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -212,7 +216,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
case editedAt = "edited_at"
|
case editedAt = "edited_at"
|
||||||
|
|
||||||
|
case pleromaExtras = "pleroma"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Status: Identifiable {}
|
extension Status: Identifiable {}
|
||||||
|
|
||||||
|
extension Status {
|
||||||
|
public struct PleromaExtras: Decodable, Sendable {
|
||||||
|
public let context: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1530"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "PushNotifications"
|
||||||
|
BuildableName = "PushNotifications"
|
||||||
|
BlueprintName = "PushNotifications"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "PushNotifications"
|
||||||
|
BuildableName = "PushNotifications"
|
||||||
|
BlueprintName = "PushNotifications"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
|
@ -0,0 +1,32 @@
|
||||||
|
// swift-tools-version: 5.10
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "PushNotifications",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "PushNotifications",
|
||||||
|
targets: ["PushNotifications"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../UserAccounts"),
|
||||||
|
.package(path: "../Pachyderm"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "PushNotifications",
|
||||||
|
dependencies: ["UserAccounts", "Pachyderm"]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "PushNotificationsTests",
|
||||||
|
dependencies: ["PushNotifications"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// DisabledPushManager.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
class DisabledPushManager: _PushManager {
|
||||||
|
var enabled: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptions: [PushSubscription] {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
|
||||||
|
throw Disabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscription(account: UserAccountInfo) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||||
|
}
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Disabled: LocalizedError {
|
||||||
|
var errorDescription: String? {
|
||||||
|
"Push notifications disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
//
|
||||||
|
// PushManager.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Pachyderm
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
public struct PushManager {
|
||||||
|
@MainActor
|
||||||
|
public static let shared = createPushManager()
|
||||||
|
|
||||||
|
public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public static var captureError: ((any Error) -> Void)?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func createPushManager() -> any _PushManager {
|
||||||
|
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
|
||||||
|
let host = info["PushProxyHost"] as? String,
|
||||||
|
!host.isEmpty else {
|
||||||
|
logger.debug("Missing proxy info, push disabled")
|
||||||
|
return DisabledPushManager()
|
||||||
|
}
|
||||||
|
var endpoint = URLComponents()
|
||||||
|
endpoint.scheme = "https"
|
||||||
|
endpoint.host = host
|
||||||
|
let url = endpoint.url!
|
||||||
|
logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)")
|
||||||
|
return PushManagerImpl(endpoint: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol _PushManager {
|
||||||
|
var enabled: Bool { get }
|
||||||
|
|
||||||
|
var subscriptions: [PushSubscription] { get }
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription
|
||||||
|
func removeSubscription(account: UserAccountInfo)
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy)
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data)
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error)
|
||||||
|
}
|
|
@ -0,0 +1,202 @@
|
||||||
|
//
|
||||||
|
// PushManagerImpl.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import UserAccounts
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
class PushManagerImpl: _PushManager {
|
||||||
|
private let endpoint: URL
|
||||||
|
|
||||||
|
var enabled: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apnsEnvironment: String {
|
||||||
|
#if DEBUG
|
||||||
|
"development"
|
||||||
|
#else
|
||||||
|
"production"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
|
||||||
|
|
||||||
|
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||||
|
public private(set) var subscriptions: [PushSubscription] {
|
||||||
|
get {
|
||||||
|
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
|
||||||
|
return array.compactMap(PushSubscription.init(defaultsDict:))
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(endpoint: URL) {
|
||||||
|
self.endpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
|
||||||
|
if let existing = pushSubscription(account: account) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let key = P256.KeyAgreement.PrivateKey()
|
||||||
|
var authSecret = Data(count: 16)
|
||||||
|
let res = authSecret.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
guard res == errSecSuccess else {
|
||||||
|
throw CreateSubscriptionError.generatingAuthSecret(res)
|
||||||
|
}
|
||||||
|
let token = try await getDeviceToken()
|
||||||
|
let subscription = PushSubscription(
|
||||||
|
accountID: account.id,
|
||||||
|
endpoint: endpointURL(deviceToken: token, accountID: account.id),
|
||||||
|
secretKey: key,
|
||||||
|
authSecret: authSecret,
|
||||||
|
alerts: [],
|
||||||
|
policy: .all
|
||||||
|
)
|
||||||
|
subscriptions.append(subscription)
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
|
||||||
|
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||||
|
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!
|
||||||
|
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
|
||||||
|
return endpoint.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscription(account: UserAccountInfo) {
|
||||||
|
subscriptions.removeAll { $0.accountID == account.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
|
||||||
|
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var copy = subscriptions[index]
|
||||||
|
copy.alerts = alerts
|
||||||
|
copy.policy = policy
|
||||||
|
subscriptions[index] = copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
|
subscriptions.first { $0.accountID == account.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
|
let subscriptions = self.subscriptions
|
||||||
|
guard !subscriptions.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let token = try await getDeviceToken()
|
||||||
|
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||||
|
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||||
|
guard newEndpoint != $0.endpoint else {
|
||||||
|
return $0
|
||||||
|
}
|
||||||
|
var copy = $0
|
||||||
|
copy.endpoint = newEndpoint
|
||||||
|
if await updateSubscription(copy) {
|
||||||
|
return copy
|
||||||
|
} else {
|
||||||
|
return $0
|
||||||
|
}
|
||||||
|
}.reduce(into: [], { partialResult, el in
|
||||||
|
partialResult.append(el)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
|
||||||
|
PushManager.captureError?(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDeviceToken() async throws -> Data {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
remoteNotificationsRegistrationContinuation = continuation
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||||
|
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
|
||||||
|
remoteNotificationsRegistrationContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
||||||
|
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
|
||||||
|
remoteNotificationsRegistrationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PushRegistrationError: LocalizedError {
|
||||||
|
case alreadyRegistering
|
||||||
|
case registeringForRemoteNotifications(any Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .alreadyRegistering:
|
||||||
|
"Already registering"
|
||||||
|
case .registeringForRemoteNotifications(let error):
|
||||||
|
"Remote notifications: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CreateSubscriptionError: LocalizedError {
|
||||||
|
case generatingAuthSecret(OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .generatingAuthSecret(let code):
|
||||||
|
"Generating auth secret: \(code)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
String(unsafeUninitializedCapacity: count * 2) { buffer in
|
||||||
|
let chars = Array("0123456789ABCDEF".utf8)
|
||||||
|
for (i, x) in enumerated() {
|
||||||
|
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
|
||||||
|
buffer[i * 2] = chars[Int(upper)]
|
||||||
|
buffer[i * 2 + 1] = chars[Int(lower)]
|
||||||
|
}
|
||||||
|
return count * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
//
|
||||||
|
// PushSubscription.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
public struct PushSubscription {
|
||||||
|
public let accountID: String
|
||||||
|
public internal(set) var endpoint: URL
|
||||||
|
public let secretKey: P256.KeyAgreement.PrivateKey
|
||||||
|
public let authSecret: Data
|
||||||
|
public var alerts: Alerts
|
||||||
|
public var policy: Policy
|
||||||
|
|
||||||
|
var defaultsDict: [String: Any] {
|
||||||
|
[
|
||||||
|
"accountID": accountID,
|
||||||
|
"endpoint": endpoint.absoluteString,
|
||||||
|
"secretKey": secretKey.rawRepresentation,
|
||||||
|
"authSecret": authSecret,
|
||||||
|
"alerts": alerts.rawValue,
|
||||||
|
"policy": policy.rawValue
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(defaultsDict: [String: Any]) {
|
||||||
|
guard let accountID = defaultsDict["accountID"] as? String,
|
||||||
|
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)),
|
||||||
|
let secretKey = (defaultsDict["secretKey"] as? Data).flatMap({ try? P256.KeyAgreement.PrivateKey(rawRepresentation: $0) }),
|
||||||
|
let authSecret = defaultsDict["authSecret"] as? Data,
|
||||||
|
let alerts = defaultsDict["alerts"] as? Int,
|
||||||
|
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.accountID = accountID
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.secretKey = secretKey
|
||||||
|
self.authSecret = authSecret
|
||||||
|
self.alerts = Alerts(rawValue: alerts)
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
init(accountID: String, endpoint: URL, secretKey: P256.KeyAgreement.PrivateKey, authSecret: Data, alerts: Alerts, policy: Policy) {
|
||||||
|
self.accountID = accountID
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.secretKey = secretKey
|
||||||
|
self.authSecret = authSecret
|
||||||
|
self.alerts = alerts
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Policy: String, CaseIterable, Identifiable {
|
||||||
|
case all, followed, followers
|
||||||
|
|
||||||
|
public var id: some Hashable {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Alerts: OptionSet, Hashable {
|
||||||
|
public static let mention = Alerts(rawValue: 1 << 0)
|
||||||
|
public static let status = Alerts(rawValue: 1 << 1)
|
||||||
|
public static let reblog = Alerts(rawValue: 1 << 2)
|
||||||
|
public static let follow = Alerts(rawValue: 1 << 3)
|
||||||
|
public static let followRequest = Alerts(rawValue: 1 << 4)
|
||||||
|
public static let favorite = Alerts(rawValue: 1 << 5)
|
||||||
|
public static let poll = Alerts(rawValue: 1 << 6)
|
||||||
|
public static let update = Alerts(rawValue: 1 << 7)
|
||||||
|
|
||||||
|
public let rawValue: Int
|
||||||
|
|
||||||
|
public init(rawValue: Int) {
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import PushNotifications
|
||||||
|
|
||||||
|
final class PushNotificationsTests: XCTestCase {
|
||||||
|
func testExample() throws {
|
||||||
|
// XCTest Documentation
|
||||||
|
// https://developer.apple.com/documentation/xctest
|
||||||
|
|
||||||
|
// Defining Test Cases and Test Methods
|
||||||
|
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
//
|
||||||
|
// AsyncPicker.swift
|
||||||
|
// TuskerComponents
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct AsyncPicker<V: Hashable, Content: View>: View {
|
||||||
|
let titleKey: LocalizedStringKey
|
||||||
|
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||||
|
let labelHidden: Bool
|
||||||
|
let alignment: Alignment
|
||||||
|
@Binding var value: V
|
||||||
|
let onChange: (V) async -> Bool
|
||||||
|
let content: Content
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
|
||||||
|
self.titleKey = titleKey
|
||||||
|
self.labelHidden = labelHidden
|
||||||
|
self.alignment = alignment
|
||||||
|
self._value = value
|
||||||
|
self.onChange = onChange
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
} else if labelHidden {
|
||||||
|
picker
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(titleKey)
|
||||||
|
Spacer()
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var picker: some View {
|
||||||
|
ZStack(alignment: alignment) {
|
||||||
|
Picker(titleKey, selection: Binding(get: {
|
||||||
|
value
|
||||||
|
}, set: { newValue in
|
||||||
|
let oldValue = value
|
||||||
|
value = newValue
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
let operationCompleted = await onChange(newValue)
|
||||||
|
if !operationCompleted {
|
||||||
|
value = oldValue
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.opacity(isLoading ? 0 : 1)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@State var value = 0
|
||||||
|
return AsyncPicker("", value: $value) { _ in
|
||||||
|
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
|
return true
|
||||||
|
} content: {
|
||||||
|
ForEach(0..<10) {
|
||||||
|
Text("\($0)").tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
//
|
||||||
|
// AsyncToggle.swift
|
||||||
|
// TuskerComponents
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct AsyncToggle: View {
|
||||||
|
let titleKey: LocalizedStringKey
|
||||||
|
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||||
|
let labelHidden: Bool
|
||||||
|
@Binding var mode: Mode
|
||||||
|
let onChange: (Bool) async -> Bool
|
||||||
|
|
||||||
|
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
||||||
|
self.titleKey = titleKey
|
||||||
|
self.labelHidden = labelHidden
|
||||||
|
self._mode = mode
|
||||||
|
self.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
toggleOrSpinner
|
||||||
|
}
|
||||||
|
} else if labelHidden {
|
||||||
|
toggleOrSpinner
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(titleKey)
|
||||||
|
Spacer()
|
||||||
|
toggleOrSpinner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var toggleOrSpinner: some View {
|
||||||
|
ZStack {
|
||||||
|
Toggle(titleKey, isOn: Binding {
|
||||||
|
mode == .on
|
||||||
|
} set: { newValue in
|
||||||
|
mode = .loading
|
||||||
|
Task {
|
||||||
|
let operationCompleted = await onChange(newValue)
|
||||||
|
if operationCompleted {
|
||||||
|
mode = newValue ? .on : .off
|
||||||
|
} else {
|
||||||
|
mode = newValue ? .off : .on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.labelsHidden()
|
||||||
|
.opacity(mode == .loading ? 0 : 1)
|
||||||
|
|
||||||
|
if mode == .loading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Mode {
|
||||||
|
case off
|
||||||
|
case loading
|
||||||
|
case on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@State var mode = AsyncToggle.Mode.on
|
||||||
|
return AsyncToggle("", mode: $mode) { _ in
|
||||||
|
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,10 +11,11 @@ import CryptoKit
|
||||||
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let instanceURL: URL
|
public let instanceURL: URL
|
||||||
public let clientID: String
|
public internal(set) var clientID: String
|
||||||
public let clientSecret: String
|
public internal(set) var 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,18 @@ public class UserAccountsManager: ObservableObject {
|
||||||
accounts[index] = account
|
accounts[index] = account
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateCredentials(_ account: UserAccountInfo, clientID: String, clientSecret: String, accessToken: String, scopes: [String]) {
|
||||||
|
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var account = account
|
||||||
|
account.clientID = clientID
|
||||||
|
account.clientSecret = clientSecret
|
||||||
|
account.accessToken = accessToken
|
||||||
|
account.scopes = scopes
|
||||||
|
accounts[index] = account
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Notification.Name {
|
public extension Notification.Name {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.space.vaccor.Tusker</string>
|
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
|
@ -3,3 +3,4 @@
|
||||||
DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||||
BUNDLE_ID_PREFIX = com.example
|
BUNDLE_ID_PREFIX = com.example
|
||||||
|
|
||||||
|
TUSKER_PUSH_PROXY_HOST =
|
||||||
|
|
|
@ -92,6 +92,16 @@
|
||||||
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9984279CA23900C26176 /* URLSession+Development.swift */; };
|
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9984279CA23900C26176 /* URLSession+Development.swift */; };
|
||||||
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 */; };
|
||||||
|
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
|
||||||
|
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
|
||||||
|
D630C3D42BC61B6100208903 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3D32BC61B6100208903 /* NotificationService.swift */; };
|
||||||
|
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
|
||||||
|
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
||||||
|
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
||||||
|
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; };
|
||||||
|
D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; };
|
||||||
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 */; };
|
||||||
|
@ -121,6 +131,9 @@
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
|
||||||
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
|
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
|
||||||
|
D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */; };
|
||||||
|
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
|
||||||
|
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
||||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
||||||
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
|
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
|
||||||
|
@ -129,6 +142,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 */; };
|
||||||
|
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 */; };
|
||||||
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
||||||
|
@ -160,6 +174,7 @@
|
||||||
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
|
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
|
||||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
|
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
|
||||||
|
D68245122BCA1F4000AFB38B /* NotificationLoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */; };
|
||||||
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */; };
|
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */; };
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
||||||
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
||||||
|
@ -352,6 +367,13 @@
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
D630C3D62BC61B6100208903 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = D630C3D02BC61B6000208903;
|
||||||
|
remoteInfo = NotificationExtension;
|
||||||
|
};
|
||||||
D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */ = {
|
D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
|
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
|
||||||
|
@ -390,6 +412,7 @@
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
files = (
|
||||||
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */,
|
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
|
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */,
|
||||||
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */,
|
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
|
@ -492,6 +515,12 @@
|
||||||
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>"; };
|
||||||
|
D630C3D12BC61B6000208903 /* NotificationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
D630C3D32BC61B6100208903 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
D630C3D52BC61B6100208903 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationExtension.entitlements; 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>"; };
|
||||||
|
@ -521,12 +550,17 @@
|
||||||
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
|
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
|
||||||
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
|
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
|
||||||
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
|
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
|
||||||
|
D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPrefsView.swift; sourceTree = "<group>"; };
|
||||||
|
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
|
||||||
|
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
|
||||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
||||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
|
D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushInstanceSettingsView.swift; 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>"; };
|
||||||
D65B4B552971F98300DABDFB /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
|
D65B4B552971F98300DABDFB /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
|
||||||
D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = "<group>"; };
|
D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -562,6 +596,7 @@
|
||||||
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
|
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
|
||||||
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
|
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
|
||||||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
|
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLoadingViewController.swift; sourceTree = "<group>"; };
|
||||||
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTrendsFooterCollectionViewCell.swift; sourceTree = "<group>"; };
|
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTrendsFooterCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
||||||
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -764,6 +799,18 @@
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
D630C3CE2BC61B6000208903 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D630C4252BC7845800208903 /* WebURL in Frameworks */,
|
||||||
|
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
|
||||||
|
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
|
||||||
|
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */,
|
||||||
|
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6A4531029EF64BA00032932 /* Frameworks */ = {
|
D6A4531029EF64BA00032932 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -781,6 +828,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */,
|
D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */,
|
||||||
|
D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */,
|
||||||
D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */,
|
D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */,
|
||||||
D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */,
|
D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */,
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||||
|
@ -955,6 +1003,16 @@
|
||||||
path = Shortcuts;
|
path = Shortcuts;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D630C3D22BC61B6100208903 /* NotificationExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */,
|
||||||
|
D630C3D32BC61B6100208903 /* NotificationService.swift */,
|
||||||
|
D630C3D52BC61B6100208903 /* Info.plist */,
|
||||||
|
);
|
||||||
|
path = NotificationExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6370B9924421FE00092A7FF /* CoreData */ = {
|
D6370B9924421FE00092A7FF /* CoreData */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1089,6 +1147,7 @@
|
||||||
D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */,
|
D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */,
|
||||||
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
|
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
|
||||||
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
|
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
|
||||||
|
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */,
|
||||||
);
|
);
|
||||||
path = Notifications;
|
path = Notifications;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1108,6 +1167,7 @@
|
||||||
children = (
|
children = (
|
||||||
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
|
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
|
||||||
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
|
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
|
||||||
|
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */,
|
||||||
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
|
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
|
||||||
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
|
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
|
||||||
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */,
|
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */,
|
||||||
|
@ -1119,6 +1179,7 @@
|
||||||
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
||||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
||||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
||||||
|
D64B96822BC3892B002C8990 /* Notifications */,
|
||||||
D60089172981FEA4005B4D00 /* Tip Jar */,
|
D60089172981FEA4005B4D00 /* Tip Jar */,
|
||||||
D68A76EF2953910A001DA1B3 /* About */,
|
D68A76EF2953910A001DA1B3 /* About */,
|
||||||
);
|
);
|
||||||
|
@ -1167,6 +1228,7 @@
|
||||||
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
||||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
||||||
D642E83D2BA7AD0F004BFD6A /* GalleryVC */,
|
D642E83D2BA7AD0F004BFD6A /* GalleryVC */,
|
||||||
|
D65A26242BC39A02005EB5D8 /* PushNotifications */,
|
||||||
);
|
);
|
||||||
path = Packages;
|
path = Packages;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1181,6 +1243,16 @@
|
||||||
path = Toast;
|
path = Toast;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D64B96822BC3892B002C8990 /* Notifications */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */,
|
||||||
|
D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */,
|
||||||
|
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */,
|
||||||
|
);
|
||||||
|
path = Notifications;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D65A37F221472F300087646E /* Frameworks */ = {
|
D65A37F221472F300087646E /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1461,6 +1533,7 @@
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */,
|
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */,
|
||||||
D6A4531429EF64BA00032932 /* ShareExtension */,
|
D6A4531429EF64BA00032932 /* ShareExtension */,
|
||||||
|
D630C3D22BC61B6100208903 /* NotificationExtension */,
|
||||||
D6D4DDCD212518A000E1C4BB /* Products */,
|
D6D4DDCD212518A000E1C4BB /* Products */,
|
||||||
D65A37F221472F300087646E /* Frameworks */,
|
D65A37F221472F300087646E /* Frameworks */,
|
||||||
);
|
);
|
||||||
|
@ -1474,6 +1547,7 @@
|
||||||
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
|
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
|
||||||
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */,
|
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */,
|
||||||
D6A4531329EF64BA00032932 /* ShareExtension.appex */,
|
D6A4531329EF64BA00032932 /* ShareExtension.appex */,
|
||||||
|
D630C3D12BC61B6000208903 /* NotificationExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1627,6 +1701,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 */,
|
||||||
|
@ -1641,6 +1716,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>";
|
||||||
|
@ -1648,6 +1724,30 @@
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
D630C3D02BC61B6000208903 /* NotificationExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = D630C3DA2BC61B6100208903 /* Build configuration list for PBXNativeTarget "NotificationExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
D630C3CD2BC61B6000208903 /* Sources */,
|
||||||
|
D630C3CE2BC61B6000208903 /* Frameworks */,
|
||||||
|
D630C3CF2BC61B6000208903 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = NotificationExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
D630C3DE2BC61C4900208903 /* PushNotifications */,
|
||||||
|
D630C3E02BC61C6700208903 /* UserAccounts */,
|
||||||
|
D630C3E42BC6313400208903 /* Pachyderm */,
|
||||||
|
D630C4222BC7842C00208903 /* HTMLStreamer */,
|
||||||
|
D630C4242BC7845800208903 /* WebURL */,
|
||||||
|
);
|
||||||
|
productName = NotificationExtension;
|
||||||
|
productReference = D630C3D12BC61B6000208903 /* NotificationExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
D6A4531229EF64BA00032932 /* ShareExtension */ = {
|
D6A4531229EF64BA00032932 /* ShareExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
buildConfigurationList = D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
|
@ -1689,6 +1789,7 @@
|
||||||
dependencies = (
|
dependencies = (
|
||||||
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */,
|
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */,
|
||||||
D6A4531C29EF64BA00032932 /* PBXTargetDependency */,
|
D6A4531C29EF64BA00032932 /* PBXTargetDependency */,
|
||||||
|
D630C3D72BC61B6100208903 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Tusker;
|
name = Tusker;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
@ -1705,6 +1806,7 @@
|
||||||
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
||||||
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
|
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
|
||||||
D6934F2B2BA7AD32002B1C8D /* GalleryVC */,
|
D6934F2B2BA7AD32002B1C8D /* GalleryVC */,
|
||||||
|
D630C3C72BC43AFD00208903 /* PushNotifications */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1773,10 +1875,13 @@
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 1430;
|
LastSwiftUpdateCheck = 1530;
|
||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
ORGANIZATIONNAME = Shadowfacts;
|
ORGANIZATIONNAME = Shadowfacts;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
D630C3D02BC61B6000208903 = {
|
||||||
|
CreatedOnToolsVersion = 15.3;
|
||||||
|
};
|
||||||
D6A4531229EF64BA00032932 = {
|
D6A4531229EF64BA00032932 = {
|
||||||
CreatedOnToolsVersion = 14.3;
|
CreatedOnToolsVersion = 14.3;
|
||||||
};
|
};
|
||||||
|
@ -1829,11 +1934,19 @@
|
||||||
D6D4DDEA212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEA212518A200E1C4BB /* TuskerUITests */,
|
||||||
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */,
|
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */,
|
||||||
D6A4531229EF64BA00032932 /* ShareExtension */,
|
D6A4531229EF64BA00032932 /* ShareExtension */,
|
||||||
|
D630C3D02BC61B6000208903 /* NotificationExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
D630C3CF2BC61B6000208903 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6A4531129EF64BA00032932 /* Resources */ = {
|
D6A4531129EF64BA00032932 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -1934,6 +2047,14 @@
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
D630C3CD2BC61B6000208903 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D630C3D42BC61B6100208903 /* NotificationService.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6A4530F29EF64BA00032932 /* Sources */ = {
|
D6A4530F29EF64BA00032932 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -2036,12 +2157,14 @@
|
||||||
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 */,
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
|
D68245122BCA1F4000AFB38B /* NotificationLoadingViewController.swift in Sources */,
|
||||||
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */,
|
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */,
|
||||||
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
|
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
|
||||||
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */,
|
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */,
|
||||||
|
@ -2065,11 +2188,13 @@
|
||||||
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */,
|
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */,
|
||||||
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
||||||
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
|
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
|
||||||
|
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */,
|
||||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||||
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
|
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
|
||||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||||
|
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */,
|
||||||
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
|
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
|
||||||
D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */,
|
D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */,
|
||||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
|
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
|
||||||
|
@ -2201,10 +2326,12 @@
|
||||||
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 */,
|
||||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||||
|
D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */,
|
||||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||||
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
|
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
|
||||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||||
|
@ -2219,6 +2346,7 @@
|
||||||
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
|
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
|
||||||
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
|
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||||
|
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */,
|
||||||
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */,
|
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */,
|
||||||
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
|
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||||
|
@ -2275,6 +2403,11 @@
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
D630C3D72BC61B6100208903 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = D630C3D02BC61B6000208903 /* NotificationExtension */;
|
||||||
|
targetProxy = D630C3D62BC61B6100208903 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
D6A4531C29EF64BA00032932 /* PBXTargetDependency */ = {
|
D6A4531C29EF64BA00032932 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = D6A4531229EF64BA00032932 /* ShareExtension */;
|
target = D6A4531229EF64BA00032932 /* ShareExtension */;
|
||||||
|
@ -2333,6 +2466,100 @@
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
D630C3DB2BC61B6100208903 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
D630C3DC2BC61B6100208903 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
D630C3DD2BC61B6100208903 /* Dist */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Dist;
|
||||||
|
};
|
||||||
D63CC705290ECE77000E19DE /* Dist */ = {
|
D63CC705290ECE77000E19DE /* Dist */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = D63CC703290EC472000E19DE /* Dist.xcconfig */;
|
baseConfigurationReference = D63CC703290EC472000E19DE /* Dist.xcconfig */;
|
||||||
|
@ -2897,6 +3124,16 @@
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
D630C3DA2BC61B6100208903 /* Build configuration list for PBXNativeTarget "NotificationExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D630C3DB2BC61B6100208903 /* Debug */,
|
||||||
|
D630C3DC2BC61B6100208903 /* Release */,
|
||||||
|
D630C3DD2BC61B6100208903 /* Dist */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
@ -3004,6 +3241,32 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Pachyderm;
|
productName = Pachyderm;
|
||||||
};
|
};
|
||||||
|
D630C3C72BC43AFD00208903 /* PushNotifications */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PushNotifications;
|
||||||
|
};
|
||||||
|
D630C3DE2BC61C4900208903 /* PushNotifications */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PushNotifications;
|
||||||
|
};
|
||||||
|
D630C3E02BC61C6700208903 /* UserAccounts */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = UserAccounts;
|
||||||
|
};
|
||||||
|
D630C3E42BC6313400208903 /* Pachyderm */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Pachyderm;
|
||||||
|
};
|
||||||
|
D630C4222BC7842C00208903 /* HTMLStreamer */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
|
||||||
|
productName = HTMLStreamer;
|
||||||
|
};
|
||||||
|
D630C4242BC7845800208903 /* WebURL */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||||
|
productName = WebURL;
|
||||||
|
};
|
||||||
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
|
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = TuskerComponents;
|
productName = TuskerComponents;
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,28 +17,29 @@ import Sentry
|
||||||
import ComposeUI
|
import ComposeUI
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private let oauthScopes = [Scope.read, .write, .follow]
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
|
||||||
|
|
||||||
final class MastodonController: ObservableObject, Sendable {
|
final class MastodonController: ObservableObject, Sendable {
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -172,13 +173,14 @@ final class MastodonController: ObservableObject, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Returns: A tuple of client ID and client secret.
|
/// - Returns: A tuple of client ID and client secret.
|
||||||
func registerApp() async throws -> (String, String) {
|
func registerApp(reregister: Bool = false) async throws -> (String, String) {
|
||||||
if let clientID = client.clientID,
|
if !reregister,
|
||||||
|
let clientID = client.clientID,
|
||||||
let clientSecret = client.clientSecret {
|
let clientSecret = client.clientSecret {
|
||||||
return (clientID, clientSecret)
|
return (clientID, clientSecret)
|
||||||
} else {
|
} else {
|
||||||
let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
|
let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
|
||||||
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
|
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
|
||||||
switch response {
|
switch response {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
|
@ -196,7 +198,7 @@ final class MastodonController: ObservableObject, Sendable {
|
||||||
/// - Returns: The access token
|
/// - Returns: The access token
|
||||||
func authorize(authorizationCode: String) async throws -> String {
|
func authorize(authorizationCode: String) async throws -> String {
|
||||||
return try await withCheckedThrowingContinuation({ continuation in
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
|
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
|
||||||
switch response {
|
switch response {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
|
|
|
@ -15,6 +15,7 @@ import Sentry
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import ComposeUI
|
import ComposeUI
|
||||||
import TuskerPreferences
|
import TuskerPreferences
|
||||||
|
import PushNotifications
|
||||||
|
|
||||||
typealias Preferences = TuskerPreferences.Preferences
|
typealias Preferences = TuskerPreferences.Preferences
|
||||||
|
|
||||||
|
@ -83,12 +84,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
BackgroundManager.shared.registerHandlers()
|
BackgroundManager.shared.registerHandlers()
|
||||||
|
|
||||||
|
initializePushNotifications()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(Sentry)
|
#if canImport(Sentry)
|
||||||
private func configureSentry() {
|
private func configureSentry() {
|
||||||
guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String,
|
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
|
||||||
|
let dsn = info["SentryDSN"] as? String,
|
||||||
!dsn.isEmpty else {
|
!dsn.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -155,7 +159,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
.search,
|
.search,
|
||||||
.bookmarks,
|
.bookmarks,
|
||||||
.myProfile,
|
.myProfile,
|
||||||
.showProfile:
|
.showProfile,
|
||||||
|
.showNotification:
|
||||||
if activity.displaysAuxiliaryScene {
|
if activity.displaysAuxiliaryScene {
|
||||||
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
|
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
|
||||||
return "auxiliary"
|
return "auxiliary"
|
||||||
|
@ -168,6 +173,35 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
PushManager.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
|
||||||
|
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializePushNotifications() {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
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:"))
|
||||||
|
@ -223,3 +257,52 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
||||||
|
let mainSceneDelegate: MainSceneDelegate
|
||||||
|
if let delegate = UIApplication.shared.activeScene?.delegate as? MainSceneDelegate {
|
||||||
|
mainSceneDelegate = delegate
|
||||||
|
} else if let scene = UIApplication.shared.connectedScenes.first(where: { $0.delegate is MainSceneDelegate }) {
|
||||||
|
mainSceneDelegate = scene.delegate as! MainSceneDelegate
|
||||||
|
} else if let accountID = UserAccountsManager.shared.mostRecentAccountID {
|
||||||
|
let activity = UserActivityManager.mainSceneActivity(accountID: accountID)
|
||||||
|
activity.addUserInfoEntries(from: ["showNotificationsPreferences": true])
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// without an account, we can't do anything
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainSceneDelegate.showNotificationsPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
completionHandler(.banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
guard let notificationID = userInfo["notificationID"] as? String,
|
||||||
|
let accountID = userInfo["accountID"] as? String,
|
||||||
|
let account = UserAccountsManager.shared.getAccount(id: accountID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let scene = response.targetScene,
|
||||||
|
let delegate = scene.delegate as? MainSceneDelegate,
|
||||||
|
let rootViewController = delegate.rootViewController {
|
||||||
|
let mastodonController = MastodonController.getForAccount(account)
|
||||||
|
|
||||||
|
// if the scene is already active, then we animate the account switching if necessary
|
||||||
|
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||||
|
|
||||||
|
rootViewController.select(route: .notifications, animated: false)
|
||||||
|
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||||
|
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||||
|
} else {
|
||||||
|
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||||
|
}
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -377,9 +377,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||||
backgroundContext.perform {
|
backgroundContext.perform {
|
||||||
let statuses = notifications.compactMap { $0.status }
|
let statuses = notifications.compactMap { $0.status }
|
||||||
// filter out mentions, otherwise we would double increment the reference count of those accounts
|
let accounts = notifications.map { $0.account }
|
||||||
// since the status has the same account as the notification
|
|
||||||
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
|
|
||||||
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
||||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
||||||
self.save(context: self.backgroundContext)
|
self.save(context: self.backgroundContext)
|
||||||
|
|
|
@ -47,6 +47,10 @@ public extension MainActor {
|
||||||
///
|
///
|
||||||
/// It will crash if run on any non-main thread.
|
/// It will crash if run on any non-main thread.
|
||||||
@_unavailableFromAsync
|
@_unavailableFromAsync
|
||||||
|
@available(macOS, obsoleted: 14.0)
|
||||||
|
@available(iOS, obsoleted: 17.0)
|
||||||
|
@available(watchOS, obsoleted: 10.0)
|
||||||
|
@available(tvOS, obsoleted: 17.0)
|
||||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||||
return try MainActor.assumeIsolated(body)
|
return try MainActor.assumeIsolated(body)
|
||||||
|
|
|
@ -36,6 +36,7 @@ class HTMLConverter {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HTMLConverter {
|
extension HTMLConverter {
|
||||||
|
// note: this is duplicated in NotificationExtension
|
||||||
struct Callbacks: HTMLConversionCallbacks {
|
struct Callbacks: HTMLConversionCallbacks {
|
||||||
static func makeURL(string: String) -> URL? {
|
static func makeURL(string: String) -> URL? {
|
||||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
|
|
|
@ -49,13 +49,7 @@
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionDomains</key>
|
<key>NSExceptionDomains</key>
|
||||||
<dict>
|
<dict/>
|
||||||
<key>localhost</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Post photos and videos from the camera.</string>
|
<string>Post photos and videos from the camera.</string>
|
||||||
|
@ -67,6 +61,7 @@
|
||||||
<string>Post photos from the photo library.</string>
|
<string>Post photos from the photo library.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
|
||||||
|
@ -76,6 +71,7 @@
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>OSLogPreferences</key>
|
<key>OSLogPreferences</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -106,8 +102,13 @@
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SentryDSN</key>
|
<key>TuskerInfo</key>
|
||||||
<string>$(SENTRY_DSN)</string>
|
<dict>
|
||||||
|
<key>PushProxyHost</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||||
|
<key>SentryDSN</key>
|
||||||
|
<string>$(SENTRY_DSN)</string>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|
|
@ -208,6 +208,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
func activateAccount(_ account: UserAccountInfo, animated: Bool) {
|
func activateAccount(_ account: UserAccountInfo, animated: Bool) {
|
||||||
|
guard (window?.rootViewController as? AccountSwitchingContainerViewController)?.currentAccountID != account.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let oldMostRecentAccount = UserAccountsManager.shared.mostRecentAccountID
|
let oldMostRecentAccount = UserAccountsManager.shared.mostRecentAccountID
|
||||||
UserAccountsManager.shared.setMostRecentAccount(account)
|
UserAccountsManager.shared.setMostRecentAccount(account)
|
||||||
window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account)
|
window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account)
|
||||||
|
@ -283,6 +287,16 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showNotificationsPreferences() {
|
||||||
|
let preferencesVC: PreferencesNavigationController?
|
||||||
|
if let presented = rootViewController?.presentedViewController as? PreferencesNavigationController {
|
||||||
|
preferencesVC = presented
|
||||||
|
} else {
|
||||||
|
preferencesVC = rootViewController?.presentPreferences(completion: nil)
|
||||||
|
}
|
||||||
|
preferencesVC?.navigationState.showNotificationPreferences = true
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSceneDelegate: OnboardingViewControllerDelegate {
|
extension MainSceneDelegate: OnboardingViewControllerDelegate {
|
||||||
|
|
|
@ -20,7 +20,7 @@ protocol AccountSwitchableViewController: TuskerRootViewController {
|
||||||
|
|
||||||
class AccountSwitchingContainerViewController: UIViewController {
|
class AccountSwitchingContainerViewController: UIViewController {
|
||||||
|
|
||||||
private var currentAccountID: String
|
private(set) var currentAccountID: String
|
||||||
private(set) var root: AccountSwitchableViewController
|
private(set) var root: AccountSwitchableViewController
|
||||||
|
|
||||||
private var userActivities: [String: NSUserActivity] = [:]
|
private var userActivities: [String: NSUserActivity] = [:]
|
||||||
|
@ -152,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
root.performSearch(query: query)
|
root.performSearch(query: query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentPreferences(completion: (() -> Void)?) {
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.presentPreferences(completion: completion)
|
return root.presentPreferences(completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
|
|
|
@ -47,7 +47,7 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
|
||||||
(child as? TuskerRootViewController)?.performSearch(query: query)
|
(child as? TuskerRootViewController)?.performSearch(query: query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentPreferences(completion: (() -> Void)?) {
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? {
|
||||||
(child as? TuskerRootViewController)?.presentPreferences(completion: completion)
|
(child as? TuskerRootViewController)?.presentPreferences(completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -623,8 +623,10 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
searchViewController.resultsController.performSearch(query: query)
|
searchViewController.resultsController.performSearch(query: query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentPreferences(completion: (() -> Void)?) {
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? {
|
||||||
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
|
let vc = PreferencesNavigationController(mastodonController: mastodonController)
|
||||||
|
present(vc, animated: true, completion: completion)
|
||||||
|
return vc
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
|
|
|
@ -342,8 +342,10 @@ extension MainTabBarViewController: TuskerRootViewController {
|
||||||
exploreController.resultsController.performSearch(query: query)
|
exploreController.resultsController.performSearch(query: query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentPreferences(completion: (() -> Void)?) {
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? {
|
||||||
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
|
let vc = PreferencesNavigationController(mastodonController: mastodonController)
|
||||||
|
present(vc, animated: true, completion: completion)
|
||||||
|
return vc
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
|
|
|
@ -17,7 +17,8 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
|
||||||
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
||||||
func getNavigationController() -> NavigationControllerProtocol
|
func getNavigationController() -> NavigationControllerProtocol
|
||||||
func performSearch(query: String)
|
func performSearch(query: String)
|
||||||
func presentPreferences(completion: (() -> Void)?)
|
@discardableResult
|
||||||
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
|
||||||
}
|
}
|
||||||
|
|
||||||
//extension TuskerRootViewController {
|
//extension TuskerRootViewController {
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
//
|
||||||
|
// NotificationLoadingViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/12/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class NotificationLoadingViewController: UIViewController {
|
||||||
|
|
||||||
|
private let notificationID: String
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
|
init(notificationID: String, mastodonController: MastodonController) {
|
||||||
|
self.notificationID = notificationID
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = .secondarySystemBackground
|
||||||
|
|
||||||
|
let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
indicator.startAnimating()
|
||||||
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(indicator)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||||
|
indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let request = Client.getNotification(id: notificationID)
|
||||||
|
do {
|
||||||
|
let (notification, _) = try await mastodonController.run(request)
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(notifications: [notification]) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification(notification)
|
||||||
|
} catch {
|
||||||
|
showLoadingError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showNotification(_ notification: Pachyderm.Notification) {
|
||||||
|
let vc: UIViewController
|
||||||
|
switch notification.kind {
|
||||||
|
case .mention, .status, .poll, .update:
|
||||||
|
guard let statusID = notification.status?.id else {
|
||||||
|
showLoadingError(Error.missingStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vc = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController)
|
||||||
|
case .reblog, .favourite:
|
||||||
|
guard let statusID = notification.status?.id else {
|
||||||
|
showLoadingError(Error.missingStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite
|
||||||
|
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController)
|
||||||
|
case .follow:
|
||||||
|
vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController)
|
||||||
|
case .followRequest:
|
||||||
|
// todo
|
||||||
|
return
|
||||||
|
case .unknown:
|
||||||
|
showLoadingError(Error.unknownType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let navigationController else {
|
||||||
|
fatalError("Don't know how to show notification VC outside of navigation controller")
|
||||||
|
}
|
||||||
|
navigationController.viewControllers[navigationController.viewControllers.count - 1] = vc
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showLoadingError(_ error: any Swift.Error) {
|
||||||
|
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
||||||
|
image.tintColor = .secondaryLabel
|
||||||
|
image.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
let title = UILabel()
|
||||||
|
title.textColor = .secondaryLabel
|
||||||
|
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
|
||||||
|
title.adjustsFontForContentSizeCategory = true
|
||||||
|
title.text = "Couldn't Load Notification"
|
||||||
|
|
||||||
|
let subtitle = UILabel()
|
||||||
|
subtitle.textColor = .secondaryLabel
|
||||||
|
subtitle.font = .preferredFont(forTextStyle: .body)
|
||||||
|
subtitle.adjustsFontForContentSizeCategory = true
|
||||||
|
subtitle.numberOfLines = 0
|
||||||
|
subtitle.textAlignment = .center
|
||||||
|
if let error = error as? Error {
|
||||||
|
subtitle.text = error.localizedDescription
|
||||||
|
} else if let error = error as? Client.Error {
|
||||||
|
subtitle.text = error.localizedDescription
|
||||||
|
} else {
|
||||||
|
subtitle.text = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
image,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.isAccessibilityElement = true
|
||||||
|
stack.accessibilityLabel = "\(title.text!). \(subtitle.text!)"
|
||||||
|
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
image.widthAnchor.constraint(equalToConstant: 64),
|
||||||
|
image.heightAnchor.constraint(equalToConstant: 64),
|
||||||
|
|
||||||
|
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
|
||||||
|
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
|
||||||
|
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Error: LocalizedError {
|
||||||
|
case missingStatus
|
||||||
|
case unknownType
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingStatus:
|
||||||
|
"Missing status for mention/status notification"
|
||||||
|
case .unknownType:
|
||||||
|
"Unknown notification type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: "read write follow"),
|
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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
// NotificationsPrefsView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/6/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
import UserAccounts
|
||||||
|
import PushNotifications
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct NotificationsPrefsView: View {
|
||||||
|
@State private var error: NotificationsSetupError?
|
||||||
|
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(userAccounts.accounts) { account in
|
||||||
|
PushInstanceSettingsView(account: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appGroupedListRowBackground()
|
||||||
|
|
||||||
|
if #available(iOS 15.4, *) {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
let str = if #available(iOS 16.0, *) {
|
||||||
|
UIApplication.openNotificationSettingsURLString
|
||||||
|
} else {
|
||||||
|
UIApplicationOpenNotificationSettingsURLString
|
||||||
|
}
|
||||||
|
if let url = URL(string: str) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Open Notification Settings…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appGroupedListRowBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
||||||
|
.navigationTitle("Notifications")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum NotificationsSetupError: LocalizedError {
|
||||||
|
case requestingAuthorization(any Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .requestingAuthorization(let error):
|
||||||
|
"Notifications authorization request failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
//
|
||||||
|
// PushInstanceSettingsView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UserAccounts
|
||||||
|
import Pachyderm
|
||||||
|
import PushNotifications
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct PushInstanceSettingsView: View {
|
||||||
|
let account: UserAccountInfo
|
||||||
|
@State private var mode: AsyncToggle.Mode
|
||||||
|
@State private var error: Error?
|
||||||
|
@State private var subscription: PushNotifications.PushSubscription?
|
||||||
|
@State private var showReLoginRequiredAlert = false
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
init(account: UserAccountInfo) {
|
||||||
|
self.account = account
|
||||||
|
let subscription = PushManager.shared.pushSubscription(account: account)
|
||||||
|
self.subscription = subscription
|
||||||
|
self.mode = subscription == nil ? .off : .on
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .prefsAvatar) {
|
||||||
|
HStack {
|
||||||
|
PrefsAccountView(account: account)
|
||||||
|
Spacer()
|
||||||
|
AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
PushSubscriptionView(account: account, subscription: subscription, updateSubscription: updateSubscription)
|
||||||
|
}
|
||||||
|
.alertWithData("An Error Occurred", data: $error) { data in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { data in
|
||||||
|
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 {
|
||||||
|
if enabled {
|
||||||
|
do {
|
||||||
|
return try await enableNotifications()
|
||||||
|
} catch {
|
||||||
|
PushManager.logger.error("Error creating instance subscription: \(String(describing: error))")
|
||||||
|
self.error = .enabling(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try await disableNotifications()
|
||||||
|
} catch {
|
||||||
|
PushManager.logger.error("Error removing instance subscription: \(String(describing: error))")
|
||||||
|
self.error = .disabling(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableNotifications() async throws -> Bool {
|
||||||
|
guard account.scopes?.contains(Scope.push.rawValue) == true else {
|
||||||
|
showReLoginRequiredAlert = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .providesAppNotificationSettings])
|
||||||
|
guard authorized else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = try await PushManager.shared.createSubscription(account: account)
|
||||||
|
let mastodonController = await MastodonController.getForAccount(account)
|
||||||
|
do {
|
||||||
|
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
||||||
|
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
|
||||||
|
self.subscription = subscription
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// if creation failed, remove the subscription locally as well
|
||||||
|
await PushManager.shared.removeSubscription(account: account)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disableNotifications() async throws {
|
||||||
|
let mastodonController = await MastodonController.getForAccount(account)
|
||||||
|
try await mastodonController.deletePushSubscription()
|
||||||
|
await PushManager.shared.removeSubscription(account: account)
|
||||||
|
subscription = nil
|
||||||
|
PushManager.logger.debug("Push subscription removed on \(account.instanceURL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool {
|
||||||
|
let mastodonController = await MastodonController.getForAccount(account)
|
||||||
|
do {
|
||||||
|
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
|
||||||
|
PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
|
||||||
|
await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
|
||||||
|
subscription?.alerts = alerts
|
||||||
|
subscription?.policy = policy
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
PushManager.logger.error("Error updating subscription: \(String(describing: error))")
|
||||||
|
self.error = .updating(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Error: LocalizedError {
|
||||||
|
case enabling(any Swift.Error)
|
||||||
|
case disabling(any Swift.Error)
|
||||||
|
case updating(any Swift.Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .enabling(let error):
|
||||||
|
"Enabling push: \(error.localizedDescription)"
|
||||||
|
case .disabling(let error):
|
||||||
|
"Disabling push: \(error.localizedDescription)"
|
||||||
|
case .updating(let error):
|
||||||
|
"Updating settings: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// PushInstanceSettingsView()
|
||||||
|
//}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// PushSubscriptionView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UserAccounts
|
||||||
|
import PushNotifications
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct PushSubscriptionView: View {
|
||||||
|
let account: UserAccountInfo
|
||||||
|
let subscription: PushSubscription?
|
||||||
|
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let subscription {
|
||||||
|
PushSubscriptionSettingsView(account: account, subscription: subscription, updateSubscription: updateSubscription)
|
||||||
|
} else {
|
||||||
|
Text("No notifications")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushSubscriptionSettingsView: View {
|
||||||
|
let account: UserAccountInfo
|
||||||
|
let subscription: PushSubscription
|
||||||
|
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
|
||||||
|
@State private var isLoading: [PushSubscription.Alerts: Bool] = [:]
|
||||||
|
|
||||||
|
init(account: UserAccountInfo, subscription: PushSubscription, updateSubscription: @escaping (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool) {
|
||||||
|
self.account = account
|
||||||
|
self.subscription = subscription
|
||||||
|
self.updateSubscription = updateSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
alertsToggles
|
||||||
|
|
||||||
|
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
|
||||||
|
await updateSubscription(subscription.alerts, newPolicy)
|
||||||
|
} content: {
|
||||||
|
ForEach(PushSubscription.Policy.allCases) {
|
||||||
|
Text($0.displayName).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
// this is the default value of the alignment guide, but this modifier is loading bearing
|
||||||
|
.alignmentGuide(.prefsAvatar, computeValue: { dimension in
|
||||||
|
dimension[.leading]
|
||||||
|
})
|
||||||
|
// otherwise the flexible view makes the containing stack extend under the edge of the list row
|
||||||
|
.padding(.leading, 38)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alertsToggles: some View {
|
||||||
|
GroupBox("Get notifications for") {
|
||||||
|
VStack {
|
||||||
|
toggle("All", alert: [.mention, .favorite, .reblog, .follow, .followRequest, .poll, .update])
|
||||||
|
toggle("Mentions", alert: .mention)
|
||||||
|
toggle("Favorites", alert: .favorite)
|
||||||
|
toggle("Reblogs", alert: .reblog)
|
||||||
|
toggle("Follows", alert: [.follow, .followRequest])
|
||||||
|
toggle("Polls finishing", alert: .poll)
|
||||||
|
toggle("Edits", alert: .update)
|
||||||
|
// status notifications not supported until we can enable/disable them in the app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {
|
||||||
|
let binding: Binding<AsyncToggle.Mode> = Binding {
|
||||||
|
isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off
|
||||||
|
} set: { newValue in
|
||||||
|
isLoading[alert] = newValue == .loading
|
||||||
|
}
|
||||||
|
return AsyncToggle(titleKey, mode: binding) {
|
||||||
|
return await updateAlert(alert, isOn: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAlert(_ alert: PushSubscription.Alerts, isOn: Bool) async -> Bool {
|
||||||
|
var newAlerts = subscription.alerts
|
||||||
|
if isOn {
|
||||||
|
newAlerts.insert(alert)
|
||||||
|
} else {
|
||||||
|
newAlerts.remove(alert)
|
||||||
|
}
|
||||||
|
return await updateSubscription(newAlerts, subscription.policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PushSubscription.Policy {
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
"Anyone"
|
||||||
|
case .followed:
|
||||||
|
"Accounts you follow"
|
||||||
|
case .followers:
|
||||||
|
"Your followers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// PushSubscriptionView()
|
||||||
|
//}
|
|
@ -10,17 +10,27 @@ import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
import AuthenticationServices
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
// TODO: replace this with NavigationStack and path once we target iOS 16
|
||||||
|
class PreferencesNavigationState: ObservableObject {
|
||||||
|
@Published var showNotificationPreferences = false
|
||||||
|
}
|
||||||
|
|
||||||
class PreferencesNavigationController: UINavigationController {
|
class PreferencesNavigationController: UINavigationController {
|
||||||
|
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
|
let navigationState: PreferencesNavigationState
|
||||||
|
|
||||||
private var isSwitchingAccounts = false
|
private var isSwitchingAccounts = false
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
let navigationState = PreferencesNavigationState()
|
||||||
|
self.navigationState = navigationState
|
||||||
|
|
||||||
let view = PreferencesView(mastodonController: mastodonController)
|
let view = PreferencesView(mastodonController: mastodonController, navigationState: navigationState)
|
||||||
let hostingController = UIHostingController(rootView: view)
|
let hostingController = UIHostingController(rootView: view)
|
||||||
super.init(rootViewController: hostingController)
|
super.init(rootViewController: hostingController)
|
||||||
hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
|
hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
|
||||||
|
@ -37,6 +47,7 @@ class PreferencesNavigationController: UINavigationController {
|
||||||
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(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) {
|
||||||
|
@ -63,7 +74,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)
|
||||||
|
@ -106,6 +117,70 @@ class PreferencesNavigationController: UINavigationController {
|
||||||
present(vc, animated: true)
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func reLogInToAccount(_ notification: Foundation.Notification) {
|
||||||
|
guard let account = notification.object as? UserAccountInfo else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let dimmingView = UIView()
|
||||||
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
dimmingView.backgroundColor = .black.withAlphaComponent(0.1)
|
||||||
|
|
||||||
|
let blur = UIBlurEffect(style: .prominent)
|
||||||
|
let blurView = UIVisualEffectView(effect: blur)
|
||||||
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
blurView.layer.cornerRadius = 15
|
||||||
|
blurView.layer.masksToBounds = true
|
||||||
|
|
||||||
|
let spinner = UIActivityIndicatorView(style: .large)
|
||||||
|
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
spinner.startAnimating()
|
||||||
|
|
||||||
|
blurView.contentView.addSubview(spinner)
|
||||||
|
dimmingView.addSubview(blurView)
|
||||||
|
view.addSubview(dimmingView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
blurView.widthAnchor.constraint(equalToConstant: 100),
|
||||||
|
blurView.heightAnchor.constraint(equalToConstant: 100),
|
||||||
|
blurView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||||
|
blurView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||||
|
|
||||||
|
spinner.centerXAnchor.constraint(equalTo: blurView.contentView.centerXAnchor),
|
||||||
|
spinner.centerYAnchor.constraint(equalTo: blurView.contentView.centerYAnchor),
|
||||||
|
])
|
||||||
|
dimmingView.layer.opacity = 0
|
||||||
|
blurView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||||
|
dimmingView.layer.opacity = 1
|
||||||
|
blurView.transform = .identity
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let mastodonController = MastodonController.getForAccount(account)
|
||||||
|
let (clientID, clientSecret) = try await mastodonController.registerApp(reregister: true)
|
||||||
|
|
||||||
|
let service = GetAuthorizationTokenService(instanceURL: account.instanceURL, clientID: clientID, presentationContextProvider: self)
|
||||||
|
let code = try await service.run()
|
||||||
|
let token = try await mastodonController.authorize(authorizationCode: code)
|
||||||
|
UserAccountsManager.shared.updateCredentials(account, clientID: clientID, clientSecret: clientSecret, accessToken: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
dimmingView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PreferencesNavigationController: OnboardingViewControllerDelegate {
|
extension PreferencesNavigationController: OnboardingViewControllerDelegate {
|
||||||
|
@ -124,3 +199,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")
|
||||||
|
}
|
||||||
|
|
|
@ -7,21 +7,23 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import WebURL
|
|
||||||
|
|
||||||
struct PreferencesView: View {
|
struct PreferencesView: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
@ObservedObject var navigationState: PreferencesNavigationState
|
||||||
|
|
||||||
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
||||||
@State private var showingLogoutConfirmation = false
|
@State private var showingLogoutConfirmation = false
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController, navigationState: PreferencesNavigationState) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
self.navigationState = navigationState
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
accountsSection
|
accountsSection
|
||||||
|
notificationsSection
|
||||||
preferencesSection
|
preferencesSection
|
||||||
aboutSection
|
aboutSection
|
||||||
}
|
}
|
||||||
|
@ -33,24 +35,12 @@ struct PreferencesView: View {
|
||||||
|
|
||||||
private var accountsSection: some View {
|
private var accountsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(userAccounts.accounts, id: \.accessToken) { (account) in
|
ForEach(userAccounts.accounts) { (account) in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
|
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
LocalAccountAvatarView(localAccountInfo: account)
|
PrefsAccountView(account: account)
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(verbatim: account.username)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
|
|
||||||
domain.render(.uncheckedUnicodeString)
|
|
||||||
} else {
|
|
||||||
account.instanceURL.host!
|
|
||||||
}
|
|
||||||
Text(verbatim: instance)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
if account == mastodonController.accountInfo! {
|
if account == mastodonController.accountInfo! {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
|
@ -102,6 +92,17 @@ struct PreferencesView: View {
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var notificationsSection: some View {
|
||||||
|
Section {
|
||||||
|
NavigationLink(isActive: $navigationState.showNotificationPreferences) {
|
||||||
|
NotificationsPrefsView()
|
||||||
|
} label: {
|
||||||
|
Text("Notifications")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appGroupedListRowBackground()
|
||||||
|
}
|
||||||
|
|
||||||
private var preferencesSection: some View {
|
private var preferencesSection: some View {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(destination: AppearancePrefsView()) {
|
NavigationLink(destination: AppearancePrefsView()) {
|
||||||
|
@ -146,10 +147,6 @@ struct PreferencesView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings")
|
|
||||||
}
|
|
||||||
|
|
||||||
//#if DEBUG
|
//#if DEBUG
|
||||||
//struct PreferencesView_Previews : PreviewProvider {
|
//struct PreferencesView_Previews : PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// PrefsAccountView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UserAccounts
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
struct PrefsAccountView: View {
|
||||||
|
let account: UserAccountInfo
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
LocalAccountAvatarView(localAccountInfo: account)
|
||||||
|
VStack(alignment: .prefsAvatar) {
|
||||||
|
Text(verbatim: account.username)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
|
||||||
|
domain.render(.uncheckedUnicodeString)
|
||||||
|
} else {
|
||||||
|
account.instanceURL.host!
|
||||||
|
}
|
||||||
|
Text(verbatim: instance)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
.alignmentGuide(.prefsAvatar, computeValue: { dimension in
|
||||||
|
dimension[.leading]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AvatarAlignment: AlignmentID {
|
||||||
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||||
|
context[.leading]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HorizontalAlignment {
|
||||||
|
static let prefsAvatar = HorizontalAlignment(AvatarAlignment.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// PrefsAccountView()
|
||||||
|
//}
|
|
@ -352,4 +352,21 @@ class UserActivityManager {
|
||||||
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Show Notification
|
||||||
|
static func showNotificationActivity(id notificationID: String, accountID: String) -> NSUserActivity {
|
||||||
|
let activity = NSUserActivity(type: .showNotification, accountID: accountID)
|
||||||
|
activity.addUserInfoEntries(from: [
|
||||||
|
"notificationID": notificationID,
|
||||||
|
])
|
||||||
|
return activity
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleShowNotification(activity: NSUserActivity) {
|
||||||
|
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.select(route: .notifications)
|
||||||
|
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ enum UserActivityType: String {
|
||||||
case showConversation = "space.vaccor.Tusker.activity.show-conversation"
|
case showConversation = "space.vaccor.Tusker.activity.show-conversation"
|
||||||
case myProfile = "space.vaccor.Tusker.activity.my-profile"
|
case myProfile = "space.vaccor.Tusker.activity.my-profile"
|
||||||
case showProfile = "space.vaccor.Tusker.activity.show-profile"
|
case showProfile = "space.vaccor.Tusker.activity.show-profile"
|
||||||
|
case showNotification = "space.vaccor.Tusker.activity.show-notification"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UserActivityType {
|
extension UserActivityType {
|
||||||
|
@ -42,6 +43,8 @@ extension UserActivityType {
|
||||||
return UserActivityManager.handleMyProfile
|
return UserActivityManager.handleMyProfile
|
||||||
case .showProfile:
|
case .showProfile:
|
||||||
return UserActivityManager.handleShowProfile
|
return UserActivityManager.handleShowProfile
|
||||||
|
case .showNotification:
|
||||||
|
return UserActivityManager.handleShowNotification
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.developer.usernotifications.communication</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>apns_enc</key>
|
||||||
|
<string>Encrypted Notification</string>
|
||||||
<key>poll votes count</key>
|
<key>poll votes count</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
// Configuration settings file format documentation can be found at:
|
// Configuration settings file format documentation can be found at:
|
||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2024.1
|
MARKETING_VERSION = 2024.2
|
||||||
CURRENT_PROJECT_VERSION = 119
|
CURRENT_PROJECT_VERSION = 120
|
||||||
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