Files
iOS/Sources/Shared/Environment/AppConstants.swift
2026-03-30 22:42:09 +02:00

394 lines
17 KiB
Swift

import Foundation
import KeychainAccess
import UIKit
import Version
/// Contains shared constants
public enum AppConstants {
public enum WebURLs {
public static var homeAssistant = URL(string: "https://www.home-assistant.io")!
public static var homeAssistantGetStarted = URL(string: "https://www.home-assistant.io/installation/")!
public static var homeAssistantCompanionGetStarted =
URL(string: "https://companion.home-assistant.io/docs/getting_started/")!
public static var companionAppDocs = URL(string: "https://companion.home-assistant.io")!
public static var companionAppDocsTroubleshooting =
URL(string: "https://companion.home-assistant.io/docs/troubleshooting/errors")!
public static var beta = URL(string: "https://companion.home-assistant.io/app/ios/beta")!
public static var betaMac = URL(string: "https://companion.home-assistant.io/app/ios/beta_mac")!
public static var review = URL(string: "https://companion.home-assistant.io/app/ios/review")!
public static var reviewMac = URL(string: "https://companion.home-assistant.io/app/ios/review_mac")!
public static var translate = URL(string: "https://companion.home-assistant.io/app/ios/translate")!
public static var forums = URL(string: "https://community.home-assistant.io/")!
public static var chat = URL(string: "https://companion.home-assistant.io/app/ios/chat")!
public static var twitter = URL(string: "https://twitter.com/home_assistant")!
public static var facebook = URL(string: "https://www.facebook.com/292963007723872")!
public static var repo = URL(string: "https://companion.home-assistant.io/app/ios/repo")!
public static var issues = URL(string: "https://companion.home-assistant.io/app/ios/issues")!
public static var companionAppConnectionSecurityLevel =
URL(string: "https://companion.home-assistant.io/docs/getting_started/connection-security-level")!
public static var companionLocalPush =
URL(string: "https://companion.home-assistant.io/app/ios/local-push")!
public static var nfcDocs =
URL(string: "https://companion.home-assistant.io/app/ios/nfc")!
}
public enum QueryItems: String, CaseIterable {
case openMoreInfoDialog = "more-info-entity-id"
case isComingFromAppIntent = "isComingFromAppIntent"
}
public enum WebRTC {
public static let iceServers = [
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
}
/// Home Assistant Blue
public static var tintColor: UIColor {
#if os(iOS)
return UIColor { [lighterTintColor, darkerTintColor] (traitCollection: UITraitCollection) -> UIColor in
traitCollection.userInterfaceStyle == .dark ? lighterTintColor : darkerTintColor
}
#else
return lighterTintColor
#endif
}
public static var lighterTintColor: UIColor {
UIColor(hue: 199.0 / 360.0, saturation: 0.99, brightness: 0.96, alpha: 1.0)
}
public static var darkerTintColor: UIColor {
UIColor(hue: 199.0 / 360.0, saturation: 0.99, brightness: 0.67, alpha: 1.0)
}
/// Help icon UIBarButtonItem
#if os(iOS)
public static var helpBarButtonItem: UIBarButtonItem {
with(UIBarButtonItem(
icon: .helpCircleOutlineIcon,
target: nil,
action: nil
)) {
$0.accessibilityLabel = L10n.helpLabel
}
}
#endif
/// The Bundle ID used for the AppGroupID
public static var BundleID: String {
let baseBundleID = Bundle.main.bundleIdentifier!
var removeBundleSuffix = baseBundleID.replacingOccurrences(of: ".APNSAttachmentService", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".Intents", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".NotificationContentExtension", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".TodayWidget", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".watchkitapp.watchkitextension", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".watchkitapp", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".Widgets", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".ShareExtension", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".PushProvider", with: "")
removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".Matter", with: "")
return removeBundleSuffix
}
public static var deeplinkURL: URL {
switch Current.appConfiguration {
case .debug:
return URL(string: "homeassistant-dev://")!
case .beta:
return URL(string: "homeassistant-beta://")!
default:
return URL(string: "homeassistant://")!
}
}
public static func invitationURL(serverURL: URL) -> URL? {
guard let encodedURLString = serverURL.absoluteString
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return nil
}
return URL(string: "https://my.home-assistant.io/invite/#url=\(encodedURLString)")
}
public static func navigateDeeplinkURL(
path: String,
serverId: String,
queryParams: String? = nil,
avoidUnnecessaryReload: Bool
) -> URL? {
var url = URL(
string: "\(AppConstants.deeplinkURL.absoluteString)navigate/\(path)?server=\(serverId)&avoidUnnecessaryReload=\(avoidUnnecessaryReload)&\(AppConstants.QueryItems.isComingFromAppIntent.rawValue)=true"
)
if let queryParams, let newURL = URL(string: "\(url?.absoluteString ?? "")&\(queryParams)") {
url = newURL
}
return url
}
public static func openPageDeeplinkURL(path: String, serverId: String) -> URL? {
AppConstants.navigateDeeplinkURL(path: path, serverId: serverId, avoidUnnecessaryReload: true)?
.withWidgetAuthenticity()
}
public static func openEntityDeeplinkURL(entityId: String, serverId: String) -> URL? {
AppConstants.navigateDeeplinkURL(
path: "",
serverId: serverId,
queryParams: "\(AppConstants.QueryItems.openMoreInfoDialog.rawValue)=\(entityId)",
avoidUnnecessaryReload: true
)?.withWidgetAuthenticity()
}
public static func openCameraDeeplinkURL(entityId: String, serverId: String) -> URL? {
URL(
string: "\(AppConstants.deeplinkURL.absoluteString)camera/?entityId=\(entityId)&serverId=\(serverId)&\(AppConstants.QueryItems.isComingFromAppIntent.rawValue)=true"
)
}
public static func openCameraListDeeplinkURL(serverId: String? = nil) -> URL? {
var urlString = "\(AppConstants.deeplinkURL.absoluteString)camera/?"
if let serverId {
urlString += "serverId=\(serverId)&"
}
urlString += "\(AppConstants.QueryItems.isComingFromAppIntent.rawValue)=true"
return URL(string: urlString)
}
@available(iOS 16.0, watchOS 9.0, *)
public static func todoListAddItemURL(listId: String, serverId: String) -> URL? {
guard !serverId.isEmpty, !listId.isEmpty else {
return nil
}
return URL(string: "\(AppConstants.deeplinkURL.absoluteString)navigate/todo")?.appending(queryItems: [
URLQueryItem(name: "entity_id", value: listId),
URLQueryItem(name: "serverId", value: serverId),
URLQueryItem(name: "add_item", value: "true"),
])
}
@available(iOS 16.0, watchOS 9.0, *)
public static func todoListOpenURL(listId: String, serverId: String) -> URL? {
guard !serverId.isEmpty, !listId.isEmpty else {
return nil
}
return URL(string: "\(AppConstants.deeplinkURL.absoluteString)navigate/todo")?.appending(queryItems: [
URLQueryItem(name: "entity_id", value: listId),
URLQueryItem(name: "serverId", value: serverId),
])
}
public static func assistDeeplinkURL(serverId: String, pipelineId: String, startListening: Bool) -> URL? {
URL(
string: "\(AppConstants.deeplinkURL.absoluteString)assist?serverId=\(serverId)&pipelineId=\(pipelineId)&startListening=\(startListening)"
)?.withWidgetAuthenticity()
}
public static var createCustomWidgetURL: URL {
URL(string: "\(AppConstants.deeplinkURL.absoluteString)createCustomWidget")!
}
/// The App Group ID used by the app and extensions for sharing data.
public static var AppGroupID: String {
"group." + BundleID.lowercased()
}
public static var AppGroupContainer: URL {
let fileManager = FileManager.default
let groupDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.AppGroupID)
guard let groupDir else {
fatalError("Unable to get groupDir.")
}
return groupDir
}
public static var appGRDBFile: URL {
let fileManager = FileManager.default
let directoryURL = Self.AppGroupContainer.appendingPathComponent("databases", isDirectory: true)
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
} catch {
Current.Log.error("Failed to create App GRDB file")
}
}
let databaseURL = directoryURL.appendingPathComponent("App.sqlite")
return databaseURL
}
public static var clientEventsFile: URL {
let fileManager = FileManager.default
let directoryURL = Self.AppGroupContainer.appendingPathComponent("databases", isDirectory: true)
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
} catch {
Current.Log.error("Failed to create Client Events file")
}
}
let eventsURL = directoryURL.appendingPathComponent("clientEvents.json")
return eventsURL
}
public static var widgetsCacheURL: URL = {
let fileManager = FileManager.default
let directoryURL = Self.AppGroupContainer.appendingPathComponent("caches/widgets", isDirectory: true)
return directoryURL
}()
public static func widgetCachedStates(widgetId: String) -> URL {
let fileManager = FileManager.default
let directoryURL = Self.widgetsCacheURL
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
} catch {
Current.Log.error("Failed to create Client Events file")
}
}
let eventsURL = directoryURL.appendingPathComponent("/widgetId-\(widgetId).json")
return eventsURL
}
public static var watchMagicItemsInfo: URL {
let fileManager = FileManager.default
let directoryURL = Self.AppGroupContainer.appendingPathComponent("caches", isDirectory: true)
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
} catch {
Current.Log.error("Failed to magic items info file")
}
}
let eventsURL = directoryURL.appendingPathComponent("magicItemsInfo.json")
return eventsURL
}
public static var LogsDirectory: URL {
let fileManager = FileManager.default
let directoryURL = AppGroupContainer.appendingPathComponent("logs", isDirectory: true)
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
} catch {
fatalError("Error while attempting to create data store URL: \(error)")
}
}
return directoryURL
}
public static var DownloadsDirectory: URL {
var directoryURL: URL = FileManager.default.urls(for: .cachesDirectory, in: .allDomainsMask).first!
// Save directly in macOS Downloads folder if running on Catalyst and allowed access to download folder when
// prompted.
if Current.isCatalyst, let macDownloadFolder = FileManager.default.urls(
for: .downloadsDirectory,
in: .userDomainMask
).first {
directoryURL = macDownloadFolder
} else {
directoryURL = directoryURL.appendingPathComponent(
"Downloads",
isDirectory: true
)
}
if !FileManager.default.fileExists(atPath: directoryURL.path) {
do {
try FileManager.default.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
fatalError("Error while attempting to create downloads path URL: \(error)")
}
}
return directoryURL
}
/// An initialized Keychain from KeychainAccess.
public static var Keychain: KeychainAccess.Keychain {
KeychainAccess.Keychain(service: BundleID)
}
/// A permanent ID stored in UserDefaults and Keychain.
public static var PermanentID: String {
let storageKey = "deviceUID"
let defaultsStore = UserDefaults(suiteName: AppConstants.AppGroupID)
let keychain = KeychainAccess.Keychain(service: storageKey)
if let keychainUID = keychain[storageKey] {
return keychainUID
}
if let userDefaultsUID = defaultsStore?.object(forKey: storageKey) as? String {
return userDefaultsUID
}
let newID = UUID().uuidString
if keychain[storageKey] == nil {
keychain[storageKey] = newID
}
if defaultsStore?.object(forKey: storageKey) == nil {
defaultsStore?.setValue(newID, forKey: storageKey)
}
return newID
}
public static var build: String {
SharedPlistFiles.Info.cfBundleVersion
}
public static var version: String {
SharedPlistFiles.Info.cfBundleShortVersionString
}
static var clientVersion: Version {
// swiftlint:disable:next force_try
var clientVersion = try! Version(version)
clientVersion.build = build
return clientVersion
}
}
public extension Version {
static let canSendDeviceID: Version = .init(minor: 104)
static let pedometerIconsAvailable: Version = .init(minor: 105)
static let tagWebhookAvailable: Version = .init(minor: 114, prerelease: "b5")
static let actionSyncing: Version = .init(minor: 115, prerelease: "any0")
static let localPushConfirm: Version = .init(major: 2021, minor: 10, prerelease: "any0")
static let externalBusCommandRestart: Version = .init(major: 2021, minor: 12, prerelease: "b6")
static let updateLocationGPSOptional: Version = .init(major: 2022, minor: 2, prerelease: "any0")
static let fullWebhookSecretKey: Version = .init(major: 2022, minor: 3)
static let conversationWebhook: Version = .init(major: 2023, minor: 2, prerelease: "any0")
static let externalBusCommandSidebar: Version = .init(major: 2023, minor: 4, prerelease: "b3")
static let externalBusCommandAutomationEditor: Version = .init(major: 2024, minor: 2, prerelease: "any0")
static let canUseAppThemeForStatusBar: Version = .init(major: 2024, minor: 7)
/// The version where the app can subscribe to entities changes with a filter (e.g. only state changes from sensor
/// domain)
static let canSubscribeEntitiesChangesWithFilter: Version = .init(major: 2024, minor: 10)
/// Allows app to ask frontend to navigate to a specific page
static let canNavigateThroughFrontend: Version = .init(major: 2025, minor: 6, prerelease: "any0")
/// Allows app to ask frontend to navigate to a more info dialog
static let canNavigateMoreInfoDialogThroughFrontend: Version = .init(major: 2026, minor: 1, prerelease: "any0")
/// Frontend introduces the quickbar with Ctrl+K keyboard shortcut in 2026.2
static let quickSearchKeyboardShortcut: Version = .init(major: 2026, minor: 2, prerelease: "any0")
var coreRequiredString: String {
L10n.requiresVersion(String(format: "core-%d.%d", major, minor ?? -1))
}
}