Tusker/Tusker/AppDelegate.swift

317 lines
14 KiB
Swift

//
// AppDelegate.swift
// Tusker
//
// Created by Shadowfacts on 8/15/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import CoreData
import OSLog
#if canImport(Sentry)
import Sentry
#endif
import UserAccounts
import ComposeUI
import TuskerPreferences
import PushNotifications
typealias Preferences = TuskerPreferences.Preferences
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if canImport(Sentry)
configureSentry()
#endif
#if !os(visionOS)
swizzleStatusBar()
swizzlePresentationController()
#endif
AppShortcutItem.createItems(for: application)
if let oldSavedData = SavedDataManager.load() {
do {
for account in oldSavedData.accountIDs {
guard let account = UserAccountsManager.shared.getAccount(id: account) else {
continue
}
let controller = MastodonController.getForAccount(account)
try oldSavedData.migrateToCoreData(accountID: account.id, context: controller.persistentContainer.viewContext)
if controller.persistentContainer.viewContext.hasChanges {
try controller.persistentContainer.viewContext.save()
}
}
try SavedDataManager.destroy()
} catch {
// no-op
}
}
// make sure the persistent container is initialized on the main thread
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
#if canImport(Sentry)
DraftsPersistentContainer.captureError = { SentrySDK.capture(error: $0) }
#endif
_ = DraftsPersistentContainer.shared
DispatchQueue.global(qos: .userInitiated).async {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {
DraftsPersistentContainer.shared.migrate(from: url) {
if case .failure(let error) = $0 {
#if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
}
}
}
}
BackgroundManager.shared.registerHandlers()
initializePushNotifications()
return true
}
#if canImport(Sentry)
private func configureSentry() {
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
let dsn = info["SentryDSN"] as? String,
!dsn.isEmpty else {
return
}
SentrySDK.start { options in
#if DEBUG
options.debug = true
options.environment = "dev"
#endif
// the '//' in the full url can't be escaped, so we have to add the scheme back
options.dsn = "https://\(dsn)"
options.enableSwizzling = false
// required to support releases/release health
options.enableAutoSessionTracking = true
options.enableWatchdogTerminationTracking = false
options.enableAutoPerformanceTracing = false
options.enableNetworkTracking = false
options.enableAppHangTracking = false
options.enableCoreDataTracing = false
// we don't care about events like battery, keyboard show/hide
options.enableAutoBreadcrumbTracking = false
options.enableUserInteractionTracing = false
options.profilesSampleRate = nil
options.tracesSampleRate = nil
options.beforeSend = { event in
// just no, why would anyone need this information
event.context?.removeValue(forKey: "culture")
return Preferences.shared.reportErrorsAutomatically ? event : nil
}
if let clazz = NSClassFromString("SentryInstallation"),
let objClazz = clazz as AnyObject as? NSObject,
let id = objClazz.perform(Selector(("idWithCacheDirectoryPath:")), with: options.cacheDirectoryPath).takeUnretainedValue() as? String {
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
}
}
}
#endif
override func buildMenu(with builder: UIMenuBuilder) {
if builder.system == .main {
MenuController.buildMainMenu(builder: builder)
}
}
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let name = getSceneNameForActivity(options.userActivities.first)
return UISceneConfiguration(name: name, sessionRole: connectingSceneSession.role)
}
private func getSceneNameForActivity(_ activity: NSUserActivity?) -> String {
guard let activity = activity,
let type = UserActivityType(rawValue: activity.activityType) else {
return "main-scene"
}
switch type {
case .mainScene:
return "main-scene"
case .showConversation,
.showTimeline,
.checkNotifications,
.search,
.bookmarks,
.myProfile,
.showProfile,
.showNotification:
if activity.displaysAuxiliaryScene {
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
return "auxiliary"
} else {
return "main-scene"
}
case .newPost:
return "compose"
}
}
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 {
#if canImport(Sentry)
PushManager.captureError = { SentrySDK.capture(error: $0) }
#endif
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.info("Updated push subscription \(result.id, privacy: .public) on \(mastodonController.instanceURL) with endpoint \($0.endpoint, privacy: .public)")
PushManager.logger.debug("New push subscription: \(String(describing: result))")
return true
} catch {
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
return false
}
})
}
}
#if !os(visionOS)
private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:"))
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UIStatusBarManager, sender: AnyObject) in
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIStatusBarManager, Selector, AnyObject) -> Void).self)
let exception = catchNSException {
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
original(self, selector, sender)
return
}
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
case .stop:
return
case .continue:
original(self, selector, sender)
}
}
if let exception {
SentrySDK.capture(exception: exception)
}
} as @convention(block) (UIStatusBarManager, AnyObject) -> Void)
originalIMP = class_replaceMethod(UIStatusBarManager.self, selector, imp, "v@:@")
if originalIMP == nil {
Logging.general.error("Unable to swizzle status bar manager")
}
}
@available(iOS, obsoleted: 17.0)
private func swizzlePresentationController() {
guard #unavailable(iOS 17.0) else {
return
}
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UIPresentationController) in
let new = UITraitCollection(pureBlackDarkMode: self.presentingViewController.traitCollection.pureBlackDarkMode)
if let existing = self.overrideTraitCollection {
self.overrideTraitCollection = UITraitCollection(traitsFrom: [existing, new])
} else {
self.overrideTraitCollection = new
}
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIPresentationController) -> Void).self)
original(self)
} as (@convention(block) (UIPresentationController) -> Void))
let sel = Selector(["Necessary", "If", "Traits", "update", "_"].reversed().joined())
originalIMP = class_replaceMethod(UIPresentationController.self, sel, imp, "v@:")
if originalIMP == nil {
Logging.general.error("Unable to swizzle presentation controller")
}
}
#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 things
let animated = scene.activationState == .foregroundActive
delegate.activateAccount(account, animated: animated)
rootViewController.runNavigation(animated: animated) { navigation in
navigation.select(route: .notifications)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
navigation.push(viewController: vc)
}
} else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
if #available(iOS 17.0, *) {
let request = UISceneSessionActivationRequest(userActivity: activity)
UIApplication.shared.activateSceneSession(for: request)
} else {
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
}
}
completionHandler()
}
}