This document provides guidance for AI coding agents (LLMs) working on the Home Assistant for Apple Platforms codebase.
Home Assistant for Apple Platforms is a native Swift companion app for Home Assistant home automation. The primary user interaction is through a WKWebView displaying the Home Assistant web frontend, with native features for notifications, sensors, location tracking, widgets, CarPlay, Apple Watch, and more.
- Language: Swift 5.8+
- Platforms: iOS, watchOS, macOS (Catalyst), CarPlay
- Build System: Xcode 26.2+, CocoaPods, Swift Package Manager
- Workspace: Always open
HomeAssistant.xcworkspace(not the.xcodeproj)
bundle install
bundle exec pod install --repo-updateCocoaPods and Swift Package Manager (SPM) manage third-party dependencies. CocoaPods is used for most dependencies, while SPM is used for select packages (e.g., swift-snapshot-testing, WebRTC, ZIPFoundation, firebase-ios-sdk).
Create Configuration/HomeAssistant.overrides.xcconfig (git-ignored):
DEVELOPMENT_TEAM = YourTeamID
BUNDLE_ID_PREFIX = some.bundle.prefix
Sources/
├── App/ # Main iOS app target
├── Shared/ # Shared code across all platforms
├── Watch/ # watchOS-specific code
├── WatchApp/ # watchOS app target
├── MacBridge/ # macOS Catalyst bridge
├── CarPlay/ # CarPlay integration
├── Extensions/ # App Extensions (widgets, notifications, intents)
├── Improv/ # Improv BLE provisioning
├── PushServer/ # Push notification server communication
├── SharedPush/ # Shared push notification handling
├── SharedTesting/ # Shared testing utilities
├── Thread/ # Thread network support
├── Launcher/ # App launcher helper
Tests/
├── App/ # App-level tests
├── Shared/ # Shared module tests
├── UI/ # UI tests
├── Widgets/ # Widget tests
├── Mocks/ # Mock objects for testing
Configuration/ # Xcode build configuration files
fastlane/ # Fastlane automation (build, test, deploy)
Tools/ # Build tools, icon generation
This project uses the "World" pattern for dependency injection, inspired by Point-Free's "How to Control the World". This is the most important architectural concept in the codebase.
A single global Current variable of type AppEnvironment holds all dependencies as mutable properties:
// Sources/Shared/Environment/Environment.swift
public var Current: AppEnvironment { ... }
public class AppEnvironment {
public var date: () -> Date = Date.init
public var calendar: () -> Calendar = { Calendar.autoupdatingCurrent }
public var servers: ServerManager = ServerManagerImpl()
public var clientEventStore: ClientEventStoreProtocol = ClientEventStore()
// ... many more dependencies
}Access dependencies through Current:
let now = Current.date()
let server = Current.servers.all.first
Current.Log.info("Something happened")Override dependencies for testing:
Current.date = { Date(timeIntervalSince1970: 1000000) }
Current.servers = FakeServerManager()Never assign to Current.* properties outside of test code. This is enforced by a custom SwiftLint rule that will fail CI. In production code, only read from Current.
The primary iOS UI is a WKWebView (WebViewController) that renders the Home Assistant web frontend; native Swift code wraps it with platform integrations. WebViewController functionality is spread across many WebViewController+*.swift extension files (navigation, gestures, alerts, URL loading, etc.). Native features communicate with the web UI via a JavaScript message bus handled by WebViewExternalMessageHandler (messages typed as WebViewExternalBusMessage / WebViewExternalBusOutgoingMessage in Sources/App/Frontend/ExternalMessageBus/) and custom URL schemes / deep links defined in AppConstants.
MagicItem (Sources/Shared/MagicItem/MagicItem.swift) is the shared model for items that can appear in Widgets, Watch, CarPlay, and App Shortcuts. It has a type (.script, .scene, .entity, .action, .folder, .assistPipeline) and an optional action override. ItemType.rawValue is persisted (Codable), so never change existing raw values.
- Add strings to the English
.stringsfile:Sources/App/Resources/en.lproj/Localizable.strings - SwiftGen auto-generates type-safe accessors in
Sources/Shared/Resources/SwiftGen/Strings.swiftwhen building the app - Use generated accessors via the
L10nenum:
// In Localizable.strings:
"settings.title" = "Settings";
"sensor.name_%@" = "Sensor: %@";
// In Swift code (auto-generated):
let title = L10n.Settings.title
let name = L10n.Sensor.name("Temperature")There are multiple string tables:
Localizable.strings→L10nenumCore.strings→CoreStringsenumFrontend.strings→FrontendStringsenum
All string lookup flows through Current.localized.string which handles locale fallback.
Important: Translations for other languages are managed externally via Lokalise. Only add/modify strings in the
en.lprojfiles.
# Check for lint issues (does not modify files)
bundle exec fastlane lint
# Auto-fix lint issues (run before committing!)
bundle exec fastlane autocorrectAlways run bundle exec fastlane autocorrect after making changes and before committing.
| Tool | Config File | Purpose |
|---|---|---|
| SwiftFormat | .swiftformat |
Code formatting (120 char max, before-first wrapping) |
| SwiftLint | .swiftlint.yml |
Code quality rules |
| Rubocop | .rubocop.yml |
Ruby/Fastlane code |
| YamlLint | .yamllint.yml |
YAML files |
- Max line width: 120 characters
- Wrap arguments/parameters/collections:
before-first selfkeyword: only in initializers (--self init-only)- Guard else: same line
- Headers: stripped (no file header comments)
- No
force_castorforce_try - Keep cyclomatic complexity low
- No assigning to
Current.*outside tests - Use
SFSafeSymbolsfor SF Symbol references:
// ❌ Wrong
Image(systemName: "house")
// ✅ Correct
Image(systemSymbol: .house)bundle exec fastlane testOr in Xcode: use the Tests-Unit scheme with ⌘U.
- Tests live in
Tests/mirroring the source structure - Mock dependencies by overriding
Current.*properties in test setup - Use
Sources/SharedTesting/for shared test utilities - Tests are excluded from SwiftLint enforcement
New SwiftUI views should have snapshot tests using helpers from SharedTesting:
import SharedTesting
func testMyView() {
assertLightDarkSnapshots(of: MyView()) // tests both light and dark mode
}CI runs on GitHub Actions (.github/workflows/ci.yml):
- Linting: SwiftFormat, SwiftLint, Rubocop, YamlLint
- Unit Tests: Runs the
Tests-Unitscheme - Build Verification: Ensures the app builds cleanly
All lint checks and tests must pass before a PR can be merged.
Prefer Swift Concurrency (async/await, Task, actors, structured concurrency) for all new asynchronous code.
- Do not introduce new PromiseKit code. PromiseKit is a legacy dependency that the codebase is gradually moving away from. Parts of
HomeAssistantAPI(HAAPI.swift) still use it, so don't assume a full migration — but new work should useasync/awaitinstead ofPromise/Guarantee. - When touching existing PromiseKit code, migrate it to
async/awaitwhere practical rather than extending the PromiseKit usage. - Use
Combineonly where an existing reactive pattern already requires it; otherwise preferasync/awaitandAsyncStream/AsyncSequence. - Annotate SwiftUI-facing view models with
@MainActor.
When implementing or fixing anything related to Live Activities (or push notifications generally), always check BOTH delivery flows — local push and remote push. They are handled by different code paths, so a field, behavior, or fix applied to one is easily missed in the other (e.g. a payload key parsed for APNs but dropped over local push, or alerting/sound handled differently per flow).
- Remote push (APNs / cloud): Home Assistant → the push relay (
Sources/PushServer) → APNs → the app /Sources/Extensions/NotificationService. The payload already carries ahomeassistantdictionary. - Local push (WebSocket /
NEAppPushProvider): delivered over the on-network channel and handled bySources/Extensions/PushProvider+Sources/Shared/Notifications/LocalPush(LocalPushManager,LocalPushEvent). The payload arrives as a flat{message, data}shape and is reshaped byLegacyNotificationParserImpl(Sources/SharedPush/Sources/NotificationParserLegacy.swift), which must explicitly promotedatafields intohomeassistant— any field not in that promotion list is silently dropped on this flow only.
Both flows converge on NotificationCommandManager → HandlerStartOrUpdateLiveActivity → LiveActivityRegistry (Sources/Shared/LiveActivity), with the UI rendered by Sources/Extensions/Widgets/LiveActivity.
Practical checklist when changing this area:
- If you add or read a notification/Live-Activity payload field, confirm it survives both the local-push parser promotion list and the remote-push payload.
- Verify alerting behavior (sound, haptics, banner suppression, the
silentflag) on both flows — local push presents notifications throughLocalPushManager, not the system directly. Sources/SharedPushis vendored as a separate copy underSources/PushServer/SharedPush(the relay). Keep the two parser copies in sync when a parsing change is relevant to both the app and the relay; some logic intentionally lives in only one copy (e.g. the Live Activitylive_updatepromotion is app-only, since the relay/cloud path already carries ahomeassistantdict). When you change one copy, decide explicitly whether the other needs the same change.- Add/extend tests for both flows (e.g.
Tests/Shared/LocalPushManager.test.swiftfor the local path,Sources/PushServer/Tests/SharedPushTestsfor the relay/parser).
Use HAKit (the Home Assistant Swift SDK) for server communication:
- REST API calls via
HAConnection - WebSocket subscriptions for real-time updates
- Connection info managed through
Current.servers - Prefer
async/awaitfor new request flows (see Concurrency); avoid adding new PromiseKit-based calls.
The project uses two database layers:
- GRDB (
GRDB.swift): newer layer, accessed viaCurrent.database(). Used for Watch config, CarPlay config, widget config, entity registry, panels, etc. When adding a new persistent model, prefer GRDB: implementDatabaseTableProtocol(definestableName,definedColumns, andcreateIfNeeded) and register it inDatabaseQueue.tables()inGRDB+Initialization.swift. The protocol'smigrateColumnshelper auto-handles additive migrations. - Realm (
RealmSwift): legacy layer, used for older models (actions, zones, sensors, etc.). Access viaCurrent.realm(). - UserDefaults: simple preferences and watch communication.
Use Current.Log (XCGLogger) — never print or NSLog:
Current.Log.info("Connected to \(server.info.name)")
Current.Log.error("Failed: \(error.localizedDescription)")
Current.Log.verbose("Debug detail")with(_:update:) in Sources/Shared/Common/With.swift is used for fluent inline initialization:
public lazy var webhooks = with(WebhookManager()) {
$0.register(responseHandler: ..., for: .updateSensors)
}- SwiftUI: Preferred for new UI (settings, widgets)
- UIKit: Used in older code and where needed for platform APIs
- Use
View.embeddedInHostingController()for SwiftUI-to-UIKit bridging - View models are annotated with
@MainActor - Support both light and dark mode
- SF Symbols via
SFSafeSymbolslibrary (Image(systemSymbol: .house)), never the string-based API - HA domain/entity icons via the
MaterialDesignIconsenum (auto-generated from JSON); names carry anIconsuffix (e.g..lightbulbIcon), andDomain.icon(deviceClass:state:)provides domain-appropriate icons - Asset catalogs in
Sources/Shared/Assets/SharedAssets.xcassets
- Install dependencies:
bundle install && bundle exec pod install --repo-update - Make your changes in the appropriate
Sources/directory - Add strings to
en.lproj/Localizable.stringsif needed (SwiftGen generates accessors on build) - Run autocorrect:
bundle exec fastlane autocorrect - Run tests:
bundle exec fastlane test - Commit your changes