// // 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 _ = 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.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.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) 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 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() } }