Files
iOS/Sources/App/AppDelegate.swift
Ryan Warner 295a2a7c9f Fix Live Activity compilation and display issues for Mac Catalyst (#4495)
## Summary

Follow-up fixes after #4444 merged, addressing issues found during Mac
Catalyst testing:

- **Fix `›` character in Frequent Updates footer** — `.strings` files
don't interpret `\uXXXX` escapes at runtime; replaced with the literal
`›` character
- **Fix Mac Catalyst compilation** — `ActivityKit` APIs
(`ActivityAttributes`, `Activity`, `ActivityUIDismissalPolicy`, etc.)
are marked unavailable on Mac Catalyst even though
`canImport(ActivityKit)` returns true there. Replaced all `#if
canImport(ActivityKit)` and bare `#if os(iOS)` guards around ActivityKit
code with `#if os(iOS) && !targetEnvironment(macCatalyst)`. Files
affected:
  - `HALiveActivityAttributes.swift`
  - `LiveActivityRegistry.swift`
  - `HandlerLiveActivity.swift`
  - `LiveActivitySettingsView.swift`
  - `HADynamicIslandView.swift`
  - `HALockScreenView.swift`
  - `HALiveActivityConfiguration.swift`
  - `Widgets.swift` (three `HALiveActivityConfiguration()` call sites)
- `Environment.swift`, `AppDelegate.swift`, `HAAPI.swift`,
`NotificationsCommandManager.swift`, `SettingsItem.swift` (inline
guards)

## Test plan

- [x] iOS builds and runs
- [x] macOS (Mac Catalyst) builds and launches
- [ ] Live Activities settings entry does not appear on macOS (filtered
by `isTestFlight` + `#available(iOS 17.2, *)`)
- [ ] Live Activities work as expected on iOS TestFlight build

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:39:37 +02:00

485 lines
17 KiB
Swift

import Alamofire
import CallbackURLKit
import Communicator
import FirebaseCore
import FirebaseMessaging
import Intents
import KeychainAccess
import MBProgressHUD
import ObjectMapper
import PromiseKit
import RealmSwift
import SafariServices
import Shared
import UIKit
import WidgetKit
import XCGLogger
let keychain = AppConstants.Keychain
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
private extension UIApplication {
var typedDelegate: AppDelegate {
// swiftlint:disable:next force_cast
delegate as! AppDelegate
}
}
extension AppEnvironment {
var sceneManager: SceneManager {
UIApplication.shared.typedDelegate.sceneManager
}
var notificationManager: NotificationManager {
UIApplication.shared.typedDelegate.notificationManager
}
}
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
let sceneManager = SceneManager()
private let lifecycleManager = LifecycleManager()
let notificationManager = NotificationManager()
private var zoneManager: ZoneManager?
private var titleSubscription: MenuManagerTitleSubscription? {
didSet {
if oldValue != titleSubscription {
oldValue?.cancel()
}
}
}
private var shouldRefreshTitleSubscription = false
private var watchCommunicatorService: WatchCommunicatorService?
func application(
_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
MaterialDesignIcons.register()
guard !Current.isRunningTests else {
return true
}
setDefaults()
// swiftlint:disable prohibit_environment_assignment
Current.backgroundTask = ApplicationBackgroundTaskRunner()
// Initialize UIApplication wrapper for shared framework
Current.application = {
UIApplication.shared
}
Current.isBackgroundRequestsImmediate = { [lifecycleManager] in
if Current.isCatalyst {
return false
} else {
return lifecycleManager.isActive
}
}
#if targetEnvironment(simulator)
Current.tags = SimulatorTagManager()
#else
Current.tags = iOSTagManager()
#endif
// swiftlint:enable prohibit_environment_assignment
notificationManager.setupNotifications()
setupLiveActivityReattachment()
setupFirebase()
setupModels()
setupLocalization()
setupMenus()
let launchingForLocation = launchOptions?[.location] != nil
let event = ClientEvent(
text: "Application Starting" + (launchingForLocation ? " due to location change" : ""),
type: .unknown
)
Current.clientEventStore.addEvent(event)
zoneManager = ZoneManager()
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
setupWatchCommunicator()
setupUIApplicationShortcutItems()
migrateIfNeeded()
return true
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if NSClassFromString("XCTest") != nil {
return true
}
lifecycleManager.didFinishLaunching()
checkForUpdate()
checkForAlerts()
return true
}
override func buildMenu(with builder: UIMenuBuilder) {
if builder.system == .main {
let manager = MenuManager(builder: builder)
manager.update()
#if targetEnvironment(macCatalyst)
titleSubscription = manager.subscribeStatusItemTitle(
existing: shouldRefreshTitleSubscription ? nil : titleSubscription,
update: Current.macBridge.configureStatusItem(title:)
)
shouldRefreshTitleSubscription = false
#endif
}
}
@objc func openAbout() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .about)
}
@objc func openMenuUrl(_ command: AnyObject) {
guard let command = command as? UICommand, let url = MenuManager.url(from: command) else {
return
}
let delegate: Guarantee<WebViewSceneDelegate> = sceneManager.scene(for: .init(activity: .webView))
delegate.done {
$0.urlHandler?.handle(url: url)
}
}
@objc func openPreferences() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .settings)
}
@objc func openActionsPreferences() {
precondition(Current.sceneManager.supportsMultipleScenes)
let delegate: Guarantee<SettingsSceneDelegate> = sceneManager.scene(for: .init(activity: .settings))
delegate.done { $0.pushActions(animated: true) }
}
@objc func openHelp() {
openURLInBrowser(
URL(string: "https://companion.home-assistant.io")!,
nil
)
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication {
return SceneActivity.carPlay.configuration
} else {
let activity = options.userActivities
.compactMap { SceneActivity(activityIdentifier: $0.activityType) }
.first ?? .webView
return activity.configuration
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
notificationManager.didFailToRegisterForRemoteNotifications(error: error)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
notificationManager.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
notificationManager.didReceiveRemoteNotification(userInfo: userInfo, fetchCompletionHandler: completionHandler)
}
func application(
_ application: UIApplication,
performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Current.clientEventStore.addEvent(ClientEvent(text: "Background fetch activated", type: .backgroundOperation))
Current.backgroundTask(withName: BackgroundTask.backgroundFetch.rawValue) { remaining in
let updatePromise: Promise<Void>
if Current.settingsStore.isLocationEnabled(for: UIApplication.shared.applicationState),
Current.settingsStore.locationSources.backgroundFetch {
updatePromise = firstly {
Current.location.oneShotLocation(.BackgroundFetch, remaining)
}.then { location in
when(fulfilled: Current.apis.map {
$0.SubmitLocation(updateType: .BackgroundFetch, location: location, zone: nil)
})
}.asVoid()
} else {
updatePromise = when(fulfilled: Current.apis.map {
$0.UpdateSensors(trigger: .BackgroundFetch, location: nil)
})
}
return updatePromise
}.done {
completionHandler(.newData)
}.catch { error in
Current.Log.error("Error when attempting to update data during background fetch: \(error)")
completionHandler(.failed)
}
}
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if WebhookManager.isManager(forSessionIdentifier: identifier) {
Current.Log.info("starting webhook handler for \(identifier)")
Current.webhooks.handleBackground(for: identifier, completionHandler: completionHandler)
} else {
Current.Log.error("couldn't find appropriate session for for \(identifier)")
completionHandler()
}
}
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
IntentHandlerFactory.handler(for: intent)
}
// MARK: - Private helpers
@objc func checkForUpdate(_ sender: AnyObject? = nil) {
guard Current.updater.isSupported else { return }
let dueToUserInteraction = sender != nil
Current.updater.check(dueToUserInteraction: dueToUserInteraction).done { [sceneManager] update in
let alert = UIAlertController(
title: L10n.Updater.UpdateAvailable.title,
message: update.body,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(
title: L10n.Updater.UpdateAvailable.open(update.name),
style: .default,
handler: { _ in
URLOpener.shared.open(update.htmlUrl, options: [:], completionHandler: nil)
}
))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}.catch { [sceneManager] error in
Current.Log.error("check error: \(error)")
if dueToUserInteraction {
let alert = UIAlertController(
title: L10n.Updater.NoUpdatesAvailable.title,
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}
}
}
private func checkForAlerts() {
firstly {
Current.serverAlerter.check(dueToUserInteraction: false)
}.done { [sceneManager] alert in
sceneManager.webViewWindowControllerPromise.done { controller in
controller.show(alert: alert)
}
}.catch { error in
Current.Log.error("check error: \(error)")
}
showNotificationCategoryAlertIfNeeded()
}
private func showNotificationCategoryAlertIfNeeded() {
guard Current.realm().objects(NotificationCategory.self).isEmpty == false else {
return
}
let userDefaults = UserDefaults.standard
let seenKey = "category-deprecation-3-" + Current.clientVersion().description
guard !userDefaults.bool(forKey: seenKey) else {
return
}
when(fulfilled: Current.apis.compactMap { $0.connection.caches.user.once().promise })
.done { [sceneManager] users in
guard users.contains(where: \.isAdmin) else {
Current.Log.info("not showing because not an admin anywhere")
return
}
let alert = UIAlertController(
title: L10n.Alerts.Deprecations.NotificationCategory.title,
message: L10n.Alerts.Deprecations.NotificationCategory.message("iOS-2022.4"),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.Nfc.List.learnMore, style: .default, handler: { _ in
userDefaults.set(true, forKey: seenKey)
openURLInBrowser(
URL(string: "https://companion.home-assistant.io/app/ios/actionable-notifications")!,
nil
)
}))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: { _ in
userDefaults.set(true, forKey: seenKey)
}))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert)
}
}.catch { error in
Current.Log.error("couldn't check for if user: \(error)")
}
}
private func setupWatchCommunicator() {
watchCommunicatorService = WatchCommunicatorService()
watchCommunicatorService?.setup()
}
func setupLocalization() {
Current.localized.add(stringProvider: { request in
if prefs.bool(forKey: "showTranslationKeys") {
return request.key
} else {
return nil
}
})
}
private func setupLiveActivityReattachment() {
#if os(iOS) && !targetEnvironment(macCatalyst)
if #available(iOS 17.2, *) {
// Pre-warm the registry on the main thread before spawning background Tasks.
// This avoids a lazy-init race if a push notification handler accesses it
// concurrently from a background thread.
guard let registry = Current.liveActivityRegistry else { return }
Task {
// Re-attach observation tasks (push token + lifecycle) to any Live Activities
// that survived the previous process termination. Must run before the first
// notification handler fires so no push token updates are missed.
await registry.reattach()
}
// Begin observing the push-to-start token stream on a separate Task.
// The stream is infinite; this Task is kept alive for the app's lifetime.
Task {
await registry.startObservingPushToStartToken()
}
}
#endif
}
private func setupFirebase() {
let optionsFile: String = {
switch Current.appConfiguration {
case .beta: return "GoogleService-Info-Beta"
case .debug, .fastlaneSnapshot: return "GoogleService-Info-Debug"
case .release: return "GoogleService-Info-Release"
}
}()
if let optionsPath = Bundle.main.path(forResource: optionsFile, ofType: "plist"),
let options = FirebaseOptions(contentsOfFile: optionsPath) {
FirebaseApp.configure(options: options)
} else {
fatalError("no firebase config found")
}
notificationManager.setupFirebase()
}
private func setupModels() {
// Force Realm migration to happen now
_ = Realm.live()
Action.setupObserver()
NotificationCategory.setupObserver()
}
private func setupMenus() {
NotificationCenter.default.addObserver(
self,
selector: #selector(menuRelatedSettingDidChange(_:)),
name: SettingsStore.menuRelatedSettingDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(apiDidConnect(_:)),
name: HomeAssistantAPI.didConnectNotification,
object: nil
)
}
@objc private func menuRelatedSettingDidChange(_ note: Notification) {
UIMenuSystem.main.setNeedsRebuild()
}
@objc private func apiDidConnect(_ note: Notification) {
// When API reconnects, rebuild the menu to refresh the status item title subscription
// Force refresh by setting the flag that will cause the subscription to be recreated
#if targetEnvironment(macCatalyst)
shouldRefreshTitleSubscription = true
#endif
UIMenuSystem.main.setNeedsRebuild()
}
private func setupUIApplicationShortcutItems() {
if Current.isCatalyst {
UIApplication.shared.shortcutItems = [.init(
type: HAApplicationShortcutItem.openSettings.rawValue,
localizedTitle: L10n.ShortcutItem.OpenSettings.title,
localizedSubtitle: nil,
icon: .init(systemSymbol: .gear)
)]
}
}
private func migrateIfNeeded() {
resetLocalPush()
}
/// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it
private func resetLocalPush() {
if !Current.settingsStore.migratedOptInLocalPush {
for server in Current.servers.all {
server.update { info in
info.connection.isLocalPushEnabled = false
}
}
Current.settingsStore.migratedOptInLocalPush = true
Current.Log.info("Reset local push for all servers due to migration")
} else {
Current.Log.info("No need to reset local push, migration already done")
}
}
// swiftlint:disable:next file_length
}