Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1451c27
Build the Mac app natively with AppKit and SwiftUI
bgoncal Sep 30, 2026
8ee8690
Give the onboarding welcome screen the background of its button block
bgoncal Sep 30, 2026
d669fb1
Name the app menu's settings item Settings…
bgoncal Oct 1, 2026
ff57454
Cover the code the native Mac app changed with tests
bgoncal Oct 1, 2026
57b20e3
Fix what the Mac app's Settings showed and asked for
bgoncal Oct 1, 2026
72c4296
Test the alerts and prompts the frontend controller raises
bgoncal Oct 1, 2026
f6586ca
Note how a grouped form draws buttons and card-less content on the Mac
bgoncal Oct 1, 2026
6a26281
Keep the frontend window's title out of its toolbar on the Mac
bgoncal Oct 1, 2026
67228a0
Give the Mac server picker sheet a size
bgoncal Oct 1, 2026
aa717be
Draw the picker sheet's settings button as a template on the Mac
bgoncal Oct 1, 2026
4b5eb3a
Label the picker sheet's settings button on the Mac
bgoncal Oct 1, 2026
94d95c3
Stop the intent tests waiting forever for a request that never came
bgoncal Oct 1, 2026
ebe6699
Draw the Location settings rows the way the other settings screens ar…
bgoncal Oct 1, 2026
03ec1aa
Show files and folders in Finder when the Mac app is asked to open them
bgoncal Oct 1, 2026
a9ce4a1
Keep the Mac release lane on Catalyst and build both Mac variants in CI
bgoncal Oct 1, 2026
25ff99c
Keep the iOS behaviour as it was and give the Settings menu item its …
bgoncal Oct 1, 2026
0e32dbb
Let the Mac app finish its sensor update before it quits and report t…
bgoncal Oct 1, 2026
ebb6453
Draw map snapshot overlays with the Mac's bottom-left origin in mind
bgoncal Oct 1, 2026
17dc436
Send menu commands to the key window and reopen a frontend window whe…
bgoncal Oct 1, 2026
7ab97f7
Give Mac alerts a preferred action and keep alerts and sheets on the …
bgoncal Oct 1, 2026
bc2606a
Wire the Mac's Find menu, handoff, reconnects and the transparent web…
bgoncal Oct 1, 2026
cb4154e
Truer Mac colours, sharper images, and the gauge widget without a com…
bgoncal Oct 1, 2026
12ddac2
Leave code that never compiles for macOS without Mac availability cla…
bgoncal Oct 1, 2026
0c14e86
Make the new tests assert what they lay out and leave the process as …
bgoncal Oct 1, 2026
f63151d
Document the native Mac app's rules after the review
bgoncal Oct 1, 2026
d2d1060
Follow the house conventions in the Mac additions
bgoncal Oct 1, 2026
c7b4152
Cover the stand-by notifications, remote push commands, sharing and h…
bgoncal Oct 1, 2026
8781743
Keep the presentation tests' controller from loading after its test
bgoncal Oct 2, 2026
ec71780
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 2, 2026
a336e64
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 3, 2026
d3e8eb6
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 3, 2026
1654abc
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 9, 2026
92a0e0d
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 9, 2026
b7f1476
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 9, 2026
6c4d7b5
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 9, 2026
41a8d0c
Merge remote-tracking branch 'origin/main' into native-macos-app
bgoncal Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .agents/skills/ha-ios-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Home Assistant iOS project layout, build setup, and the "World" dep
Home Assistant for Apple Platforms is a native Swift companion app for [Home Assistant](https://www.home-assistant.io/) 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
- **Platforms**: iOS, watchOS, macOS (native, with the Catalyst build kept until it is retired; see the `ha-ios-macos` skill), CarPlay
- **Build System**: Xcode 27.0+, Swift Package Manager
- **Project**: Open `HomeAssistant.xcodeproj` directly (dependencies are managed via Swift Package Manager)

Expand Down Expand Up @@ -39,7 +39,7 @@ Sources/
├── Shared/ # Shared code across all platforms
├── Watch/ # watchOS-specific code
├── WatchApp/ # watchOS app target
├── MacBridge/ # macOS Catalyst bridge
├── MacBridge/ # AppKit-only code: a bundle loaded by the Catalyst app, compiled into Shared natively
├── CarPlay/ # CarPlay integration
├── Extensions/ # App Extensions (widgets, notifications, intents)
├── Improv/ # Improv BLE provisioning
Expand Down
53 changes: 53 additions & 0 deletions .agents/skills/ha-ios-macos/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
name: ha-ios-macos
description: How the native macOS app is built from the shared iOS code. Use when a change touches anything under `#if os(macOS)`, when UIKit code needs an AppKit counterpart, when presenting or dismissing a SwiftUI screen from AppKit, when adding a Mac window, menu or sheet, when a screen looks wrong on the Mac, or when building and running the Mac app.
---

# The native macOS app

The same targets build for iOS, Mac Catalyst and native macOS (`SUPPORTED_PLATFORMS = iphoneos iphonesimulator macosx`). The deployment target is macOS 13.3 for the app and its extensions, what the Catalyst app already requires through iOS 16.4 and what the native app's SwiftUI windows need; the Widgets target asks for macOS 14.0, as its widgets are App Intents based. Release still archives the Catalyst app (`fastlane/lanes/macos.rb` passes the Catalyst destination explicitly now that the targets also build natively); there is no signing or provisioning for the native app yet, so it is a developer build. Native macOS is `#if os(macOS)`; under Catalyst `os(iOS)` is true. `Current.isCatalyst` means "running on a Mac" and is true for both Mac variants, and code that was `#if targetEnvironment(macCatalyst)` now reads `#if targetEnvironment(macCatalyst) || os(macOS)`: when in doubt about what the native app should do, do what the Catalyst app does. Leave code that never compiles for macOS alone, though: a file or region under `#if os(iOS)` or `#if !os(macOS)` gains nothing from `|| os(macOS)` or a `macOS x.y` availability clause.

## Build and run

```bash
xcodebuild -project HomeAssistant.xcodeproj -scheme App-Debug -destination 'platform=macOS,arch=arm64' \
-skipPackagePluginValidation -skipMacroValidation CODE_SIGNING_ALLOWED=NO build
```

`CODE_SIGNING_ALLOWED=NO` is for a machine without a Mac signing identity; the product still launches from `Build/Products/Debug/`. Such a build has no entitlements at all: no sandbox, no app group, no keychain access group and no push environment. That hides a whole class of problems, so do not read "works unsigned" as "works": keychain items are not shared with the extensions, widgets and the other extensions cannot reach the app's data, push registration fails, and Apple Events to other apps (revealing a file in the Finder through `NSWorkspace`, say) are refused silently. A window that is behind other windows is not painted by WebKit, so bring the app to the front before judging a blank frontend.

## The compatibility layer

Write a screen once; only the view layer is per platform.

- **Value types keep their UIKit names.** `UIColor`, `UIImage`, `UIFont`, `UIEdgeInsets`, `UIBezierPath` and the semantic colour names are aliases or extensions on AppKit in `Sources/HAIconic/Sources/Platform/AppKitCompatibility.swift`; `UIRectCorner`, `UIGraphicsImageRenderer`, `UIPasteboard` and the feedback generators are stand-in types in their own files next to it. `UIColor.rgbaComponents` and `UIColor.dynamic(light:dark:)` (`PlatformColor+Components.swift`) replace `getRed` and trait-based colours. These are covered by `HADesignSystem`'s package tests, which the `build-mac` CI job runs on macOS.
- **iOS-only SwiftUI modifiers have Mac stand-ins** in `Sources/HADesignSystem/Sources/Platform/SwiftUI+macOSCompatibility.swift` (`navigationBarTitleDisplayMode`, `fullScreenCover` → sheet, `keyboardType`, `listSectionSpacing`, …), with the enums they take in their own files next to it. They exist so a modifier does not need an `#if` at every use; add to that file rather than sprinkling `#if os(macOS)` around modifiers.
- **A transparent `WKWebView`** is `makeBackgroundTransparent()` (`WKWebView+TransparentBackground.swift`): `drawsBackground` is not API on macOS, so it is set through key-value coding, guarded by `responds(to:)` so a WebKit that drops the key leaves the page opaque rather than crashing.
- **Views, controllers and windows are not aliased.** `PlatformView`, `PlatformViewController`, `PlatformWindow`, `PlatformHostingController` (`Sources/Shared/Common/PlatformTypes.swift`) are for code that only passes them along. Anything that builds a view is written per platform with real AppKit (`NSViewRepresentable`, `NSViewControllerRepresentable`) or, better, plain SwiftUI.
- **`NavigationView` is a stack on the Mac.** `Sources/App/Utilities/NavigationView.swift` takes the name over for the app target on macOS and wraps `NavigationStack`, because SwiftUI's own `NavigationView` always lays out in columns there.
- **Lifecycle:** `AppLifecycle.*Notification` and `ApplicationState.current` instead of `UIApplication`. A Mac app is never in the background.
- **Alerts from non-SwiftUI code** are described with `AppAlert` and shown with `appCoordinator.present(alert:)`. Mark the action the user most likely wants `isPreferred`; `NSAlert` orders its buttons from the right and only the preferred one gets the default key equivalent, which `macButtonOrder` takes care of.
- **Location:** never set `allowsBackgroundLocationUpdates` on macOS; Core Location throws, and an exception thrown during launch leaves every SwiftUI `List` in the process empty.

## Presenting SwiftUI screens from AppKit

SwiftUI's `dismiss` action does nothing inside an `NSHostingController` that AppKit presented. So AppKit code never calls `presentAsSheet` on a SwiftUI screen; it calls `presentSheet(_:)` (`Sources/App/Utilities/MacSheet/`), which takes the screen back out of the hosting controller and shows it in a sheet SwiftUI owns. `presentedSheets`, `dismissSheet()` and `dismissPresentedSheets()` are the counterparts of the UIKit presentation calls the shared code uses; `WebViewController` and `AppContainerCoordinator` route through them.

- A controller that called `presentsAsTransparentOverlay()` (a bottom sheet drawn over the frontend on iOS) gets a sheet sized to its content; every other screen opens at `preferredContentSize` or a phone-like default.
- `AppleLikeBottomSheet` reads `\.isPresentedInMacSheet` and draws only its card inside such a sheet. Set that environment value whenever a Mac sheet stands in for an iOS full-screen presentation.

## Screens

- **Settings rows use `GroupedList`**: the rule and what counts as a settings screen are in the `ha-ios-ui` skill. On the Mac it is a grouped `Form`, where buttons are `.borderless` so a row's action reads as a row rather than a push button with its own bezel, and `.listRowBackground` is ignored, so content that must sit outside a card (a logo, a hint) goes in the `header:` of an otherwise empty `Section`. A plain `List` on the Mac is a table that cuts every section footer to one line. A `Form` that is not a `GroupedList` is only for a screen that is a form on iOS too.
- **`dismiss` and pushed pages:** a view that reads `@Environment(\.dismiss)` and also drives a `navigationDestination(isPresented:)` from its own state is re-evaluated forever on the Mac, because the action changes each time the destination is rebuilt and the destination is rebuilt each time the view is (`OnboardingServersListView` was the case). Reading `dismiss` next to a plain `NavigationLink` is fine. When the loop appears, read the action in a leaf instead: `.dismiss(when:)` (`DismissWhenModifier`). A screen that is the content of a Settings window pane rather than a sheet must not `dismiss()` itself when its model goes away (a deleted server): `SettingsView` resets the sidebar selection instead.
- **Windows** are SwiftUI scenes in `HAApp` (main `WindowGroup`, `Window` scenes for Settings, About and Assist, a `WindowGroup` for adding a server). Code outside the scene graph opens them through `MacWindowOpener` / `Current.sceneManager.activateAnyScene(for:)`; `SceneActivity.initialWindowSize` sets first-launch sizes.
- **Menus** are SwiftUI `Commands` (`MainWindowGroupCommands`, `AppMenuBarCommands`, `MacWebViewCommands`); the Dock menu is `applicationDockMenu` in `AppDelegate`; the menu bar item is `StatusItemManager` over `MacBridge`.
- **The frontend window** is `WebViewController` on both platforms (an `NSViewController` on macOS) with `MacWebViewTitleBar` providing the toolbar and `NSTextFinder` the find bar.

## Extensions

Widgets, Intents, NotificationService, NotificationContent and Share build for macOS from the same targets; NotificationContent has its own `Info-macOS.plist` (a principal class instead of a storyboard). New files under `Sources/Extensions` must be listed in the project's exception sets like their neighbours, because that folder is owned by the Widgets target.

## What the native app does not do

CarPlay, Apple Watch complications, NFC, kiosk mode, gestures and app icon shortcuts are compiled out or hidden, exactly as they were under Catalyst.
5 changes: 3 additions & 2 deletions .agents/skills/ha-ios-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: "SwiftUI and UIKit UI conventions for Home Assistant iOS. Use when

- **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
- Use `View.embeddedInHostingController()` for SwiftUI-to-UIKit bridging (on the Mac, present the result with `presentSheet(_:)`; see the `ha-ios-macos` skill)
- View models are annotated with `@MainActor`
- Support both light and dark mode

Expand All @@ -16,7 +16,8 @@ description: "SwiftUI and UIKit UI conventions for Home Assistant iOS. Use when
- **One struct per file**: whenever a new `View` struct is introduced, put it in its own file. Do not stack multiple view structs in one file.
- **Keep everything in `body`**: build the view's content inline inside `body`. Do not abstract portions out into separate reusable subviews (helper view structs or computed `some View` properties) just to break `body` up. Extract a new struct only when it is genuinely reused elsewhere, and when you do, it gets its own file (see above).
- **Always add a `#Preview`**: every SwiftUI view must ship with a preview so it can be checked quickly in Xcode.
- **No navigation title under an Apple-like header**: when a screen's `List` opens with `AppleLikeListTopRowHeader`, that header is the screen's title, so do not also apply `.navigationTitle` — the two titles end up stacked once the header scrolls under the bar. Screens without the header (a "How it works?" explainer, say) still set `.navigationTitle` as usual.
- **Settings screens are a `GroupedList`**: a screen made of sections of settings rows uses `GroupedList` from `HADesignSystem` rather than `List`, so it draws as a grouped form on the Mac. A `List` stays for lists of data.
- **No navigation title under an Apple-like header**: when a screen's `List` or `GroupedList` opens with `AppleLikeListTopRowHeader`, that header is the screen's title, so do not also apply `.navigationTitle` — the two titles end up stacked once the header scrolls under the bar. Screens without the header (a "How it works?" explainer, say) still set `.navigationTitle` as usual.
- **Snapshot tests for new features**: any new feature that adds UI must include snapshot tests (see the `ha-ios-testing` skill).

## Design System & Reusable Components (`HADesignSystem`)
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,61 @@ jobs:
# so the reason lands where the discussion is. The review is dismissed automatically
# as soon as a push clears the bar, and a maintainer can dismiss it to land a change
# whose new code genuinely cannot be unit tested.
build-mac:
name: Build Mac (${{ matrix.variant }})
runs-on: xcode-27
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- variant: Catalyst
destination: generic/platform=macOS,variant=Mac Catalyst
- variant: native
destination: generic/platform=macOS
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
name: "Restore: DerivedData"
id: derived_data
with:
path: ~/Library/Developer/Xcode/DerivedData
key: >-
${{ format('{0}-deriveddata-{1}-{2}',
runner.os,
env.DEVELOPER_DIR,
hashFiles('Package.resolved')) }}
restore-keys: >-
${{ format('{0}-deriveddata-{1}-',
runner.os,
env.DEVELOPER_DIR) }}

- name: "Drop precompiled modules from a fallback DerivedData restore"
if: steps.derived_data.outputs.cache-hit != 'true'
run: rm -rf ~/Library/Developer/Xcode/DerivedData/*/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules

- name: Resolve Swift packages
run: >-
xcodebuild -resolvePackageDependencies
-project HomeAssistant.xcodeproj
-scheme App-Debug

- name: Build
run: >-
xcodebuild build
-project HomeAssistant.xcodeproj
-scheme App-Debug
-destination "${{ matrix.destination }}"
-skipPackagePluginValidation
-skipMacroValidation
COMPILER_INDEX_STORE_ENABLE=NO
CODE_SIGNING_ALLOWED=NO

- name: Test HADesignSystem on macOS
if: matrix.variant == 'native'
run: swift test --package-path Sources/HADesignSystem

patch-coverage:
name: Patch coverage
needs: test
Expand Down
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,5 @@ custom_rules:
name: "Safe SFSymbol"
message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety."
regex: >-
(\bImage\(systemName:)|(\bNSImage\(symbolName:)|(\bLabel[^,]+?,\s*systemImage:)|(\bUIApplicationShortcutIcon\(systemImageName:)
(\bImage\(systemName:)|(\bNSImage\(systemSymbolName:)|(\bLabel[^,]+?,\s*systemImage:)|(\bUIApplicationShortcutIcon\(systemImageName:)
severity: warning
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This is the router for AI coding agents (LLMs) working on the Home Assistant for
Home Assistant for Apple Platforms is a native Swift companion app for [Home Assistant](https://www.home-assistant.io/) 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
- **Platforms**: iOS, watchOS, macOS (native, with the Catalyst build kept until it is retired), CarPlay
- **Build System**: Xcode 27.0+, Swift Package Manager
- **Project**: Open `HomeAssistant.xcodeproj` directly (dependencies are managed via Swift Package Manager)

Expand All @@ -14,7 +14,7 @@ Home Assistant for Apple Platforms is a native Swift companion app for [Home Ass
These apply to every change, even if you don't load the matching skill:

- **One type per file**: each `struct`/`class`/`enum` lives in its own file named after the type — never stack multiple top-level types (especially SwiftUI `View` structs) in one file. Small `private` helper types nested inside the type are the only exception. Every SwiftUI view ships with a `#Preview`. See [`ha-ios-ui`](.agents/skills/ha-ios-ui/SKILL.md) for the full UI rules.
- **No navigation title under an Apple-like header**: a screen whose `List` starts with `AppleLikeListTopRowHeader` already shows its title in that header, so it must not also set `.navigationTitle`. Two titles stack as the user scrolls.
- **No navigation title under an Apple-like header**: a screen whose `List` or `GroupedList` starts with `AppleLikeListTopRowHeader` already shows its title in that header, so it must not also set `.navigationTitle`. Two titles stack as the user scrolls.
- **SwiftUI confirmations stay on the trigger**: apply `.confirmationDialog` directly to the `Button` that opens it, not to a parent `List`, `Section`, or container view. This keeps confirmation ownership local and avoids dialogs firing from unrelated controls.
- **TestFlight gating is `Current.isTestFlight`, and always paired with an ungating PR**: `Current.isTestFlight` is the only supported way to limit a feature to beta builds — never add a bespoke flag, build setting, or `#if` for it. Every change that puts a feature behind that gate must be accompanied by a parallel draft PR that removes the gate, so shipping the feature to everyone is a one-click merge. See [`ha-ios-workflow-ci`](.agents/skills/ha-ios-workflow-ci/SKILL.md) for the full procedure.
- **Every sensor is opt-in, always listed, and asks for its own permission**: nothing reports until the user switches it on, so never add a sensor to a default-on list and never make installing an update start reporting something new (`SensorRegistry.legacyEraSensorIDs` is frozen at the release that introduced the allowlist, which is what keeps a new sensor out of the upgrade path; a new ID belongs in `WebhookSensorId` alone). A `SensorProvider` must still list its sensor when it cannot read one, reporting the state as `unavailable` rather than erroring out, because an errored provider drops out of the sensors list and takes the row that switches it on with it. Where a sensor needs an iOS permission, map its ID in `SensorPermission.required(forSensorUniqueID:)` so switching it on is what prompts for that permission. Only a device that can never produce the sensor is an error. Classify every new sensor in `SensorEntityCategory.category(forSensorUniqueID:)` — its switch is exhaustive, so adding a `WebhookSensorId` case without deciding won't compile — and never set `entityCategory` where the sensor is built. Enablement is per server — `Current.sensors.isEnabled(uniqueID:for:)` for what a given server receives, `isEnabledForAnyServer(uniqueID:)` for device-level work that happens once however many servers the values reach — and a server the user adds starts with nothing enabled, so never seed a new server from another one's selection.
Expand All @@ -34,6 +34,7 @@ These apply to every change, even if you don't load the matching skill:
| [`ha-ios-ui`](.agents/skills/ha-ios-ui/SKILL.md) | Building UI, choosing SwiftUI vs UIKit, or following the one-struct-per-file, inline-body, and `#Preview` rules |
| [`ha-ios-testing`](.agents/skills/ha-ios-testing/SKILL.md) | Writing unit or snapshot tests, or mocking dependencies by overriding `Current` |
| [`ha-ios-workflow-ci`](.agents/skills/ha-ios-workflow-ci/SKILL.md) | Preparing a change for commit, gating a feature behind TestFlight, or understanding the CI gates that must pass before merge |
| [`ha-ios-macos`](.agents/skills/ha-ios-macos/SKILL.md) | Touching `#if os(macOS)` code, giving UIKit code an AppKit counterpart, presenting a SwiftUI screen from AppKit, adding a Mac window, menu or sheet, or building and running the native Mac app |
| [`ha-ios-skill-maintenance`](.agents/skills/ha-ios-skill-maintenance/SKILL.md) | Adding, editing, or reorganizing these skills, or updating this router |

## Additional Resources
Expand Down
Loading
Loading