diff --git a/.agents/skills/ha-ios-architecture/SKILL.md b/.agents/skills/ha-ios-architecture/SKILL.md
index e0f0f09b03..a60cdf0497 100644
--- a/.agents/skills/ha-ios-architecture/SKILL.md
+++ b/.agents/skills/ha-ios-architecture/SKILL.md
@@ -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)
@@ -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
diff --git a/.agents/skills/ha-ios-macos/SKILL.md b/.agents/skills/ha-ios-macos/SKILL.md
new file mode 100644
index 0000000000..21ba2bd4d5
--- /dev/null
+++ b/.agents/skills/ha-ios-macos/SKILL.md
@@ -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.
diff --git a/.agents/skills/ha-ios-ui/SKILL.md b/.agents/skills/ha-ios-ui/SKILL.md
index af29648919..44fe7b516f 100644
--- a/.agents/skills/ha-ios-ui/SKILL.md
+++ b/.agents/skills/ha-ios-ui/SKILL.md
@@ -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
@@ -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`)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0c28fe4c9..a6faa6d43d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.swiftlint.yml b/.swiftlint.yml
index c1b79ccf2f..b7a3141484 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index f4bdeeb6b6..81c4986d01 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
@@ -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.
@@ -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
diff --git a/Configuration/Entitlements/App-macos.entitlements b/Configuration/Entitlements/App-macos.entitlements
new file mode 100644
index 0000000000..7910ca0e68
--- /dev/null
+++ b/Configuration/Entitlements/App-macos.entitlements
@@ -0,0 +1,41 @@
+
+
+
+
+ com.apple.developer.aps-environment
+ development
+ com.apple.developer.associated-domains
+
+ applinks:home-assistant.io
+ applinks:*.home-assistant.io
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.$(BUNDLE_ID_PREFIX).homeassistant$(BUNDLE_ID_SUFFIX)
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+ com.apple.security.personal-information.location
+
+ com.apple.security.personal-information.photos-library
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX)
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.bluetooth
+
+ com.apple.security.files.downloads.read-write
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/Configuration/HomeAssistant.xcconfig b/Configuration/HomeAssistant.xcconfig
index cf2ff8ab73..dde38463e9 100644
--- a/Configuration/HomeAssistant.xcconfig
+++ b/Configuration/HomeAssistant.xcconfig
@@ -45,7 +45,8 @@ SDKROOT = iphoneos
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2,6
IPHONEOS_DEPLOYMENT_TARGET = 16.4
-MACOSX_DEPLOYMENT_TARGET = 12.0
+// What the Catalyst app already needs through iOS 16.4, and what the native app's SwiftUI windows need.
+MACOSX_DEPLOYMENT_TARGET = 13.3
WATCHOS_DEPLOYMENT_TARGET = 9.0
CLANG_ENABLE_MODULES = YES
diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj
index ba2be1f16e..e724d6f8b5 100644
--- a/HomeAssistant.xcodeproj/project.pbxproj
+++ b/HomeAssistant.xcodeproj/project.pbxproj
@@ -38,13 +38,13 @@
1148A44E24E8B59100345050 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1171506A24DFCDE60065E874 /* WidgetKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
114CBAEB2839FC2500A9BAFF /* SecurityExceptions.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114CBAEA2839FC2500A9BAFF /* SecurityExceptions.test.swift */; };
114CBAED283AB92D00A9BAFF /* SecTrust+TestAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114CBAEC283AB92D00A9BAFF /* SecTrust+TestAdditions.swift */; };
- 1155DD10250F4101003405C0 /* HomeAssistant-Extensions-Share.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1155DD06250F4100003405C0 /* HomeAssistant-Extensions-Share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 1155DD10250F4101003405C0 /* HomeAssistant-Extensions-Share.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1155DD06250F4100003405C0 /* HomeAssistant-Extensions-Share.appex */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1155DD1E250F446F003405C0 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
1165707C2702BAF5003906A7 /* DiskCache.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116570792702B9EB003906A7 /* DiskCache.test.swift */; };
116570802702D325003906A7 /* URLComponents+WidgetAuthenticity.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1165707F2702D325003906A7 /* URLComponents+WidgetAuthenticity.test.swift */; };
1171506B24DFCDE60065E874 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1171506A24DFCDE60065E874 /* WidgetKit.framework */; };
1171506D24DFCDE60065E874 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1171506C24DFCDE60065E874 /* SwiftUI.framework */; };
- 1171507624DFCDEE0065E874 /* HomeAssistant-Extensions-Widgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1171506924DFCDE60065E874 /* HomeAssistant-Extensions-Widgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 1171507624DFCDEE0065E874 /* HomeAssistant-Extensions-Widgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1171506924DFCDE60065E874 /* HomeAssistant-Extensions-Widgets.appex */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1171507B24DFCE0D0065E874 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
117318AB25199E1A0013E010 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 117318AA25199E1A0013E010 /* AppKit.framework */; };
117318AD25199E220013E010 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 117318AC25199E220013E010 /* Foundation.framework */; };
@@ -67,8 +67,8 @@
11B92A5B266F17AA00786588 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
11BC9E5524FDB88200B9FBF7 /* ActiveStateManager.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */; };
11CB98CD249E637300B05222 /* Version+HA.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98CC249E637300B05222 /* Version+HA.test.swift */; };
- 11D826F124E39F2E005B8A86 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 11D826F024E39F2D005B8A86 /* CoreNFC.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
- 11DE9FBE25B6186E0081C0ED /* Home Assistant Launcher.app in Embed Mac Launcher */ = {isa = PBXBuildFile; fileRef = 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 11D826F124E39F2E005B8A86 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 11D826F024E39F2D005B8A86 /* CoreNFC.framework */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (Weak, ); }; };
+ 11DE9FBE25B6186E0081C0ED /* Home Assistant Launcher.app in Embed Mac Launcher */ = {isa = PBXBuildFile; fileRef = 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */; platformFilters = (maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
11EE9B4C24C5181A00404AF8 /* ModelManager.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */; };
13E4D63B90C14B0DA25F2827 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = C8FA7E5B23954D258050DD72 /* SFSafeSymbols */; };
17200629C6080FA329C8F802 /* Domain.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5459C05BEB77340680D8C6 /* Domain.test.swift */; };
@@ -219,7 +219,7 @@
42F1C0153140B00011AABB15 /* TokenManagerRevocation.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0163140B00011AABB16 /* TokenManagerRevocation.test.swift */; };
42F1C0173140B00011AABB17 /* TokenManagerRefreshTokenRejection.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0183140B00011AABB18 /* TokenManagerRefreshTokenRejection.test.swift */; };
42F1C0323140B00011AACC02 /* ConnectivityWrapper.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0223140B00011AACC01 /* ConnectivityWrapper.test.swift */; };
- 42F384052FB49C9500390AFC /* DebugSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 42F384042FB49C9500390AFC /* DebugSwift */; };
+ 42F384052FB49C9500390AFC /* DebugSwift in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = 42F384042FB49C9500390AFC /* DebugSwift */; };
42FA000000000000000C0002 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 42FA000000000000000C0001 /* WebRTC */; };
42FA000000000000000C0009 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 42FA000000000000000C0008 /* SFSafeSymbols */; };
42FF5E972E22E5C100BDF5EF /* TodoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FF5E962E22E5C100BDF5EF /* TodoListItem.swift */; };
@@ -246,19 +246,20 @@
A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; };
A307E253426FAE95903A3493 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3402A10CAEE631B133C57DBE /* RealmSwift */; };
B0B0B0B00000000000000031 /* Color+Hex.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B0B0B00000000000000030 /* Color+Hex.test.swift */; };
+ 42E2E1000000000000001003 /* ColorCodable.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E2E1000000000000001002 /* ColorCodable.test.swift */; };
B0B0B0B00000000000000035 /* FrontendThemeProvider.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B0B0B00000000000000034 /* FrontendThemeProvider.test.swift */; };
B0B0B0B00000000000000033 /* UIColor+CSSColor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B0B0B00000000000000032 /* UIColor+CSSColor.test.swift */; };
B3FD7A16B24D03BE49029BB0 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 638EC9AD85B8C58C9C77DCC7 /* GRDB */; };
B627CB091D83C87B0057173E /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B627CB081D83C87B0057173E /* UserNotifications.framework */; };
B627CB0B1D83C87B0057173E /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B627CB0A1D83C87B0057173E /* UserNotificationsUI.framework */; };
- B627CB151D83C87B0057173E /* HomeAssistant-Extensions-NotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B627CB071D83C87B0057173E /* HomeAssistant-Extensions-NotificationContent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ B627CB151D83C87B0057173E /* HomeAssistant-Extensions-NotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B627CB071D83C87B0057173E /* HomeAssistant-Extensions-NotificationContent.appex */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B6393F881CB2561100503916 /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6393F871CB2561100503916 /* MapKit.framework */; };
B657A90C1CA646EB00121384 /* HomeAssistantUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657A90B1CA646EB00121384 /* HomeAssistantUITests.swift */; };
- B66C58AC215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B66C58A5215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ B66C58AC215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B66C58A5215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B67CE82B22200D420034C1D0 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B67CE82422200D420034C1D0 /* Shared.framework */; };
B67CE82C22200D420034C1D0 /* Shared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B67CE82422200D420034C1D0 /* Shared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
B699339C1E2338960054453D /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B699339B1E2338960054453D /* SnapshotHelper.swift */; };
- B6AAD7A81D827DD40090B220 /* HomeAssistant-Extensions-NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B6AAD7A11D827DD40090B220 /* HomeAssistant-Extensions-NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ B6AAD7A81D827DD40090B220 /* HomeAssistant-Extensions-NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B6AAD7A11D827DD40090B220 /* HomeAssistant-Extensions-NotificationService.appex */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B6C09153215206BB00A326DC /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
B6CC5D9E2159D10F00833E5D /* HomeAssistant-WatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = B6CC5D822159D10D00833E5D /* HomeAssistant-WatchApp.app */; platformFilter = ios; };
B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
@@ -706,6 +707,7 @@
9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; };
9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; };
B0B0B0B00000000000000030 /* Color+Hex.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.test.swift"; sourceTree = ""; };
+ 42E2E1000000000000001002 /* ColorCodable.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorCodable.test.swift; sourceTree = ""; };
B0B0B0B00000000000000034 /* FrontendThemeProvider.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontendThemeProvider.test.swift; sourceTree = ""; };
B0B0B0B00000000000000032 /* UIColor+CSSColor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+CSSColor.test.swift"; sourceTree = ""; };
B627CB071D83C87B0057173E /* HomeAssistant-Extensions-NotificationContent.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "HomeAssistant-Extensions-NotificationContent.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1067,6 +1069,7 @@
NotificationContent/NotificationErrorViewController.swift,
NotificationContent/NotificationLoadingViewController.swift,
NotificationContent/NotificationViewController.swift,
+ "NotificationContent/Resources/Info-macOS.plist",
NotificationContent/Resources/Info.plist,
NotificationContent/Resources/TestNotifications/camera_notification.apns,
NotificationContent/Resources/TestNotifications/dynamic_notification.apns,
@@ -1600,7 +1603,6 @@
Notifications/NotificationSettingsView.swift,
Notifications/NotificationSnoozeActionsView.swift,
Notifications/NotificationSoundsView.swift,
- Notifications/NotificationsShareSheet.swift,
"Observation/NotificationCategory+Observation.swift",
PrivacyView.swift,
Sensors/Details/SensorDetailLabelRowView.swift,
@@ -1625,6 +1627,7 @@
Settings/ClosestServerSourceBadge.swift,
Settings/HomeAssistantAccountRowView.swift,
Settings/MacSettingsSidebarLabelStyle.swift,
+ Settings/MacSettingsSidebarLink.swift,
Settings/MacSettingsSidebarRowStyle.swift,
Settings/MacSettingsSidebarSelection.swift,
Settings/OpenInBrowser.swift,
@@ -1832,7 +1835,6 @@
Notifications/NotificationSettingsView.swift,
Notifications/NotificationSnoozeActionsView.swift,
Notifications/NotificationSoundsView.swift,
- Notifications/NotificationsShareSheet.swift,
"Observation/NotificationCategory+Observation.swift",
PrivacyView.swift,
Sensors/Details/SensorDetailLabelRowView.swift,
@@ -1857,6 +1859,7 @@
Settings/ClosestServerSourceBadge.swift,
Settings/HomeAssistantAccountRowView.swift,
Settings/MacSettingsSidebarLabelStyle.swift,
+ Settings/MacSettingsSidebarLink.swift,
Settings/MacSettingsSidebarRowStyle.swift,
Settings/MacSettingsSidebarSelection.swift,
Settings/OpenInBrowser.swift,
@@ -2561,6 +2564,13 @@
);
target = B6CC5D812159D10D00833E5D /* WatchApp */;
};
+ 42AC0000000100510051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Resources/Info.plist,
+ );
+ target = D03D891620E0A85200D4F28D /* Shared-iOS */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -2593,7 +2603,7 @@
42EDB6203002FB9A0051FC9B /* Improv */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Improv; sourceTree = ""; };
42EDB6453002FB9A0051FC9B /* CarPlay */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CarPlay; sourceTree = ""; };
42EDB66D3002FB9A0051FC9B /* Thread */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Thread; sourceTree = ""; };
- 42EDB6803002FB9A0051FC9B /* MacBridge */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (42EDBB533002FB9B0051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MacBridge; sourceTree = ""; };
+ 42EDB6803002FB9A0051FC9B /* MacBridge */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (42EDBB533002FB9B0051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 42AC0000000100510051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MacBridge; sourceTree = ""; };
42EDB68B3002FB9A0051FC9B /* Watch */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Watch; sourceTree = ""; };
42EDBB4B3002FB9A0051FC9B /* SharedTesting */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (42EDBB5F3002FB9B0051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 42EDBB603002FB9B0051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 42EDBB613002FB9B0051FC9B /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = SharedTesting; sourceTree = ""; };
42EDBB633002FBA70051FC9B /* Mocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Mocks; sourceTree = ""; };
@@ -3238,6 +3248,7 @@
11267D0825BBA9FE00F28E5C /* Updater.test.swift */,
1133F5E425F1DBEA00AD776F /* CLLocation+Sanitize.test.swift */,
B0B0B0B00000000000000030 /* Color+Hex.test.swift */,
+ 42E2E1000000000000001002 /* ColorCodable.test.swift */,
B0B0B0B00000000000000034 /* FrontendThemeProvider.test.swift */,
B0B0B0B00000000000000032 /* UIColor+CSSColor.test.swift */,
11B7DC0D266C3EEB0090BD3B /* LocalPushEvent.test.swift */,
@@ -3865,6 +3876,7 @@
fileSystemSynchronizedGroups = (
11B38EE0275C545C00205C7B /* Intents */,
42CA28AC2B101D320093B31A /* DesignSystem */,
+ 42EDB6803002FB9A0051FC9B /* MacBridge */,
42EDBE083002FC020051FC9B /* LiveActivity */,
42EDBE1F3002FC020051FC9B /* Database */,
42EDBE463002FC020051FC9B /* Toast */,
@@ -4765,6 +4777,7 @@
110AA57B25B38C02005061A0 /* ServerAlerter.test.swift in Sources */,
1133F5F625F1DBF000AD776F /* CLLocation+Sanitize.test.swift in Sources */,
B0B0B0B00000000000000031 /* Color+Hex.test.swift in Sources */,
+ 42E2E1000000000000001003 /* ColorCodable.test.swift in Sources */,
B0B0B0B00000000000000035 /* FrontendThemeProvider.test.swift in Sources */,
B0B0B0B00000000000000033 /* UIColor+CSSColor.test.swift in Sources */,
11764A6C26817FC3007D47F3 /* UserDefaultsValueSync.test.swift in Sources */,
@@ -4796,6 +4809,11 @@
};
1155DD0F250F4101003405C0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
+ platformFilters = (
+ ios,
+ maccatalyst,
+ macos,
+ );
target = 1155DD05250F4100003405C0 /* Extensions-Share */;
targetProxy = 1155DD0E250F4101003405C0 /* PBXContainerItemProxy */;
};
@@ -4806,6 +4824,11 @@
};
1171507524DFCDEE0065E874 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
+ platformFilters = (
+ ios,
+ maccatalyst,
+ macos,
+ );
target = 1171506824DFCDE60065E874 /* Extensions-Widgets */;
targetProxy = 1171507424DFCDEE0065E874 /* PBXContainerItemProxy */;
};
@@ -4844,7 +4867,10 @@
};
11DE9F9825B6173D0081C0ED /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
- platformFilter = maccatalyst;
+ platformFilters = (
+ maccatalyst,
+ macos,
+ );
target = 11DE9D8225B6103C0081C0ED /* Launcher */;
targetProxy = 11DE9F9725B6173D0081C0ED /* PBXContainerItemProxy */;
};
@@ -4875,6 +4901,11 @@
};
B627CB141D83C87B0057173E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
+ platformFilters = (
+ ios,
+ maccatalyst,
+ macos,
+ );
target = B627CB061D83C87B0057173E /* Extensions-NotificationContent */;
targetProxy = B627CB131D83C87B0057173E /* PBXContainerItemProxy */;
};
@@ -4890,11 +4921,21 @@
};
B66C58AB215086F0004AB261 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
+ platformFilters = (
+ ios,
+ maccatalyst,
+ macos,
+ );
target = B66C58A4215086F0004AB261 /* Extensions-Intents */;
targetProxy = B66C58AA215086F0004AB261 /* PBXContainerItemProxy */;
};
B6AAD7A71D827DD40090B220 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
+ platformFilters = (
+ ios,
+ maccatalyst,
+ macos,
+ );
target = B6AAD7A01D827DD40090B220 /* Extensions-NotificationService */;
targetProxy = B6AAD7A61D827DD40090B220 /* PBXContainerItemProxy */;
};
@@ -4949,7 +4990,13 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .ShareExtension;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Debug;
@@ -4965,7 +5012,13 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .ShareExtension;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Release;
@@ -5017,12 +5070,19 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements";
INFOPLIST_FILE = Sources/Extensions/Widgets/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .Widgets;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG WIDGET_EXTENSION";
TARGETED_DEVICE_FAMILY = "1,2";
@@ -5038,12 +5098,19 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements";
INFOPLIST_FILE = Sources/Extensions/Widgets/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .Widgets;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = WIDGET_EXTENSION;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -5583,12 +5650,23 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements";
INFOPLIST_FILE = Sources/Extensions/NotificationContent/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+ "EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]" = "$(inherited) $(HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_$(IS_MACCATALYST))";
+ HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_NO = MainInterface.storyboard;
+ HA_INFOPLIST_FILE_MACCATALYST_NO = "Sources/Extensions/NotificationContent/Resources/Info-macOS.plist";
+ HA_INFOPLIST_FILE_MACCATALYST_YES = Sources/Extensions/NotificationContent/Resources/Info.plist;
+ "INFOPLIST_FILE[sdk=macosx*]" = "$(HA_INFOPLIST_FILE_MACCATALYST_$(IS_MACCATALYST))";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .NotificationContentExtension;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Debug;
@@ -5600,12 +5678,23 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements";
INFOPLIST_FILE = Sources/Extensions/NotificationContent/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+ "EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]" = "$(inherited) $(HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_$(IS_MACCATALYST))";
+ HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_NO = MainInterface.storyboard;
+ HA_INFOPLIST_FILE_MACCATALYST_NO = "Sources/Extensions/NotificationContent/Resources/Info-macOS.plist";
+ HA_INFOPLIST_FILE_MACCATALYST_YES = Sources/Extensions/NotificationContent/Resources/Info.plist;
+ "INFOPLIST_FILE[sdk=macosx*]" = "$(HA_INFOPLIST_FILE_MACCATALYST_$(IS_MACCATALYST))";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .NotificationContentExtension;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Release;
@@ -5634,14 +5723,25 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_OTHER_FLAGS = "$(inherited) --enable-icon-stack-fallback-generation=disabled";
CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/App-ios.entitlements";
- "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/App-catalyst.entitlements";
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "$(HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_$(IS_MACCATALYST))";
DISABLE_SWIFTLINT = 1;
ENV_URL_HANDLER = "homeassistant-dev";
+ HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_NO = "Configuration/Entitlements/App-macos.entitlements";
+ HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_YES = "Configuration/Entitlements/App-catalyst.entitlements";
+ HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_NO = "LaunchScreen.storyboard gallery.ckcomplication";
+ HA_INFOPLIST_FILE_MACCATALYST_NO = "Sources/App/Info-macOS.plist";
+ HA_INFOPLIST_FILE_MACCATALYST_YES = Sources/App/Resources/Info.plist;
+ "EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]" = "$(inherited) $(HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_$(IS_MACCATALYST))";
INFOPLIST_FILE = Sources/App/Resources/Info.plist;
+ "INFOPLIST_FILE[sdk=macosx*]" = "$(HA_INFOPLIST_FILE_MACCATALYST_$(IS_MACCATALYST))";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -5651,14 +5751,13 @@
"\"Security\"",
"-framework",
"\"SystemConfiguration\"",
- "-framework",
- "\"UIKit\"",
"$(inherited)",
"-fprofile-instr-generate",
);
PRODUCT_MODULE_NAME = HomeAssistant;
PRODUCT_NAME = "Home Assistant Δ";
SKIP_INSTALL = NO;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Debug;
@@ -5671,14 +5770,25 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_OTHER_FLAGS = "$(inherited) --enable-icon-stack-fallback-generation=disabled";
CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/App-ios.entitlements";
- "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/App-catalyst.entitlements";
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "$(HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_$(IS_MACCATALYST))";
DISABLE_SWIFTLINT = 1;
ENV_URL_HANDLER = homeassistant;
+ HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_NO = "Configuration/Entitlements/App-macos.entitlements";
+ HA_CODE_SIGN_ENTITLEMENTS_MACCATALYST_YES = "Configuration/Entitlements/App-catalyst.entitlements";
+ HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_NO = "LaunchScreen.storyboard gallery.ckcomplication";
+ HA_INFOPLIST_FILE_MACCATALYST_NO = "Sources/App/Info-macOS.plist";
+ HA_INFOPLIST_FILE_MACCATALYST_YES = Sources/App/Resources/Info.plist;
+ "EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]" = "$(inherited) $(HA_EXCLUDED_SOURCE_FILE_NAMES_MACCATALYST_$(IS_MACCATALYST))";
INFOPLIST_FILE = Sources/App/Resources/Info.plist;
+ "INFOPLIST_FILE[sdk=macosx*]" = "$(HA_INFOPLIST_FILE_MACCATALYST_$(IS_MACCATALYST))";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -5688,14 +5798,13 @@
"\"Security\"",
"-framework",
"\"SystemConfiguration\"",
- "-framework",
- "\"UIKit\"",
"$(inherited)",
"-fprofile-instr-generate",
);
PRODUCT_MODULE_NAME = HomeAssistant;
PRODUCT_NAME = "Home Assistant";
SKIP_INSTALL = NO;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Release;
@@ -5760,14 +5869,18 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
"\"Foundation\"",
- "-framework",
- "\"UIKit\"",
);
PROVISIONING_SUFFIX = .Intents;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Debug;
@@ -5784,14 +5897,18 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
"\"Foundation\"",
- "-framework",
- "\"UIKit\"",
);
PROVISIONING_SUFFIX = .Intents;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Release;
@@ -5857,7 +5974,13 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .APNSAttachmentService;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Debug;
@@ -5873,7 +5996,13 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
PROVISIONING_SUFFIX = .APNSAttachmentService;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = YES;
};
name = Release;
@@ -5939,6 +6068,7 @@
PRODUCT_NAME = Shared;
PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_SUFFIX = .Shared;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -5963,6 +6093,7 @@
PRODUCT_NAME = Shared;
PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_SUFFIX = .Shared;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift
index 584f05b92f..8a5374a638 100644
--- a/Sources/App/AppDelegate.swift
+++ b/Sources/App/AppDelegate.swift
@@ -1,6 +1,6 @@
import Alamofire
import CallbackURLKit
-#if DEBUG
+#if DEBUG && os(iOS)
import DebugSwift
#endif
import FirebaseCore
@@ -8,9 +8,15 @@ import FirebaseMessaging
import Intents
import KeychainAccess
import PromiseKit
+#if os(iOS)
import SafariServices
+#endif
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import WidgetKit
import XCGLogger
@@ -18,6 +24,7 @@ let keychain = AppConstants.Keychain
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
+#if os(iOS)
private extension UIApplication {
/// Under the SwiftUI `App` lifecycle `UIApplication.shared.delegate` is SwiftUI's own internal
/// delegate — not the `@UIApplicationDelegateAdaptor`-managed `AppDelegate` — so the bare
@@ -40,14 +47,27 @@ extension AppEnvironment {
UIApplication.shared.typedDelegate.notificationManager
}
}
+#else
+extension AppEnvironment {
+ // The adaptor creates the delegate before anything else in the app runs, so it is always there to ask.
+ var sceneManager: SceneManager {
+ AppDelegate.shared!.sceneManager
+ }
+ var notificationManager: NotificationManager {
+ AppDelegate.shared!.notificationManager
+ }
+}
+#endif
+
+#if os(iOS)
// `@main` is on `HAApp`; this delegate is installed via `@UIApplicationDelegateAdaptor`.
class AppDelegate: UIResponder, UIApplicationDelegate {
/// Set from `init` so the adaptor-managed delegate stays reachable; see `UIApplication.typedDelegate`.
private(set) static var shared: AppDelegate?
let sceneManager = SceneManager()
- private let lifecycleManager = LifecycleManager()
+ fileprivate let lifecycleManager = LifecycleManager()
let notificationManager = NotificationManager()
override init() {
@@ -60,10 +80,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
#endif
private var zoneManager: ZoneManager?
#if targetEnvironment(macCatalyst)
- private let statusItemManager = StatusItemManager()
+ fileprivate let statusItemManager = StatusItemManager()
#endif
private var watchCommunicatorService: WatchCommunicatorService?
+ private var liveActivityPendingEndObserver: Any?
+ private var liveActivityPendingStartObserver: Any?
func application(
_ application: UIApplication,
@@ -279,45 +301,192 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
IntentHandlerFactory.handler(for: intent)
}
+}
+#else
+// `@main` is on `HAApp`; this delegate is installed via `@NSApplicationDelegateAdaptor`.
+class AppDelegate: NSObject, NSApplicationDelegate {
+ /// Set from `init` so the adaptor-managed delegate stays reachable from anywhere in the app.
+ private(set) static var shared: AppDelegate?
+
+ let sceneManager = SceneManager()
+ fileprivate let lifecycleManager = LifecycleManager()
+ let notificationManager = NotificationManager()
+ fileprivate let statusItemManager = StatusItemManager()
+ private var zoneManager: ZoneManager?
+
+ override init() {
+ super.init()
+ AppDelegate.shared = self
+ }
+
+ func applicationWillFinishLaunching(_ notification: Notification) {
+ MaterialDesignIcons.register()
+
+ guard !Current.isRunningTests else {
+ return
+ }
+
+ setDefaults()
+
+ // swiftlint:disable prohibit_environment_assignment
+ Current.requestSensorPermissions = { uniqueIDs in
+ Task { @MainActor in
+ SensorPermissionRequester.shared.requestPermissionsIfNeeded(forSensorUniqueIDs: uniqueIDs)
+ }
+ }
+
+ Current.tags = TagActivityManager()
+ // swiftlint:enable prohibit_environment_assignment
+
+ notificationManager.setupNotifications()
+ setupFirebase()
+ setupModels()
+ setupLocalization()
+ setupMenus()
+
+ AnimatedSVGWebViewCache.shared
+ .preloadOnFirstActivation(HomeAssistantStandByView.loadingLogoResourceName)
+
+ Current.clientEventStore.addEvent(ClientEvent(text: "Application Starting", type: .unknown))
+
+ zoneManager = ZoneManager()
+
+ BackgroundRefreshManager.register()
+ BackgroundRefreshManager.scheduleAppRefresh()
+ RemindersSyncBackgroundRefresher.register()
+ RemindersSyncBackgroundRefresher.schedule()
+
+ migrateIfNeeded()
+ RemindersSyncManager.shared.start()
+ if #available(macOS 15.0, *) {
+ SpotlightEntityIndexer.shared.start()
+ }
+ if #available(macOS 14.0, *) {
+ HomeAssistantAppShortcuts.updateAppShortcutParameters()
+ }
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ if NSClassFromString("XCTest") != nil {
+ return
+ }
+
+ lifecycleManager.didFinishLaunching()
+ FlightGreetingManager.shared.start()
+ LocationBasedServerSwitcher.shared.start()
+
+ statusItemManager.configure()
+
+ checkForUpdate()
+ checkForAlerts()
+ }
+
+ /// The menu behind the Dock icon, on top of the entries macOS puts there itself.
+ func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
+ let menu = NSMenu()
+ let openSettings = NSMenuItem(
+ title: L10n.ShortcutItem.OpenSettings.title,
+ action: #selector(openSettingsFromDock),
+ keyEquivalent: ""
+ )
+ openSettings.target = self
+ menu.addItem(openSettings)
+ return menu
+ }
+
+ @objc private func openSettingsFromDock() {
+ Current.sceneManager.activateAnyScene(for: .settings)
+ }
+
+ /// The Dock icon was clicked with no window on screen. When "Open Home Assistant UI in browser" is on
+ /// there is no in-app web view to show, so the browser opens instead of a new window.
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ if !flag, StatusItemPrimaryAction.openInBrowserIfNeeded() {
+ return false
+ }
+ return true
+ }
+
+ /// Closing the last window leaves the app running: it keeps reporting sensors, and the menu bar item
+ /// and Dock icon bring a window back.
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ false
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ // Posted before termination is under way, so whoever observes it can still start the work it needs
+ // to finish, such as reporting that the Mac is no longer active; the reply waits for that work.
+ NotificationCenter.default.post(name: Current.macBridge.terminationWillBeginNotification, object: nil)
+ ProcessInfoBackgroundTaskRunner.whenIdle(
+ after: Self.terminationSettleTime,
+ timeout: Self.terminationGracePeriod
+ ) {
+ Current.Log.info("terminating with \(ProcessInfoBackgroundTaskRunner.inFlight) background tasks left")
+ sender.reply(toApplicationShouldTerminate: true)
+ }
+ return .terminateLater
+ }
+
+ /// How long the work the notification above starts gets to begin before an idle app quits.
+ private static let terminationSettleTime: TimeInterval = 0.5
+ private static let terminationGracePeriod: TimeInterval = 5
+
+ func applicationWillTerminate(_ notification: Notification) {
+ Current.forceCloseWarningManager.postImmediateWarning()
+ }
+
+ func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ notificationManager.didFailToRegisterForRemoteNotifications(error: error)
+ }
+
+ func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ notificationManager.didRegisterForRemoteNotifications(deviceToken: deviceToken)
+ }
+
+ func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) {
+ notificationManager.didReceiveRemoteNotification(userInfo: userInfo)
+ }
+
+ func application(_ application: NSApplication, handlerFor intent: INIntent) -> Any? {
+ IntentHandlerFactory.handler(for: intent)
+ }
+}
+#endif
- // MARK: - Private helpers
+// MARK: - Launch steps shared by the iOS and macOS delegates
+extension AppDelegate {
@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(
+ let alert = AppAlert(
title: L10n.Updater.UpdateAvailable.title,
- message: nil,
- preferredStyle: .alert
+ actions: [
+ .init(title: L10n.Updater.UpdateAvailable.open(update.name)) {
+ URLOpener.shared.open(update.htmlUrl, options: [:], completionHandler: nil)
+ },
+ .init(title: L10n.okLabel, style: .cancel),
+ ]
)
- 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.appCoordinator.done {
- $0.present(alert, animated: true, completion: nil)
+ $0.present(alert: alert)
}
}.catch { [sceneManager] error in
Current.Log.error("check error: \(error)")
if dueToUserInteraction {
- let alert = UIAlertController(
+ let alert = AppAlert(
title: L10n.Updater.NoUpdatesAvailable.title,
message: error.localizedDescription,
- preferredStyle: .alert
+ actions: [.init(title: L10n.okLabel, style: .cancel)]
)
- alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.appCoordinator.done {
- $0.present(alert, animated: true, completion: nil)
+ $0.present(alert: alert)
}
}
}
@@ -356,33 +525,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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
+ let alert = Self.notificationCategoryDeprecationAlert(userDefaults: userDefaults, seenKey: seenKey)
+ sceneManager.appCoordinator.done {
+ $0.present(alert: alert)
+ }
+ }.catch { error in
+ Current.Log.error("couldn't check for if user: \(error)")
+ }
+ }
+
+ /// Tells an admin that notification categories are now configured on the server. Either button marks
+ /// the alert as seen so it stays away afterwards; the first one also opens the documentation.
+ static func notificationCategoryDeprecationAlert(userDefaults: UserDefaults, seenKey: String) -> AppAlert {
+ AppAlert(
+ title: L10n.Alerts.Deprecations.NotificationCategory.title,
+ message: L10n.Alerts.Deprecations.NotificationCategory.message("iOS-2022.4"),
+ actions: [
+ .init(title: L10n.Nfc.List.learnMore) {
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
+ },
+ .init(title: L10n.okLabel, style: .cancel) {
userDefaults.set(true, forKey: seenKey)
- }))
- sceneManager.appCoordinator.done {
- $0.present(alert)
- }
- }.catch { error in
- Current.Log.error("couldn't check for if user: \(error)")
- }
+ },
+ ]
+ )
}
+ #if os(iOS)
private func setupWatchCommunicator() {
watchCommunicatorService = WatchCommunicatorService()
watchCommunicatorService?.setup()
}
+ #endif
func setupLocalization() {
Current.localized.add(stringProvider: { request in
@@ -394,9 +572,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
})
}
- private var liveActivityPendingEndObserver: Any?
- private var liveActivityPendingStartObserver: Any?
-
+ #if os(iOS)
private func setupLiveActivityReattachment() {
#if os(iOS) && !targetEnvironment(macCatalyst)
if #available(iOS 17.2, *) {
@@ -437,6 +613,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
#endif
}
+ #endif
private func setupFirebase() {
let optionsFile: String = {
@@ -464,7 +641,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// and region monitoring has nothing to track.
Current.modelManager.cleanup().cauterize()
Current.modelManager.subscribe(isAppInForeground: {
- UIApplication.shared.applicationState == .active
+ ApplicationState.current == .active
})
}
@@ -484,22 +661,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
@objc private func menuRelatedSettingDidChange(_ note: Notification) {
+ #if os(iOS)
UIMenuSystem.main.setNeedsRebuild()
- #if targetEnvironment(macCatalyst)
+ #endif
+ #if targetEnvironment(macCatalyst) || os(macOS)
statusItemManager.configure()
#endif
}
@objc private func apiDidConnect(_ note: Notification) {
+ #if os(iOS)
UIMenuSystem.main.setNeedsRebuild()
- #if targetEnvironment(macCatalyst)
+ #endif
+ #if targetEnvironment(macCatalyst) || os(macOS)
statusItemManager.apiDidConnect()
#endif
}
+ #if os(iOS)
private func setupUIApplicationShortcutItems() {
AppIconShortcutItemsUpdater.start()
}
+ #endif
private func migrateIfNeeded() {
resetLocalPush()
diff --git a/Sources/App/Assist/AssistSettingsView.swift b/Sources/App/Assist/AssistSettingsView.swift
index 6f514e71d1..7a53bbd4eb 100644
--- a/Sources/App/Assist/AssistSettingsView.swift
+++ b/Sources/App/Assist/AssistSettingsView.swift
@@ -81,7 +81,7 @@ struct AssistSettingsView: View {
@ViewBuilder
private var labs: some View {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
Section {
Toggle(isOn: $viewModel.configuration.enableOnDeviceSTT) {
toggleLabel(symbol: .micFill, text: L10n.Assist.Settings.OnDeviceStt.title)
diff --git a/Sources/App/Assist/AssistSettingsViewModel.swift b/Sources/App/Assist/AssistSettingsViewModel.swift
index a6a409f2c3..d8dd56a548 100644
--- a/Sources/App/Assist/AssistSettingsViewModel.swift
+++ b/Sources/App/Assist/AssistSettingsViewModel.swift
@@ -30,7 +30,7 @@ final class AssistSettingsViewModel: ObservableObject {
/// filled in asynchronously instead of being read while the settings view builds.
func loadSupportedSTTLocales() async {
// On-device transcription is an iOS 17 feature, so older versions never show the list.
- guard #available(iOS 17.0, *) else { return }
+ guard #available(iOS 17.0, macOS 14.0, *) else { return }
supportedSTTLocales = await SupportedSpeechLocales.shared.locales()
selectDefaultSTTLocaleIfNeeded()
}
diff --git a/Sources/App/Assist/AssistTypingIndicator.swift b/Sources/App/Assist/AssistTypingIndicator.swift
index 5c0f6bc38b..8c1f78731e 100644
--- a/Sources/App/Assist/AssistTypingIndicator.swift
+++ b/Sources/App/Assist/AssistTypingIndicator.swift
@@ -3,10 +3,10 @@ import SwiftUI
struct AssistTypingIndicator: View {
var body: some View {
- if #available(iOS 18.0, watchOS 11.0, *) {
+ if #available(iOS 18.0, macOS 15.0, watchOS 11.0, *) {
icon
.symbolEffect(.variableColor.iterative.dimInactiveLayers.reversing, options: .repeat(.continuous))
- } else if #available(iOS 17.0, watchOS 10, *) {
+ } else if #available(iOS 17.0, macOS 14.0, watchOS 10, *) {
icon
.symbolEffect(.variableColor)
} else {
diff --git a/Sources/App/Assist/AssistView.swift b/Sources/App/Assist/AssistView.swift
index 0842bb13a0..8508298b26 100644
--- a/Sources/App/Assist/AssistView.swift
+++ b/Sources/App/Assist/AssistView.swift
@@ -134,8 +134,8 @@ struct AssistView: View {
}
}
- #if !targetEnvironment(macCatalyst)
- if #available(iOS 26.0, *) {
+ #if !(targetEnvironment(macCatalyst) || os(macOS))
+ if #available(iOS 26.0, macOS 26.0, *) {
ToolbarItem(placement: .topBarTrailing) {
settingsButton
}
@@ -144,16 +144,21 @@ struct AssistView: View {
#endif
}
.sheet(isPresented: $showSettings) {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
AssistSettingsView()
+ #if !os(macOS)
.navigationTransition(
.zoom(sourceID: Constants.settingsGeometryID, in: settingsGeometry)
)
+ #endif
}
}
}
.navigationViewStyle(.stack)
- .background(VerticalBarObserver(hasVerticalBar: $hasVerticalBar))
+ #if !os(macOS)
+ // Only an iPhone lays its bars out in a vertical column.
+ .background(VerticalBarObserver(hasVerticalBar: $hasVerticalBar))
+ #endif
}
@ViewBuilder
@@ -279,7 +284,7 @@ struct AssistView: View {
.padding(.vertical, Constants.bubbleVerticalPadding)
.padding(.horizontal, Constants.bubbleHorizontalPadding)
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
view.glassEffect(
.regular.tint(glassTintForChatItemType(item.itemType)),
in: RoundedCorner(
@@ -362,7 +367,7 @@ struct AssistView: View {
}
.scrollDismissesKeyboard(.immediately)
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
// Softens the conversation as it runs under the navigation bar.
view.scrollEdgeEffectStyle(.soft, for: .top)
} else {
@@ -373,7 +378,10 @@ struct AssistView: View {
bottomBar
.padding(.leading, occlusionInsets.leading)
.padding(.trailing, occlusionInsets.trailing)
+ #if !os(macOS)
+ // A Mac window has no camera cutout reaching into it, so there is nothing to read.
.background(OcclusionRegionsReader(insets: $occlusionInsets))
+ #endif
.ignoresSafeArea(edges: hasVerticalBar ? .horizontal : [])
}
}
@@ -405,7 +413,7 @@ struct AssistView: View {
inputActionButton
}
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
GlassEffectContainer(spacing: Constants.inputRowGlassSpacing) {
view
}
@@ -444,7 +452,7 @@ struct AssistView: View {
}
)
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
view.glassEffect(.regular.interactive(), in: .capsule)
} else {
view
@@ -465,7 +473,7 @@ struct AssistView: View {
}
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view
.onKeyPress(.upArrow) {
viewModel.recallPreviousRequest() ? .handled : .ignored
@@ -492,7 +500,7 @@ struct AssistView: View {
.frame(height: Constants.inputActionButtonHeight)
.padding(DesignSystem.Spaces.oneAndHalf)
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
view.glassEffect(.regular.interactive().tint(.haPrimary), in: .circle)
} else {
view
@@ -549,7 +557,7 @@ struct AssistView: View {
}
.buttonStyle(.plain)
.modify { view in
- if #available(iOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, *), !forcesLegacyAppearance {
view.glassEffect(.regular.interactive(), in: .circle)
} else {
view
diff --git a/Sources/App/Assist/AssistViewModel.swift b/Sources/App/Assist/AssistViewModel.swift
index 1a70a03991..db2672cb1e 100644
--- a/Sources/App/Assist/AssistViewModel.swift
+++ b/Sources/App/Assist/AssistViewModel.swift
@@ -447,7 +447,7 @@ final class AssistViewModel: NSObject, ObservableObject {
@MainActor private func startOnDeviceTranscription() {
// Use a pre-injected transcriber (e.g. from tests) or create a production one.
if speechTranscriber == nil {
- guard #available(iOS 17.0, *) else { return }
+ guard #available(iOS 17.0, macOS 14.0, *) else { return }
let localeIdentifier = configuration.onDeviceSTTLocaleIdentifier
speechTranscriber = localeIdentifier.map { SpeechTranscriber(localeIdentifier: $0) } ?? SpeechTranscriber()
}
diff --git a/Sources/App/Assist/Audio/AudioPlayer.swift b/Sources/App/Assist/Audio/AudioPlayer.swift
index 37e9aea50b..82968c1a5e 100644
--- a/Sources/App/Assist/Audio/AudioPlayer.swift
+++ b/Sources/App/Assist/Audio/AudioPlayer.swift
@@ -19,9 +19,11 @@ final class AudioPlayer: NSObject, AudioPlayerProtocol {
private var dataPlayer: AVAudioPlayer?
private var downloadTask: URLSessionDataTask?
+ /// A Mac has no audio session to configure, and its players go straight to the system output.
func play(url: URL, server: Server?) {
stopCurrentPlayback()
+ #if !os(macOS)
let audioSession = AVAudioSession.sharedInstance()
// Each step is attempted independently: if deactivation fails (e.g. while the
@@ -53,6 +55,7 @@ final class AudioPlayer: NSObject, AudioPlayerProtocol {
delegate?.volumeIsZero()
return
}
+ #endif
// Falls back to streaming when the server is unknown: for the servers streaming can
// handle, playback keeps working, and the certificate-requiring ones failed either way.
diff --git a/Sources/App/Assist/Audio/AudioRecorder.swift b/Sources/App/Assist/Audio/AudioRecorder.swift
index ce6c45c288..2750133daf 100644
--- a/Sources/App/Assist/Audio/AudioRecorder.swift
+++ b/Sources/App/Assist/Audio/AudioRecorder.swift
@@ -37,6 +37,11 @@ final class AudioRecorder: NSObject, AudioRecorderProtocol {
/// Buffers arrive far faster than a screen refresh, and every level costs the main thread a
/// published change, so they are emitted at about 30 Hz instead of once per buffer.
static let levelInterval: TimeInterval = 1.0 / 30
+ #if os(macOS)
+ /// The rate the capture output is asked to convert to on a Mac, where there is no audio
+ /// session to state a preference on. It is the rate Assist pipelines work at.
+ static let macSampleRate: Double = 16000
+ #endif
}
weak var delegate: AudioRecorderDelegate?
@@ -85,7 +90,9 @@ final class AudioRecorder: NSObject, AudioRecorderProtocol {
}
private func setupAudioRecorder() {
+ #if !os(macOS)
let audioSession = AVAudioSession.sharedInstance()
+ #endif
guard let captureDevice = AVCaptureDevice.default(for: .audio) else {
Current.Log.error("Failed to get capture device to record audio for Assist")
delegate?.didFailToRecord(error: AudioRecorderError.captureDeviceUnavailable)
@@ -93,6 +100,7 @@ final class AudioRecorder: NSObject, AudioRecorderProtocol {
}
do {
+ #if !os(macOS)
if managesAudioSession {
try audioSession.setActive(false)
try audioSession.setCategory(.record, mode: .default)
@@ -100,20 +108,42 @@ final class AudioRecorder: NSObject, AudioRecorderProtocol {
try audioSession.setPreferredSampleRate(16000.0)
try audioSession.setActive(true)
}
+ #endif
let audioInput = try AVCaptureDeviceInput(device: captureDevice)
captureSession = AVCaptureSession()
+ #if !os(macOS)
captureSession?.automaticallyConfiguresApplicationAudioSession = false
+ #endif
captureSession?.addInput(audioInput)
- Current.Log.info("Audio sample rate: \(audioSession.sampleRate)")
- if audioSession.sampleRate == 0 {
+ #if os(macOS)
+ let sampleRate = Constants.macSampleRate
+ #else
+ let sampleRate = audioSession.sampleRate
+ #endif
+ Current.Log.info("Audio sample rate: \(sampleRate)")
+ if sampleRate == 0 {
throw AudioRecorderError.invalidSampleRate
} else {
- audioSampleRate = audioSession.sampleRate
+ audioSampleRate = sampleRate
}
let audioOutput = AVCaptureAudioDataOutput()
+ #if os(macOS)
+ // Left alone, a Mac's capture output hands over whatever the microphone produces, which
+ // is usually stereo floating point at the device's own rate. The audio session is what
+ // settles the format on iOS; here the output converts to the same 16-bit mono samples.
+ audioOutput.audioSettings = [
+ AVFormatIDKey: kAudioFormatLinearPCM,
+ AVSampleRateKey: sampleRate,
+ AVNumberOfChannelsKey: 1,
+ AVLinearPCMBitDepthKey: 16,
+ AVLinearPCMIsFloatKey: false,
+ AVLinearPCMIsBigEndianKey: false,
+ AVLinearPCMIsNonInterleaved: false,
+ ]
+ #endif
audioOutput.setSampleBufferDelegate(self, queue: DispatchQueue.global(qos: .userInteractive))
captureSession?.addOutput(audioOutput)
} catch {
diff --git a/Sources/App/Assist/Local/SpeechSynthesizer.swift b/Sources/App/Assist/Local/SpeechSynthesizer.swift
index f77f86db3f..f8c0f5c4b0 100644
--- a/Sources/App/Assist/Local/SpeechSynthesizer.swift
+++ b/Sources/App/Assist/Local/SpeechSynthesizer.swift
@@ -39,10 +39,12 @@ final class SpeechSynthesizer: NSObject, SpeechSynthesizerProtocol, AVSpeechSynt
synthesizer.delegate = self
}
+ /// A Mac has no audio session: the synthesizer speaks through the system output as it is.
func speak(_ text: String) {
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
utterance.voice = voice
+ #if !os(macOS)
if managesAudioSession {
do {
// The mode has to be named explicitly: it is a session property of its own that
@@ -60,6 +62,7 @@ final class SpeechSynthesizer: NSObject, SpeechSynthesizerProtocol, AVSpeechSynt
Current.Log.error("Failed to set audio session category for speech synthesis: \(error)")
}
}
+ #endif
synthesizer.speak(utterance)
}
diff --git a/Sources/App/Assist/Local/SpeechTranscriber.swift b/Sources/App/Assist/Local/SpeechTranscriber.swift
index e3bd5c7d1b..375d96ecae 100644
--- a/Sources/App/Assist/Local/SpeechTranscriber.swift
+++ b/Sources/App/Assist/Local/SpeechTranscriber.swift
@@ -22,7 +22,7 @@ protocol SpeechTranscriberProtocol: AnyObject {
/// A speech-to-text transcriber using Apple's Speech framework.
/// Supports real-time transcription with partial results.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
@MainActor
public final class SpeechTranscriber: ObservableObject, SpeechTranscriberProtocol {
// MARK: - Types
@@ -214,9 +214,10 @@ public final class SpeechTranscriber: ObservableObject, SpeechTranscriberProtoco
}
/// Configures the audio session, engine and recognition task for a session the caller has
- /// already marked as listening, and which it unwinds if this throws.
+ /// already marked as listening, and which it unwinds if this throws. A Mac has no audio session:
+ /// the engine records from the system input as it is.
private func startRecognition(with speechRecognizer: SFSpeechRecognizer) throws {
- // Configure audio session
+ #if !os(macOS)
if managesAudioSession {
let audioSession = AVAudioSession.sharedInstance()
// `.default` rather than `.measurement`, which the framework documents as disabling the
@@ -236,6 +237,7 @@ public final class SpeechTranscriber: ObservableObject, SpeechTranscriberProtoco
// only when deactivating.
try audioSession.setActive(true)
}
+ #endif
// Create audio engine
audioEngine = AVAudioEngine()
@@ -259,6 +261,10 @@ public final class SpeechTranscriber: ObservableObject, SpeechTranscriberProtoco
// Get input node
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
+ // A machine without an audio input reports an empty format, and installing a tap for it raises.
+ guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else {
+ throw TranscriberError.notAvailable
+ }
// Capture recognitionRequest locally so the tap closure does not access a @MainActor property
// from a background thread. The level's rate limit is held the same way, and the tap calls
@@ -319,9 +325,11 @@ public final class SpeechTranscriber: ObservableObject, SpeechTranscriberProtoco
isListening = false
// Deactivate audio session
+ #if !os(macOS)
if managesAudioSession {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
+ #endif
if wasListening {
onListeningStateChange?(false)
diff --git a/Sources/App/BackgroundRefreshManager.swift b/Sources/App/BackgroundRefreshManager.swift
index c710d8daf2..3fb53e6e01 100644
--- a/Sources/App/BackgroundRefreshManager.swift
+++ b/Sources/App/BackgroundRefreshManager.swift
@@ -1,14 +1,28 @@
+#if os(iOS)
import BackgroundTasks
+#endif
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
enum BackgroundRefreshManager {
static let taskIdentifier = "io.robbie.homeassistant.backgroundfetch"
private static let earliestBeginInterval: TimeInterval = 15 * 60
+ #if os(macOS)
+ // A Mac app is never suspended, so nothing has to wake it to refresh: the periodic update timer keeps
+ // running for as long as the app does. These exist so the launch sequence reads the same on both
+ // platforms.
+ static func register() {}
+
+ static func scheduleAppRefresh() {}
+ #else
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: .main) { task in
handleAppRefresh(task: task)
@@ -48,7 +62,7 @@ enum BackgroundRefreshManager {
Current.backgroundTask(withName: BackgroundTask.backgroundFetch.rawValue) { remaining in
let updatePromise: Promise
- if Current.settingsStore.isLocationEnabled(for: UIApplication.shared.applicationState),
+ if Current.settingsStore.isLocationEnabled(for: ApplicationState.current),
Current.settingsStore.locationSources.backgroundFetch {
updatePromise = firstly {
Current.location.oneShotLocation(.BackgroundFetch, remaining)
@@ -71,4 +85,5 @@ enum BackgroundRefreshManager {
complete(false)
}
}
+ #endif
}
diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift
index b19e968628..dcd0820417 100644
--- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift
+++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift
@@ -1,7 +1,11 @@
import AVFoundation
import CoreImage
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
protocol BarcodeScannerCameraDelegate: AnyObject {
func didDetectBarcode(_ code: String, format: String)
@@ -17,16 +21,23 @@ final class BarcodeScannerCamera: NSObject, @unchecked Sendable {
private let feedbackGenerator = UINotificationFeedbackGenerator()
private var allBackCaptureDevices: [AVCaptureDevice] {
AVCaptureDevice.DiscoverySession(
- deviceTypes: [
- .builtInTripleCamera,
- .builtInDualCamera,
- .builtInWideAngleCamera,
- ],
+ deviceTypes: Self.backCaptureDeviceTypes,
mediaType: .video,
position: .back
).devices
}
+ #if os(macOS)
+ /// A Mac has no multi-lens cameras.
+ private static let backCaptureDeviceTypes: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera]
+ #else
+ private static let backCaptureDeviceTypes: [AVCaptureDevice.DeviceType] = [
+ .builtInTripleCamera,
+ .builtInDualCamera,
+ .builtInWideAngleCamera,
+ ]
+ #endif
+
public var screenSize: CGSize? {
didSet {
// Prevent unnecessary updates
@@ -156,6 +167,12 @@ final class BarcodeScannerCamera: NSObject, @unchecked Sendable {
metadataObjectTypes.append(.codabar)
+ #if os(macOS)
+ // Asking for a type the output cannot produce raises, and not every Mac camera offers them all.
+ let availableTypes = metadataOutput.availableMetadataObjectTypes
+ metadataObjectTypes = metadataObjectTypes.filter { availableTypes.contains($0) }
+ #endif
+
metadataOutput.metadataObjectTypes = metadataObjectTypes
self.deviceInput = deviceInput
@@ -306,6 +323,8 @@ final class BarcodeScannerCamera: NSObject, @unchecked Sendable {
}
}
+ // A Mac is not held at an angle: its cameras are left at the orientation they deliver.
+ #if !os(macOS)
// TODO: Modernization - `UIScreen.main` is deprecated. Rotate frames with
// `AVCaptureDevice.RotationCoordinator` (iOS 17+) driven by the preview, or read the screen of the
// window hosting the preview (`view.window?.windowScene?.screen`) instead of the main screen.
@@ -316,6 +335,7 @@ final class BarcodeScannerCamera: NSObject, @unchecked Sendable {
}
return orientation
}
+ #endif
}
extension BarcodeScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate {
@@ -326,10 +346,12 @@ extension BarcodeScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate {
) {
guard let pixelBuffer = sampleBuffer.imageBuffer else { return }
+ #if !os(macOS)
if connection.isVideoOrientationSupported,
let videoOrientation = AVCaptureVideoOrientation(deviceOrientation: deviceOrientation) {
connection.videoOrientation = videoOrientation
}
+ #endif
addToPreviewStream?(CIImage(cvPixelBuffer: pixelBuffer))
}
diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift
index 63c3116e5f..eaf84372ef 100644
--- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift
+++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift
@@ -3,6 +3,12 @@ import SwiftUI
struct BarcodeScannerView: View {
static let cameraSquareSize: CGFloat = 320
+ #if os(macOS)
+ /// Presented on a Mac, the scanner takes its size from its content instead of from a screen it
+ /// covers, and everything in it but the instructions stretches to fit. This leaves room for the
+ /// camera square below them.
+ private static let minimumSize = CGSize(width: 480, height: 640)
+ #endif
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: BarcodeScannerViewModel
@@ -41,6 +47,9 @@ struct BarcodeScannerView: View {
topInformation
}
+ #if os(macOS)
+ .frame(minWidth: Self.minimumSize.width, minHeight: Self.minimumSize.height)
+ #endif
.onAppear {
cameraDataModel.delegate = viewModel
}
@@ -143,10 +152,12 @@ struct BarcodeScannerView: View {
BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", incomingMessageId: 1)
}
-final class BarcodeScannerHostingController: UIHostingController {
+final class BarcodeScannerHostingController: PlatformHostingController {
+ #if !os(macOS)
// TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+)
// as the modern replacement for orientation locking via `supportedInterfaceOrientations`.
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
[.portrait]
}
+ #endif
}
diff --git a/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift b/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift
index 7ac791723c..81ff86af37 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
/// A SwiftUI view for displaying MJPEG camera streams.
struct CameraMJPEGPlayerView: View {
@@ -68,8 +67,51 @@ struct CameraMJPEGPlayerView: View {
}
}
-// MARK: - UIViewControllerRepresentable wrapper
+// MARK: - View controller representable wrapper
+#if os(macOS)
+private struct MJPEGStreamContainerView: NSViewControllerRepresentable {
+ let server: Server
+ let cameraEntityId: String
+ @Binding var isLoading: Bool
+ @Binding var errorMessage: String?
+
+ func makeNSViewController(context: Context) -> MJPEGStreamViewController {
+ MJPEGStreamViewController(
+ server: server,
+ cameraEntityId: cameraEntityId,
+ coordinator: context.coordinator
+ )
+ }
+
+ func updateNSViewController(_ nsViewController: MJPEGStreamViewController, context: Context) {
+ // No updates needed
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(isLoading: $isLoading, errorMessage: $errorMessage)
+ }
+
+ class Coordinator {
+ @Binding var isLoading: Bool
+ @Binding var errorMessage: String?
+
+ init(isLoading: Binding, errorMessage: Binding) {
+ _isLoading = isLoading
+ _errorMessage = errorMessage
+ }
+
+ func didReceiveFirstFrame() {
+ isLoading = false
+ }
+
+ func didEncounterError(_ error: Error) {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+}
+#else
private struct MJPEGStreamContainerView: UIViewControllerRepresentable {
let server: Server
let cameraEntityId: String
@@ -111,16 +153,21 @@ private struct MJPEGStreamContainerView: UIViewControllerRepresentable {
}
}
}
+#endif
-// MARK: - UIKit View Controller for MJPEG streaming
+// MARK: - View controller for MJPEG streaming
-private class MJPEGStreamViewController: UIViewController {
+private class MJPEGStreamViewController: PlatformViewController {
private let server: Server
private let cameraEntityId: String
private weak var coordinator: MJPEGStreamContainerView.Coordinator?
private var streamer: MJPEGStreamer?
+ #if os(macOS)
+ private let imageView = NSImageView()
+ #else
private let imageView = UIImageView()
+ #endif
private var hasReceivedFirstFrame = false
init(
@@ -143,13 +190,31 @@ private class MJPEGStreamViewController: UIViewController {
streamer?.cancel()
}
+ #if os(macOS)
+ override func loadView() {
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.backgroundColor = NSColor.black.cgColor
+ view = container
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if os(macOS)
+ imageView.imageScaling = .scaleProportionallyUpOrDown
+ // An image view asks for its image's own size; the stream fits the player, not the reverse.
+ for orientation in [NSLayoutConstraint.Orientation.horizontal, .vertical] {
+ imageView.setContentHuggingPriority(.defaultLow, for: orientation)
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: orientation)
+ }
+ #else
view.backgroundColor = .black
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
+ #endif
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)
diff --git a/Sources/App/Cameras/CameraPlayer/CameraOverlayHostingController.swift b/Sources/App/Cameras/CameraPlayer/CameraOverlayHostingController.swift
index b8ad5f58d9..e6060f7816 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraOverlayHostingController.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraOverlayHostingController.swift
@@ -1,3 +1,4 @@
+#if !os(macOS)
import SwiftUI
import UIKit
@@ -21,3 +22,4 @@ final class CameraOverlayHostingController: UIHostingController {
onDismiss?()
}
}
+#endif
diff --git a/Sources/App/Cameras/CameraPlayer/CameraOverlayPresenter.swift b/Sources/App/Cameras/CameraPlayer/CameraOverlayPresenter.swift
index df2026c63c..b1b99e882d 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraOverlayPresenter.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraOverlayPresenter.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
final class CameraOverlayPresenter {
static let shared = CameraOverlayPresenter()
@@ -10,8 +9,24 @@ final class CameraOverlayPresenter {
let serverIdentifier: Identifier
}
+ #if os(macOS)
+ /// Carries the hosted view's appearance callbacks to the controller that is created after the view.
+ private final class MacOverlayLifecycle {
+ var onAppear: (() -> Void)?
+ var onDisappear: (() -> Void)?
+ }
+
+ /// Presented on a Mac, the player takes its size from its content instead of from a screen it
+ /// covers. It is happy at any size, so it is given one to open at and a floor that keeps its
+ /// controls usable.
+ private enum PlayerSize {
+ static let minimum = CGSize(width: 480, height: 270)
+ static let ideal = CGSize(width: 960, height: 540)
+ }
+ #endif
+
private(set) var displayedCamera: Camera?
- private weak var overlayController: UIViewController?
+ private weak var overlayController: PlatformViewController?
/// Set from `show` until the overlay has appeared: the web view presents on a later turn of the main
/// queue, so for a moment the overlay exists without being on screen.
private var isPresenting = false
@@ -64,13 +79,37 @@ final class CameraOverlayPresenter {
// request would present its camera when this overlay is eventually dismissed.
pendingShow = nil
- let controller = CameraPlayerView(
+ let player = CameraPlayerView(
server: server,
cameraEntityId: entityId,
cameraName: cameraName,
onCameraChange: overlayDidSwitchCamera(to:)
)
- .embeddedInHostingController { CameraOverlayHostingController(rootView: $0) }
+ #if os(macOS)
+ // The Mac shows the player in a sheet SwiftUI owns, which hosts the view again outside this
+ // controller, so the view reports its own appearance; a sheet is never covered by another one.
+ let lifecycle = MacOverlayLifecycle()
+ let controller = player
+ .frame(
+ minWidth: PlayerSize.minimum.width,
+ idealWidth: PlayerSize.ideal.width,
+ minHeight: PlayerSize.minimum.height,
+ idealHeight: PlayerSize.ideal.height
+ )
+ .onAppear { lifecycle.onAppear?() }
+ .onDisappear { lifecycle.onDisappear?() }
+ .embeddedInHostingController()
+ controller.preferredContentSize = PlayerSize.ideal
+ lifecycle.onAppear = { [weak self, weak controller] in
+ guard let controller else { return }
+ self?.overlayDidAppear(controller)
+ }
+ lifecycle.onDisappear = { [weak self, weak controller] in
+ guard let controller else { return }
+ self?.overlayDidDisappear(controller)
+ }
+ #else
+ let controller = player.embeddedInHostingController { CameraOverlayHostingController(rootView: $0) }
controller.onAppear = { [weak self, weak controller] in
guard let controller else { return }
self?.overlayDidAppear(controller)
@@ -80,6 +119,7 @@ final class CameraOverlayPresenter {
self?.overlayDidDisappear(controller)
}
controller.modalPresentationStyle = .overFullScreen
+ #endif
overlayController = controller
displayedCamera = camera
@@ -119,7 +159,7 @@ final class CameraOverlayPresenter {
}
/// Called by the hosting controller once the overlay is on screen.
- func overlayDidAppear(_ controller: UIViewController) {
+ func overlayDidAppear(_ controller: PlatformViewController) {
guard controller === overlayController else { return }
isPresenting = false
guard hideWhenPresented else { return }
@@ -129,7 +169,7 @@ final class CameraOverlayPresenter {
}
/// Called by the hosting controller when the overlay has been dismissed.
- func overlayDidDisappear(_ controller: UIViewController) {
+ func overlayDidDisappear(_ controller: PlatformViewController) {
// Only the overlay on record matters: a replaced one going away must not clear its successor.
guard controller === overlayController else { return }
clearState()
@@ -142,10 +182,15 @@ final class CameraOverlayPresenter {
displayedCamera = Camera(entityId: entityId, serverIdentifier: serverIdentifier)
}
- private func dismissDirectly(_ controller: UIViewController) {
+ private func dismissDirectly(_ controller: PlatformViewController) {
+ #if os(macOS)
+ controller.dismissSheet()
+ dismissalDidFinish()
+ #else
(controller.presentingViewController ?? controller).dismiss(animated: true) { [weak self] in
self?.dismissalDidFinish()
}
+ #endif
}
private func dismissalDidFinish() {
diff --git a/Sources/App/Cameras/CameraPlayer/CameraPickerSnapshotCache.swift b/Sources/App/Cameras/CameraPlayer/CameraPickerSnapshotCache.swift
index 68041f8836..e95da15216 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraPickerSnapshotCache.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraPickerSnapshotCache.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@MainActor
final class CameraPickerSnapshotCache {
diff --git a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift
index afef2664a2..b98c57d7db 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift
@@ -34,6 +34,13 @@ struct CameraPlayerView: View {
private let maxTitleTextWidth: CGFloat = 100
private let topScrimHeight: CGFloat = 140
+ #if os(macOS)
+ /// AppKit draws a menu item's image at the size it comes in, so the picker's stills are made as
+ /// small as a menu row.
+ private let snapshotThumbnailSize = CGSize(width: 32, height: 32)
+ #else
+ private let snapshotThumbnailSize = CGSize(width: 120, height: 120)
+ #endif
init(
server: Server,
@@ -110,31 +117,39 @@ struct CameraPlayerView: View {
nameBadge
}
}
+ #if !os(macOS)
+ // A Mac window keeps its toolbar; only the items in it come and go with the controls.
.modify { view in
- if #available(iOS 18.0, *) {
+ if #available(iOS 18.0, macOS 15.0, *) {
view.toolbarVisibility(controlsVisible ? .automatic : .hidden, for: .navigationBar)
} else {
view
}
}
+ #endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Liquid Glass backs the toolbar items from iOS 26 onwards, so the scrim is only needed before that.
private var needsTopScrim: Bool {
- if #available(iOS 26.0, *) {
+ #if os(macOS)
+ // A Mac window's toolbar sits beside the stream rather than over it.
+ return false
+ #else
+ if #available(iOS 26.0, macOS 26.0, *) {
return false
} else {
return true
}
+ #endif
}
/// Mirrors the navigation bar visibility so the scrim comes and goes with the items it backs. Hiding
/// the bar requires `toolbarVisibility`, so before iOS 18 the toolbar — and therefore the scrim —
/// stays on screen even while the controls are dimmed.
private var isToolbarVisible: Bool {
- if #available(iOS 18.0, *) {
+ if #available(iOS 18.0, macOS 15.0, *) {
return controlsVisible
} else {
return true
@@ -324,7 +339,7 @@ struct CameraPlayerView: View {
guard cache.shouldFetch(key) else { continue }
do {
let image = try await api.getCameraSnapshot(cameraEntityID: camera.entityId).asyncValue()
- let thumbnail = await image.byPreparingThumbnail(ofSize: CGSize(width: 120, height: 120)) ?? image
+ let thumbnail = await makeThumbnail(of: image)
cache.store(thumbnail, for: key)
cameraSnapshots[camera.entityId] = thumbnail
} catch {
@@ -334,6 +349,22 @@ struct CameraPlayerView: View {
}
}
+ private func makeThumbnail(of image: UIImage) async -> UIImage {
+ #if os(macOS)
+ let scale = min(
+ snapshotThumbnailSize.width / image.size.width,
+ snapshotThumbnailSize.height / image.size.height
+ )
+ guard scale.isFinite, scale < 1 else { return image }
+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ return UIGraphicsImageRenderer(size: size).image { _ in
+ image.draw(in: CGRect(origin: .zero, size: size))
+ }
+ #else
+ return await image.byPreparingThumbnail(ofSize: snapshotThumbnailSize) ?? image
+ #endif
+ }
+
/// Non-private for tests.
func switchCamera(to entityId: String) {
guard entityId != cameraEntityId else { return }
diff --git a/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift b/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift
index f51544b17e..025fdd0cf4 100644
--- a/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift
+++ b/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift
@@ -134,13 +134,16 @@ struct CameraStreamHLSView: View {
return baseURL.appendingPathComponent(relativePath)
}
+ /// A Mac has no audio session: the player goes straight to the system output.
@MainActor
private func setupPlayer(with url: URL, api: HomeAssistantAPI) {
+ #if !os(macOS)
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
} catch {
Current.Log.error("Failed to set audio session category: \(error.localizedDescription)")
}
+ #endif
// A server behind a client certificate, or reached with a security exception, needs its
// requests to go through the app's own session; AVFoundation cannot present either itself.
diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/CameraZoomGestureOverlay.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/CameraZoomGestureOverlay.swift
index 4dd446db9b..3aa108ce9d 100644
--- a/Sources/App/Cameras/CameraPlayer/WebRTC/CameraZoomGestureOverlay.swift
+++ b/Sources/App/Cameras/CameraPlayer/WebRTC/CameraZoomGestureOverlay.swift
@@ -1,6 +1,89 @@
import SwiftUI
-import UIKit
+#if os(macOS)
+struct CameraZoomGestureOverlay: NSViewRepresentable {
+ var onPinchBegan: (CGPoint) -> Void
+ var onPinchChanged: (CGFloat, CGPoint) -> Void
+ var onPinchEnded: () -> Void
+ var onDoubleTap: (CGPoint) -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ func makeNSView(context: Context) -> NSView {
+ let view = GestureView()
+
+ let pinch = NSMagnificationGestureRecognizer(
+ target: context.coordinator,
+ action: #selector(Coordinator.handlePinch(_:))
+ )
+ pinch.delegate = context.coordinator
+ view.addGestureRecognizer(pinch)
+
+ let doubleTap = NSClickGestureRecognizer(
+ target: context.coordinator,
+ action: #selector(Coordinator.handleDoubleTap(_:))
+ )
+ doubleTap.numberOfClicksRequired = 2
+ // Clicks still reach the player underneath as they happen, so a single one toggles its
+ // controls without waiting to see whether a second follows.
+ doubleTap.delaysPrimaryMouseButtonEvents = false
+ doubleTap.delegate = context.coordinator
+ view.addGestureRecognizer(doubleTap)
+
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ context.coordinator.parent = self
+ }
+
+ final class Coordinator: NSObject, NSGestureRecognizerDelegate {
+ var parent: CameraZoomGestureOverlay
+
+ init(parent: CameraZoomGestureOverlay) {
+ self.parent = parent
+ }
+
+ @objc
+ func handlePinch(_ recognizer: NSMagnificationGestureRecognizer) {
+ guard let view = recognizer.view else { return }
+ let location = recognizer.location(in: view)
+ switch recognizer.state {
+ case .began:
+ parent.onPinchBegan(location)
+ case .changed:
+ // A magnification counts from zero where a pinch's scale counts from one.
+ parent.onPinchChanged(1 + recognizer.magnification, location)
+ case .ended, .cancelled, .failed:
+ parent.onPinchEnded()
+ default:
+ break
+ }
+ }
+
+ @objc
+ func handleDoubleTap(_ recognizer: NSClickGestureRecognizer) {
+ guard recognizer.state == .ended, let view = recognizer.view else { return }
+ let location = recognizer.location(in: view)
+ parent.onDoubleTap(location)
+ }
+
+ func gestureRecognizer(
+ _ gestureRecognizer: NSGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: NSGestureRecognizer
+ ) -> Bool {
+ true
+ }
+ }
+
+ /// Measures from the top left, as SwiftUI and the zoom arithmetic do; AppKit starts at the bottom.
+ private final class GestureView: NSView {
+ override var isFlipped: Bool { true }
+ }
+}
+#else
struct CameraZoomGestureOverlay: UIViewRepresentable {
var onPinchBegan: (CGPoint) -> Void
var onPinchChanged: (CGFloat, CGPoint) -> Void
@@ -76,3 +159,4 @@ struct CameraZoomGestureOverlay: UIViewRepresentable {
}
}
}
+#endif
diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift
index 227a8595fa..f66143d0f9 100644
--- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift
+++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift
@@ -14,6 +14,8 @@ protocol WebRTCClientDelegate: AnyObject {
func webRTCClient(_ client: WebRTCStreamClient, didReceiveData data: Data)
}
+// The WebRTC build for macOS has no injectable audio device, and no audio session for one to configure.
+#if !os(macOS)
/// Custom WebRTC audio device that supports playout only and never opens input.
final class PlaybackOnlyRTCAudioDevice: NSObject, RTCAudioDevice {
private enum Constants {
@@ -262,13 +264,16 @@ final class PlaybackOnlyRTCAudioDevice: NSObject, RTCAudioDevice {
)
}
}
+#endif
/// WebRTCClient manages a WebRTC peer connection, media tracks, and data channels.
/// It abstracts the setup and control of a WebRTC session for use in the Home Assistant iOS app.
///
/// - Note: Based on example project from WebRTC iOS SDK https://github.com/stasel/WebRTC
final class WebRTCClient: NSObject, WebRTCStreamClient {
+ #if !os(macOS)
private static let playbackOnlyAudioDevice = PlaybackOnlyRTCAudioDevice()
+ #endif
// The `RTCPeerConnectionFactory` is in charge of creating new RTCPeerConnection instances.
// A new RTCPeerConnection should be created every new call, but the factory is shared.
@@ -277,11 +282,20 @@ final class WebRTCClient: NSObject, WebRTCStreamClient {
RTCInitializeSSL()
let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
+ #if os(macOS)
+ // WebRTC's own audio device plays through the system output here. It only opens the
+ // microphone for a connection that sends audio, and this one is receive-only.
+ return RTCPeerConnectionFactory(
+ encoderFactory: videoEncoderFactory,
+ decoderFactory: videoDecoderFactory
+ )
+ #else
return RTCPeerConnectionFactory(
encoderFactory: videoEncoderFactory,
decoderFactory: videoDecoderFactory,
audioDevice: playbackOnlyAudioDevice
)
+ #endif
}()
weak var delegate: WebRTCClientDelegate?
diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift
index e3fd3b1f90..b1c6efeadd 100644
--- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift
+++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift
@@ -74,7 +74,7 @@ struct WebRTCVideoPlayerControlsView: View {
.transition(.move(edge: .trailing).combined(with: .scale))
}
.modify({ view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.buttonStyle(.glassProminent)
} else {
diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift
index 5ba4d22e32..3b77d36298 100644
--- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift
+++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift
@@ -1,8 +1,106 @@
import Shared
import SwiftUI
-import UIKit
import WebRTC
+#if os(macOS)
+class WebRTCVideoPlayerViewController: NSViewController {
+ private let viewModel: WebRTCViewPlayerViewModel
+ private var remoteVideoView: RTCMTLNSVideoView!
+ /// Holds the video view to the stream's proportions. WebRTC's Mac view has no content mode and
+ /// stretches each frame over its whole bounds, so fitting the picture is done by sizing the view.
+ private var aspectRatioConstraint: NSLayoutConstraint?
+ /// Set when the view left its window, so the stream it stopped on the way out is started again
+ /// when the window comes back from being minimised or hidden.
+ private var isStopped = false
+
+ var onVideoStarted: (() -> Void)?
+ var onVideoSizeChanged: ((CGSize) -> Void)?
+
+ init(viewModel: WebRTCViewPlayerViewModel) {
+ self.viewModel = viewModel
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.backgroundColor = NSColor.black.cgColor
+ view = container
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupVideoView()
+ // The peer connection is created after the client config is fetched, so the renderer is
+ // registered up front and attached by the view model once the connection exists.
+ viewModel.attach(renderer: remoteVideoView)
+ viewModel.start()
+ }
+
+ override func viewWillAppear() {
+ super.viewWillAppear()
+ guard isStopped else { return }
+ isStopped = false
+ viewModel.start()
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ isStopped = true
+ viewModel.stop()
+ }
+
+ private func setupVideoView() {
+ remoteVideoView = RTCMTLNSVideoView(frame: view.bounds)
+ remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
+ remoteVideoView.delegate = self
+ view.addSubview(remoteVideoView)
+
+ // As large as the container allows in both directions; once the stream's proportions are
+ // known only one of the two can hold, which is what letterboxes the picture.
+ let fillWidth = remoteVideoView.widthAnchor.constraint(equalTo: view.widthAnchor)
+ fillWidth.priority = .defaultHigh
+ let fillHeight = remoteVideoView.heightAnchor.constraint(equalTo: view.heightAnchor)
+ fillHeight.priority = .defaultHigh
+ NSLayoutConstraint.activate([
+ remoteVideoView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ remoteVideoView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ remoteVideoView.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor),
+ remoteVideoView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor),
+ fillWidth,
+ fillHeight,
+ ])
+ }
+
+ private func fitVideo(ofSize size: CGSize) {
+ guard size.width > 0, size.height > 0 else { return }
+ aspectRatioConstraint?.isActive = false
+ let constraint = remoteVideoView.widthAnchor.constraint(
+ equalTo: remoteVideoView.heightAnchor,
+ multiplier: size.width / size.height
+ )
+ constraint.isActive = true
+ aspectRatioConstraint = constraint
+ }
+}
+
+extension WebRTCVideoPlayerViewController: RTCVideoViewDelegate {
+ func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
+ // Hide loader when the first frame is rendered
+ DispatchQueue.main.async { [weak self] in
+ self?.fitVideo(ofSize: size)
+ self?.viewModel.handleVideoRendered()
+ self?.onVideoStarted?()
+ self?.onVideoSizeChanged?(size)
+ }
+ }
+}
+#else
class WebRTCVideoPlayerViewController: UIViewController {
private let viewModel: WebRTCViewPlayerViewModel
private var remoteVideoView: RTCMTLVideoView!
@@ -60,3 +158,4 @@ extension WebRTCVideoPlayerViewController: RTCVideoViewDelegate {
}
}
}
+#endif
diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift
index 982d7137f6..a1083b6e50 100644
--- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift
+++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift
@@ -1,5 +1,44 @@
import SwiftUI
+#if os(macOS)
+struct WebRTCVideoPlayerViewControllerWrapper: NSViewControllerRepresentable {
+ private let viewModel: WebRTCViewPlayerViewModel
+ @Binding var isVideoPlaying: Bool
+
+ init(viewModel: WebRTCViewPlayerViewModel, isVideoPlaying: Binding) {
+ self.viewModel = viewModel
+ self._isVideoPlaying = isVideoPlaying
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ func makeNSViewController(context: Context) -> WebRTCVideoPlayerViewController {
+ let vc = WebRTCVideoPlayerViewController(viewModel: viewModel)
+ vc.onVideoStarted = { [weak coordinator = context.coordinator] in
+ coordinator?.videoDidStart()
+ }
+ return vc
+ }
+
+ func updateNSViewController(_ nsViewController: WebRTCVideoPlayerViewController, context: Context) {
+ /* no-op */
+ }
+
+ class Coordinator {
+ var parent: WebRTCVideoPlayerViewControllerWrapper
+
+ init(parent: WebRTCVideoPlayerViewControllerWrapper) {
+ self.parent = parent
+ }
+
+ func videoDidStart() {
+ parent.isVideoPlaying = true
+ }
+ }
+}
+#else
struct WebRTCVideoPlayerViewControllerWrapper: UIViewControllerRepresentable {
private let viewModel: WebRTCViewPlayerViewModel
@Binding var isVideoPlaying: Bool
@@ -37,3 +76,4 @@ struct WebRTCVideoPlayerViewControllerWrapper: UIViewControllerRepresentable {
}
}
}
+#endif
diff --git a/Sources/App/ClientEvents/LocationHistoryDetailView.swift b/Sources/App/ClientEvents/LocationHistoryDetailView.swift
new file mode 100644
index 0000000000..50fd4d6cfc
--- /dev/null
+++ b/Sources/App/ClientEvents/LocationHistoryDetailView.swift
@@ -0,0 +1,125 @@
+#if os(macOS)
+import GRDB
+import SFSafeSymbols
+import Shared
+import SwiftUI
+
+/// One location history entry on a map, on the Mac. The toolbar steps through the entries around it,
+/// recenters the map, explains what is drawn on it and shares a report of the entry.
+struct LocationHistoryDetailView: View {
+ @State private var currentEntry: LocationHistoryEntry
+ /// Every entry, most recent first: what the up and down buttons step through.
+ @State private var entries: [LocationHistoryEntry] = []
+ @State private var entriesObservation: AnyDatabaseCancellable?
+ @State private var map = LocationHistoryMapView.Coordinator()
+ @State private var showHelp = false
+
+ init(currentEntry: LocationHistoryEntry) {
+ self._currentEntry = State(initialValue: currentEntry)
+ }
+
+ var body: some View {
+ LocationHistoryMapView(entry: currentEntry, coordinator: map)
+ .navigationTitle(DateFormatter.localizedString(
+ from: currentEntry.createdAt,
+ dateStyle: .short,
+ timeStyle: .medium
+ ))
+ .toolbar {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button {
+ move(by: -1)
+ } label: {
+ Image(systemSymbol: .arrowUp)
+ }
+ .disabled(!canMove(by: -1))
+
+ Button {
+ move(by: 1)
+ } label: {
+ Image(systemSymbol: .arrowDown)
+ }
+ .disabled(!canMove(by: 1))
+
+ Button {
+ map.center(animated: true)
+ } label: {
+ Image(systemSymbol: .scope)
+ }
+
+ Button {
+ showHelp = true
+ } label: {
+ Image(systemSymbol: .questionmarkCircle)
+ }
+ .help(L10n.helpLabel)
+
+ Button {
+ map.share(report: currentEntry.debugReport())
+ } label: {
+ Image(systemSymbol: .squareAndArrowUp)
+ }
+ }
+ }
+ .alert(L10n.helpLabel, isPresented: $showHelp) {
+ Button(L10n.okLabel, role: .cancel) {}
+ } message: {
+ Text(L10n.Settings.LocationHistory.Detail.explanation)
+ }
+ .onAppear(perform: observeEntries)
+ .onDisappear {
+ entriesObservation?.cancel()
+ entriesObservation = nil
+ }
+ }
+
+ private func observeEntries() {
+ guard entriesObservation == nil else { return }
+
+ let observation = ValueObservation.tracking { db in
+ try LocationHistoryEntry
+ .order(Column(DatabaseTables.LocationHistory.createdAt.rawValue).desc)
+ .fetchAll(db)
+ }
+
+ entriesObservation = observation.start(
+ in: Current.database(),
+ onError: { error in
+ Current.Log.error("couldn't observe location history: \(error)")
+ },
+ onChange: { entries in
+ self.entries = entries
+ }
+ )
+ }
+
+ private func index(movingBy offset: Int) -> Int? {
+ guard let currentIndex = entries.firstIndex(where: { $0.id == currentEntry.id }) else { return nil }
+ let index = currentIndex + offset
+ return entries.indices.contains(index) ? index : nil
+ }
+
+ private func canMove(by offset: Int) -> Bool {
+ index(movingBy: offset) != nil
+ }
+
+ private func move(by offset: Int) {
+ guard let index = index(movingBy: offset) else { return }
+ currentEntry = entries[index]
+ }
+}
+
+#Preview {
+ NavigationView {
+ LocationHistoryDetailView(
+ currentEntry: LocationHistoryEntry(
+ updateType: .Manual,
+ location: .init(latitude: 41.1234, longitude: 52.2),
+ zone: nil,
+ accuracyAuthorization: .fullAccuracy,
+ payload: "payload"
+ )
+ )
+ }
+}
+#endif
diff --git a/Sources/App/ClientEvents/LocationHistoryDetailViewController.swift b/Sources/App/ClientEvents/LocationHistoryDetailViewController.swift
index b821da6022..01e4f61ea4 100644
--- a/Sources/App/ClientEvents/LocationHistoryDetailViewController.swift
+++ b/Sources/App/ClientEvents/LocationHistoryDetailViewController.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import GRDB
import MapKit
import Shared
@@ -129,82 +130,7 @@ final class LocationHistoryDetailViewController: UIViewController {
}
private func report() -> String {
- var value = "# Debug Information\n\n"
-
- let accuracyNote: String
-
- if currentEntry.accuracy == 65 {
- accuracyNote = " (from Wi-Fi)"
- } else if currentEntry.accuracy == 1414 {
- accuracyNote = " (from cell tower)"
- } else {
- accuracyNote = ""
- }
-
- let accuracyAuthorization: String
-
- if let authorization = currentEntry.clAccuracyAuthorization {
- switch authorization {
- case .fullAccuracy: accuracyAuthorization = "full"
- case .reducedAccuracy: accuracyAuthorization = "reduced"
- @unknown default: accuracyAuthorization = "unknown"
- }
- } else {
- accuracyAuthorization = "missing"
- }
-
- func latLongString(_ value: Double) -> String {
- String(format: "%.06lf", value)
- }
-
- func distanceString(_ value: Double) -> String {
- String(format: "%04.02lfm", max(0, value))
- }
-
- value.append(
- """
- ## Payload
- ```json
- \(currentEntry.payload)
- ```
-
- ## Location
- - Trigger: \(currentEntry.trigger ?? "(unknown)")
- - Center: (\(latLongString(currentEntry.latitude)), \(latLongString(currentEntry.longitude)))
- - Accuracy: \(distanceString(currentEntry.accuracy))\(accuracyNote)
- - Accuracy Authorization: \(accuracyAuthorization)
-
- ## Regions
- """ + "\n"
- )
-
- let allRegions = AppZone.all()
- .flatMap(\.circularRegionsForMonitoring)
- .sorted(by: { a, b in
- a.distanceWithAccuracy(from: currentEntry.clLocation) < b
- .distanceWithAccuracy(from: currentEntry.clLocation)
- })
- for region in allRegions {
- let regionLocation = CLLocation(latitude: region.center.latitude, longitude: region.center.longitude)
- let distanceWithoutAccuracy = regionLocation.distance(from: currentEntry.clLocation)
- let distanceWithAccuracy = region.distanceWithAccuracy(from: currentEntry.clLocation)
- let contains = region.containsWithAccuracy(currentEntry.clLocation)
-
- value.append(
- """
- ### \(region.identifier)
- - Center: (\(latLongString(region.center.latitude)), \(latLongString(region.center.longitude)))
- - Radius: \(distanceString(region.radius))
- - Distance From Perimeter: \(distanceString(distanceWithAccuracy))
- - Distance From Center: \(distanceString(distanceWithoutAccuracy))
- - Relative State: \(contains ? "inside" : "outside")
- """
- )
-
- value.append("\n\n")
- }
-
- return value
+ currentEntry.debugReport()
}
@objc private func help(_ sender: AnyObject?) {
@@ -404,3 +330,4 @@ extension LocationHistoryDetailViewController: MKMapViewDelegate {
}
}
}
+#endif
diff --git a/Sources/App/ClientEvents/LocationHistoryEntry+DebugReport.swift b/Sources/App/ClientEvents/LocationHistoryEntry+DebugReport.swift
new file mode 100644
index 0000000000..aae24830a5
--- /dev/null
+++ b/Sources/App/ClientEvents/LocationHistoryEntry+DebugReport.swift
@@ -0,0 +1,84 @@
+import CoreLocation
+import Shared
+
+extension LocationHistoryEntry {
+ /// A Markdown report of the entry for a bug report: the payload that was sent, where the device was,
+ /// and how that location relates to every region the app monitors.
+ func debugReport() -> String {
+ var value = "# Debug Information\n\n"
+
+ let accuracyNote: String
+
+ if accuracy == 65 {
+ accuracyNote = " (from Wi-Fi)"
+ } else if accuracy == 1414 {
+ accuracyNote = " (from cell tower)"
+ } else {
+ accuracyNote = ""
+ }
+
+ let accuracyAuthorization: String
+
+ if let authorization = clAccuracyAuthorization {
+ switch authorization {
+ case .fullAccuracy: accuracyAuthorization = "full"
+ case .reducedAccuracy: accuracyAuthorization = "reduced"
+ @unknown default: accuracyAuthorization = "unknown"
+ }
+ } else {
+ accuracyAuthorization = "missing"
+ }
+
+ func latLongString(_ value: Double) -> String {
+ String(format: "%.06lf", value)
+ }
+
+ func distanceString(_ value: Double) -> String {
+ String(format: "%04.02lfm", max(0, value))
+ }
+
+ value.append(
+ """
+ ## Payload
+ ```json
+ \(payload)
+ ```
+
+ ## Location
+ - Trigger: \(trigger ?? "(unknown)")
+ - Center: (\(latLongString(latitude)), \(latLongString(longitude)))
+ - Accuracy: \(distanceString(accuracy))\(accuracyNote)
+ - Accuracy Authorization: \(accuracyAuthorization)
+
+ ## Regions
+ """ + "\n"
+ )
+
+ let allRegions = AppZone.all()
+ .flatMap(\.circularRegionsForMonitoring)
+ .sorted(by: { a, b in
+ a.distanceWithAccuracy(from: clLocation) < b.distanceWithAccuracy(from: clLocation)
+ })
+ for region in allRegions {
+ let regionLocation = CLLocation(latitude: region.center.latitude, longitude: region.center.longitude)
+ let distanceWithoutAccuracy = regionLocation.distance(from: clLocation)
+ let distanceWithAccuracy = region.distanceWithAccuracy(from: clLocation)
+ let contains = region.containsWithAccuracy(clLocation)
+
+ value.append(
+ """
+ ### \(region.identifier)
+ - Center: (\(latLongString(region.center.latitude)), \(latLongString(region.center.longitude)))
+ - Radius: \(distanceString(region.radius))
+ - Distance From Perimeter: \(distanceString(distanceWithAccuracy))
+ - Distance From Center: \(distanceString(distanceWithoutAccuracy))
+ - Relative State: \(contains ? "inside" : "outside")
+ """
+ )
+
+ value.append("\n\n")
+ }
+
+ return value
+ }
+}
diff --git a/Sources/App/ClientEvents/LocationHistoryEntryListItemView.swift b/Sources/App/ClientEvents/LocationHistoryEntryListItemView.swift
index b9c31f021a..469bfb3844 100644
--- a/Sources/App/ClientEvents/LocationHistoryEntryListItemView.swift
+++ b/Sources/App/ClientEvents/LocationHistoryEntryListItemView.swift
@@ -15,10 +15,14 @@ struct LocationHistoryEntryListItemView: View {
var body: some View {
NavigationLink {
+ #if os(macOS)
+ LocationHistoryDetailView(currentEntry: entry)
+ #else
LocationHistoryDetailViewControllerWrapper(
currentEntry: entry
)
.edgesIgnoringSafeArea([.top, .bottom])
+ #endif
} label: {
VStack(alignment: .leading) {
Text(dateFormatter.string(from: entry.createdAt))
diff --git a/Sources/App/ClientEvents/LocationHistoryListView.swift b/Sources/App/ClientEvents/LocationHistoryListView.swift
index f7dbacf49b..c66ede4717 100644
--- a/Sources/App/ClientEvents/LocationHistoryListView.swift
+++ b/Sources/App/ClientEvents/LocationHistoryListView.swift
@@ -85,7 +85,7 @@ struct LocationHistoryListView: View {
}
}
-final class LocationHistoryListViewHostingController: UIHostingController {}
+final class LocationHistoryListViewHostingController: PlatformHostingController {}
private struct PreviewLocationHistoryListView: View {
private var locationHistory: [LocationHistoryEntry]
diff --git a/Sources/App/ClientEvents/LocationHistoryMapView.swift b/Sources/App/ClientEvents/LocationHistoryMapView.swift
new file mode 100644
index 0000000000..589ef931cd
--- /dev/null
+++ b/Sources/App/ClientEvents/LocationHistoryMapView.swift
@@ -0,0 +1,217 @@
+#if os(macOS)
+import MapKit
+import Shared
+import SwiftUI
+
+/// The map behind a location history entry on the Mac: the entry's position and accuracy, over every zone
+/// and the regions the app monitors for it.
+struct LocationHistoryMapView: NSViewRepresentable {
+ /// The zone as the server defines it.
+ private final class ZoneCircle: MKCircle {}
+ /// One of the regions monitored in place of a zone too small to monitor on its own.
+ private final class RegionCircle: MKCircle {}
+ /// The accuracy of the entry's position.
+ private final class GPSCircle: MKCircle {}
+
+ let entry: LocationHistoryEntry
+ /// Created by the screen that shows the map, so its toolbar can recenter and share it.
+ let coordinator: Coordinator
+
+ func makeCoordinator() -> Coordinator {
+ coordinator
+ }
+
+ func makeNSView(context: Context) -> MKMapView {
+ // A size to frame the first region in; the map is laid out to its real one right after.
+ let mapView = MKMapView(frame: CGRect(x: 0, y: 0, width: 600, height: 400))
+ mapView.pointOfInterestFilter = .excludingAll
+ mapView.showsBuildings = true
+ mapView.showsCompass = false
+ mapView.showsTraffic = false
+ mapView.showsUserLocation = false
+ mapView.showsScale = false
+ mapView.delegate = context.coordinator
+ context.coordinator.mapView = mapView
+ context.coordinator.show(entry, animated: false)
+ return mapView
+ }
+
+ func updateNSView(_ nsView: MKMapView, context: Context) {
+ guard context.coordinator.entry != entry else { return }
+ context.coordinator.show(entry, animated: true)
+ }
+
+ final class Coordinator: NSObject, MKMapViewDelegate {
+ fileprivate weak var mapView: MKMapView?
+ fileprivate var entry: LocationHistoryEntry?
+ private var snapshotter: MKMapSnapshotter?
+
+ /// Brings the entry back to the middle of the map.
+ func center(animated: Bool) {
+ guard let mapView, let entry else { return }
+ mapView.setRegion(
+ .init(
+ center: entry.clLocation.coordinate,
+ latitudinalMeters: 300,
+ longitudinalMeters: 300
+ ),
+ animated: animated
+ )
+ }
+
+ /// Offers a picture of the map as it is framed now, together with `report`, to the sharing
+ /// services.
+ func share(report: String) {
+ guard let mapView, let entry, mapView.bounds.width > 0, mapView.bounds.height > 0 else { return }
+
+ let options = MKMapSnapshotter.Options()
+ options.region = mapView.region
+ options.size = mapView.bounds.size
+ options.pointOfInterestFilter = .excludingAll
+ options.appearance = mapView.effectiveAppearance
+
+ let circles = mapView.overlays.compactMap { $0 as? MKCircle }
+ let pin = entry.clLocation.coordinate
+ let snapshotter = MKMapSnapshotter(options: options)
+ // Kept until it finishes: nothing else holds on to it while it renders.
+ self.snapshotter = snapshotter
+ snapshotter.start { [weak self] snapshot, error in
+ guard let self, let mapView = self.mapView else { return }
+ self.snapshotter = nil
+
+ var items: [Any] = [report]
+ if let snapshot {
+ items.insert(Self.image(of: snapshot, circles: circles, pin: pin), at: 0)
+ } else {
+ Current.Log.error("couldn't take a picture of the location history map: \(error.debugDescription)")
+ }
+
+ // Anchored to the top trailing corner, under the toolbar button that asked for it.
+ let bounds = mapView.bounds
+ let top = mapView.isFlipped ? bounds.minY : bounds.maxY - 1
+ let anchor = CGRect(x: bounds.maxX - 1, y: top, width: 1, height: 1)
+ NSSharingServicePicker(items: items).show(relativeTo: anchor, of: mapView, preferredEdge: .minY)
+ }
+ }
+
+ fileprivate func show(_ entry: LocationHistoryEntry, animated: Bool) {
+ guard let mapView else { return }
+ self.entry = entry
+
+ mapView.removeOverlays(mapView.overlays)
+ mapView.addOverlays(AppZone.all().flatMap { zone -> [MKOverlay] in
+ var overlays = [MKOverlay]()
+
+ let regions = zone.circularRegionsForMonitoring
+ if regions.count > 1 {
+ // for non-single-region zones, show the <100m as well
+ overlays.append(contentsOf: regions.map { RegionCircle(center: $0.center, radius: $0.radius) })
+ }
+
+ overlays.append(ZoneCircle(center: zone.center, radius: zone.radius))
+ return overlays
+ })
+ mapView.addOverlay(GPSCircle(
+ center: entry.clLocation.coordinate,
+ radius: entry.clLocation.horizontalAccuracy
+ ))
+
+ mapView.removeAnnotations(mapView.annotations)
+ mapView.addAnnotation(with(MKPointAnnotation()) {
+ $0.coordinate = entry.clLocation.coordinate
+ })
+
+ center(animated: animated)
+ }
+
+ func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
+ let view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: nil)
+ view.markerTintColor = .purple
+ return view
+ }
+
+ func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
+ guard let circle = overlay as? MKCircle else {
+ return MKOverlayRenderer(overlay: overlay)
+ }
+ let renderer = MKCircleRenderer(circle: circle)
+ renderer.fillColor = Self.fillColor(for: circle)
+ return renderer
+ }
+
+ private static func fillColor(for circle: MKCircle) -> UIColor? {
+ switch circle {
+ case is ZoneCircle: return AppConstants.tintColor.withAlphaComponent(0.75)
+ case is RegionCircle: return UIColor.orange.withAlphaComponent(0.25)
+ case is GPSCircle: return UIColor.purple.withAlphaComponent(0.75)
+ default: return nil
+ }
+ }
+
+ /// The snapshot with the circles and the entry's position drawn over it. A snapshot has the map
+ /// alone, so what the map view adds on top is drawn again here.
+ private static func image(
+ of snapshot: MKMapSnapshotter.Snapshot,
+ circles: [MKCircle],
+ pin: CLLocationCoordinate2D
+ ) -> UIImage {
+ let size = snapshot.image.size
+ return UIGraphicsImageRenderer(size: size).image { _ in
+ snapshot.image.draw(
+ in: CGRect(origin: .zero, size: size),
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1,
+ respectFlipped: true,
+ hints: nil
+ )
+
+ for circle in circles {
+ guard let color = fillColor(for: circle) else { continue }
+ let center = snapshot.topLeftPoint(for: circle.coordinate)
+ // Project a point on the circle's edge to know the radius in points.
+ let edge = snapshot.topLeftPoint(for: circle.coordinate.moving(
+ distance: .init(value: circle.radius, unit: .meters),
+ direction: .init(value: 90, unit: .degrees)
+ ))
+ let radius = abs(edge.x - center.x)
+ color.setFill()
+ UIBezierPath(ovalIn: CGRect(
+ x: center.x - radius,
+ y: center.y - radius,
+ width: radius * 2,
+ height: radius * 2
+ )).fill()
+ }
+
+ let position = snapshot.topLeftPoint(for: pin)
+ let dotRadius: CGFloat = 6
+ let dot = UIBezierPath(ovalIn: CGRect(
+ x: position.x - dotRadius,
+ y: position.y - dotRadius,
+ width: dotRadius * 2,
+ height: dotRadius * 2
+ ))
+ UIColor.purple.setFill()
+ dot.fill()
+ UIColor.white.setStroke()
+ dot.lineWidth = 2
+ dot.stroke()
+ }
+ }
+ }
+}
+
+#Preview {
+ LocationHistoryMapView(
+ entry: LocationHistoryEntry(
+ updateType: .Manual,
+ location: .init(latitude: 41.1234, longitude: 52.2),
+ zone: nil,
+ accuracyAuthorization: .fullAccuracy,
+ payload: "payload"
+ ),
+ coordinator: .init()
+ )
+}
+#endif
diff --git a/Sources/App/Container/AppContainerCoordinator.swift b/Sources/App/Container/AppContainerCoordinator.swift
index d90e6527f9..141958ac3a 100644
--- a/Sources/App/Container/AppContainerCoordinator.swift
+++ b/Sources/App/Container/AppContainerCoordinator.swift
@@ -2,7 +2,6 @@ import Foundation
import PromiseKit
import Shared
import SwiftUI
-import UIKit
/// App coordinator for the primary web view, owned by `ContainerView` — the SwiftUI replacement for
/// `WebViewWindowController`'s presentation duties. It drives the active `WebFrontend` (published by the
@@ -52,8 +51,35 @@ final class AppContainerCoordinator: AppCoordinator {
seals.forEach { $0(frontend) }
}
- var window: UIWindow? { frontend?.presentationWindow }
+ var window: PlatformWindow? { frontend?.presentationWindow }
+ #if os(macOS)
+ /// The controller every presentation of this window hangs off: the window's own content controller,
+ /// or the frontmost window's when the frontend is not on screen yet.
+ private var presentationRoot: NSViewController? {
+ (window ?? NSApp.keyWindow ?? NSApp.mainWindow)?.contentViewController
+ }
+
+ var presentedViewController: NSViewController? {
+ var current = presentationRoot
+ while let next = current?.presentedSheets.last {
+ current = next.sheetContentController
+ }
+ // A sheet SwiftUI put up itself (the server picker) is not in the stack above, but is still on top.
+ return current?.foreignSheetContentController ?? current
+ }
+
+ func present(_ viewController: NSViewController, animated: Bool, completion: (() -> Void)?) {
+ presentedViewController?.presentSheet(viewController)
+ completion?()
+ }
+
+ func present(alert: AppAlert) {
+ // An alert goes on whichever window is showing on top of this one's content: a sheet that is up
+ // is a window of its own, and the alert belongs on it rather than behind it.
+ alert.present(on: window?.attachedSheet ?? window ?? NSApp.keyWindow)
+ }
+ #else
/// The controller every presentation of this scene hangs off. Falls back to the active scene's key
/// window because a full-screen presentation detaches the frontend's own view, which would otherwise
/// leave us with no way to reach — or clear — what is on screen.
@@ -79,6 +105,11 @@ final class AppContainerCoordinator: AppCoordinator {
presentedViewController?.present(viewController, animated: animated, completion: completion)
}
+ func present(alert: AppAlert) {
+ present(alert.makeAlertController())
+ }
+ #endif
+
func show(alert: ServerAlert) {
frontend?.show(alert: alert)
}
@@ -167,9 +198,19 @@ final class AppContainerCoordinator: AppCoordinator {
// Dismissing from the root tears down the whole presentation chain, SwiftUI sheets included. The
// frontend's own overlays are the fallback for a link handled before there is a scene root.
+ #if os(macOS)
+ if let root = presentationRoot, !root.presentedSheets.isEmpty {
+ root.dismissPresentedSheets()
+ completion?()
+ return
+ }
+ #else
if let root = presentationRoot, root.presentedViewController != nil {
root.dismiss(animated: true, completion: completion)
- } else if let frontend {
+ return
+ }
+ #endif
+ if let frontend {
frontend.dismissOverlayController(animated: true, completion: completion)
} else {
completion?()
@@ -301,17 +342,17 @@ final class AppContainerCoordinator: AppCoordinator {
return
}
- let alert = UIAlertController(
+ present(alert: AppAlert(
title: L10n.Alerts.OpenUrlFromNotification.title,
message: from.message(with: openUrlRaw),
- preferredStyle: .alert
- )
- alert.addAction(UIAlertAction(title: L10n.cancelLabel, style: .cancel, handler: nil))
- alert.addAction(UIAlertAction(title: L10n.alwaysOpenLabel, style: .default) { _ in
- prefs.set(false, forKey: "confirmBeforeOpeningUrl")
- triggerOpen()
- })
- alert.addAction(UIAlertAction(title: L10n.openLabel, style: .default) { _ in triggerOpen() })
- present(alert)
+ actions: [
+ .init(title: L10n.cancelLabel, style: .cancel),
+ .init(title: L10n.alwaysOpenLabel) {
+ prefs.set(false, forKey: "confirmBeforeOpeningUrl")
+ triggerOpen()
+ },
+ .init(title: L10n.openLabel, isPreferred: true) { triggerOpen() },
+ ]
+ ))
}
}
diff --git a/Sources/App/Container/ContainerView.swift b/Sources/App/Container/ContainerView.swift
index 5f5cec7732..05554bba78 100644
--- a/Sources/App/Container/ContainerView.swift
+++ b/Sources/App/Container/ContainerView.swift
@@ -1,7 +1,6 @@
import PromiseKit
import Shared
import SwiftUI
-import UIKit
struct ContainerView: View {
@StateObject private var state = OnboardingStateObservable()
@@ -32,6 +31,9 @@ struct ContainerView: View {
coordinator.settingsPresenter = appSettings
appSettings.appCoordinator = coordinator
coordinator.onShowSettings = { [weak coordinator] pushOntoNavigationStack in
+ #if os(macOS)
+ appSettings.presentSettings()
+ #else
// Push only in compact width, read from the window at presentation time.
let sizeClass = coordinator?.window?.traitCollection.horizontalSizeClass
if pushOntoNavigationStack, sizeClass == .compact, !NativeTabBarState.shared.isEnabled {
@@ -39,6 +41,7 @@ struct ContainerView: View {
} else {
appSettings.presentSettings()
}
+ #endif
}
coordinator.onShowAssistSettings = { viewModel.presentAssistSettings() }
coordinator.onShowDownloadManager = { viewModel.presentDownloadManager($0) }
@@ -58,9 +61,9 @@ struct ContainerView: View {
AssistSettingsView()
case let .downloadManager(viewModel):
// The case is only ever set on iOS 17+ (the `WKDownload` delegate path); guard for the floor.
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
DownloadManagerView(viewModel: viewModel)
- #if !targetEnvironment(macCatalyst)
+ #if !(targetEnvironment(macCatalyst) || os(macOS))
.presentationDetents([.medium, .large])
#endif
}
@@ -95,23 +98,33 @@ struct ContainerView: View {
Current.sceneManager.setWebViewController(webViewController)
}
.id(server.identifier.rawValue)
+ #if os(macOS)
+ .onDisappear {
+ if let webViewController = coordinator.frontend as? WebViewController {
+ Current.sceneManager.unregisterWebViewController(webViewController)
+ }
+ Current.sceneManager.unregisterAppCoordinator(coordinator)
+ }
+ #endif
- if #available(iOS 26, *), nativeTabBar.isEnabled {
+ if #available(iOS 26, macOS 26, *), nativeTabBar.isEnabled {
homeAssistant
} else {
NavigationStack(path: $appSettings.pushPath) {
homeAssistant
- .toolbar(.hidden, for: .navigationBar)
- .navigationDestination(for: AppSettingsPushRoute.self) { route in
- switch route {
- case .settings:
- SettingsView(embedInOwnNavigation: false)
- .injectingViewControllerProvider()
- case let .item(item):
- item.destinationView
- .injectingViewControllerProvider()
- }
+ #if os(iOS)
+ .toolbar(.hidden, for: .navigationBar)
+ #endif
+ .navigationDestination(for: AppSettingsPushRoute.self) { route in
+ switch route {
+ case .settings:
+ SettingsView(embedInOwnNavigation: false)
+ .injectingViewControllerProvider()
+ case let .item(item):
+ item.destinationView
+ .injectingViewControllerProvider()
}
+ }
}
}
}
diff --git a/Sources/App/Container/LaunchSplash/LaunchSplashOverlayState.swift b/Sources/App/Container/LaunchSplash/LaunchSplashOverlayState.swift
index bf1944980c..d387bd277e 100644
--- a/Sources/App/Container/LaunchSplash/LaunchSplashOverlayState.swift
+++ b/Sources/App/Container/LaunchSplash/LaunchSplashOverlayState.swift
@@ -29,7 +29,7 @@ final class LaunchSplashOverlayState: ObservableObject {
// macOS has no system launch screen to bridge from, so the fake splash is disabled on Catalyst
// by starting already `.finished`: the overlay renders nothing and every phase consumer
// (server-pill fade-in, logo anchors) behaves as if the launch hand-off already completed.
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
self.phase = .finished
#else
self.phase = .waiting
diff --git a/Sources/App/FlightGreetings/CabinPressureMonitor.swift b/Sources/App/FlightGreetings/CabinPressureMonitor.swift
index 5ed128325d..28422bd054 100644
--- a/Sources/App/FlightGreetings/CabinPressureMonitor.swift
+++ b/Sources/App/FlightGreetings/CabinPressureMonitor.swift
@@ -50,7 +50,12 @@ final class CabinPressureMonitor {
}
var isObserving: Bool {
- Current.barometerObserver.hasSubscriber(id: Self.subscriberID)
+ #if os(macOS)
+ // No Mac has a barometer, so nothing is ever observed there.
+ return false
+ #else
+ return Current.barometerObserver.hasSubscriber(id: Self.subscriberID)
+ #endif
}
/// The highest pressure recently seen while the device had a network, which stands in for "the
@@ -73,11 +78,13 @@ final class CabinPressureMonitor {
guard !isObserving else { return }
+ #if !os(macOS)
let started = Current.barometerObserver.addSubscriber(id: Self.subscriberID) { [weak self] data, _ in
guard let data else { return }
self?.record(pressureKpa: data.pressure.doubleValue)
}
guard !started else { return }
+ #endif
// Every foreground pass retries, so log the reason once rather than on each attempt.
lock.lock()
let shouldLog = !didLogUnavailable
@@ -89,7 +96,9 @@ final class CabinPressureMonitor {
}
func stop() {
+ #if !os(macOS)
Current.barometerObserver.removeSubscriber(id: Self.subscriberID)
+ #endif
lock.lock()
samples.removeAll()
didIndicateFlight = false
@@ -97,6 +106,7 @@ final class CabinPressureMonitor {
lock.unlock()
}
+ #if !os(macOS)
private func record(pressureKpa: Double) {
let now = Current.date()
updateGroundBaselineIfNeeded(pressureKpa: pressureKpa, now: now)
@@ -141,6 +151,7 @@ final class CabinPressureMonitor {
prefs.set(pressureKpa, forKey: Self.baselinePressureKey)
prefs.set(now, forKey: Self.baselineDateKey)
}
+ #endif
static func evidence(
samples: [CabinPressureSample],
diff --git a/Sources/App/FlightGreetings/FlightGreetingManager.swift b/Sources/App/FlightGreetings/FlightGreetingManager.swift
index cd35a965c5..000e618525 100644
--- a/Sources/App/FlightGreetings/FlightGreetingManager.swift
+++ b/Sources/App/FlightGreetings/FlightGreetingManager.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Coordinates the "have a great flight" greeting: watches cabin pressure while the app is in the
/// foreground, runs flight detection when the app becomes active or the web view loses connection,
@@ -29,7 +33,7 @@ final class FlightGreetingManager {
func start() {
guard didBecomeActiveObserver == nil else { return }
didBecomeActiveObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didBecomeActiveNotification,
+ forName: AppLifecycle.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
@@ -38,7 +42,7 @@ final class FlightGreetingManager {
}
}
didEnterBackgroundObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didEnterBackgroundNotification,
+ forName: AppLifecycle.didEnterBackgroundNotification,
object: nil,
queue: .main
) { _ in
@@ -73,7 +77,7 @@ final class FlightGreetingManager {
/// Shows the greeting toast, respecting the user setting and the once-per-flight cooldown.
func presentGreetingToastIfAllowed() {
guard Current.settingsStore.flightGreetingsEnabled, canGreet else { return }
- guard #available(iOS 18, *) else { return }
+ guard #available(iOS 18, macOS 15, *) else { return }
ToastPresenter.shared.show(
id: Self.toastID,
symbol: .airplane,
@@ -98,7 +102,7 @@ final class FlightGreetingManager {
/// the app opened was the end of it.
func startPressureMonitoringIfNeeded() {
// The toast itself needs iOS 18, so below that there is nothing detection could lead to.
- guard #available(iOS 18, *), Current.settingsStore.flightGreetingsEnabled, canGreet else {
+ guard #available(iOS 18, macOS 15, *), Current.settingsStore.flightGreetingsEnabled, canGreet else {
CabinPressureMonitor.shared.stop()
return
}
diff --git a/Sources/App/FlightGreetings/GreetingsSettingsView.swift b/Sources/App/FlightGreetings/GreetingsSettingsView.swift
index de7ac731e4..387c0dd66d 100644
--- a/Sources/App/FlightGreetings/GreetingsSettingsView.swift
+++ b/Sources/App/FlightGreetings/GreetingsSettingsView.swift
@@ -5,7 +5,7 @@ struct GreetingsSettingsView: View {
@State private var flightGreetingsEnabled = Current.settingsStore.flightGreetingsEnabled
var body: some View {
- List {
+ GroupedList {
Section(footer: Text(L10n.Settings.Greetings.Flight.footer)) {
Toggle(L10n.Settings.Greetings.Flight.title, isOn: $flightGreetingsEnabled)
.onChange(of: flightGreetingsEnabled) { value in
diff --git a/Sources/App/Frontend/DownloadManager/ActivityViewController.swift b/Sources/App/Frontend/DownloadManager/ActivityViewController.swift
index 68f6e4cc3e..5fff43295e 100644
--- a/Sources/App/Frontend/DownloadManager/ActivityViewController.swift
+++ b/Sources/App/Frontend/DownloadManager/ActivityViewController.swift
@@ -1,4 +1,5 @@
import Foundation
+import Shared
import SwiftUI
struct ShareWrapper: Identifiable {
@@ -6,6 +7,35 @@ struct ShareWrapper: Identifiable {
let url: URL
}
+#if os(macOS)
+/// A Mac shares from a menu attached to the control that opens it rather than from a sheet of its own, so
+/// the sheet this is shown in names the file and offers that control.
+struct ActivityViewController: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let shareWrapper: ShareWrapper
+
+ var body: some View {
+ VStack(spacing: DesignSystem.Spaces.three) {
+ Label(shareWrapper.url.lastPathComponent, systemSymbol: .doc)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ HStack(spacing: DesignSystem.Spaces.two) {
+ Button(L10n.doneLabel) {
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ ShareLink(item: shareWrapper.url)
+ }
+ }
+ .padding(DesignSystem.Spaces.three)
+ }
+}
+
+#Preview {
+ ActivityViewController(shareWrapper: ShareWrapper(url: URL(fileURLWithPath: "/tmp/home-assistant.log")))
+}
+#else
struct ActivityViewController: UIViewControllerRepresentable {
let shareWrapper: ShareWrapper
func makeUIViewController(context: Context) -> UIActivityViewController {
@@ -14,3 +44,4 @@ struct ActivityViewController: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
+#endif
diff --git a/Sources/App/Frontend/DownloadManager/DownloadManagerView.swift b/Sources/App/Frontend/DownloadManager/DownloadManagerView.swift
index fec5b65911..1de7334f0e 100644
--- a/Sources/App/Frontend/DownloadManager/DownloadManagerView.swift
+++ b/Sources/App/Frontend/DownloadManager/DownloadManagerView.swift
@@ -1,7 +1,7 @@
import Shared
import SwiftUI
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct DownloadManagerView: View {
@Environment(\.dismiss) private var dismiss
@@ -65,7 +65,7 @@ struct DownloadManagerView: View {
let checkmark = Image(systemSymbol: .checkmark)
.foregroundStyle(.green)
.font(.system(size: 100))
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
checkmark
.symbolEffect(
.bounce,
@@ -134,7 +134,7 @@ struct DownloadManagerView: View {
}
#Preview {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
DownloadManagerView(viewModel: .init())
} else {
Text("Hey there")
diff --git a/Sources/App/Frontend/EntityDeeplink/DeeplinkPresenter.swift b/Sources/App/Frontend/EntityDeeplink/DeeplinkPresenter.swift
index b97ad7ee50..889884d93b 100644
--- a/Sources/App/Frontend/EntityDeeplink/DeeplinkPresenter.swift
+++ b/Sources/App/Frontend/EntityDeeplink/DeeplinkPresenter.swift
@@ -1,10 +1,16 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
enum DeeplinkPresenter {
static let sheetHeightFraction: CGFloat = 0.7
+ #if os(iOS)
private static let detentIdentifier = UISheetPresentationController.Detent.Identifier("deeplink")
+ #endif
static func present(target: DeeplinkTarget, from webViewController: WebViewControllerProtocol) {
Current.Log.info("Presenting deeplink sheet")
@@ -17,7 +23,10 @@ enum DeeplinkPresenter {
onClose: closeAction(for: webViewController)
).embeddedInHostingController()
+ // A Mac sheet has no detents to pick from: the window presenting it decides its size.
+ #if os(iOS)
configurePresentation(of: hostingController)
+ #endif
webViewController.presentOverlayController(controller: hostingController, animated: true)
}
@@ -31,6 +40,7 @@ enum DeeplinkPresenter {
maximum * sheetHeightFraction
}
+ #if os(iOS)
private static func configurePresentation(of controller: UIViewController) {
if Current.isCatalyst {
controller.modalPresentationStyle = .formSheet
@@ -44,4 +54,5 @@ enum DeeplinkPresenter {
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
}
+ #endif
}
diff --git a/Sources/App/Frontend/EntityDeeplink/DeeplinkView.swift b/Sources/App/Frontend/EntityDeeplink/DeeplinkView.swift
index 0a04363d86..0e495b13f6 100644
--- a/Sources/App/Frontend/EntityDeeplink/DeeplinkView.swift
+++ b/Sources/App/Frontend/EntityDeeplink/DeeplinkView.swift
@@ -13,7 +13,7 @@ struct DeeplinkView: View {
var body: some View {
NavigationView {
- List {
+ GroupedList {
Section {
AppleLikeListTopRowHeader(
image: .linkVariantIcon,
@@ -65,7 +65,7 @@ struct DeeplinkView: View {
}
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.two)
} else {
view
@@ -74,7 +74,7 @@ struct DeeplinkView: View {
}
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view
.listSectionSpacing(.zero)
.contentMargins(.top, 0)
@@ -107,7 +107,7 @@ struct DeeplinkView: View {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
#Preview {
Color.clear
.sheet(isPresented: .constant(true)) {
diff --git a/Sources/App/Frontend/EntityDeeplink/DeeplinkViewModel.swift b/Sources/App/Frontend/EntityDeeplink/DeeplinkViewModel.swift
index 2c58a04a93..a1a9df1804 100644
--- a/Sources/App/Frontend/EntityDeeplink/DeeplinkViewModel.swift
+++ b/Sources/App/Frontend/EntityDeeplink/DeeplinkViewModel.swift
@@ -1,7 +1,6 @@
import Foundation
import Shared
import SwiftUI
-import UIKit
final class DeeplinkViewModel: ObservableObject {
let target: DeeplinkTarget
diff --git a/Sources/App/Frontend/Extensions/NotificationPermissionRequestView.swift b/Sources/App/Frontend/Extensions/NotificationPermissionRequestView.swift
index 0cf2e7d61d..2188ba4275 100644
--- a/Sources/App/Frontend/Extensions/NotificationPermissionRequestView.swift
+++ b/Sources/App/Frontend/Extensions/NotificationPermissionRequestView.swift
@@ -1,5 +1,6 @@
import Shared
import SwiftUI
+import UserNotifications
struct NotificationPermissionRequestView: View {
@Environment(\.dismiss) private var dismiss
@@ -49,8 +50,13 @@ struct NotificationPermissionRequestView: View {
if let error {
Current.Log.error("Error when requesting notifications permissions: \(error)")
}
+ Current.settingsStore.notificationPermissionPromptAnswered = true
DispatchQueue.main.async {
+ #if os(macOS)
+ NSApplication.shared.registerForRemoteNotifications()
+ #else
UIApplication.shared.registerForRemoteNotifications()
+ #endif
}
}
}
diff --git a/Sources/App/Frontend/Extensions/WebViewController+PostOnboarding.swift b/Sources/App/Frontend/Extensions/WebViewController+PostOnboarding.swift
index a9f2293084..e6810cb207 100644
--- a/Sources/App/Frontend/Extensions/WebViewController+PostOnboarding.swift
+++ b/Sources/App/Frontend/Extensions/WebViewController+PostOnboarding.swift
@@ -1,6 +1,7 @@
import Foundation
import Shared
import SwiftUI
+import UserNotifications
// MARK: - Post onboarding
@@ -11,24 +12,34 @@ extension WebViewController {
DispatchQueue.main.asyncAfter(deadline: .now() + delayedSeconds) { [weak self] in
Task {
let settings = await Current.userNotificationCenter.notificationSettings()
- if ![.authorized, .denied].contains(settings.authorizationStatus) {
+ if Self.needsNotificationPermissionPrompt(status: settings.authorizationStatus) {
self?.showNotificationPermissionRequest()
}
}
}
}
+ /// Whether the app's own prompt is still owed: the system has not decided and the prompt was never answered.
+ static func needsNotificationPermissionPrompt(status: UNAuthorizationStatus) -> Bool {
+ guard !Current.settingsStore.notificationPermissionPromptAnswered else { return false }
+ return ![.authorized, .denied].contains(status)
+ }
+
/// A system sheet, so it stays clear of the vertical bar and the safe area the way any other
- /// sheet does.
+ /// sheet does. On the Mac it is a sheet sized to its content.
func showNotificationPermissionRequest() {
let controller = NotificationPermissionRequestView().embeddedInHostingController()
+ #if os(macOS)
+ controller.presentsAsTransparentOverlay()
+ #else
if Current.isCatalyst {
controller.modalPresentationStyle = .formSheet
} else if let sheet = controller.sheetPresentationController {
sheet.detents = [.medium()]
sheet.prefersGrabberVisible = true
}
+ #endif
present(controller, animated: true)
}
}
diff --git a/Sources/App/Frontend/Extensions/WebViewGestureHandler.swift b/Sources/App/Frontend/Extensions/WebViewGestureHandler.swift
index 32e93a9e1b..c9376596a7 100644
--- a/Sources/App/Frontend/Extensions/WebViewGestureHandler.swift
+++ b/Sources/App/Frontend/Extensions/WebViewGestureHandler.swift
@@ -163,7 +163,7 @@ final class WebViewGestureHandler {
/// The scene the gesture was made in, so what it asks for happens in that window rather than in
/// whichever one the app last registered.
- private var sceneOfGesture: UIWindowScene? {
+ private var sceneOfGesture: PlatformWindowScene? {
webView?.presentationWindow?.windowScene
}
}
diff --git a/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift b/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift
index a20c7e2530..3596d73db2 100644
--- a/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift
+++ b/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift
@@ -25,7 +25,7 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
]
weak var webView: WebViewControllerProtocol?
- var isAppInBackground: @MainActor () -> Bool = { UIApplication.shared.applicationState == .background }
+ var isAppInBackground: @MainActor () -> Bool = { ApplicationState.current == .background }
private var deferredExternalBusMessages: [[String: Any]] = []
@@ -92,6 +92,16 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
/// Handle theme changes from frontend, updating local cache and UI
private func handleThemeUpdate(_ messageBody: [String: Any]) {
+ #if os(macOS)
+ guard let appearance = webView?.effectiveAppearance else {
+ let message = "WebViewController missing appearance for theme update"
+ Current.Log.error(message)
+ assertionFailure(message)
+ return
+ }
+
+ ThemeColors.updateCache(with: messageBody, for: appearance)
+ #else
guard let traitCollection = webView?.traitCollection else {
let message = "WebViewController missing traitCollection for theme update"
Current.Log.error(message)
@@ -100,6 +110,7 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
}
ThemeColors.updateCache(with: messageBody, for: traitCollection)
+ #endif
webView?.styleUI(publishesThemedStatusBar: true)
}
@@ -139,7 +150,7 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
/// drawn in the user's configured colors. Separate from `updateThemeColors`, which carries only the
/// handful the status bar needs and is what keeps that path working if this one ever fails.
private func handleUpdateThemeVariables(_ messageBody: [String: Any]) {
- guard let server = webView?.server, let traitCollection = webView?.traitCollection else {
+ guard let server = webView?.server, let isDarkAppearance = webView?.isDarkAppearance else {
Current.Log.error("Received theme variables with no web view to attribute them to")
return
}
@@ -147,7 +158,7 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
guard let capture = FrontendThemeCaptureMessage(
messageBody: messageBody,
serverId: serverId,
- fallbackAppearance: traitCollection.userInterfaceStyle == .dark ? .dark : .light,
+ fallbackAppearance: isDarkAppearance ? .dark : .light,
capturedAt: Current.date()
) else {
Current.Log.error("Received a theme variables message with nothing worth storing")
diff --git a/Sources/App/Frontend/ExternalMessageBus/EntityAddToHandler.swift b/Sources/App/Frontend/ExternalMessageBus/EntityAddToHandler.swift
index ae194844a5..d1cd04f2e6 100644
--- a/Sources/App/Frontend/ExternalMessageBus/EntityAddToHandler.swift
+++ b/Sources/App/Frontend/ExternalMessageBus/EntityAddToHandler.swift
@@ -31,7 +31,7 @@ final class EntityAddToHandler {
let domain = Domain(entityId: entityId)
// CarPlay is available on iPhone only (not iPad) for supported domains
- #if !targetEnvironment(macCatalyst)
+ #if !(targetEnvironment(macCatalyst) || os(macOS))
if !Current.isCatalyst, UIDevice.current.userInterfaceIdiom == .phone {
let isCarPlaySupported = domain.map { Domain.carPlaySupported.contains($0) } ?? false
if isCarPlaySupported {
@@ -163,7 +163,9 @@ final class EntityAddToHandler {
let watchSettingsView = WatchConfigurationView(needsNavigationStack: true, viewModel: viewModel)
.preferredColorScheme(.dark)
let viewController = watchSettingsView.embeddedInHostingController()
+ #if os(iOS)
viewController.overrideUserInterfaceStyle = .dark
+ #endif
webViewController.presentOverlayController(controller: viewController, animated: true)
}
@@ -265,6 +267,9 @@ final class EntityAddToHandler {
let hostingController = selectionView.embeddedInHostingController()
+ #if os(macOS)
+ webViewController.presentOverlayController(controller: hostingController, animated: true)
+ #else
if Current.isCatalyst {
let navigationController = UINavigationController(rootViewController: hostingController)
webViewController.presentOverlayController(controller: navigationController, animated: true)
@@ -277,6 +282,7 @@ final class EntityAddToHandler {
}
webViewController.presentOverlayController(controller: hostingController, animated: true)
}
+ #endif
}
private func handleWidgetSelection(
diff --git a/Sources/App/Frontend/ExternalMessageBus/WebViewExternalMessageHandler.swift b/Sources/App/Frontend/ExternalMessageBus/WebViewExternalMessageHandler.swift
index 27b3add7aa..bdd2c8a624 100644
--- a/Sources/App/Frontend/ExternalMessageBus/WebViewExternalMessageHandler.swift
+++ b/Sources/App/Frontend/ExternalMessageBus/WebViewExternalMessageHandler.swift
@@ -26,7 +26,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
private let entityControlDonation: EntityControlDonation
private lazy var entityAddToHandler: EntityAddToHandler = .init(webViewController: webViewController)
- private var improvController: UIViewController?
+ private var improvController: PlatformViewController?
private var nextOutgoingMessageID = 1
private var pendingCommands: [Int: PendingExternalBusCommand] = [:]
@@ -373,28 +373,24 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
}
let threadManagementView =
- UIHostingController(
+ PlatformHostingController(
rootView: ThreadCredentialsSharingView
.buildTransferToHomeAssistant(server: webViewController.server)
)
- threadManagementView.view.backgroundColor = .clear
- threadManagementView.modalPresentationStyle = .overFullScreen
- threadManagementView.modalTransitionStyle = .crossDissolve
+ threadManagementView.presentsAsTransparentOverlay()
webViewController.presentOverlayController(controller: threadManagementView, animated: true)
}
private func transferHAThreadCredentialsToKeychain(macExtendedAddress: String, activeOperationalDataset: String) {
let threadManagementView =
- UIHostingController(
+ PlatformHostingController(
rootView: ThreadCredentialsSharingView
.buildTransferToAppleKeychain(
macExtendedAddress: macExtendedAddress,
activeOperationalDataset: activeOperationalDataset
)
)
- threadManagementView.view.backgroundColor = .clear
- threadManagementView.modalPresentationStyle = .overFullScreen
- threadManagementView.modalTransitionStyle = .crossDissolve
+ threadManagementView.presentsAsTransparentOverlay()
webViewController?.presentOverlayController(controller: threadManagementView, animated: true)
}
@@ -410,7 +406,9 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
alternativeOptionLabel: alternativeOptionLabel,
incomingMessageId: incomingMessageId
))
+ #if os(iOS)
barcodeController.modalPresentationStyle = .fullScreen
+ #endif
webViewController?.presentOverlayController(controller: barcodeController, animated: true)
}
@@ -457,16 +455,17 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
.error(
"Error saving credentials in keychain while comissioning matter device, error: \(error.localizedDescription)"
)
- let alert = UIAlertController(
- title: L10n.Thread.SaveCredential.Fail.Alert.title(error.localizedDescription),
- message: L10n.Thread.SaveCredential.Fail.Alert.message,
- preferredStyle: .alert
- )
- alert.addAction(.init(title: L10n.cancelLabel, style: .default))
- alert.addAction(.init(title: L10n.continueLabel, style: .destructive, handler: { [weak self] _ in
+ let alert = Self.threadCredentialSaveFailedAlert(error: error) { [weak self] in
self?.comissionMatterDevice()
- }))
- self?.webViewController?.presentOverlayController(controller: alert, animated: false)
+ }
+ #if os(macOS)
+ alert.present(on: self?.webViewController?.presentationWindow)
+ #else
+ self?.webViewController?.presentOverlayController(
+ controller: alert.makeAlertController(),
+ animated: false
+ )
+ #endif
} else {
Current.Log
.verbose(
@@ -497,7 +496,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
@MainActor
private func showToast(payload: ToastShowPayload) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: payload.id,
symbol: .infoCircleFill,
@@ -513,13 +512,25 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
@MainActor
private func hideToast(id: String) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.hide(id: id)
} else {
Current.Log.verbose("Not hiding toast with id \(id), Toast not available on this OS version.")
}
}
+ /// Offers to go on commissioning a Matter device after its Thread credential could not be saved.
+ static func threadCredentialSaveFailedAlert(error: Error, continueAnyway: @escaping () -> Void) -> AppAlert {
+ AppAlert(
+ title: L10n.Thread.SaveCredential.Fail.Alert.title(error.localizedDescription),
+ message: L10n.Thread.SaveCredential.Fail.Alert.message,
+ actions: [
+ .init(title: L10n.cancelLabel),
+ .init(title: L10n.continueLabel, style: .destructive, handler: continueAnyway),
+ ]
+ )
+ }
+
private func cleanPreferredThreadCredentials() {
Current.settingsStore.matterLastPreferredNetWorkMacExtendedAddress = nil
Current.settingsStore.matterLastPreferredNetWorkActiveOperationalDataset = nil
@@ -576,6 +587,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
)
Current.sceneManager.activateAnyScene(for: .assist)
} else {
+ #if os(iOS)
// On iOS/iPad, present modally as before
let assistView = UIHostingController(rootView: AssistView.build(
server: server,
@@ -597,6 +609,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
assistView.modalTransitionStyle = .crossDissolve
}
webViewController?.presentOverlayController(controller: assistView, animated: true)
+ #endif
}
}
@@ -610,7 +623,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
default:
// Mac Catalyst doesn't trigger bluetooth permission for some reason
guard !Current.isCatalyst else { return }
- let bluetoothPermissionView = UIHostingController(rootView: BluetoothPermissionView())
+ let bluetoothPermissionView = PlatformHostingController(rootView: BluetoothPermissionView())
webViewController?.presentOverlayController(controller: bluetoothPermissionView, animated: true)
}
}
@@ -620,7 +633,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
improvManager.delegate = nil
improvController =
- UIHostingController(rootView: ImprovDiscoverView(
+ PlatformHostingController(rootView: ImprovDiscoverView(
improvManager: improvManager,
deviceName: deviceName,
redirectRequest: { [weak self] redirectUrlPath in
@@ -629,9 +642,7 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
))
guard let improvController else { return }
- improvController.modalTransitionStyle = .crossDissolve
- improvController.modalPresentationStyle = .overFullScreen
- improvController.view.backgroundColor = .clear
+ improvController.presentsAsTransparentOverlay()
webViewController?.presentOverlayController(controller: improvController, animated: true)
}
diff --git a/Sources/App/Frontend/ExternalMessageBus/WidgetSelectionView.swift b/Sources/App/Frontend/ExternalMessageBus/WidgetSelectionView.swift
index ffaf868309..06d25b3ccc 100644
--- a/Sources/App/Frontend/ExternalMessageBus/WidgetSelectionView.swift
+++ b/Sources/App/Frontend/ExternalMessageBus/WidgetSelectionView.swift
@@ -47,7 +47,7 @@ struct WidgetSelectionView: View {
Section {
VStack(spacing: DesignSystem.Spaces.two) {
Image(systemSymbol: {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
return .squareBadgePlusFill
} else {
return .squareshapeDashedSquareshape
diff --git a/Sources/App/Frontend/IncomingURLHandler.swift b/Sources/App/Frontend/IncomingURLHandler.swift
index 372f16a0ef..bb16f68434 100644
--- a/Sources/App/Frontend/IncomingURLHandler.swift
+++ b/Sources/App/Frontend/IncomingURLHandler.swift
@@ -4,7 +4,9 @@ import Foundation
import HAKit
import HAKit_PromiseKit
import PromiseKit
+#if os(iOS)
import SafariServices
+#endif
import Shared
import SwiftUI
@@ -122,6 +124,7 @@ class IncomingURLHandler {
// instead of being dropped when `components.url` is nil.
let rawURL = explicitDestination ?? components.url?.absoluteString ?? ""
+ #if os(iOS)
if
let presenting = coordinator.presentedViewController,
presenting is SFSafariViewController {
@@ -152,6 +155,26 @@ class IncomingURLHandler {
isComingFromAppIntent: isComingFromAppIntent
)
}
+ #else
+ // A my.* link opens in the browser on the Mac, so there is nothing of ours on top to dismiss.
+ if let server {
+ coordinator.open(
+ from: .deeplink,
+ server: server,
+ urlString: rawURL,
+ skipConfirm: isFromWidget,
+ isComingFromAppIntent: isComingFromAppIntent
+ )
+ } else {
+ coordinator.openSelectingServer(
+ from: .deeplink,
+ urlString: rawURL,
+ skipConfirm: isFromWidget,
+ queryParameters: queryParameters,
+ isComingFromAppIntent: isComingFromAppIntent
+ )
+ }
+ #endif
case .assist:
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryParameters = components.queryItems else {
@@ -189,7 +212,7 @@ class IncomingURLHandler {
}
}
}
- let controller = UIHostingController(rootView: AnyView(
+ let controller = PlatformHostingController(rootView: AnyView(
NavigationStack {
mainView
}
@@ -267,6 +290,7 @@ class IncomingURLHandler {
return handle(url: url)
}
+ #if os(iOS)
func handle(shortcutItem: UIApplicationShortcutItem) -> Promise {
Current.backgroundTask(withName: BackgroundTask.shortcutItem.rawValue) { remaining -> Promise in
switch shortcutItem.type {
@@ -299,6 +323,7 @@ class IncomingURLHandler {
}
}
}
+ #endif
private func handleAppIconShortcut(
identifier: AppIconShortcutItemsUpdater.ShortcutIdentifier
@@ -596,7 +621,7 @@ class IncomingURLHandler {
/// Presents on top of everything currently on screen. Used for the transient prompts (confirmations,
/// results, tag approval) that only need to be seen — unlike `presentOverFrontend(_:)` they don't take
/// the screen over, so a sheet the user opened stays where it was.
- private func presentOnTopmost(_ controller: UIViewController, animated: Bool = true) {
+ private func presentOnTopmost(_ controller: PlatformViewController, animated: Bool = true) {
let appCoordinator: AppCoordinator? = coordinator
Current.sceneManager.webViewControllerPromise.done { webViewController in
guard let appCoordinator else {
@@ -613,35 +638,35 @@ class IncomingURLHandler {
handler: @escaping () -> Void,
cancelHandler: (() -> Void)? = nil
) {
- let alert = UIAlertController(
- title: title,
- message: message,
- preferredStyle: UIAlertController.Style.alert
- )
-
- alert.addAction(UIAlertAction(
- title: L10n.cancelLabel,
- style: .cancel,
- handler: { _ in
- cancelHandler?()
- }
- ))
-
- alert.addAction(UIAlertAction(
- title: L10n.yesLabel,
- style: .default,
- handler: { _ in
- handler()
- }
- ))
+ presentOnTopmost(AppAlert(title: title, message: message, actions: [
+ .init(title: L10n.cancelLabel, style: .cancel) { cancelHandler?() },
+ .init(title: L10n.yesLabel) { handler() },
+ ]))
+ }
- presentOnTopmost(alert)
+ /// Shows an alert on top of everything currently on screen; see `presentOnTopmost(_:animated:)`.
+ private func presentOnTopmost(_ alert: AppAlert) {
+ #if os(macOS)
+ if let coordinator {
+ coordinator.present(alert: alert)
+ return
+ }
+ Current.sceneManager.webViewControllerPromise.done { webViewController in
+ alert
+ .present(
+ on: webViewController.presentationWindow?.attachedSheet ?? webViewController
+ .presentationWindow
+ )
+ }
+ #else
+ presentOnTopmost(alert.makeAlertController())
+ #endif
}
private func showTagApproval(tag: String, type: TagManagerHandleResult.HandledType) {
// Built empty first so `onDismiss` can weakly reference the controller it lives in: it has to dismiss
// this sheet specifically — not whatever is top-most, which may be an overlay that appeared above it.
- let controller = UIHostingController(rootView: AnyView(EmptyView()))
+ let controller = PlatformHostingController(rootView: AnyView(EmptyView()))
controller.rootView = AnyView(TagApprovalBottomSheet(
tag: tag,
onAllowOnce: { [weak self] in
@@ -652,11 +677,14 @@ class IncomingURLHandler {
self?.fireApprovedTag(tag, type: type)
},
onDismiss: { [weak controller] in
+ #if os(macOS)
+ controller?.dismissSheet()
+ #else
controller?.dismiss(animated: false)
+ #endif
}
))
- controller.modalPresentationStyle = .overFullScreen
- controller.view.backgroundColor = .clear
+ controller.presentsAsTransparentOverlay()
presentOnTopmost(controller, animated: false)
}
@@ -687,13 +715,7 @@ class IncomingURLHandler {
}
private func showAlert(title: String, message: String) {
- let alert = UIAlertController(
- title: title,
- message: message,
- preferredStyle: .alert
- )
- alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil))
- presentOnTopmost(alert)
+ presentOnTopmost(AppAlert(title: title, message: message, actions: [.init(title: L10n.okLabel)]))
}
private func showMy(for url: URL) -> Bool {
@@ -710,8 +732,14 @@ class IncomingURLHandler {
return false
}
+ #if os(macOS)
+ // The Mac has no in-app browser: the page opens in the default one, and the link it redirects to
+ // comes back to the app through its URL scheme.
+ URLOpener.shared.open(updatedURL, options: [:], completionHandler: nil)
+ #else
// not animated in because it looks weird during the app launch animation
coordinator?.present(SFSafariViewController(url: updatedURL), animated: false, completion: nil)
+ #endif
return true
}
diff --git a/Sources/App/Frontend/OnscreenEntity/OnscreenEntityIdentifier.swift b/Sources/App/Frontend/OnscreenEntity/OnscreenEntityIdentifier.swift
index 43cbc5f015..2a5de76a77 100644
--- a/Sources/App/Frontend/OnscreenEntity/OnscreenEntityIdentifier.swift
+++ b/Sources/App/Frontend/OnscreenEntity/OnscreenEntityIdentifier.swift
@@ -12,7 +12,7 @@ import Shared
/// `OpenableEntityAppEntity` for covers, `DimmableLightAppEntity`, `ThermostatAppEntity` and
/// `LockAppEntity` for dimming, setting and locking — while `HAAppEntityAppIntentEntity` names every
/// entity and is what "add this to CarPlay" and "show this" take.
-@available(iOS 18.2, *)
+@available(iOS 18.2, macOS 15.2, *)
enum OnscreenEntityIdentifier {
/// Every identifier the entity answers to, the one an activity should carry first.
///
diff --git a/Sources/App/Frontend/OnscreenPage/OnscreenPageIdentifier.swift b/Sources/App/Frontend/OnscreenPage/OnscreenPageIdentifier.swift
index 7182cf4fec..8a9a3dc89b 100644
--- a/Sources/App/Frontend/OnscreenPage/OnscreenPageIdentifier.swift
+++ b/Sources/App/Frontend/OnscreenPage/OnscreenPageIdentifier.swift
@@ -7,7 +7,7 @@ import Shared
///
/// iOS 18.2 is where `NSUserActivity.appEntityIdentifier`, the only thing that carries this to the
/// system, arrived — earlier releases have nowhere to put it.
-@available(iOS 18.2, *)
+@available(iOS 18.2, macOS 15.2, *)
enum OnscreenPageIdentifier {
static func make(for page: OnscreenPage) -> EntityIdentifier? {
// Hiding a server from Siri hides its screens too. Saying which page someone is looking at is
diff --git a/Sources/App/Frontend/TagApprovalBottomSheet.swift b/Sources/App/Frontend/TagApprovalBottomSheet.swift
index 2a1aa96ee2..5288134e5d 100644
--- a/Sources/App/Frontend/TagApprovalBottomSheet.swift
+++ b/Sources/App/Frontend/TagApprovalBottomSheet.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
struct TagApprovalBottomSheet: View {
@State private var bottomSheetState: AppleLikeBottomSheetViewState?
diff --git a/Sources/App/Frontend/WebView/ClipboardWriteMessageHandler.swift b/Sources/App/Frontend/WebView/ClipboardWriteMessageHandler.swift
index 1623bdc650..2ad8df4260 100644
--- a/Sources/App/Frontend/WebView/ClipboardWriteMessageHandler.swift
+++ b/Sources/App/Frontend/WebView/ClipboardWriteMessageHandler.swift
@@ -1,5 +1,9 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@preconcurrency import WebKit
/// WKWebView silently no-ops `navigator.clipboard.writeText` from ingress iframes, so this bridge
diff --git a/Sources/App/Frontend/WebView/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift b/Sources/App/Frontend/WebView/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift
index 46893566da..3fb4c4d181 100644
--- a/Sources/App/Frontend/WebView/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift
+++ b/Sources/App/Frontend/WebView/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift
@@ -52,7 +52,7 @@ struct ConnectionSecurityLevelBlockView: View {
showHomeNetworkSettings = false
showConnectionSecurityPreferences = false
}
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
.fullScreenCover(isPresented: $showHomeNetworkSettings) {
homeNetworkView
}
@@ -74,11 +74,7 @@ struct ConnectionSecurityLevelBlockView: View {
case .notDetermined:
Current.Log.info("Location permission not determined")
case .denied, .restricted:
- URLOpener.shared.open(
- URL(string: UIApplication.openSettingsURLString)!,
- options: [:],
- completionHandler: nil
- )
+ URLOpener.shared.openSettings(destination: .location, completionHandler: nil)
case .authorizedWhenInUse, .authorizedAlways:
// Handle permission change - reload requirements to update UI
viewModel.loadRequirements()
diff --git a/Sources/App/Frontend/WebView/FrontendView.swift b/Sources/App/Frontend/WebView/FrontendView.swift
index cbc87e9342..58b825cccd 100644
--- a/Sources/App/Frontend/WebView/FrontendView.swift
+++ b/Sources/App/Frontend/WebView/FrontendView.swift
@@ -1,10 +1,9 @@
import Shared
import SwiftUI
-import UIKit
-/// Hosts the UIKit `WebViewController` as a SwiftUI view. A server switch or SwiftUI identity reset builds a
+/// Hosts the `WebViewController` as a SwiftUI view. A server switch or SwiftUI identity reset builds a
/// fresh controller and discards the previous one.
-struct FrontendView: UIViewControllerRepresentable {
+struct FrontendView {
let server: Server
var initialPath: String?
var onWebViewController: ((WebViewController) -> Void)?
@@ -31,16 +30,6 @@ struct FrontendView: UIViewControllerRepresentable {
self.overlayState = overlayState
}
- func makeUIViewController(context: Context) -> WebViewController {
- let webViewController = makeWebViewController()
- onWebViewController?(webViewController)
- return webViewController
- }
-
- func updateUIViewController(_ webViewController: WebViewController, context: Context) {
- // No-op: a server change recreates this view (keyed by server in `ContainerView`), never updates it.
- }
-
/// Non-private for tests.
func makeWebViewController() -> WebViewController {
let controller = WebViewController(server: server)
@@ -52,3 +41,29 @@ struct FrontendView: UIViewControllerRepresentable {
return controller
}
}
+
+#if os(macOS)
+extension FrontendView: NSViewControllerRepresentable {
+ func makeNSViewController(context: Context) -> WebViewController {
+ let webViewController = makeWebViewController()
+ onWebViewController?(webViewController)
+ return webViewController
+ }
+
+ func updateNSViewController(_ webViewController: WebViewController, context: Context) {
+ // No-op: a server change recreates this view (keyed by server in `ContainerView`), never updates it.
+ }
+}
+#else
+extension FrontendView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> WebViewController {
+ let webViewController = makeWebViewController()
+ onWebViewController?(webViewController)
+ return webViewController
+ }
+
+ func updateUIViewController(_ webViewController: WebViewController, context: Context) {
+ // No-op: a server change recreates this view (keyed by server in `ContainerView`), never updates it.
+ }
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantPullToRefreshObserver.swift b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantPullToRefreshObserver.swift
index c1013599e9..13ded0fc70 100644
--- a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantPullToRefreshObserver.swift
+++ b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantPullToRefreshObserver.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
import WebKit
@@ -155,3 +156,4 @@ final class HomeAssistantPullToRefreshObserver: NSObject {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift
index 8467c2bcdb..e51fe7c3fe 100644
--- a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift
+++ b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
struct HomeAssistantView: View, WebFrontendView {
private enum Constants {
@@ -46,6 +45,7 @@ struct HomeAssistantView: View, WebFrontendView {
var body: some View {
ZStack {
+ #if os(iOS)
if #available(iOS 26, *), isNativeTabBarActive {
NativeTabBarContainerView(
viewModel: viewModel.tabBar,
@@ -55,6 +55,7 @@ struct HomeAssistantView: View, WebFrontendView {
onNeedsWebViewController: viewModel.ensureWebViewController
)
}
+ #endif
// The frontend chrome keeps one structural identity whichever App Labs layout is on, so its
// appear/disappear fades never race each other when a layout is toggled.
frontendContent
@@ -75,7 +76,11 @@ struct HomeAssistantView: View, WebFrontendView {
/// The strip belongs to the frontend: over a native tab it would cover the bar items that share the
/// status bar's row on wide screens.
private var showsThemedStatusBar: Bool {
+ #if os(iOS)
!isNativeTabBarActive || viewModel.tabBar.showsFrontend
+ #else
+ true
+ #endif
}
private var frontendContent: some View {
diff --git a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift
index 94a9aab56a..b29adce6f0 100644
--- a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift
+++ b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift
@@ -1,7 +1,6 @@
import Combine
import Shared
import SwiftUI
-import UIKit
@MainActor
final class HomeAssistantViewModel: ObservableObject {
@@ -32,8 +31,10 @@ final class HomeAssistantViewModel: ObservableObject {
let reconnectManager: WebViewReconnectManager
/// Feeds the App Labs native iOS tab bar; only started once it is on screen.
let sidebar: MacSidebarViewModel
+ #if os(iOS)
/// Lays `sidebar` out as tabs for the App Labs native iOS tab bar.
let tabBar: NativeTabBarViewModel
+ #endif
@Published var webViewResetID = UUID()
@Published var webViewController: WebViewController?
@@ -63,7 +64,9 @@ final class HomeAssistantViewModel: ObservableObject {
// is enough to dismiss the loader. A recreated view model implies a fresh page load, which fires it again.
private var frontendLoadedOnce = false
private var reduceMotion = false
+ #if os(iOS)
private var pullToRefreshObserver: HomeAssistantPullToRefreshObserver?
+ #endif
private var cancellables = Set()
init(
@@ -81,11 +84,14 @@ final class HomeAssistantViewModel: ObservableObject {
self.reconnectManager = reconnectManager ?? WebViewReconnectManager()
self.onWebViewController = onWebViewController
self.sidebar = MacSidebarViewModel(server: server, overlayState: self.overlayState)
+ #if os(iOS)
self.tabBar = NativeTabBarViewModel(sidebar: sidebar, overlayState: self.overlayState)
+ #endif
sidebar.onNavigate = { [weak self] path in
self?.webViewController?.openSidebarPath(path)
}
+ #if os(iOS)
tabBar.onQuickSearch = { [weak self] in
self?.webViewController?.webViewGestureHandler.handleGestureAction(.quickSearch)
}
@@ -102,6 +108,7 @@ final class HomeAssistantViewModel: ObservableObject {
autoStartRecording: false
)
}
+ #endif
sidebar.onShowNotifications = { [weak self] in
guard let webViewController = self?.webViewController else { return }
NotificationDrawerToggle(
@@ -238,6 +245,7 @@ final class HomeAssistantViewModel: ObservableObject {
}
func handleWebViewLoaded(_ controller: WebViewController) {
+ #if os(iOS)
guard !Current.isCatalyst else { return }
pullToRefreshObserver = HomeAssistantPullToRefreshObserver(
webView: controller.webView,
@@ -250,8 +258,10 @@ final class HomeAssistantViewModel: ObservableObject {
self?.performPullToRefresh(using: controller)
}
)
+ #endif
}
+ #if os(iOS)
private func performPullToRefresh(using controller: WebViewController?) {
Current.Log.info("Pull-to-refresh: resetting frontend cache before reload")
Current.websiteDataStoreHandler
@@ -259,6 +269,7 @@ final class HomeAssistantViewModel: ObservableObject {
controller?.pullToRefreshActions()
}
}
+ #endif
private func bindObservableChildren() {
overlayState.objectWillChange
@@ -269,9 +280,11 @@ final class HomeAssistantViewModel: ObservableObject {
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
+ #if os(iOS)
tabBar.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
+ #endif
}
private func bindOverlayState() {
@@ -283,7 +296,9 @@ final class HomeAssistantViewModel: ObservableObject {
self?.frontendLoadedOnce = false
self?.beginFullScreenLoaderCycle()
} else {
+ #if os(iOS)
self?.pullToRefreshObserver?.finishRefreshing()
+ #endif
self?.armLoaderWatchdog()
}
}
diff --git a/Sources/App/Frontend/WebView/MacBannerModel.swift b/Sources/App/Frontend/WebView/MacBannerModel.swift
new file mode 100644
index 0000000000..d6c2d0ad58
--- /dev/null
+++ b/Sources/App/Frontend/WebView/MacBannerModel.swift
@@ -0,0 +1,10 @@
+#if os(macOS)
+import Foundation
+
+/// What `MacBannerOverlayView` and the SwiftUI banner it hosts share: whether the banner is on screen, and
+/// where it ended up, so the overlay knows which clicks are the banner's.
+final class MacBannerModel: ObservableObject {
+ @Published var isPresented = false
+ var bannerFrame: CGRect = .zero
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/MacBannerOverlayView.swift b/Sources/App/Frontend/WebView/MacBannerOverlayView.swift
new file mode 100644
index 0000000000..de150e6d9c
--- /dev/null
+++ b/Sources/App/Frontend/WebView/MacBannerOverlayView.swift
@@ -0,0 +1,75 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// Hosts a banner over the web view on the Mac: `MacBannerView` drawn by SwiftUI inside an AppKit view, with
+/// the same entry points the iOS overlay gives `DefaultBannerPresenter`.
+final class MacBannerOverlayView: NSView {
+ private let request: BannerRequest
+ private let model = MacBannerModel()
+ private var hostingView: NSHostingView?
+
+ var onDismissRequested: (() -> Void)?
+ var onActionRequested: (() -> Void)?
+
+ init(request: BannerRequest) {
+ self.request = request
+ super.init(frame: .zero)
+
+ let hostingView = NSHostingView(rootView: MacBannerView(
+ request: request,
+ model: model,
+ onDismiss: { [weak self] in self?.onDismissRequested?() },
+ onAction: { [weak self] in self?.onActionRequested?() }
+ ))
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(hostingView)
+ NSLayoutConstraint.activate([
+ hostingView.topAnchor.constraint(equalTo: topAnchor),
+ hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+ self.hostingView = hostingView
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ /// A banner that does not dim the page only takes clicks on the banner itself, so the page beneath it
+ /// stays usable.
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard let hit = super.hitTest(point) else { return nil }
+ if request.dimming.isInteractive {
+ return hit
+ }
+ let pointInSelf = convert(point, from: superview)
+ let bannerFrame = isFlipped ? model.bannerFrame : CGRect(
+ x: model.bannerFrame.minX,
+ y: bounds.height - model.bannerFrame.maxY,
+ width: model.bannerFrame.width,
+ height: model.bannerFrame.height
+ )
+ return bannerFrame.contains(pointInSelf) ? hit : nil
+ }
+
+ func present() {
+ model.isPresented = true
+ }
+
+ func dismiss(animated: Bool, completion: @escaping () -> Void) {
+ model.isPresented = false
+ let finish = { [weak self] in
+ self?.removeFromSuperview()
+ completion()
+ }
+ if animated {
+ DispatchQueue.main.asyncAfter(deadline: .now() + MacBannerView.animationDuration, execute: finish)
+ } else {
+ finish()
+ }
+ }
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/MacBannerView.swift b/Sources/App/Frontend/WebView/MacBannerView.swift
new file mode 100644
index 0000000000..83d8fdc8fd
--- /dev/null
+++ b/Sources/App/Frontend/WebView/MacBannerView.swift
@@ -0,0 +1,107 @@
+#if os(macOS)
+import Shared
+import SwiftUI
+
+/// A banner along the bottom of the web view, optionally over a dimmed page: the Mac drawing of the
+/// request `DefaultBannerPresenter` is asked to show.
+struct MacBannerView: View {
+ static let animationDuration: TimeInterval = 0.25
+
+ let request: BannerRequest
+ @ObservedObject var model: MacBannerModel
+ let onDismiss: () -> Void
+ let onAction: () -> Void
+
+ /// How far below its resting place the banner starts and ends its slide.
+ private static let hiddenOffset: CGFloat = 120
+ private static let shadowRadius: CGFloat = 18
+ private static let shadowOffset: CGFloat = 6
+ private static let shadowOpacity: CGFloat = 0.18
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ if request.dimming != .none {
+ Color(uiColor: request.dimming.color)
+ .opacity(model.isPresented ? 1 : 0)
+ .onTapGesture {
+ if request.dimming.isInteractive {
+ onDismiss()
+ }
+ }
+ .accessibilityLabel(request.dimmingAccessibilityLabel ?? "")
+ .accessibilityAddTraits(request.dimming.isInteractive ? .isButton : [])
+ }
+
+ HStack(spacing: DesignSystem.Spaces.oneAndHalf) {
+ VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
+ if let title = request.title {
+ Text(title)
+ .font(.headline)
+ }
+ if let message = request.message {
+ Text(message)
+ .font(.subheadline)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color(uiColor: request.style.foregroundColor))
+
+ if let action = request.action {
+ Button(action: onAction) {
+ HStack(spacing: DesignSystem.Spaces.half) {
+ if let image = action.image {
+ Image(uiImage: image)
+ .renderingMode(.template)
+ }
+ if let title = action.title {
+ Text(title)
+ .font(.headline)
+ }
+ }
+ .foregroundStyle(Color(uiColor: action.tintColor))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(action.accessibilityLabel ?? action.title ?? "")
+ }
+ }
+ .padding(.vertical, DesignSystem.Spaces.oneAndHalf)
+ .padding(.horizontal, DesignSystem.Spaces.two)
+ .background(
+ Color(uiColor: request.style.backgroundColor),
+ in: RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.two, style: .continuous)
+ )
+ .shadow(color: .black.opacity(Self.shadowOpacity), radius: Self.shadowRadius, y: Self.shadowOffset)
+ .accessibilityIdentifier(request.id)
+ .padding(DesignSystem.Spaces.two)
+ .background(GeometryReader { proxy in
+ Color.clear
+ .onAppear { model.bannerFrame = proxy.frame(in: .global) }
+ .onChange(of: proxy.frame(in: .global)) { model.bannerFrame = $0 }
+ })
+ .offset(y: model.isPresented ? 0 : Self.hiddenOffset)
+ .opacity(model.isPresented ? 1 : 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .animation(.easeOut(duration: Self.animationDuration), value: model.isPresented)
+ }
+}
+
+#Preview {
+ let model = MacBannerModel()
+ model.isPresented = true
+ return MacBannerView(
+ request: .init(
+ title: nil,
+ message: "An update is available for your server.",
+ duration: .forever,
+ dimming: .gray(interactive: true),
+ style: .warning,
+ action: .init(title: "Open", tintColor: .white, handler: {})
+ ),
+ model: model,
+ onDismiss: {},
+ onAction: {}
+ )
+ .frame(width: 480, height: 320)
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/MacWebViewTitleBar.swift b/Sources/App/Frontend/WebView/MacWebViewTitleBar.swift
index 53daaf75b1..7d9463d733 100644
--- a/Sources/App/Frontend/WebView/MacWebViewTitleBar.swift
+++ b/Sources/App/Frontend/WebView/MacWebViewTitleBar.swift
@@ -1,11 +1,63 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
-#if targetEnvironment(macCatalyst)
+#if targetEnvironment(macCatalyst) || os(macOS)
import AppKit
#endif
+#if os(iOS)
+import UIKit
+#endif
+
+#if os(macOS)
+/// Installs the main window's toolbar. It draws nothing itself: it only needs to be in the window so it can
+/// find it.
+struct MacWebViewTitleBar: NSViewRepresentable {
+ let server: Server
+ weak var webViewController: WebViewController?
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ func makeNSView(context: Context) -> NSView {
+ MacWebViewTitleBarAnchorView { [weak coordinator = context.coordinator] window in
+ coordinator?.attach(to: window)
+ }
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ context.coordinator.update(server: server, webViewController: webViewController)
+ context.coordinator.attach(to: nsView.window)
+ }
+
+ static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
+ coordinator.removeToolbar()
+ }
+
+ private final class MacWebViewTitleBarAnchorView: NSView {
+ private let attachToolbar: (NSWindow?) -> Void
+
+ init(attachToolbar: @escaping (NSWindow?) -> Void) {
+ self.attachToolbar = attachToolbar
+ super.init(frame: .zero)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ attachToolbar(window)
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+ }
+}
+#else
struct MacWebViewTitleBar: UIViewControllerRepresentable {
let server: Server
weak var webViewController: WebViewController?
@@ -54,8 +106,9 @@ private final class MacWebViewTitleBarViewController: UIViewController {
attachToolbar(view.window?.windowScene)
}
}
+#endif
-#if targetEnvironment(macCatalyst)
+#if targetEnvironment(macCatalyst) || os(macOS)
extension MacWebViewTitleBar {
@MainActor
final class Coordinator: NSObject, NSToolbarDelegate {
@@ -77,8 +130,21 @@ extension MacWebViewTitleBar {
].contains($0) }
private weak var webViewController: WebViewController?
+ #if os(macOS)
+ /// The window the toolbar is installed in. A Mac window is also the scene requests are routed to.
+ private weak var windowScene: NSWindow?
+ private var installedToolbar: NSToolbar? {
+ get { windowScene?.toolbar }
+ set { windowScene?.toolbar = newValue }
+ }
+ #else
private weak var titlebar: UITitlebar?
private weak var windowScene: UIWindowScene?
+ private var installedToolbar: NSToolbar? {
+ get { titlebar?.toolbar }
+ set { titlebar?.toolbar = newValue }
+ }
+ #endif
private weak var serverPickerItem: NSMenuToolbarItem?
private var toolbar: NSToolbar?
private var server: Server?
@@ -102,6 +168,28 @@ extension MacWebViewTitleBar {
refreshToolbarState()
}
+ #if os(macOS)
+ func attach(to window: NSWindow?) {
+ guard let window else { return }
+ windowScene = window
+
+ if toolbar == nil || window.toolbar !== toolbar {
+ let toolbar = NSToolbar(identifier: Constants.toolbarIdentifier)
+ toolbar.delegate = self
+ toolbar.displayMode = .iconOnly
+ toolbar.allowsUserCustomization = true
+ toolbar.autosavesConfiguration = true
+
+ window.titleVisibility = .hidden
+ window.toolbarStyle = .unifiedCompact
+ window.titlebarSeparatorStyle = .none
+ window.toolbar = toolbar
+ self.toolbar = toolbar
+ }
+
+ refreshToolbarState()
+ }
+ #else
func attach(to windowScene: UIWindowScene?) {
guard let titlebar = windowScene?.titlebar else { return }
self.titlebar = titlebar
@@ -125,9 +213,10 @@ extension MacWebViewTitleBar {
refreshToolbarState()
}
+ #endif
private func refreshToolbarState() {
- guard let toolbar, titlebar?.toolbar === toolbar else { return }
+ guard let toolbar, installedToolbar === toolbar else { return }
updateEnabledItems()
updateServerPicker()
}
@@ -137,8 +226,8 @@ extension MacWebViewTitleBar {
NotificationCenter.default.removeObserver(macToolbarConfigObserver)
self.macToolbarConfigObserver = nil
}
- guard titlebar?.toolbar === toolbar else { return }
- titlebar?.toolbar = nil
+ guard installedToolbar === toolbar else { return }
+ installedToolbar = nil
toolbar = nil
}
@@ -165,7 +254,7 @@ extension MacWebViewTitleBar {
private func handleMacToolbarConfigChanged(_ change: MacToolbarConfigChange?) {
loadMacToolbarItems()
- guard let toolbar, titlebar?.toolbar === toolbar else { return }
+ guard let toolbar, installedToolbar === toolbar else { return }
switch change {
case let .added(item):
@@ -357,7 +446,11 @@ extension MacWebViewTitleBar {
serverPickerItem.paletteLabel = L10n.ServersSelection.title
serverPickerItem.toolTip = title
serverPickerItem.image = serverPickerTitleImage(title: title)
+ #if os(macOS)
+ serverPickerItem.menu = serverPickerMenu()
+ #else
serverPickerItem.itemMenu = serverPickerMenu()
+ #endif
}
private func serverPickerTitleImage(title: String) -> UIImage {
@@ -378,6 +471,36 @@ extension MacWebViewTitleBar {
.withRenderingMode(.alwaysTemplate)
}
+ #if os(macOS)
+ private func serverPickerMenu() -> NSMenu {
+ let selectedIdentifier = server?.identifier
+ let menu = NSMenu(title: L10n.WebView.ServerSelection.title)
+ for server in Current.servers.all {
+ let item = NSMenuItem(
+ title: server.info.name,
+ action: #selector(selectServer(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = server.identifier.rawValue
+ item.state = server.identifier == selectedIdentifier ? .on : .off
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ @objc private func selectServer(_ sender: NSMenuItem) {
+ guard let identifier = sender.representedObject as? String,
+ let server = Current.servers.all.first(where: { $0.identifier.rawValue == identifier }) else {
+ return
+ }
+ // Not `activate(server:)`: like the server-cycling gestures, the toolbar menu
+ // switches in place without sending the user back to the Home Assistant root.
+ Current.sceneManager.appCoordinator(for: windowScene).done { coordinator in
+ coordinator.open(server: server)
+ }
+ }
+ #else
private func serverPickerMenu() -> UIMenu {
let selectedIdentifier = server?.identifier
let actions = Current.servers.all.map { server in
@@ -394,6 +517,7 @@ extension MacWebViewTitleBar {
}
return UIMenu(title: L10n.WebView.ServerSelection.title, children: actions)
}
+ #endif
private func gestureToolbarItem(for identifier: NSToolbarItem.Identifier) -> NSToolbarItem? {
guard let action = identifier.gestureAction else { return nil }
@@ -494,12 +618,21 @@ extension MacWebViewTitleBar {
}
private func toolbarImage(symbol: SFSymbol, accessibilityLabel: String) -> UIImage {
+ #if os(macOS)
+ let configuration = NSImage.SymbolConfiguration(
+ pointSize: Constants.symbolPointSize,
+ weight: .regular
+ )
+ let symbolImage = NSImage(systemSymbol: symbol)
+ .withSymbolConfiguration(configuration) ?? NSImage(systemSymbol: symbol)
+ #else
let configuration = UIImage.SymbolConfiguration(
pointSize: Constants.symbolPointSize,
weight: .regular
)
let symbolImage = UIImage(systemSymbol: symbol)
.applyingSymbolConfiguration(configuration) ?? UIImage(systemSymbol: symbol)
+ #endif
return UIGraphicsImageRenderer(size: Constants.imageCanvasSize).image { _ in
symbolImage.draw(in: CGRect(
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarAvatarImage.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarAvatarImage.swift
index 9404833010..d7f6c1849f 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarAvatarImage.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarAvatarImage.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -41,3 +42,4 @@ enum NativeTabBarAvatarImage {
)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarButtonLocator.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarButtonLocator.swift
index 45182af73a..d5993b32df 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarButtonLocator.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarButtonLocator.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
/// Finds where the tab bar draws a tab, in window coordinates, so a presentation can zoom out of it.
@@ -60,3 +61,4 @@ enum NativeTabBarButtonLocator {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarContainerView.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarContainerView.swift
index e25d56d3c9..51154fc3d1 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarContainerView.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarContainerView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -149,3 +150,4 @@ struct NativeTabBarContainerView: View {
onNeedsWebViewController: {}
)
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarCustomizeView.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarCustomizeView.swift
index 3267f67193..1369978cf4 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarCustomizeView.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarCustomizeView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -95,3 +96,4 @@ struct NativeTabBarCustomizeView: View {
NativeTabBarCustomizeView(viewModel: .preview(hiddenPanelPaths: ["logbook"]))
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtras.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtras.swift
index 86047af786..dab156b68b 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtras.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtras.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
/// Where the app's own entries sit in the tab bar's list, per server.
@@ -23,3 +24,4 @@ struct NativeTabBarExtras: Codable, Equatable {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtrasStore.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtrasStore.swift
index 396a1b6dd3..1b47205d81 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtrasStore.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarExtrasStore.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
@@ -45,3 +46,4 @@ final class NativeTabBarExtrasStore {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendSlot.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendSlot.swift
index f256e63352..7d682337d7 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendSlot.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendSlot.swift
@@ -1,6 +1,6 @@
+#if os(iOS)
import Shared
import SwiftUI
-import UIKit
/// Hosts the single web frontend inside a tab.
struct NativeTabBarFrontendSlot: UIViewControllerRepresentable {
@@ -92,3 +92,4 @@ struct NativeTabBarFrontendSlot: UIViewControllerRepresentable {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendTabView.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendTabView.swift
index 98a59c3d4d..17d54a1e3e 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendTabView.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarFrontendTabView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -89,3 +90,4 @@ struct NativeTabBarFrontendTabView: View {
onNeedsWebViewController: {}
)
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItem.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItem.swift
index 5c7693c1a8..dc36839f88 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItem.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItem.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
@@ -53,3 +54,4 @@ struct NativeTabBarItem: Identifiable, Hashable {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItemLabel.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItemLabel.swift
index 94b288bc9b..738f67896b 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItemLabel.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarItemLabel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import HAKit
import Shared
import SwiftUI
@@ -54,3 +55,4 @@ struct NativeTabBarItemLabel: View {
NativeTabBarItemLabel(item: .init(kind: .assist), server: ServerFixture.standard, user: nil)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarLongPressInstaller.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarLongPressInstaller.swift
index bb8752060d..b1445f716e 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarLongPressInstaller.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarLongPressInstaller.swift
@@ -1,6 +1,6 @@
+#if os(iOS)
import Shared
import SwiftUI
-import UIKit
/// Opens Customize Tabs on a long press anywhere on the tab bar, which SwiftUI's `Tab` offers no hook for.
struct NativeTabBarLongPressInstaller: UIViewRepresentable {
@@ -60,3 +60,4 @@ struct NativeTabBarLongPressInstaller: UIViewRepresentable {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarMoreView.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarMoreView.swift
index 73570e4d4a..499b8ca19e 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarMoreView.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarMoreView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -128,3 +129,4 @@ struct NativeTabBarMoreView: View {
NativeTabBarMoreView(viewModel: .preview())
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarNotificationsToolbarItem.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarNotificationsToolbarItem.swift
index 319b36f8f6..77b98c87e3 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarNotificationsToolbarItem.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarNotificationsToolbarItem.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -40,3 +41,4 @@ struct NativeTabBarNotificationsToolbarItem: ToolbarContent {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileMenuItems.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileMenuItems.swift
index 762e5c47a0..d16b1dc1f6 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileMenuItems.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileMenuItems.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -37,3 +38,4 @@ struct NativeTabBarProfileMenuItems: View {
Text(viewModel.sidebar.server.info.name)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileToolbarItem.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileToolbarItem.swift
index 8e753a0a53..7c7d7bb2d3 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileToolbarItem.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarProfileToolbarItem.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -54,3 +55,4 @@ struct NativeTabBarProfileToolbarItem: ToolbarContent {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarSettingsToolbarItem.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarSettingsToolbarItem.swift
index 9167dc2858..e41d374333 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarSettingsToolbarItem.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarSettingsToolbarItem.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -70,3 +71,4 @@ struct NativeTabBarSettingsToolbarItem: ToolbarContent {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarState.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarState.swift
index 9a548f8bef..0488c1a56b 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarState.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarState.swift
@@ -12,6 +12,10 @@ final class NativeTabBarState: ObservableObject {
private var cancellables = Set()
init() {
+ #if os(macOS)
+ // The tab bar is an iOS layout; the Mac has the native sidebar in its place.
+ self.isEnabled = false
+ #else
self.isEnabled = AppLabsFeature.iosNativeTabBar.isEnabled
Current.appLabs.enabledFeatureIdsPublisher
@@ -22,6 +26,7 @@ final class NativeTabBarState: ObservableObject {
self?.isEnabled = isEnabled
}
.store(in: &cancellables)
+ #endif
}
/// The frontend's `sidebar/show`: the hamburger with no sidebar of its own to open.
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarTab.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarTab.swift
index 0cc41dfbb6..08b87dbf8c 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarTab.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarTab.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
/// A tab of the App Labs native iOS tab bar.
@@ -7,3 +8,4 @@ enum NativeTabBarTab: Hashable {
case search
case assist
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel+Preview.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel+Preview.swift
index 422deed7de..cb17262935 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel+Preview.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel+Preview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
@@ -66,3 +67,4 @@ extension NativeTabBarViewModel {
]
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel.swift b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel.swift
index 4340315cea..81cf479e85 100644
--- a/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel.swift
+++ b/Sources/App/Frontend/WebView/NativeTabBar/NativeTabBarViewModel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Combine
import Foundation
import Shared
@@ -382,3 +383,4 @@ final class NativeTabBarViewModel: ObservableObject {
moreShowsFrontend = false
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/ScaleFactorMutator.swift b/Sources/App/Frontend/WebView/ScaleFactorMutator.swift
index c4d50a983c..3c936db22d 100644
--- a/Sources/App/Frontend/WebView/ScaleFactorMutator.swift
+++ b/Sources/App/Frontend/WebView/ScaleFactorMutator.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
@@ -96,3 +97,4 @@ fileprivate extension NSObject {
}
}
#endif
+#endif
diff --git a/Sources/App/Frontend/WebView/StatusBar/StatusBarButtonsConfigurator.swift b/Sources/App/Frontend/WebView/StatusBar/StatusBarButtonsConfigurator.swift
index 063c2b06ff..b6a2dea631 100644
--- a/Sources/App/Frontend/WebView/StatusBar/StatusBarButtonsConfigurator.swift
+++ b/Sources/App/Frontend/WebView/StatusBar/StatusBarButtonsConfigurator.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -378,3 +379,4 @@ enum StatusBarButtonsConfigurator {
}
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/StatusBar/WebViewControllerButtons.swift b/Sources/App/Frontend/WebView/StatusBar/WebViewControllerButtons.swift
index e077788e18..7658382f2c 100644
--- a/Sources/App/Frontend/WebView/StatusBar/WebViewControllerButtons.swift
+++ b/Sources/App/Frontend/WebView/StatusBar/WebViewControllerButtons.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import SFSafeSymbols
import Shared
@@ -51,3 +52,4 @@ enum WebViewControllerButtons {
navigationButton(symbol: .docOnClipboard, accessibilityLabel: L10n.Mac.Paste.accessibilityLabel)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/Views/AnimatedSVGView.swift b/Sources/App/Frontend/WebView/Views/AnimatedSVGView.swift
index 2a5dfad5da..5be77c41ba 100644
--- a/Sources/App/Frontend/WebView/Views/AnimatedSVGView.swift
+++ b/Sources/App/Frontend/WebView/Views/AnimatedSVGView.swift
@@ -7,10 +7,25 @@ import SwiftUI
/// The view is vended by `AnimatedSVGWebViewCache`, which keeps it warm (preloaded at launch) so it
/// appears without WKWebView's cold-start delay. The SVG should declare a `viewBox` so it scales to
/// fill; the wrapper HTML forces it to 100% width/height on a transparent background.
-struct AnimatedSVGView: UIViewRepresentable {
+struct AnimatedSVGView {
/// Name of the `.svg` resource in the main bundle (without extension).
let resourceName: String
+}
+
+#if os(macOS)
+extension AnimatedSVGView: NSViewRepresentable {
+ func makeNSView(context: Context) -> AnimatedSVGWebView {
+ AnimatedSVGWebViewCache.shared.webView(for: resourceName)
+ }
+ func updateNSView(_ nsView: AnimatedSVGWebView, context: Context) {
+ // Another chance to catch a page WebKit stopped or reclaimed while it was off-screen; it
+ // costs nothing when the animation is already running.
+ nsView.resumeAnimationIfNeeded()
+ }
+}
+#else
+extension AnimatedSVGView: UIViewRepresentable {
func makeUIView(context: Context) -> AnimatedSVGWebView {
AnimatedSVGWebViewCache.shared.webView(for: resourceName)
}
@@ -21,6 +36,7 @@ struct AnimatedSVGView: UIViewRepresentable {
uiView.resumeAnimationIfNeeded()
}
}
+#endif
#Preview {
AnimatedSVGView(resourceName: "home-assistant-logo-loading")
diff --git a/Sources/App/Frontend/WebView/Views/AnimatedSVGWebView.swift b/Sources/App/Frontend/WebView/Views/AnimatedSVGWebView.swift
index 2d67e0b9de..ec55a8c8db 100644
--- a/Sources/App/Frontend/WebView/Views/AnimatedSVGWebView.swift
+++ b/Sources/App/Frontend/WebView/Views/AnimatedSVGWebView.swift
@@ -1,5 +1,9 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import WebKit
/// A transparent, non-interactive `WKWebView` that renders an animated SVG bundled with the app and
@@ -48,17 +52,21 @@ final class AnimatedSVGWebView: WKWebView {
init(resourceName: String) {
self.resourceName = resourceName
super.init(frame: .zero, configuration: WKWebViewConfiguration())
+ #if os(macOS)
+ makeBackgroundTransparent()
+ #else
isOpaque = false
backgroundColor = .clear
scrollView.backgroundColor = .clear
scrollView.isScrollEnabled = false
// Let taps fall through to the logo dismiss gesture behind the view.
isUserInteractionEnabled = false
+ #endif
navigationDelegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillEnterForeground),
- name: UIApplication.willEnterForegroundNotification,
+ name: AppLifecycle.willEnterForegroundNotification,
object: nil
)
load()
@@ -69,10 +77,22 @@ final class AnimatedSVGWebView: WKWebView {
fatalError("init(coder:) has not been implemented")
}
+ #if os(macOS)
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ resumeAnimationIfNeeded()
+ }
+
+ /// Let clicks fall through to the logo dismiss gesture behind the view.
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+ #else
override func didMoveToWindow() {
super.didMoveToWindow()
resumeAnimationIfNeeded()
}
+ #endif
/// Gets the document animating again now that it can be seen. Safe to call repeatedly: a running
/// animation is left untouched, so this never restarts a healthy loop from its first frame.
diff --git a/Sources/App/Frontend/WebView/Views/AnimatedSVGWebViewCache.swift b/Sources/App/Frontend/WebView/Views/AnimatedSVGWebViewCache.swift
index 4653e48bfa..0bcb1a6651 100644
--- a/Sources/App/Frontend/WebView/Views/AnimatedSVGWebViewCache.swift
+++ b/Sources/App/Frontend/WebView/Views/AnimatedSVGWebViewCache.swift
@@ -1,4 +1,9 @@
+import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Keeps a warm, already-loaded `AnimatedSVGWebView` instance for animated SVGs so the loading logo
/// appears instantly instead of paying WKWebView's web-content-process spin-up and first-paint cost
@@ -39,7 +44,7 @@ final class AnimatedSVGWebViewCache {
func preloadOnFirstActivation(_ resourceName: String) {
guard firstActivationObserver == nil else { return }
firstActivationObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didBecomeActiveNotification,
+ forName: AppLifecycle.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
diff --git a/Sources/App/Frontend/WebView/Views/ConnectionErrorDetailsView.swift b/Sources/App/Frontend/WebView/Views/ConnectionErrorDetailsView.swift
index 55a3d7122f..779f46112a 100644
--- a/Sources/App/Frontend/WebView/Views/ConnectionErrorDetailsView.swift
+++ b/Sources/App/Frontend/WebView/Views/ConnectionErrorDetailsView.swift
@@ -318,9 +318,7 @@ struct ConnectionErrorDetailsView: View {
icon: Image("github.fill"),
title: L10n.Connection.Error.Details.Button.searchGithub,
url: searchURL,
- tint: .init(uiColor: .init(dynamicProvider: { trait in
- trait.userInterfaceStyle == .dark ? .white : .black
- }))
+ tint: .init(uiColor: .dynamic(light: .black, dark: .white))
)
}
}
diff --git a/Sources/App/Frontend/WebView/Views/HomeAssistantStandByView.swift b/Sources/App/Frontend/WebView/Views/HomeAssistantStandByView.swift
index 0a0130cfa7..9c74dcb5a9 100644
--- a/Sources/App/Frontend/WebView/Views/HomeAssistantStandByView.swift
+++ b/Sources/App/Frontend/WebView/Views/HomeAssistantStandByView.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
struct HomeAssistantStandByView: View {
static let logoDismissTapThreshold = 10
@@ -28,6 +27,8 @@ struct HomeAssistantStandByView: View {
/// renders the settled state straight away, which is what a snapshot needs: the fade is driven
/// frame by frame, and a test window gets no frames.
private let contentFadeAnimation: Animation?
+ /// Where the lifecycle and connectivity notifications arrive from; a test posts to a center of its own.
+ private let notificationCenter: NotificationCenter
@Environment(\.appSettingsPresenter) private var appSettingsPresenter
@@ -110,7 +111,8 @@ struct HomeAssistantStandByView: View {
onCleanCacheAndReload: (() -> Void)? = nil,
delayedSettingsButtonDelay: Duration = .seconds(5),
cleanCacheButtonDelay: Duration = .seconds(15),
- contentFadeAnimation: Animation? = DesignSystem.Animation.default
+ contentFadeAnimation: Animation? = DesignSystem.Animation.default,
+ notificationCenter: NotificationCenter = .default
) {
self.server = server
self.emptyState = emptyState
@@ -123,6 +125,7 @@ struct HomeAssistantStandByView: View {
self.delayedSettingsButtonDelay = delayedSettingsButtonDelay
self.cleanCacheButtonDelay = cleanCacheButtonDelay
self.contentFadeAnimation = contentFadeAnimation
+ self.notificationCenter = notificationCenter
self._showsAnimatedLogo = State(initialValue: emptyState == nil)
}
@@ -217,87 +220,88 @@ struct HomeAssistantStandByView: View {
.opacity(standByContentOpacity)
// Sits in front of the background colour but behind the content, so swipes over empty areas reach it
// while buttons keep priority.
- .background {
- if let onGestureAction {
- WebFrontendGesturesOverlay(onGestureAction: onGestureAction)
+ #if os(iOS)
+ .background {
+ if let onGestureAction {
+ WebFrontendGesturesOverlay(onGestureAction: onGestureAction)
+ }
}
- }
- .background(Color(uiColor: .systemBackground))
- .overlay(alignment: .topLeading) {
- delayedSettingsButton
- }
- .safeAreaInset(edge: .top) {
- if let emptyState {
- WebViewEmptyStateHeader(
- style: emptyState.style,
- server: server,
- isLoading: isLoading,
- showsServerSelection: emptyState.style.showsServerPicker
- && Current.servers.all.count > 1
- && !Current.isCatalyst,
- showsErrorDetailsButton: canShowErrorDetailsButton(for: emptyState),
- settingsAction: emptyState.settingsAction,
- serverSelectionAction: selectServer,
- dismissAction: emptyState.dismissAction,
- // The accessories stay at the safe-area edges (shifting them would push one under
- // the vertical bar); only the picker follows the display-centered logo below it.
- serverSelectionHorizontalOffset: contentOffset.width
- )
- .opacity(contentOpacity)
+ #endif
+ .background(Color(uiColor: .systemBackground))
+ .overlay(alignment: .topLeading) {
+ delayedSettingsButton
}
- }
- .safeAreaInset(edge: .bottom) {
- // Same horizontal shift as the logo and message, so the bottom actions line up with them.
- if let emptyState {
- WebViewEmptyStateActionButtons(
- style: emptyState.style,
- availableReauthURLTypes: emptyState.availableReauthURLTypes,
- showsErrorDetailsButton: canShowErrorDetailsButton(for: emptyState),
- retryAction: emptyState.retryAction,
- settingsAction: emptyState.settingsAction,
- errorDetailsAction: emptyState.errorDetailsAction,
- reauthAction: emptyState.reauthAction,
- clientCertificateAction: emptyState.clientCertificateAction
- )
- .padding(.horizontal, abs(contentOffset.width))
- .offset(x: contentOffset.width)
- .opacity(contentOpacity)
- } else if showsCleanCacheAndReloadButton {
- cleanCacheButton
+ .safeAreaInset(edge: .top) {
+ if let emptyState {
+ WebViewEmptyStateHeader(
+ style: emptyState.style,
+ server: server,
+ isLoading: isLoading,
+ showsServerSelection: emptyState.style.showsServerPicker
+ && Current.servers.all.count > 1
+ && !Current.isCatalyst,
+ showsErrorDetailsButton: canShowErrorDetailsButton(for: emptyState),
+ settingsAction: emptyState.settingsAction,
+ serverSelectionAction: selectServer,
+ dismissAction: emptyState.dismissAction,
+ // The accessories stay at the safe-area edges (shifting them would push one under
+ // the vertical bar); only the picker follows the display-centered logo below it.
+ serverSelectionHorizontalOffset: contentOffset.width
+ )
+ .opacity(contentOpacity)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ // Same horizontal shift as the logo and message, so the bottom actions line up with them.
+ if let emptyState {
+ WebViewEmptyStateActionButtons(
+ style: emptyState.style,
+ availableReauthURLTypes: emptyState.availableReauthURLTypes,
+ showsErrorDetailsButton: canShowErrorDetailsButton(for: emptyState),
+ retryAction: emptyState.retryAction,
+ settingsAction: emptyState.settingsAction,
+ errorDetailsAction: emptyState.errorDetailsAction,
+ reauthAction: emptyState.reauthAction,
+ clientCertificateAction: emptyState.clientCertificateAction
+ )
.padding(.horizontal, abs(contentOffset.width))
.offset(x: contentOffset.width)
- .transition(.opacity)
+ .opacity(contentOpacity)
+ } else if showsCleanCacheAndReloadButton {
+ cleanCacheButton
+ .padding(.horizontal, abs(contentOffset.width))
+ .offset(x: contentOffset.width)
+ .transition(.opacity)
+ }
}
- }
- .animation(contentFadeAnimation, value: standByContentOpacity)
- .animation(contentFadeAnimation, value: showsEmptyState)
- .onAppear {
- withAnimation(contentFadeAnimation) {
- hasAppeared = true
- showsEmptyStateContent = emptyState != nil
+ .animation(contentFadeAnimation, value: standByContentOpacity)
+ .animation(contentFadeAnimation, value: showsEmptyState)
+ .onAppear {
+ withAnimation(contentFadeAnimation) {
+ hasAppeared = true
+ showsEmptyStateContent = emptyState != nil
+ }
}
- }
- .onChange(of: emptyState != nil, perform: handleEmptyStateChange)
- .task(id: showsEmptyState, restoreAnimatedLogoIfNeeded)
- .onReceive(
- NotificationCenter.default
- .publisher(for: Current.connectivity.connectivityDidChangeNotification())
- ) { _ in
- networkType = Current.connectivity.simpleNetworkType()
- }
- // `$phase` replays its current value on subscription, so when the splash already finished
- // (server switches, reloads) the pill fades in immediately on appear.
- .onReceive(LaunchSplashOverlayState.shared.$phase, perform: fadeInServerPillIfNeeded)
- .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
- guard !showsEmptyState else { return }
- showsDelayedSettingsButton = false
- showsCleanCacheButton = false
- loaderCountdownRestartToken += 1
- }
- .task(
- id: [AnyHashable(showsEmptyState), AnyHashable(loaderCountdownRestartToken)],
- runDelayedButtonsCountdown
- )
+ .onChange(of: emptyState != nil, perform: handleEmptyStateChange)
+ .task(id: showsEmptyState, restoreAnimatedLogoIfNeeded)
+ .onReceive(
+ notificationCenter.publisher(for: Current.connectivity.connectivityDidChangeNotification())
+ ) { _ in
+ networkType = Current.connectivity.simpleNetworkType()
+ }
+ // `$phase` replays its current value on subscription, so when the splash already finished
+ // (server switches, reloads) the pill fades in immediately on appear.
+ .onReceive(LaunchSplashOverlayState.shared.$phase, perform: fadeInServerPillIfNeeded)
+ .onReceive(notificationCenter.publisher(for: AppLifecycle.willEnterForegroundNotification)) { _ in
+ guard !showsEmptyState else { return }
+ showsDelayedSettingsButton = false
+ showsCleanCacheButton = false
+ loaderCountdownRestartToken += 1
+ }
+ .task(
+ id: [AnyHashable(showsEmptyState), AnyHashable(loaderCountdownRestartToken)],
+ runDelayedButtonsCountdown
+ )
}
private func handleEmptyStateChange(_ showsEmptyState: Bool) {
@@ -379,7 +383,7 @@ struct HomeAssistantStandByView: View {
@ViewBuilder
private var currentServerPill: some View {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
GlassEffectContainer {
currentServerPillContent
}
@@ -407,7 +411,7 @@ struct HomeAssistantStandByView: View {
}
.buttonStyle(.plain)
.modify { view in
- if #available(iOS 18.0, *), let serverSelectionNamespace {
+ if #available(iOS 18.0, macOS 15.0, *), let serverSelectionNamespace {
view.matchedTransitionSource(id: Self.serverSelectionTransitionID, in: serverSelectionNamespace)
} else {
view
@@ -426,7 +430,7 @@ struct HomeAssistantStandByView: View {
.padding(.horizontal, DesignSystem.Spaces.two)
.frame(height: Self.serverPillHeight)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.glassEffect(.regular.interactive(), in: .capsule)
.contentShape(Capsule())
@@ -445,7 +449,7 @@ struct HomeAssistantStandByView: View {
.foregroundStyle(Color.haPrimary)
.frame(width: Self.serverPillHeight, height: Self.serverPillHeight)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.frame(
width: Self.connectionTypeIndicatorSize.width,
@@ -502,7 +506,7 @@ struct HomeAssistantStandByView: View {
}
private func showConnectionTypeToast() {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: Self.connectionTypeToastID,
symbol: connectionTypeIndicatorIcon,
@@ -550,7 +554,7 @@ struct HomeAssistantStandByView: View {
.rotationEffect(.degrees(-45))
.padding(DesignSystem.Spaces.one)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.glassEffect(.regular.interactive(), in: .circle)
.contentShape(.circle)
diff --git a/Sources/App/Frontend/WebView/Views/WebFrontendGesturesOverlay.swift b/Sources/App/Frontend/WebView/Views/WebFrontendGesturesOverlay.swift
index e6abd2774c..800ffd2de7 100644
--- a/Sources/App/Frontend/WebView/Views/WebFrontendGesturesOverlay.swift
+++ b/Sources/App/Frontend/WebView/Views/WebFrontendGesturesOverlay.swift
@@ -1,6 +1,6 @@
+#if os(iOS)
import Shared
import SwiftUI
-import UIKit
/// Recreates the `WebViewController` multi-touch swipe and screen-edge gestures for SwiftUI overlays that
/// cover the webview (like the stand-by view), so the user's configured gesture actions keep working there.
@@ -82,3 +82,4 @@ struct WebFrontendGesturesOverlay: UIViewRepresentable {
#Preview {
WebFrontendGesturesOverlay { _ in }
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WKWebView+TransparentBackground.swift b/Sources/App/Frontend/WebView/WKWebView+TransparentBackground.swift
new file mode 100644
index 0000000000..05aadc4468
--- /dev/null
+++ b/Sources/App/Frontend/WebView/WKWebView+TransparentBackground.swift
@@ -0,0 +1,12 @@
+#if os(macOS)
+import WebKit
+
+extension WKWebView {
+ /// AppKit's web view has no `isOpaque`; `drawsBackground` is the private setting that lets what is
+ /// behind the view show through while a page loads. It is only set where WebKit still answers to it.
+ func makeBackgroundTransparent() {
+ guard responds(to: Selector(("setDrawsBackground:"))) else { return }
+ setValue(false, forKey: "drawsBackground")
+ }
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebFrontend.swift b/Sources/App/Frontend/WebView/WebFrontend.swift
index a372a2b41e..0f0f5794b5 100644
--- a/Sources/App/Frontend/WebView/WebFrontend.swift
+++ b/Sources/App/Frontend/WebView/WebFrontend.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
/// A swappable SwiftUI host for the web frontend. `ContainerView` renders one of these for the active
/// server and receives its backing `WebViewController` via `onWebViewController`; swap the concrete type and
@@ -13,15 +12,15 @@ protocol WebFrontendView: View {
/// (`AppContainerCoordinator`) never references it directly. `WebViewController` conforms below.
protocol WebFrontend: AnyObject {
var server: Server { get }
- var presentationWindow: UIWindow? { get }
+ var presentationWindow: PlatformWindow? { get }
func show(alert: ServerAlert)
func open(inline url: URL, avoidUnnecessaryReload: Bool)
func openPanel(_ url: URL)
func navigateToRoot()
func dismissOverlayController(animated: Bool, completion: (() -> Void)?)
- func presentOverlayController(controller: UIViewController, animated: Bool)
+ func presentOverlayController(controller: PlatformViewController, animated: Bool)
}
extension WebViewController: WebFrontend {
- var presentationWindow: UIWindow? { viewIfLoaded?.window }
+ var presentationWindow: PlatformWindow? { viewIfLoaded?.window }
}
diff --git a/Sources/App/Frontend/WebView/WebFrontendOverlayState.swift b/Sources/App/Frontend/WebView/WebFrontendOverlayState.swift
index f12aa99aaa..2573fcc6de 100644
--- a/Sources/App/Frontend/WebView/WebFrontendOverlayState.swift
+++ b/Sources/App/Frontend/WebView/WebFrontendOverlayState.swift
@@ -1,6 +1,10 @@
import Combine
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Observable state the `WebViewController` publishes to its SwiftUI host (`HomeAssistantView`) so blocking
/// screens can be layered over the web view in SwiftUI (a `ZStack`) instead of presented as UIKit
diff --git a/Sources/App/Frontend/WebView/WebViewController/AssistZoomAnchorView.swift b/Sources/App/Frontend/WebView/WebViewController/AssistZoomAnchorView.swift
index 80d3996e33..40a25a333d 100644
--- a/Sources/App/Frontend/WebView/WebViewController/AssistZoomAnchorView.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/AssistZoomAnchorView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
/// Stand-in source for the zoom transition into Assist.
@@ -52,3 +53,4 @@ final class AssistZoomAnchorView: UIView {
return anchor
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Alerts.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Alerts.swift
index 6d1564a3f7..84b19bcc0d 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Alerts.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Alerts.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
enum BannerDuration {
case seconds(TimeInterval)
@@ -152,7 +151,7 @@ private extension BannerAction? {
}
protocol BannerPresenter: AnyObject {
- func show(on viewController: UIViewController, request: BannerRequest)
+ func show(on viewController: PlatformViewController, request: BannerRequest)
func hide(id: String)
}
@@ -161,7 +160,7 @@ final class DefaultBannerPresenter: BannerPresenter {
private var currentRequest: BannerRequest?
private var autoDismissWorkItem: DispatchWorkItem?
- func show(on viewController: UIViewController, request: BannerRequest) {
+ func show(on viewController: PlatformViewController, request: BannerRequest) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let currentRequest, currentOverlay != nil, currentRequest.matchesPresentation(of: request) {
@@ -170,7 +169,12 @@ final class DefaultBannerPresenter: BannerPresenter {
dismissCurrent(animated: false)
+ #if os(macOS)
+ // `loadViewIfNeeded()` needs macOS 14; reading the view loads it on every release.
+ _ = viewController.view
+ #else
viewController.loadViewIfNeeded()
+ #endif
let overlay = BannerOverlayView(request: request)
overlay.translatesAutoresizingMaskIntoConstraints = false
@@ -245,6 +249,10 @@ final class DefaultBannerPresenter: BannerPresenter {
}
}
+#if os(macOS)
+/// The banner is drawn by `MacBannerOverlayView` on the Mac; the presenter above drives both the same way.
+private typealias BannerOverlayView = MacBannerOverlayView
+#else
private final class BannerOverlayView: UIView {
private let request: BannerRequest
private let backgroundButton = UIButton(type: .custom)
@@ -439,6 +447,7 @@ private final class BannerOverlayView: UIView {
onActionRequested?()
}
}
+#endif
// MARK: - Alerts & Message Presentation
@@ -488,12 +497,16 @@ extension WebViewController {
/// The shake gesture only exists on a phone, so only a phone offers the toggle. Read from this
/// controller's traits rather than the device, which is what a resizable window reports.
var showsShakeDisclaimerToggle: Bool {
+ #if os(macOS)
+ false
+ #else
traitCollection.userInterfaceIdiom == .phone
+ #endif
}
func openDebug() {
let showsShakeDisclaimerToggle = showsShakeDisclaimerToggle
- let controller = UIHostingController(rootView: AnyView(
+ let controller = PlatformHostingController(rootView: AnyView(
NavigationView {
VStack {
if showsShakeDisclaimerToggle {
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+AppKitPresentation.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+AppKitPresentation.swift
new file mode 100644
index 0000000000..e0f6b04c0c
--- /dev/null
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+AppKitPresentation.swift
@@ -0,0 +1,22 @@
+#if os(macOS)
+import AppKit
+
+// The extensions of `WebViewController` are shared with iOS and are written against UIKit's presentation
+// API. These give the AppKit controller the same few entry points, so that shared code reads the same on
+// both platforms: presenting means showing a sheet on the window, and there is no animation flag to honour.
+extension WebViewController {
+ var presentedViewController: NSViewController? {
+ presentedSheets.last
+ }
+
+ func present(_ viewController: NSViewController, animated: Bool, completion: (() -> Void)? = nil) {
+ presentSheet(viewController)
+ completion?()
+ }
+
+ func dismissAllViewControllersAbove(completion: (() -> Void)? = nil) {
+ dismissPresentedSheets()
+ completion?()
+ }
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+AssistZoomOrigin.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+AssistZoomOrigin.swift
index 2289fccaa2..bcd3a6d7c0 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+AssistZoomOrigin.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+AssistZoomOrigin.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
extension WebViewController {
@@ -14,3 +15,4 @@ extension WebViewController {
pendingAssistZoomSourceView = anchor
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+BlankFrontend.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+BlankFrontend.swift
index 5c6061f045..8e49229c0b 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+BlankFrontend.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+BlankFrontend.swift
@@ -1,5 +1,9 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@preconcurrency import WebKit
// MARK: - Blank Frontend Detection & Recovery
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ClientCertificate.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ClientCertificate.swift
index e3b85a9e84..7fb2aabf19 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ClientCertificate.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ClientCertificate.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
import WebKit
// MARK: - Client certificate (mTLS)
@@ -96,10 +95,12 @@ extension WebViewController {
/// Opens the same certificate import the onboarding uses, over the empty state. Importing stores the
/// certificate on the server and reloads; cancelling leaves the empty state up.
func presentClientCertificateImport() {
- let controller = UIHostingController(
+ let controller = PlatformHostingController(
rootView: NavigationView { makeClientCertificateImportView() }.navigationViewStyle(.stack)
)
+ #if os(iOS)
controller.modalPresentationStyle = .formSheet
+ #endif
presentOverlayController(controller: controller, animated: true)
}
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift
index 59f917c19a..13b9ddeb7c 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift
@@ -1,7 +1,6 @@
import Alamofire
import Shared
import SwiftUI
-import UIKit
// MARK: - Empty State
@@ -88,9 +87,13 @@ extension WebViewController {
connectionState == .disconnected && latestLoadError != nil
}
- /// The scene this frontend is shown in, once its view is in a window.
- var frontendWindowScene: UIWindowScene? {
+ /// The scene this frontend is shown in, once its view is in a window. On the Mac that is the window.
+ var frontendWindowScene: PlatformWindowScene? {
+ #if os(macOS)
+ viewIfLoaded?.window
+ #else
viewIfLoaded?.window?.windowScene
+ #endif
}
@objc func sceneDidEnterBackground(_ notification: Notification) {
@@ -107,7 +110,13 @@ extension WebViewController {
/// others' transitions say nothing about this frontend. A frontend that is not in a window cannot
/// tell the scenes apart, so for it every scene counts, as the application's state would.
private func concernsFrontendScene(_ notification: Notification) -> Bool {
+ #if os(macOS)
+ // Hiding the app takes every window away; a window notification names the one it is about.
+ if notification.object is NSApplication { return true }
+ guard let scene = notification.object as? NSWindow else { return false }
+ #else
guard let scene = notification.object as? UIScene else { return false }
+ #endif
guard let frontendWindowScene else { return true }
return scene === frontendWindowScene
}
@@ -208,7 +217,7 @@ extension WebViewController {
func presentLatestLoadErrorDetails() {
guard let latestLoadError else { return }
presentOverlayController(
- controller: UIHostingController(rootView: ConnectionErrorDetailsView(
+ controller: PlatformHostingController(rootView: ConnectionErrorDetailsView(
server: server,
error: latestLoadError
)),
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Gestures.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Gestures.swift
index de3e41bda1..3ee48cc119 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Gestures.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Gestures.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -41,3 +42,4 @@ extension WebViewController {
webViewGestureHandler.handleGestureAction(action)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Hinge.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Hinge.swift
index ec64f1b0b9..8a2a7ca831 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Hinge.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Hinge.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -18,3 +19,9 @@ extension WebViewController {
view.addInteraction(interaction)
}
}
+#else
+extension WebViewController {
+ /// A Mac has no hinge to observe; the hinge sensors report themselves unsupported on their own.
+ func setupHingeObservation() {}
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Navigation.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Navigation.swift
index 6b44d89baa..6d806f28c3 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Navigation.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Navigation.swift
@@ -1,7 +1,6 @@
import PromiseKit
import Shared
import SwiftUI
-import UIKit
@preconcurrency import WebKit
// MARK: - Public Navigation API
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Onboarding.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Onboarding.swift
index aac157b73d..641868e94c 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Onboarding.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Onboarding.swift
@@ -1,7 +1,6 @@
import PromiseKit
import Shared
import SwiftUI
-import UIKit
// MARK: - Onboarding & Security Level
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+OnscreenContent.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+OnscreenContent.swift
index b39c9012be..cc06214fb0 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+OnscreenContent.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+OnscreenContent.swift
@@ -1,7 +1,11 @@
import AppIntents
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
extension WebViewController {
/// Publishes what the frontend is showing onto the web view's user activity.
@@ -35,7 +39,7 @@ extension WebViewController {
knownPanelPaths: Self.knownPanelPaths(serverId: serverId)
)
guard !Task.isCancelled, let self else { return }
- if #available(iOS 18.4, *) {
+ if #available(iOS 18.4, macOS 15.4, *) {
publishOnscreenEntityElements(identifiers)
}
}
@@ -48,7 +52,7 @@ extension WebViewController {
///
/// The web view is one opaque view, so the element takes its whole area — there is no way to know
/// where inside it the dialog was drawn, and the dialog covers it anyway.
- @available(iOS 18.4, *)
+ @available(iOS 18.4, macOS 15.4, *)
func publishOnscreenEntityElements(_ identifiers: [EntityIdentifier]) {
guard let webView else { return }
guard !identifiers.isEmpty else {
@@ -62,7 +66,7 @@ extension WebViewController {
/// One element per identifier, all of them the entity the dialog is showing under a different
/// type, so whichever type a command's parameter takes finds it.
- @available(iOS 18.4, *)
+ @available(iOS 18.4, macOS 15.4, *)
static func onscreenEntityElements(
_ identifiers: [EntityIdentifier],
bounds: CGRect
@@ -161,7 +165,7 @@ extension WebViewController {
}
var entityIdentifiers: [EntityIdentifier] = []
- if #available(iOS 18.2, *), let onscreenEntityId {
+ if #available(iOS 18.2, macOS 15.2, *), let onscreenEntityId {
entityIdentifiers = await OnscreenEntityIdentifier.makeAll(
entityId: onscreenEntityId,
serverId: serverId
@@ -173,7 +177,7 @@ extension WebViewController {
guard !Task.isCancelled else { return [] }
userActivity.title = page?.title
- if #available(iOS 18.2, *) {
+ if #available(iOS 18.2, macOS 15.2, *) {
// The page stands in whenever the dialog names nothing the system could resolve.
userActivity.appEntityIdentifier = entityIdentifiers.first
?? page.flatMap { OnscreenPageIdentifier.make(for: $0) }
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift
index 72ceb79c85..fa10a6b1bb 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import WebKit
extension WebViewController: WebViewControllerProtocol {
@@ -20,24 +24,41 @@ extension WebViewController: WebViewControllerProtocol {
webView.goForward()
}
- var overlayedController: UIViewController? {
+ #if os(macOS)
+ var effectiveAppearance: NSAppearance {
+ view.effectiveAppearance
+ }
+
+ var isDarkAppearance: Bool {
+ view.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ }
+ #else
+ var isDarkAppearance: Bool {
+ traitCollection.userInterfaceStyle == .dark
+ }
+ #endif
+
+ var overlayedController: PlatformViewController? {
presentedViewController ?? detachedOverlayController
}
- func presentOverlayController(controller: UIViewController, animated: Bool) {
+ func presentOverlayController(controller: PlatformViewController, animated: Bool) {
DispatchQueue.main.async { [weak self] in
self?.dismissOverlayController(animated: false, completion: { [weak self] in
guard let self else { return }
+ #if os(iOS)
if view.window == nil, NativeTabBarState.shared.isEnabled, let presenter = Self.topMostPresenter() {
detachedOverlayController = controller
presenter.present(controller, animated: animated, completion: nil)
- } else {
- present(controller, animated: animated, completion: nil)
+ return
}
+ #endif
+ present(controller, animated: animated, completion: nil)
})
}
}
+ #if os(iOS)
private static func topMostPresenter() -> UIViewController? {
var presenter = NativeTabBarButtonLocator.keyWindow?.rootViewController
while let presented = presenter?.presentedViewController {
@@ -45,15 +66,20 @@ extension WebViewController: WebViewControllerProtocol {
}
return presenter
}
+ #endif
- func presentAlertController(controller: UIViewController, animated: Bool) {
+ func presentAlertController(controller: PlatformViewController, animated: Bool) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
+ #if os(macOS)
+ (overlayedController?.sheetContentController ?? self).presentSheet(controller)
+ #else
if let overlayedController {
overlayedController.present(controller, animated: animated, completion: nil)
} else {
present(controller, animated: animated, completion: nil)
}
+ #endif
}
}
@@ -62,10 +88,15 @@ extension WebViewController: WebViewControllerProtocol {
}
func makeWebViewFirstResponder() {
+ #if os(macOS)
+ view.window?.makeFirstResponder(webView)
+ #else
webView.becomeFirstResponder()
+ #endif
}
func dismissOverlayController(animated: Bool, completion: (() -> Void)?) {
+ #if os(iOS)
if let detachedOverlayController, detachedOverlayController.presentingViewController != nil {
self.detachedOverlayController = nil
detachedOverlayController.presentingViewController?.dismiss(animated: animated) { [weak self] in
@@ -73,11 +104,16 @@ extension WebViewController: WebViewControllerProtocol {
}
return
}
+ #endif
dismissAllViewControllersAbove(completion: completion)
}
func dismissControllerAboveOverlayController() {
+ #if os(macOS)
+ overlayedController?.sheetContentController.dismissPresentedSheets()
+ #else
overlayedController?.dismissAllViewControllersAbove()
+ #endif
}
func updateFrontendConnectionState(state: String) {
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift
index 522cc9c457..ee3c92dfab 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift
@@ -1,7 +1,6 @@
import PromiseKit
import Shared
import SwiftUI
-import UIKit
extension WebViewController: OnboardingStateObserver {
/// Surfaces the re-authentication empty state when the server rejects this server's refresh
@@ -48,19 +47,26 @@ extension WebViewController {
authDetails.clientCertificate = connectionInfo.clientCertificate
let loginViewModel = OnboardingAuthLoginViewModel(authDetails: authDetails)
- let loginController = UIHostingController(
+ let loginController = PlatformHostingController(
rootView: OnboardingAuthLoginView(viewModel: loginViewModel, style: .modal)
)
+ #if os(iOS)
loginController.isModalInPresentation = true
+ #endif
present(loginController, animated: true)
firstly {
loginViewModel.resultPromise
}.ensureThen {
Guarantee { seal in
+ #if os(macOS)
+ loginController.dismissSheet()
+ seal(())
+ #else
loginController.dismiss(animated: true) {
seal(())
}
+ #endif
}
}.then { result -> Promise<(URL?, TokenInfo)> in
// The login web view may have been redirected to a different port/scheme; re-authenticate
@@ -118,12 +124,15 @@ extension WebViewController {
}
private func showReauthFailureAlert(error: Error) {
- let alert = UIAlertController(
+ let alert = AppAlert(
title: L10n.Alerts.AuthRequired.title,
message: error.localizedDescription,
- preferredStyle: .alert
+ actions: [.init(title: L10n.okLabel)]
)
- alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default))
- present(alert, animated: true)
+ #if os(macOS)
+ alert.present(on: view.window)
+ #else
+ present(alert.makeAlertController(), animated: true)
+ #endif
}
}
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Settings.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Settings.swift
index 39ae907a49..eae4306071 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+Settings.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+Settings.swift
@@ -1,6 +1,10 @@
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@preconcurrency import WebKit
// MARK: - Settings, Appearance & Pull-to-Refresh
@@ -13,8 +17,13 @@ extension WebViewController {
func styleUI(publishesThemedStatusBar: Bool) {
precondition(isViewLoaded && webView != nil)
- let cachedColors = ThemeColors.cachedThemeColors(for: traitCollection)
+ let cachedColors = cachedThemeColors
+ #if os(macOS)
+ view.wantsLayer = true
+ view.layer?.backgroundColor = cachedColors[.primaryBackgroundColor].cgColor
+ webView?.underPageBackgroundColor = cachedColors[.primaryBackgroundColor]
+ #else
view.backgroundColor = cachedColors[.primaryBackgroundColor]
webView?.backgroundColor = cachedColors[.primaryBackgroundColor]
webView?.scrollView.backgroundColor = cachedColors[.primaryBackgroundColor]
@@ -24,14 +33,26 @@ extension WebViewController {
statusBarView.backgroundColor = themedStatusBarColor()
statusBarView.isOpaque = true
}
+ #endif
if publishesThemedStatusBar {
updateThemedStatusBar()
}
+ #if os(iOS)
let headerBackgroundIsLight = cachedColors[.appThemeColor].isLight
underlyingPreferredStatusBarStyle = headerBackgroundIsLight ? .darkContent : .lightContent
setNeedsStatusBarAppearanceUpdate()
+ #endif
+ }
+
+ /// The frontend's theme colours for the appearance this controller is shown in.
+ private var cachedThemeColors: ThemeColors {
+ #if os(macOS)
+ ThemeColors.cachedThemeColors(for: view.effectiveAppearance)
+ #else
+ ThemeColors.cachedThemeColors(for: traitCollection)
+ #endif
}
func updateWebViewSettings(reason: WebViewSettingsUpdateReason) {
@@ -43,7 +64,12 @@ extension WebViewController {
// resizing the scrolling viewport.
let viewScale = Current.settingsStore.pageZoom.viewScaleValue
Current.Log.info("setting view scale to \(viewScale)")
+ #if os(macOS)
+ // The Mac's web view has the zoom Safari uses, which scales content without resizing the viewport.
+ webView.pageZoom = CGFloat(Double(viewScale) ?? 1)
+ #else
webView.setValue(viewScale, forKey: "viewScale")
+ #endif
if !Current.isCatalyst {
let zoomValue = Current.settingsStore.pinchToZoom ? "true" : "false"
@@ -51,8 +77,10 @@ extension WebViewController {
}
if reason == .settingChange {
+ #if os(iOS)
setNeedsUpdateOfHomeIndicatorAutoHidden()
setNeedsStatusBarAppearanceUpdate()
+ #endif
// The web view is always edge-to-edge (see `setupWebViewConstraints`); only the SwiftUI-themed
// status-bar strip reacts to setting changes.
updateThemedStatusBar()
@@ -65,7 +93,7 @@ extension WebViewController {
/// The themed colour for the top status-bar area (web app theme, or header background on older cores).
func themedStatusBarColor() -> UIColor {
- let cachedColors = ThemeColors.cachedThemeColors(for: traitCollection)
+ let cachedColors = cachedThemeColors
return server.info.version < .canUseAppThemeForStatusBar
? cachedColors[.appHeaderBackgroundColor]
: cachedColors[.appThemeColor]
@@ -76,6 +104,12 @@ extension WebViewController {
/// once the status bar is hidden by full screen or kiosk mode. The strip is drawn for older cores, in
/// regular-width layouts, or when the developer "always below status bar" override is on.
func updateThemedStatusBar() {
+ #if os(macOS)
+ // A Mac window's title bar is drawn by AppKit, so there is no strip for the frontend to colour.
+ DispatchQueue.main.async { [weak self] in
+ self?.overlayState?.statusBarColor = nil
+ }
+ #else
let isCompactWidth = traitCollection.horizontalSizeClass == .compact
let coreSupportsEdgeToEdge = server.info.version >= .canDisplayEdgeToEdge
let belowStatusBarOverride = Current.settingsStore.webViewAlwaysBelowStatusBar
@@ -85,6 +119,7 @@ extension WebViewController {
guard let self else { return }
overlayState?.statusBarColor = (edgeToEdge || Current.isCatalyst) ? nil : themedStatusBarColor()
}
+ #endif
}
func pullToRefreshActions() {
@@ -96,7 +131,7 @@ extension WebViewController {
// called via menu/keyboard shortcut too
firstly {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .userRequested
)
}.catch { error in
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+StatusBar.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+StatusBar.swift
index 20d57241a4..569d30e416 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+StatusBar.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+StatusBar.swift
@@ -1,14 +1,14 @@
import Shared
import SwiftUI
-import UIKit
@preconcurrency import WebKit
-#if targetEnvironment(macCatalyst)
+#if os(macOS)
import AppKit
#endif
// MARK: - Status Bar & Toolbar
extension WebViewController {
+ #if os(iOS)
func setupStatusBarView() -> UIView {
let statusBarView = UIView()
statusBarView.tag = 111
@@ -30,6 +30,7 @@ extension WebViewController {
return statusBarView
}
+ #endif
func openServer(_ server: Server) {
// Not `activate(server:)`: like the server-cycling gestures, the status bar menu switches
@@ -40,7 +41,9 @@ extension WebViewController {
}
@objc func customizeToolbar() {
- #if targetEnvironment(macCatalyst)
+ #if os(macOS)
+ view.window?.toolbar?.runCustomizationPalette(nil)
+ #elseif targetEnvironment(macCatalyst)
view.window?.windowScene?.titlebar?.toolbar?.runCustomizationPalette(nil)
#endif
}
@@ -292,10 +295,25 @@ extension WebViewController {
return false;
}
"""
+
@objc func showFindInteraction() {
+ #if os(macOS)
+ textFinder.performAction(.showFindInterface)
+ #else
// Present the find interaction UI
if let findInteraction = webView.findInteraction {
findInteraction.presentFindNavigator(showingReplace: false)
}
+ #endif
+ }
+}
+
+#if os(macOS)
+extension WebViewController: NSUserInterfaceValidations {
+ func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
+ guard item.action == #selector(performTextFinderAction(_:)) else { return true }
+ guard let action = NSTextFinder.Action(rawValue: item.tag) else { return false }
+ return textFinder.validateAction(action)
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift
index fdb7a1c185..660861946d 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift
@@ -1,7 +1,6 @@
import HAKit
import Shared
import SwiftUI
-import UIKit
@preconcurrency import WebKit
// MARK: - URL Loading & Connection Lifecycle
@@ -10,7 +9,7 @@ extension WebViewController {
func observeConnectionNotifications() {
for name: Notification.Name in [
HomeAssistantAPI.didConnectNotification,
- UIApplication.didBecomeActiveNotification,
+ AppLifecycle.didBecomeActiveNotification,
] {
NotificationCenter.default.addObserver(
self,
@@ -23,23 +22,36 @@ extension WebViewController {
NotificationCenter.default.addObserver(
self,
selector: #selector(scheduleReconnectBackgroundTimer),
- name: UIApplication.didEnterBackgroundNotification,
+ name: AppLifecycle.didEnterBackgroundNotification,
object: nil
)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(sceneDidEnterBackground(_:)),
- name: UIScene.didEnterBackgroundNotification,
- object: nil
- )
+ // The Mac has no scenes: a window leaving the screen (minimised, or the app hidden) is the
+ // background, and it coming back is the activation.
+ #if os(macOS)
+ let sceneDidEnterBackgroundNames = [NSWindow.didMiniaturizeNotification, NSApplication.didHideNotification]
+ let sceneDidActivateNames = [NSWindow.didDeminiaturizeNotification, NSApplication.didUnhideNotification]
+ #else
+ let sceneDidEnterBackgroundNames = [UIScene.didEnterBackgroundNotification]
+ let sceneDidActivateNames = [UIScene.didActivateNotification]
+ #endif
+ for name in sceneDidEnterBackgroundNames {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(sceneDidEnterBackground(_:)),
+ name: name,
+ object: nil
+ )
+ }
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(sceneDidActivate(_:)),
- name: UIScene.didActivateNotification,
- object: nil
- )
+ for name in sceneDidActivateNames {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(sceneDidActivate(_:)),
+ name: name,
+ object: nil
+ )
+ }
NotificationCenter.default.addObserver(
self,
@@ -390,7 +402,7 @@ extension WebViewController {
_ = webViewExternalMessageHandler.sendExternalBus(message: .init(command: "restart"))
}
- if UIApplication.shared.applicationState == .active {
+ if ApplicationState.current == .active {
timer.invalidate()
}
}
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebKitDelegates.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebKitDelegates.swift
index 37c03a24e2..19796f3a6c 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebKitDelegates.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebKitDelegates.swift
@@ -1,7 +1,6 @@
import PromiseKit
import Shared
import SwiftUI
-import UIKit
import WebKit
// MARK: - WebView
@@ -126,7 +125,7 @@ extension WebViewController {
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
let viewModel = DownloadManagerViewModel()
download.delegate = viewModel
// Present via `ContainerView`'s sheet (SwiftUI) instead of a UIKit overlay; the same view model
@@ -208,6 +207,99 @@ extension WebViewController {
}
// WKUIDelegate
+ #if os(macOS)
+ func webView(
+ _ webView: WKWebView,
+ runJavaScriptConfirmPanelWithMessage message: String,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping (Bool) -> Void
+ ) {
+ guard presentedViewController == nil, view.window?.attachedSheet == nil else {
+ Current.Log.error("attempted to present an alert when already presenting, bailing")
+ completionHandler(false)
+ return
+ }
+
+ AppAlert(message: message, actions: [
+ .init(title: L10n.Alerts.Confirm.ok) { completionHandler(true) },
+ .init(title: L10n.Alerts.Confirm.cancel, style: .cancel) { completionHandler(false) },
+ ]).present(on: view.window)
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ runJavaScriptTextInputPanelWithPrompt prompt: String,
+ defaultText: String?,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping (String?) -> Void
+ ) {
+ guard presentedViewController == nil, view.window?.attachedSheet == nil else {
+ Current.Log.error("attempted to present an alert when already presenting, bailing")
+ completionHandler(nil)
+ return
+ }
+
+ let alert = NSAlert()
+ alert.messageText = prompt
+ alert.addButton(withTitle: L10n.Alerts.Prompt.ok)
+ alert.addButton(withTitle: L10n.Alerts.Prompt.cancel)
+
+ let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
+ textField.stringValue = defaultText ?? ""
+ alert.accessoryView = textField
+ alert.window.initialFirstResponder = textField
+
+ let handle: (NSApplication.ModalResponse) -> Void = { response in
+ completionHandler(response == .alertFirstButtonReturn ? textField.stringValue : nil)
+ }
+
+ if let window = view.window {
+ alert.beginSheetModal(for: window, completionHandler: handle)
+ } else {
+ handle(alert.runModal())
+ }
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ runJavaScriptAlertPanelWithMessage message: String,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping () -> Void
+ ) {
+ guard presentedViewController == nil, view.window?.attachedSheet == nil else {
+ Current.Log.error("attempted to present an alert when already presenting, bailing")
+ completionHandler()
+ return
+ }
+
+ AppAlert(message: message, actions: [
+ .init(title: L10n.Alerts.Alert.ok) { completionHandler() },
+ ]).present(on: view.window)
+ }
+
+ /// The frontend's file inputs: AppKit's web view asks its delegate for the open panel.
+ func webView(
+ _ webView: WKWebView,
+ runOpenPanelWith parameters: WKOpenPanelParameters,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping ([URL]?) -> Void
+ ) {
+ let panel = NSOpenPanel()
+ panel.allowsMultipleSelection = parameters.allowsMultipleSelection
+ panel.canChooseDirectories = parameters.allowsDirectories
+ panel.canChooseFiles = true
+
+ let handle: (NSApplication.ModalResponse) -> Void = { response in
+ completionHandler(response == .OK ? panel.urls : nil)
+ }
+
+ if let window = view.window {
+ panel.beginSheetModal(for: window, completionHandler: handle)
+ } else {
+ handle(panel.runModal())
+ }
+ }
+ #else
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
@@ -300,6 +392,7 @@ extension WebViewController {
present(alertController, animated: true, completion: nil)
}
}
+ #endif
func webView(
_ webView: WKWebView,
@@ -312,6 +405,7 @@ extension WebViewController {
}
}
+#if os(iOS)
extension WebViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
@@ -320,6 +414,7 @@ extension WebViewController: UIGestureRecognizerDelegate {
true
}
}
+#endif
extension WebViewController {
enum MainFrameErrorResponseDecision: Equatable {
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift
index 4368bdceba..90006c9f49 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift
@@ -1,5 +1,9 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@preconcurrency import WebKit
// MARK: - Web View Configuration & Setup
@@ -28,6 +32,20 @@ extension WebViewController {
return userContentController
}
+ #if os(macOS)
+ /// The web view fills the controller's view; only the find bar, when it is up, takes room above it.
+ func setupWebViewConstraints() {
+ webView.translatesAutoresizingMaskIntoConstraints = false
+ let topConstraint = webView.topAnchor.constraint(equalTo: view.topAnchor)
+ webViewTopConstraint = topConstraint
+ NSLayoutConstraint.activate([
+ topConstraint,
+ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+ #else
func setupWebViewConstraints(statusBarView: UIView) {
webView.translatesAutoresizingMaskIntoConstraints = false
webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
@@ -51,6 +69,7 @@ extension WebViewController {
webViewTopConstraint?.isActive = true
updateWindowControlsInset()
}
+ #endif
func setupURLObserver() {
urlObserver = webView.observe(\.url) { [weak self] webView, _ in
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowControls.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowControls.swift
index cf10db325d..73e8c1f7ce 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowControls.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowControls.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -48,3 +49,4 @@ extension WebViewController {
statusBarView?.isHidden = inset == 0
}
}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowTitle.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowTitle.swift
index b301d00b65..039eee376d 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowTitle.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WindowTitle.swift
@@ -1,6 +1,10 @@
import Combine
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
// MARK: - Window Title
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift
index 31342fe3b4..e2ebd6a404 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift
@@ -8,10 +8,12 @@ import KeychainAccess
import PromiseKit
import Shared
import SwiftUI
-import UIKit
@preconcurrency import WebKit
-final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
+/// Hosts the Home Assistant frontend in a `WKWebView`. One class for both platforms: a `UIViewController` on
+/// iOS and an `NSViewController` on the Mac, with the chrome each platform draws around the web view kept
+/// behind `#if`.
+final class WebViewController: PlatformViewController, WKNavigationDelegate, WKUIDelegate {
var webView: WKWebView!
let server: Server
@@ -35,17 +37,21 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
var emptyStateTitleObserver: AnyCancellable?
var tokens = [HACancellable]()
+ #if os(iOS)
let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer
let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer
+ #endif
- var statusBarView: UIView?
+ var statusBarView: PlatformView?
/// Stands in for the frontend's Assist button as the zoom transition's source; see `AssistZoomAnchorView`.
- var assistZoomAnchorView: UIView?
- var pendingAssistZoomSourceView: UIView?
+ var assistZoomAnchorView: PlatformView?
+ var pendingAssistZoomSourceView: PlatformView?
var presentsNextAssistAsSheet = false
/// An overlay presented from the window while this view was off screen behind the App Labs tab bar.
- weak var detachedOverlayController: UIViewController?
+ weak var detachedOverlayController: PlatformViewController?
+ #if os(iOS)
var tabBarAssistZoomAnchor: AssistZoomAnchorView?
+ #endif
var webViewTopConstraint: NSLayoutConstraint?
/// Pins the bottom of `statusBarView`; on iOS it follows the web view's top edge.
var statusBarBottomConstraint: NSLayoutConstraint?
@@ -54,7 +60,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
var initialURL: URL?
var initialURLPath: String?
+ #if os(iOS)
var statusBarButtonsStack: UIStackView?
+ #endif
var lastNavigationWasServerError = false
var didHandleServerErrorResponse = false
var reconnectBackgroundTimer: Timer? {
@@ -109,15 +117,22 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
var redirectToRootTask: Task?
/// Wrapper around the application state; replaceable in tests.
- var isAppInBackground: @MainActor () -> Bool = { UIApplication.shared.applicationState == .background }
+ var isAppInBackground: @MainActor () -> Bool = { ApplicationState.current == .background }
/// Whether the scene showing this frontend is active, i.e. on screen and receiving events; replaceable
/// in tests. The scene's state rather than the application's: with several windows open, one frontend
/// can be in the background while the app as a whole stays active. Without a scene to ask (the view is
/// not in a window) the application's state is the best answer available.
- var isSceneActive: @MainActor (UIWindowScene?) -> Bool = { scene in
+ var isSceneActive: @MainActor (PlatformWindowScene?) -> Bool = { scene in
+ #if os(macOS)
+ // A Mac window is the scene; it is "active" while someone can see it. Minimised or hidden with
+ // the app, it is as good as backgrounded.
+ guard let scene else { return !NSApp.isHidden }
+ return scene.isVisible && !scene.isMiniaturized && !NSApp.isHidden
+ #else
guard let scene else { return UIApplication.shared.applicationState == .active }
return scene.activationState == .foregroundActive
+ #endif
}
/// Set when the disconnected empty state was asked for while the scene was not active. Nobody could
@@ -135,10 +150,11 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
var hasRenderedFrontendCheck: (@MainActor ((Bool) -> Void) -> Void)?
/// Where the window's title lands; replaceable in tests, which all share the host process's one scene.
- var applyWindowSceneTitle: @MainActor (UIWindowScene, String) -> Void = { windowScene, title in
+ var applyWindowSceneTitle: @MainActor (PlatformWindowScene, String) -> Void = { windowScene, title in
windowScene.title = title
}
+ #if os(iOS)
/// How far down a view must start to clear the window controls; replaceable in tests, which have none.
var cornerAdaptedSafeAreaTop: @MainActor (UIView) -> CGFloat = { view in
guard #available(iOS 26, *) else { return view.safeAreaInsets.top }
@@ -156,6 +172,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
guard let window = view.window, let screen = window.windowScene?.screen else { return false }
return WebViewController.sceneIsWindowed(windowSize: window.bounds.size, screenSize: screen.bounds.size)
}
+ #endif
/// Handler for messages sent from the webview to the app
var webViewExternalMessageHandler: WebViewExternalMessageHandlerProtocol = WebViewExternalMessageHandler(
@@ -178,6 +195,13 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
/// updateFrontendConnectionState in WebViewController+ProtocolConformance.swift)
var emptyStateTimer: Timer?
+ #if os(macOS)
+ /// Drives the find bar; the web view is its client and `findBarContainer` gives the bar its place.
+ lazy var textFinder = NSTextFinder()
+ lazy var findBarContainer = WebViewFindBarContainer(webViewController: self)
+ /// Watches the window's appearance so the frontend can follow a switch between light and dark.
+ var appearanceObserver: NSKeyValueObservation?
+ #else
var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent
override var prefersHomeIndicatorAutoHidden: Bool {
@@ -246,17 +270,20 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
return commands
}
#endif
+ #endif
// MARK: - Initialization
init(server: Server, shouldLoadImmediately: Bool = false) {
self.server = server
+ #if os(iOS)
self.leftEdgePanGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .left
}
self.rightEdgeGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .right
}
+ #endif
super.init(nibName: nil, bundle: nil)
@@ -264,8 +291,10 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
$0.isEligibleForHandoff = true
}
+ #if os(iOS)
leftEdgePanGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
rightEdgeGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
+ #endif
if shouldLoadImmediately {
loadViewIfNeeded()
@@ -293,7 +322,13 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
}
deinit {
+ #if os(iOS)
tabBarAssistZoomAnchor?.removeFromSuperview()
+ #else
+ self.appearanceObserver = nil
+ textFinder.client = nil
+ textFinder.findBarContainer = nil
+ #endif
self.urlObserver = nil
self.windowTitleObserver = nil
if let siriExposureObserver {
@@ -310,7 +345,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
WebKitEnhancedSecurity.prepareForConfiguredServers()
let config = WKWebViewConfiguration()
+ #if os(iOS)
config.allowsInlineMediaPlayback = true
+ #endif
// Avoid interrupting background audio when the frontend loads media-capable elements.
config.mediaTypesRequiringUserActionForPlayback = Current.settingsStore
.mediaTypesRequiringUserActionForPlayback
@@ -320,9 +357,28 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
// MARK: - View Lifecycle
+ #if os(macOS)
+ /// AppKit has no view to hand a controller that was not loaded from a nib.
+ override func loadView() {
+ view = NSView()
+ }
+
+ // AppKit only gained these two in macOS 14. The extensions shared with iOS rely on them, so they are
+ // provided here for the releases before that.
+ override var viewIfLoaded: NSView? {
+ isViewLoaded ? view : nil
+ }
+
+ override func loadViewIfNeeded() {
+ _ = view
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if os(iOS)
becomeFirstResponder()
+ #endif
observeConnectionNotifications()
setupKioskModeObservation()
@@ -333,7 +389,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
// Weakly held; surfaces re-authentication when this server's refresh token is rejected.
Current.onboardingObservation.register(observer: self)
+ #if os(iOS)
let statusBarView = setupStatusBarView()
+ #endif
let config = Self.makeWebViewConfiguration()
@@ -369,24 +427,40 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
config.applicationNameForUserAgent = HomeAssistantAPI.applicationNameForUserAgent
config.defaultWebpagePreferences.preferredContentMode = Current.isCatalyst ? .desktop : .mobile
- webView = WKWebView(frame: view!.frame, configuration: config)
+ webView = WKWebView(frame: view.frame, configuration: config)
+ #if os(macOS)
+ webView.makeBackgroundTransparent()
+ webView.allowsBackForwardNavigationGestures = true
+ #else
webView.isOpaque = false
- view!.addSubview(webView)
+ #endif
+ view.addSubview(webView)
+ #if os(iOS)
setupGestures(numberOfTouchesRequired: 2)
setupGestures(numberOfTouchesRequired: 3)
setupEdgeGestures()
+ #endif
setupURLObserver()
setupWindowTitleObserver()
webView.navigationDelegate = self
webView.uiDelegate = self
+ #if os(macOS)
+ setupWebViewConstraints()
+ textFinder.client = webView
+ textFinder.findBarContainer = findBarContainer
+ textFinder.isIncrementalSearchingEnabled = true
+ textFinder.incrementalSearchingShouldDimContentView = true
+ observeAppearance()
+ #else
setupWebViewConstraints(statusBarView: statusBarView)
// Above the web view so it lands where the frontend draws its Assist button; it takes no touches,
// so the button underneath keeps working. Aligned to the web view so it follows the frontend's offset.
assistZoomAnchorView = AssistZoomAnchorView.install(in: view, alignedTo: webView)
+ #endif
NotificationCenter.default.addObserver(
self,
@@ -400,13 +474,53 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
webView.isInspectable = true
+ #if os(iOS)
webView.isFindInteractionEnabled = true
+ #endif
postOnboardingNotificationPermission()
checkForLocalSecurityLevelDecisionNeeded()
onWebViewLoaded?(self)
}
+ #if os(macOS)
+ override func viewWillAppear() {
+ super.viewWillAppear()
+ loadActiveURLIfNeeded()
+ }
+
+ /// The Edit > Find menu items: Find Next, Find Previous, Use Selection for Find and the bar itself.
+ override func performTextFinderAction(_ sender: Any?) {
+ guard let item = sender as? NSMenuItem, let action = NSTextFinder.Action(rawValue: item.tag) else { return }
+ textFinder.performAction(action)
+ }
+
+ override func viewDidAppear() {
+ super.viewDidAppear()
+ updateDatabaseAndPanels()
+ updateWindowSceneTitle()
+ userActivity?.becomeCurrent()
+ updateOnscreenContent()
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ userActivity?.resignCurrent()
+ }
+
+ override func viewDidLayout() {
+ super.viewDidLayout()
+ findBarContainer.layoutIfVisible()
+ }
+
+ /// The frontend picks its theme from the colours the app reports, so it has to be told when the
+ /// window moves between light and dark.
+ private func observeAppearance() {
+ appearanceObserver = view.observe(\.effectiveAppearance) { [weak self] _, _ in
+ self?.webView?.evaluateJavaScript("notifyThemeColors()", completionHandler: nil)
+ }
+ }
+ #else
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateWindowControlsInset()
@@ -466,6 +580,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
webViewGestureHandler.handleGestureAction(action)
}
}
+ #endif
}
private extension Set {
@@ -503,7 +618,9 @@ extension WebViewController {
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
+ #if os(iOS)
self?.setNeedsStatusBarAppearanceUpdate()
+ #endif
self?.updateThemedStatusBar()
}
.store(in: &kioskCancellables)
diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift
index 5b196e4440..89afe0b337 100644
--- a/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift
+++ b/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift
@@ -4,12 +4,12 @@ import Shared
protocol WebViewControllerProtocol: AnyObject {
var server: Server { get }
var connectionState: FrontEndConnectionState { get }
- var overlayedController: UIViewController? { get }
+ var overlayedController: PlatformViewController? { get }
/// Source view the zoom transition into Assist grows from; see `AssistZoomAnchorView`. Nil when the
/// frontend isn't on screen to zoom out of, in which case Assist cross-dissolves in instead.
- var assistZoomAnchorView: UIView? { get }
+ var assistZoomAnchorView: PlatformView? { get }
/// A one-off zoom source for the next Assist presentation, set by the App Labs tab bar.
- var pendingAssistZoomSourceView: UIView? { get set }
+ var pendingAssistZoomSourceView: PlatformView? { get set }
var presentsNextAssistAsSheet: Bool { get set }
var webViewExternalMessageHandler: any WebViewExternalMessageHandlerProtocol { get }
var canGoBack: Bool { get }
@@ -17,12 +17,19 @@ protocol WebViewControllerProtocol: AnyObject {
/// The URL currently displayed, without the `external_auth` query item that only makes sense to the
/// frontend running inside our webview.
var currentPageURL: URL? { get }
+ #if os(macOS)
+ /// The appearance the frontend is drawn in, which its cached theme colours are keyed by.
+ var effectiveAppearance: NSAppearance { get }
+ #else
var traitCollection: UITraitCollection { get }
+ #endif
+ /// Whether the frontend is being shown in dark mode.
+ var isDarkAppearance: Bool { get }
/// The window the controller is on, for routing a request back to the scene it came from.
- var presentationWindow: UIWindow? { get }
+ var presentationWindow: PlatformWindow? { get }
- func presentOverlayController(controller: UIViewController, animated: Bool)
- func presentAlertController(controller: UIViewController, animated: Bool)
+ func presentOverlayController(controller: PlatformViewController, animated: Bool)
+ func presentAlertController(controller: PlatformViewController, animated: Bool)
func evaluateJavaScript(_ script: String, completion: ((Any?, (any Error)?) -> Void)?)
/// Gives the web view keyboard focus, so a scripted `focus()` raises the keyboard like a tap would.
func makeWebViewFirstResponder()
diff --git a/Sources/App/Frontend/WebView/WebViewFindBarContainer.swift b/Sources/App/Frontend/WebView/WebViewFindBarContainer.swift
new file mode 100644
index 0000000000..c6b748d686
--- /dev/null
+++ b/Sources/App/Frontend/WebView/WebViewFindBarContainer.swift
@@ -0,0 +1,68 @@
+#if os(macOS)
+import AppKit
+
+/// Gives the find bar its place above the web view. `NSTextFinder` hands over the bar it wants shown and
+/// says when it should be on screen; this pins it under the window's toolbar and moves the web view down
+/// to start below it.
+final class WebViewFindBarContainer: NSObject, NSTextFinderBarContainer {
+ private weak var webViewController: WebViewController?
+
+ init(webViewController: WebViewController) {
+ self.webViewController = webViewController
+ }
+
+ var findBarView: NSView? {
+ didSet {
+ oldValue?.removeFromSuperview()
+ layout()
+ }
+ }
+
+ var isFindBarVisible = false {
+ didSet {
+ layout()
+ }
+ }
+
+ func findBarViewDidChangeHeight() {
+ layout()
+ }
+
+ func contentView() -> NSView? {
+ webViewController?.webView
+ }
+
+ /// Keeps the bar under the toolbar as the window's layout changes, such as entering full screen.
+ func layoutIfVisible() {
+ guard isFindBarVisible else { return }
+ layout()
+ }
+
+ private func layout() {
+ guard let webViewController, webViewController.isViewLoaded else { return }
+ let container = webViewController.view
+
+ guard isFindBarVisible, let findBarView else {
+ self.findBarView?.removeFromSuperview()
+ webViewController.webViewTopConstraint?.constant = 0
+ return
+ }
+
+ if findBarView.superview !== container {
+ container.addSubview(findBarView)
+ }
+ // The controller's view runs under the title bar and toolbar, which is where the top of it is
+ // hidden: the bar goes below that strip, not at the very top.
+ let top = container.safeAreaInsets.top
+ let height = findBarView.frame.height
+ findBarView.frame = CGRect(
+ x: 0,
+ y: container.isFlipped ? top : container.bounds.height - top - height,
+ width: container.bounds.width,
+ height: height
+ )
+ findBarView.autoresizingMask = container.isFlipped ? [.width, .maxYMargin] : [.width, .minYMargin]
+ webViewController.webViewTopConstraint?.constant = top + height
+ }
+}
+#endif
diff --git a/Sources/App/Frontend/WebView/WebViewReconnectManager.swift b/Sources/App/Frontend/WebView/WebViewReconnectManager.swift
index 535d7c1df3..481209d85f 100644
--- a/Sources/App/Frontend/WebView/WebViewReconnectManager.swift
+++ b/Sources/App/Frontend/WebView/WebViewReconnectManager.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@MainActor
final class WebViewReconnectManager: ObservableObject {
@@ -27,7 +31,7 @@ final class WebViewReconnectManager: ObservableObject {
init(
configuration: Configuration = .default,
- isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active },
+ isAppActive: @escaping @MainActor () -> Bool = WebViewReconnectManager.defaultIsAppActive,
scheduleTimer: @escaping TimerScheduler = WebViewReconnectManager.defaultScheduleTimer
) {
self.configuration = configuration
@@ -65,6 +69,17 @@ final class WebViewReconnectManager: ObservableObject {
}
}
+ /// On iOS the frontend is only worth reconnecting while the app is in front; a Mac keeps rendering
+ /// its windows while another app is, so there any window on screen counts.
+ @MainActor
+ static func defaultIsAppActive() -> Bool {
+ #if os(macOS)
+ return NSApp.windows.contains { $0.isVisible && $0.occlusionState.contains(.visible) }
+ #else
+ return ApplicationState.current == .active
+ #endif
+ }
+
private func performScheduledAttempt() {
guard isWatching else { return }
diff --git a/Sources/App/HAApp.swift b/Sources/App/HAApp.swift
index aa1bae24b0..472b4e2c6c 100644
--- a/Sources/App/HAApp.swift
+++ b/Sources/App/HAApp.swift
@@ -2,8 +2,98 @@ import CoreSpotlight
import PromiseKit
import Shared
import SwiftUI
-import UIKit
+#if os(macOS)
+@main
+struct HAApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @Environment(\.openWindow) private var openWindow
+
+ var body: some Scene {
+ // Registered while the scene graph is built rather than when a window appears: the app can be
+ // running with no window at all, and the menu bar item still has to be able to open one.
+ let _ = MacWindowOpener.shared.register(openWindow)
+
+ // Main Onboarding + Home Assistant Frontend
+ WindowGroup(id: SceneActivity.webView.activityIdentifier) {
+ ConditionalContainerView()
+ .toastOverlay()
+ .onOpenURL { handleIncoming(url: $0) }
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { handleIncoming(userActivity: $0) }
+ .onContinueUserActivity(CSSearchableItemActionType) { handleIncoming(userActivity: $0) }
+ // A link opens in the window that is already there instead of in a new one.
+ .handlesExternalEvents(preferring: ["*"], allowing: ["*"])
+ .toggleStyle(.haStyle)
+ .background(MacBrowserModeWindowGuard())
+ .frame(
+ minWidth: SceneActivity.webView.minimumWindowSize.width,
+ minHeight: SceneActivity.webView.minimumWindowSize.height
+ )
+ }
+ .defaultSize(SceneActivity.webView.initialWindowSize)
+ #if os(macOS)
+ // The page's title is the window's name in the Window menu and the Dock, not a label in the toolbar:
+ // the frontend shows the page's own title, and the toolbar is for the items the user puts there.
+ .windowToolbarStyle(.unifiedCompact(showsTitle: false))
+ #endif
+ .commands {
+ MainWindowGroupCommands()
+ AppMenuBarCommands()
+ MacWebViewCommands()
+ }
+
+ Window(L10n.Settings.NavigationBar.title, id: SceneActivity.settings.activityIdentifier) {
+ SettingsView()
+ .injectingViewControllerProvider()
+ }
+ .defaultSize(SceneActivity.settings.initialWindowSize)
+ .commandsRemoved()
+
+ Window(L10n.About.title, id: SceneActivity.about.activityIdentifier) {
+ NavigationStack {
+ AboutView()
+ }
+ .injectingViewControllerProvider()
+ }
+ .defaultSize(SceneActivity.about.initialWindowSize)
+ .commandsRemoved()
+
+ Window(L10n.Assist.ModernUi.Header.title, id: SceneActivity.assist.activityIdentifier) {
+ AssistWindowView()
+ .injectingViewControllerProvider()
+ }
+ .defaultSize(SceneActivity.assist.initialWindowSize)
+
+ WindowGroup(id: SceneActivity.onboarding.activityIdentifier) {
+ OnboardingNavigationView(onboardingStyle: .secondary)
+ .injectingViewControllerProvider()
+ .frame(
+ minWidth: SceneActivity.onboarding.minimumWindowSize.width,
+ minHeight: SceneActivity.onboarding.minimumWindowSize.height
+ )
+ }
+ .defaultSize(SceneActivity.onboarding.initialWindowSize)
+ }
+
+ /// Routes deep links (`homeassistant://…`) and universal web links into `IncomingURLHandler` once the
+ /// app coordinator is available.
+ @MainActor
+ private func handleIncoming(url: URL) {
+ // Synchronously, before waiting on the coordinator: the link names where to land, so
+ // location-based home switching must not fire for the activation it is opening.
+ LocationBasedServerSwitcher.shared.deepLinkWillOpen()
+ Current.sceneManager.appCoordinator.done { IncomingURLHandler(coordinator: $0).handle(url: url) }
+ }
+
+ @MainActor
+ private func handleIncoming(userActivity: NSUserActivity) {
+ LocationBasedServerSwitcher.shared.deepLinkWillOpen()
+ Current.sceneManager.appCoordinator.done {
+ IncomingURLHandler(coordinator: $0).handle(userActivity: userActivity)
+ }
+ }
+}
+#else
@main
struct HAApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@@ -78,3 +168,4 @@ struct HAApp: App {
}
}
}
+#endif
diff --git a/Sources/App/Info-macOS.plist b/Sources/App/Info-macOS.plist
new file mode 100644
index 0000000000..c76a24014b
--- /dev/null
+++ b/Sources/App/Info-macOS.plist
@@ -0,0 +1,145 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ $(PRODUCT_NAME)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleURLSchemes
+
+ ${ENV_URL_HANDLER}
+
+
+
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ FIREBASE_ANALYTICS_COLLECTION_ENABLED
+
+ FirebaseAppDelegateProxyEnabled
+
+ FirebaseMessagingAutoInitEnabled
+
+ INAlternativeAppNames
+
+
+ INAlternativeAppName
+ HA
+ INAlternativeAppNamePronunciationHint
+ H A
+
+
+ INAlternativeAppName
+ Hass
+
+
+ INAlternativeAppName
+ Home
+
+
+ INIntentsSupported
+
+ ReloadWidgetsAppIntent
+ ScriptAppIntent
+
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationCategoryType
+ public.app-category.lifestyle
+ LSApplicationQueriesSchemes
+
+ googlechrome
+ googlechromes
+ googlechrome-x-callback
+ fb
+ tweetbot
+ twitter
+ shortcuts
+ firefox
+ firefox-focus
+ firefox-klar
+
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBluetoothAlwaysUsageDescription
+ Used to scan for Improv devices
+ NSBonjourServices
+
+ _hass-mobile-app._tcp
+ _home-assistant._tcp
+ _matter._tcp
+ _wyoming._tcp
+
+ NSCameraUsageDescription
+ Take photos and send them to your Home Assistant server.
+ NSCrossWebsiteTrackingUsageDescription
+ Optionally enable cross-website tracking if your configuration requires it.
+ NSFocusStatusUsageDescription
+ Report your focus status as a sensor.
+ NSLocalNetworkUsageDescription
+ Locate and communicate with your Home Assistant instance.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ We suggest selecting "Always Allow" for the best location experience.
+ Selecting "Only While Using the App" will disable iBeacons, geofences,
+ background location updates and accurate reporting.
+ NSLocationAlwaysUsageDescription
+ We always need access to your location for features like iBeacons, geofences,
+ background location updates and accurate reporting.
+ NSLocationTemporaryUsageDescriptionDictionary
+
+ TemporaryFullAccuracyReasonManualUpdate
+ [localized in strings file]
+
+ NSLocationUsageDescription
+ When enabled, your device location is sent to your Home Assistant server as a device
+ tracker.
+ NSLocationWhenInUseUsageDescription
+ Only allowing location access while app is in use will disable iBeacons, geofences,
+ background location updates and accurate reporting.
+ NSMicrophoneUsageDescription
+ Record audio using your Home Assistant frontend.
+ NSPhotoLibraryAddUsageDescription
+ Photo Library access is needed to allow saving photos from the web view.
+ NSPhotoLibraryUsageDescription
+ Photo Library access is needed to allow saving photos from the web view.
+ NSPrincipalClass
+ NSApplication
+ NSRemindersFullAccessUsageDescription
+ Reminders access is used to sync the Reminders lists you choose with your Home Assistant to-do lists.
+ NSRemindersUsageDescription
+ Reminders access is used to sync the Reminders lists you choose with your Home Assistant to-do lists.
+ NSSiriUsageDescription
+ We use Siri to allow created shortcuts to interact with the app.
+ NSSpeechRecognitionUsageDescription
+ Used to dictate text to Assist.
+ NSUserActivityTypes
+
+ com.apple.corespotlightitem
+ INSendMessageIntent
+ ReloadWidgetsAppIntent
+ ScriptAppIntent
+
+
+
diff --git a/Sources/App/LifecycleManager.swift b/Sources/App/LifecycleManager.swift
index 13c1a237b1..f43ae9ce49 100644
--- a/Sources/App/LifecycleManager.swift
+++ b/Sources/App/LifecycleManager.swift
@@ -1,11 +1,15 @@
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
class LifecycleManager {
private let periodicUpdateManager = PeriodicUpdateManager(
- applicationStateGetter: { UIApplication.shared.applicationState }
+ applicationStateGetter: { ApplicationState.current }
)
private var underlyingActive: UInt32 = 0
private(set) var isActive: Bool {
@@ -25,7 +29,7 @@ class LifecycleManager {
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
- name: UIApplication.didBecomeActiveNotification,
+ name: AppLifecycle.didBecomeActiveNotification,
object: nil
)
if Current.isCatalyst {
@@ -42,22 +46,26 @@ class LifecycleManager {
NotificationCenter.default.addObserver(
self,
selector: #selector(warmConnect),
- name: UIApplication.willEnterForegroundNotification,
+ name: AppLifecycle.willEnterForegroundNotification,
object: nil
)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(willEnterForeground),
- name: UIApplication.willEnterForegroundNotification,
+ name: AppLifecycle.willEnterForegroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(didEnterBackground),
- name: UIApplication.didEnterBackgroundNotification,
+ name: AppLifecycle.didEnterBackgroundNotification,
object: nil
)
+ #if os(macOS)
+ // A Mac app has no foreground transition to mark it active, so it is from the moment it launches.
+ self.isActive = true
+ #endif
}
func didFinishLaunching() {
@@ -66,7 +74,7 @@ class LifecycleManager {
// database access could be caught holding the app-group SQLite file lock when the process
// freezes (0xdead10cc). Catalyst is excluded like the rest of its lifecycle handling: it can
// report .background at launch without a foreground transition ever following to resume.
- if !Current.isCatalyst, UIApplication.shared.applicationState == .background {
+ if !Current.isCatalyst, ApplicationState.current == .background {
AppDatabaseSuspension.suspendIfIdle()
}
Current.backgroundTask(withName: BackgroundTask.lifecycleManagerDidFinishLaunching.rawValue) { _ in
@@ -82,7 +90,7 @@ class LifecycleManager {
// connects to it whenever it likes, so it has to be up before anything asks for it. A
// background launch is skipped — the settings it reads live in the database that was just
// suspended above, and the first foreground starts it anyway.
- if Current.isCatalyst || UIApplication.shared.applicationState != .background {
+ if Current.isCatalyst || ApplicationState.current != .background {
Task { @MainActor in
WyomingServerController.shared.applyConfiguration()
}
@@ -170,7 +178,7 @@ class LifecycleManager {
needsAppOpenLocationUpdate = false
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .appOpened
).catch { error in
Current.Log.error("failed to update location on app open: \(error)")
diff --git a/Sources/App/MainWindowGroupCommands.swift b/Sources/App/MainWindowGroupCommands.swift
index 8ccfbfcf15..be63f62d5a 100644
--- a/Sources/App/MainWindowGroupCommands.swift
+++ b/Sources/App/MainWindowGroupCommands.swift
@@ -24,7 +24,7 @@ struct AppMenuBarCommands: Commands {
}
CommandGroup(replacing: .appSettings) {
- Button(L10n.Menu.Application.preferences) {
+ Button(L10n.Menu.Application.settings) {
Current.sceneManager.activateAnyScene(for: .settings)
}
.keyboardShortcut(",", modifiers: .command)
@@ -48,7 +48,13 @@ struct MainWindowGroupCommands: Commands {
/// The Entities menu only appears where a menu bar exists. On iPhone it would never be shown,
/// but UIKit would still build the whole tree during keyboard-shortcut lookup on every key
/// event, hanging the main thread on large setups.
- private static let showsEntitiesMenu: Bool = Current.isCatalyst || UIDevice.current.userInterfaceIdiom == .pad
+ private static let showsEntitiesMenu: Bool = {
+ #if os(macOS)
+ true
+ #else
+ Current.isCatalyst || UIDevice.current.userInterfaceIdiom == .pad
+ #endif
+ }()
@StateObject private var dataSource = DataSource()
diff --git a/Sources/App/Notifications/AppIconBadge.swift b/Sources/App/Notifications/AppIconBadge.swift
new file mode 100644
index 0000000000..7cf1484b93
--- /dev/null
+++ b/Sources/App/Notifications/AppIconBadge.swift
@@ -0,0 +1,30 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+/// The number on the app's icon: the label on the Dock tile on the Mac, the application icon badge elsewhere.
+struct AppIconBadge {
+ var count: () -> Int
+ var clear: () -> Void
+
+ @MainActor
+ static let application = AppIconBadge(
+ count: {
+ #if os(macOS)
+ NSApp.dockTile.badgeLabel.flatMap { Int($0) } ?? 0
+ #else
+ UIApplication.shared.applicationIconBadgeNumber
+ #endif
+ },
+ clear: {
+ #if os(macOS)
+ NSApp.dockTile.badgeLabel = nil
+ #else
+ UIApplication.shared.applicationIconBadgeNumber = 0
+ #endif
+ }
+ )
+}
diff --git a/Sources/App/Notifications/KioskPushCommand.swift b/Sources/App/Notifications/KioskPushCommand.swift
index dd28f5a0ea..48acb431a1 100644
--- a/Sources/App/Notifications/KioskPushCommand.swift
+++ b/Sources/App/Notifications/KioskPushCommand.swift
@@ -169,7 +169,7 @@ enum KioskPushCommand: String, CaseIterable {
/// The toast confirming this command ran, or `nil` when the kiosk is set to take commands
/// silently. The command itself runs either way — this is only its visual confirmation.
- @available(iOS 18, *)
+ @available(iOS 18, macOS 15, *)
func confirmationToast(id: String, settings: KioskSettings) -> Toast? {
guard settings.showRemoteCommandConfirmations else { return nil }
return Toast(
diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift
index f50988a817..611ac873d3 100644
--- a/Sources/App/Notifications/NotificationManager.swift
+++ b/Sources/App/Notifications/NotificationManager.swift
@@ -20,7 +20,9 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}
#endif
- #if targetEnvironment(simulator)
+ #if targetEnvironment(simulator) || os(macOS)
+ // Neither the simulator nor a Mac has an app push provider to hand the connection to, so the app
+ // keeps it open itself.
return NotificationManagerLocalPushInterfaceDirect(delegate: self)
#else
if Current.isCatalyst {
@@ -36,15 +38,17 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
/// Offers a notification's own actions when a plain tap on it has nothing else to do.
let tapActionPresenter = NotificationTapActionPresenter()
+ #if os(iOS)
/// Hidden, off-screen volume view; `MPVolumeView` only drives the hardware volume while in a window.
private lazy var volumeControlView = MPVolumeView(frame: CGRect(x: -2000, y: -2000, width: 1, height: 1))
+ #endif
override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
- name: UIApplication.didBecomeActiveNotification,
+ name: AppLifecycle.didBecomeActiveNotification,
object: nil
)
}
@@ -59,7 +63,7 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
@objc private func didBecomeActive() {
if Current.settingsStore.clearBadgeAutomatically {
- UIApplication.shared.applicationIconBadgeNumber = 0
+ AppIconBadge.application.clear()
}
localPushManager.scheduleAppOpenLocalPushRetries()
#if os(iOS) && !targetEnvironment(macCatalyst)
@@ -145,15 +149,23 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}
}
+ /// A Mac's display brightness is not the app's to set, so the command is accepted and does nothing there.
private func setScreenBrightness(_ level: Float) {
+ #if os(iOS)
let clamped = CGFloat(min(max(level, 0), 1))
DispatchQueue.main.async {
Current.setScreenBrightness(clamped)
Current.Log.info("Kiosk set screen brightness to \(clamped)")
}
+ #else
+ Current.Log.info("Ignoring kiosk brightness command, the screen brightness cannot be set on a Mac")
+ #endif
}
+ /// A Mac has no volume view to drive the system volume through, so the command is accepted and does
+ /// nothing there.
private func setSystemVolume(_ level: Float) {
+ #if os(iOS)
let clamped = min(max(level, 0), 1)
Current.sceneManager.webViewControllerPromise
.done(on: .main) { [weak self] webViewController in
@@ -174,6 +186,9 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}.catch { error in
Current.Log.error("Failed to set volume from push command: \(error)")
}
+ #else
+ Current.Log.info("Ignoring kiosk volume command, the system volume cannot be set on a Mac")
+ #endif
}
func resetPushID() -> Promise {
@@ -189,8 +204,13 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}
func setupFirebase() {
+ #if os(macOS)
+ Current.Log.verbose("Calling NSApplication.shared.registerForRemoteNotifications()")
+ NSApplication.shared.registerForRemoteNotifications()
+ #else
Current.Log.verbose("Calling UIApplication.shared.registerForRemoteNotifications()")
UIApplication.shared.registerForRemoteNotifications()
+ #endif
Messaging.messaging().delegate = self
Messaging.messaging().isAutoInitEnabled = Current.settingsStore.privacy.messaging
@@ -214,6 +234,7 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
Messaging.messaging().setAPNSToken(deviceToken, type: tokenType)
}
+ #if os(iOS)
func didReceiveRemoteNotification(
userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
@@ -222,10 +243,19 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
firstly {
handleRemoteNotification(userInfo: userInfo)
- }.done(
- completionHandler
- )
+ }.done { handled in
+ completionHandler(handled ? .newData : .failed)
+ }
}
+ #else
+ /// AppKit hands over a remote notification without asking for a fetch result: a Mac app is never woken
+ /// in the background to fetch, so there is nobody to report one to.
+ func didReceiveRemoteNotification(userInfo: [String: Any]) {
+ Messaging.messaging().appDidReceiveMessage(userInfo)
+
+ handleRemoteNotification(userInfo: userInfo).cauterize()
+ }
+ #endif
func localPushManager(
_ manager: LocalPushManager,
@@ -234,13 +264,14 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
handleRemoteNotification(userInfo: userInfo).cauterize()
}
- private func handleRemoteNotification(userInfo: [AnyHashable: Any]) -> Guarantee {
+ /// Runs the command the notification carries, resolving to whether it was handled.
+ private func handleRemoteNotification(userInfo: [AnyHashable: Any]) -> Guarantee {
Current.Log.verbose("remote notification: \(userInfo)")
return commandManager.handle(userInfo).map {
- UIBackgroundFetchResult.newData
+ true
}.recover { _ in
- Guarantee.value(.failed)
+ Guarantee.value(false)
}
}
@@ -255,7 +286,8 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
let eventName = "ios.shortcut_run"
let deviceDict: [String: String] = [
- "sourceDevicePermanentID": AppConstants.PermanentID, "sourceDeviceName": UIDevice.current.name,
+ "sourceDevicePermanentID": AppConstants.PermanentID,
+ "sourceDeviceName": Current.device.deviceName(),
"sourceDeviceID": Current.settingsStore.deviceID,
]
var eventData: [String: Any] = ["name": shortcutName, "input": shortcutDict, "device": deviceDict]
@@ -478,7 +510,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
}
if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil,
- UIApplication.shared.applicationState != .background {
+ ApplicationState.current != .background {
completionHandler([])
return
}
@@ -531,7 +563,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
// The command already ran above; the toast is only its visual confirmation, which the user can
// switch off for a kiosk that should react silently.
- if #available(iOS 18, *),
+ if #available(iOS 18, macOS 15, *),
let toast = command.confirmationToast(id: request.identifier, settings: kioskSettings) {
Task { @MainActor in
ToastPresenter.shared.show(toast: toast, duration: 4)
@@ -608,6 +640,13 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
.navigationViewStyle(.stack)
let hostingController = rootView.embeddedInHostingController()
+ #if os(macOS)
+ Current.sceneManager.appCoordinator.done { coordinator in
+ coordinator.dismissPresentedContent {
+ coordinator.present(hostingController)
+ }
+ }
+ #else
Current.sceneManager.appCoordinator.done {
var rootViewController = $0.window?.rootViewController
if let navigationController = rootViewController as? UINavigationController {
@@ -617,6 +656,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
rootViewController?.present(hostingController, animated: true, completion: nil)
})
}
+ #endif
}
}
diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift
index 2431a01ea8..748055e027 100644
--- a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift
+++ b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift
@@ -4,6 +4,9 @@ import NetworkExtension
import PromiseKit
import Shared
+// Local push through an app push provider extension exists on iOS only, so everything that drives one is
+// left out of the Mac app, which keeps the connection open itself (`NotificationManagerLocalPushInterfaceDirect`).
+#if os(iOS)
final class NotificationManagerLocalPushInterfaceExtension: NSObject, NotificationManagerLocalPushInterface {
/// Delay in seconds before reloading managers after configuration changes.
/// This allows the system to persist changes before attempting to reload them.
@@ -420,6 +423,7 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati
}
}
}
+#endif
enum LocalPushRetryDiagnostics {
static func matchesExpectedNetworkConditions(server: Server, currentSSID: String?) -> Bool {
@@ -460,6 +464,7 @@ enum LocalPushRetryDiagnostics {
}
}
+#if os(iOS)
extension NotificationManagerLocalPushInterfaceExtension: ServerObserver {
func serversDidChange(_ serverManager: ServerManager) {
updateManagers(reason: .serverChanged)
@@ -474,3 +479,4 @@ extension NotificationManagerLocalPushInterfaceExtension: NEAppPushDelegate {
// we do not have calls
}
}
+#endif
diff --git a/Sources/App/Notifications/NotificationTapActionPresenter.swift b/Sources/App/Notifications/NotificationTapActionPresenter.swift
index 49d7071022..07f32c72df 100644
--- a/Sources/App/Notifications/NotificationTapActionPresenter.swift
+++ b/Sources/App/Notifications/NotificationTapActionPresenter.swift
@@ -1,6 +1,10 @@
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
/// Offers a notification's actions when the user taps it rather than pressing and holding to reveal them.
@@ -94,6 +98,82 @@ final class NotificationTapActionPresenter {
return true
}
+ #if os(macOS)
+ /// The alert listing `actions`, headed by the notification itself so it stays obvious which one is
+ /// being acted on.
+ func makeAlert(
+ for actions: [NotificationAction],
+ content: UNNotificationContent,
+ server: Server
+ ) -> AppAlert {
+ var alertActions: [AppAlert.Action] = actions.map { action in
+ AppAlert.Action(
+ title: action.title,
+ style: action.destructive ? .destructive : .default,
+ handler: { [weak self] in
+ self?.select(action, content: content, server: server)
+ }
+ )
+ }
+
+ alertActions.append(AppAlert.Action(title: L10n.cancelLabel, style: .cancel))
+
+ return AppAlert(
+ title: content.title.isEmpty ? L10n.NotificationTapActions.title : content.title,
+ message: content.body.isEmpty ? nil : content.body,
+ actions: alertActions
+ )
+ }
+
+ /// A text-input action needs to know what to send before it can run, so it gets a second alert to
+ /// type into; everything else runs straight away.
+ func select(_ action: NotificationAction, content: UNNotificationContent, server: Server) {
+ guard action.textInput else {
+ perform(action, content: content, server: server, textInput: nil)
+ return
+ }
+
+ // The alert this was picked from is still closing, so the reply alert waits for the next run loop
+ // turn to take its place on the window.
+ DispatchQueue.main.async { [weak self] in
+ Current.sceneManager.appCoordinator.done { coordinator in
+ self?.presentTextInputAlert(for: action, content: content, server: server, on: coordinator.window)
+ }
+ }
+ }
+
+ /// The reply alert for a text-input action, matching the field the system would have shown under the
+ /// notification: the action's own placeholder and send button. An empty reply is still a reply — the
+ /// system's own response path forwards it (`UNTextInputNotificationResponse.userText` is
+ /// non-optional), so swallowing it here would lose an event the user asked to send.
+ private func presentTextInputAlert(
+ for action: NotificationAction,
+ content: UNNotificationContent,
+ server: Server,
+ on window: NSWindow?
+ ) {
+ let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 22))
+ textField.placeholderString = action.textInputPlaceholder
+
+ let alert = NSAlert()
+ alert.messageText = action.title
+ alert.accessoryView = textField
+ alert.addButton(withTitle: action.textInputButtonTitle)
+ alert.addButton(withTitle: L10n.cancelLabel).keyEquivalent = "\u{1b}"
+ alert.window.initialFirstResponder = textField
+
+ let handle: (NSApplication.ModalResponse) -> Void = { [weak self] response in
+ guard response == .alertFirstButtonReturn else { return }
+ self?.perform(action, content: content, server: server, textInput: textField.stringValue)
+ }
+
+ if let window {
+ alert.beginSheetModal(for: window, completionHandler: handle)
+ } else {
+ handle(alert.runModal())
+ }
+ }
+ #else
/// The alert listing `actions`, headed by the notification itself so it stays obvious which one is
/// being acted on.
func makeAlert(
@@ -169,6 +249,7 @@ final class NotificationTapActionPresenter {
) {
perform(action, content: content, server: server, textInput: alert?.textFields?.first?.text ?? "")
}
+ #endif
/// Runs `action` as if the user had picked it from the notification itself: whatever URL it carries
/// is opened, and Home Assistant hears which action fired, along with anything typed for it.
@@ -199,7 +280,13 @@ final class NotificationTapActionPresenter {
}
}
+ #if os(macOS)
+ private func present(_ alert: AppAlert) {
+ Current.sceneManager.appCoordinator.done { $0.present(alert: alert) }
+ }
+ #else
private func present(_ alert: UIAlertController) {
Current.sceneManager.appCoordinator.done { $0.present(alert) }
}
+ #endif
}
diff --git a/Sources/App/Onboarding/API/OnboardingAuthLoginView.swift b/Sources/App/Onboarding/API/OnboardingAuthLoginView.swift
index e93a8e4486..26b7d19465 100644
--- a/Sources/App/Onboarding/API/OnboardingAuthLoginView.swift
+++ b/Sources/App/Onboarding/API/OnboardingAuthLoginView.swift
@@ -56,7 +56,7 @@ struct OnboardingAuthLoginView: View {
}
.navigationTitle(viewModel.authDetails.url.host ?? "")
.navigationBarTitleDisplayMode(.inline)
- .toolbarBackground(.visible, for: .navigationBar)
+ .toolbarBackground(.visible, for: .platformNavigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
@@ -75,6 +75,17 @@ struct OnboardingAuthLoginView: View {
}
/// Hosts the view model's `WKWebView`; all WebKit behavior lives on the view model.
+ #if os(macOS)
+ private struct LoginWebView: NSViewRepresentable {
+ let webView: WKWebView
+
+ func makeNSView(context: Context) -> WKWebView {
+ webView
+ }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+ }
+ #else
private struct LoginWebView: UIViewRepresentable {
let webView: WKWebView
@@ -84,6 +95,7 @@ struct OnboardingAuthLoginView: View {
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
+ #endif
}
#Preview {
diff --git a/Sources/App/Onboarding/Container/OnboardingNavigationView.swift b/Sources/App/Onboarding/Container/OnboardingNavigationView.swift
index 46beb66f91..c3ee980743 100644
--- a/Sources/App/Onboarding/Container/OnboardingNavigationView.swift
+++ b/Sources/App/Onboarding/Container/OnboardingNavigationView.swift
@@ -94,14 +94,14 @@ struct OnboardingNavigationView: View {
case let .deviceName(request):
DeviceNameView(request: request)
.navigationBarBackButtonHidden(true)
- .toolbar(.hidden, for: .navigationBar)
+ .toolbar(.hidden, for: .platformNavigationBar)
case let .permissions(server):
OnboardingPermissionsNavigationView(
onboardingServer: server,
onDismiss: { finishFlow() }
)
.navigationBarBackButtonHidden(true)
- .toolbar(.hidden, for: .navigationBar)
+ .toolbar(.hidden, for: .platformNavigationBar)
case let .clientCertificate(request):
// Mac Catalyst only; the user resolves the step through the page's own buttons.
ClientCertificateOnboardingView(
diff --git a/Sources/App/Onboarding/Steps/DeviceName/DeviceNameView.swift b/Sources/App/Onboarding/Steps/DeviceName/DeviceNameView.swift
index 680833ac32..e70f22853f 100644
--- a/Sources/App/Onboarding/Steps/DeviceName/DeviceNameView.swift
+++ b/Sources/App/Onboarding/Steps/DeviceName/DeviceNameView.swift
@@ -44,6 +44,8 @@ struct DeviceNameView: View {
.onAppear {
#if DEBUG
deviceName = "Simulator \(UUID().uuidString.prefix(4))"
+ #elseif os(macOS)
+ deviceName = Current.device.deviceName()
#else
deviceName = UIDevice.current.name
#endif
diff --git a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift
index 5ccd3d704d..4c7b6e7184 100644
--- a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift
+++ b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift
@@ -1,7 +1,11 @@
import CoreLocation
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
final class OnboardingPermissionsNavigationViewModel: NSObject, ObservableObject {
/// Defines the different steps in the onboarding permissions flow
@@ -287,9 +291,13 @@ final class OnboardingPermissionsNavigationViewModel: NSObject, ObservableObject
return
}
// Open iOS settings for user to manually enable location
+ #if os(macOS)
+ URLOpener.shared.openSettings(destination: .location, completionHandler: nil)
+ #else
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
URLOpener.shared.open(settingsUrl, options: [:], completionHandler: nil)
}
+ #endif
case .authorizedWhenInUse, .authorizedAlways:
// Permission already granted, apply the context-specific needs
applyLocationPermissionNeeds()
@@ -375,8 +383,13 @@ extension OnboardingPermissionsNavigationViewModel: CLLocationManagerDelegate {
}
// Only proceed if we have some form of location authorization
+ #if os(macOS)
+ // A Mac grants location in a single step, straight to always, so that is the answer to act on.
+ guard manager.authorizationStatus == .authorizedAlways else { return }
+ #else
// No need to proceed if permission is .authorizedAlways since the code has run before for .authorizedWhenInUse
guard manager.authorizationStatus == .authorizedWhenInUse else { return }
+ #endif
applyLocationPermissionNeeds()
}
}
diff --git a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/LocalAccess/SelectionOptionView.swift b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/LocalAccess/SelectionOptionView.swift
index 65884f9bed..66ed0e214f 100644
--- a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/LocalAccess/SelectionOptionView.swift
+++ b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/LocalAccess/SelectionOptionView.swift
@@ -166,7 +166,7 @@ private struct SelectionIndicator: View {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
#Preview("Single Selection") {
@Previewable @State var selection: String? = "secure"
@@ -206,7 +206,7 @@ private struct SelectionIndicator: View {
.padding()
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
#Preview("Multiple Selection") {
@Previewable @State var selections: Set = ["option1"]
diff --git a/Sources/App/Onboarding/Steps/Servers/ManualURLEntry/ManualURLEntryView.swift b/Sources/App/Onboarding/Steps/Servers/ManualURLEntry/ManualURLEntryView.swift
index 8784a88d97..228059cd9d 100644
--- a/Sources/App/Onboarding/Steps/Servers/ManualURLEntry/ManualURLEntryView.swift
+++ b/Sources/App/Onboarding/Steps/Servers/ManualURLEntry/ManualURLEntryView.swift
@@ -133,7 +133,11 @@ protocol KeyboardReadable {
extension KeyboardReadable {
var keyboardPublisher: AnyPublisher {
- Publishers.Merge(
+ #if os(macOS)
+ // A Mac has no software keyboard, so there is never a change to publish.
+ return Empty(completeImmediately: false).eraseToAnyPublisher()
+ #else
+ return Publishers.Merge(
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { _ in true },
@@ -143,5 +147,6 @@ extension KeyboardReadable {
.map { _ in false }
)
.eraseToAnyPublisher()
+ #endif
}
}
diff --git a/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift b/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift
index 02f8eb04c5..ce12185c52 100644
--- a/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift
+++ b/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift
@@ -10,8 +10,9 @@ struct OnboardingServersListView: View {
static let manualEntryTransitionID = "manual-entry"
}
+ /// Set to leave the screen. The list does not read `\.dismiss` itself, see `DismissWhenModifier`.
+ @State private var isDismissRequested = false
@Namespace private var manualEntryGeometry
- @Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -103,11 +104,7 @@ struct OnboardingServersListView: View {
.onDisappear {
onDisappear()
}
- .onChange(of: viewModel.shouldDismiss) { newValue in
- if newValue {
- dismiss()
- }
- }
+ .dismiss(when: viewModel.shouldDismiss || isDismissRequested)
.onChange(of: viewModel.discoveredInstances) { newValue in
if newValue.count == 1 {
scheduleAutoConnect()
@@ -249,14 +246,14 @@ struct OnboardingServersListView: View {
if onboardingStyle.insertsCancelButton, !Current.isCatalyst {
ToolbarItem(placement: .topBarLeading) {
Button(L10n.cancelLabel) {
- dismiss()
+ isDismissRequested = true
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if prefillURL != nil {
CloseButton {
- dismiss()
+ isDismissRequested = true
}
} else if viewModel.manualInputLoading {
// Loading happens when URL is manually inputed by user
@@ -361,7 +358,7 @@ struct OnboardingServersListView: View {
private func rejectInvitation() {
if onboardingStyle == .secondary {
- dismiss()
+ isDismissRequested = true
return
}
rejectedInvitation = true
diff --git a/Sources/App/Onboarding/Steps/Welcome/OnboardingWelcomeView.swift b/Sources/App/Onboarding/Steps/Welcome/OnboardingWelcomeView.swift
index c18138160e..32d344a129 100644
--- a/Sources/App/Onboarding/Steps/Welcome/OnboardingWelcomeView.swift
+++ b/Sources/App/Onboarding/Steps/Welcome/OnboardingWelcomeView.swift
@@ -64,6 +64,9 @@ struct OnboardingWelcomeView: View {
}
}
}
+ #if os(macOS)
+ .background(Color(uiColor: .systemBackground))
+ #endif
.sheet(isPresented: $showLearnMore) {
SafariWebView(url: AppConstants.WebURLs.homeAssistantCompanionGetStarted)
}
diff --git a/Sources/App/Onboarding/Views/CheckmarkDrawOnView.swift b/Sources/App/Onboarding/Views/CheckmarkDrawOnView.swift
index 1e1ceb157d..cf5a7cf7da 100644
--- a/Sources/App/Onboarding/Views/CheckmarkDrawOnView.swift
+++ b/Sources/App/Onboarding/Views/CheckmarkDrawOnView.swift
@@ -12,7 +12,7 @@ struct CheckmarkDrawOnView: View {
.frame(height: 150)
.foregroundStyle(.haPrimary)
.modify({ view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.symbolEffect(.drawOn.individually, options: .speed(0.7), isActive: isActive)
.sensoryFeedback(.success, trigger: isActive)
diff --git a/Sources/App/Onboarding/Views/SafariWebView.swift b/Sources/App/Onboarding/Views/SafariWebView.swift
index 80ff75c080..be948f99f4 100644
--- a/Sources/App/Onboarding/Views/SafariWebView.swift
+++ b/Sources/App/Onboarding/Views/SafariWebView.swift
@@ -1,6 +1,25 @@
import SafariServices
import SwiftUI
+#if os(macOS)
+/// A Mac has no in-app Safari: the page opens in the default browser and the sheet this was presented
+/// in closes again, which is what `SFSafariViewController` does under Mac Catalyst.
+struct SafariWebView: View {
+ let url: URL
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ Color.clear
+ .frame(width: 1, height: 1)
+ .task {
+ openURL(url)
+ dismiss()
+ }
+ }
+}
+#else
struct SafariWebView: UIViewControllerRepresentable {
let url: URL
@@ -10,3 +29,8 @@ struct SafariWebView: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
+#endif
+
+#Preview {
+ SafariWebView(url: URL(string: "https://www.home-assistant.io")!)
+}
diff --git a/Sources/App/Onboarding/Views/ShareActivityView.swift b/Sources/App/Onboarding/Views/ShareActivityView.swift
index 107999870c..5ec8772ef6 100644
--- a/Sources/App/Onboarding/Views/ShareActivityView.swift
+++ b/Sources/App/Onboarding/Views/ShareActivityView.swift
@@ -1,6 +1,71 @@
import SwiftUI
-import UIKit
+#if os(macOS)
+/// Offers the items to the Mac's sharing services. The picker opens as soon as the view is on screen and
+/// the presentation that showed this view is dismissed once the picker closes, so presenting it in a
+/// sheet behaves like the share sheet does on iOS.
+struct ShareActivityView: NSViewRepresentable {
+ var activityItems: [Any]
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ func makeNSView(context: Context) -> NSView {
+ let view = AnchorView()
+ view.onMoveToWindow = { [activityItems, coordinator = context.coordinator] view in
+ coordinator.showPicker(items: activityItems, from: view)
+ }
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ let dismiss = context.environment.dismiss
+ context.coordinator.onFinish = { dismiss() }
+ }
+
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? {
+ CGSize(width: 1, height: 1)
+ }
+
+ /// The view the picker is anchored to. It draws nothing.
+ private final class AnchorView: NSView {
+ var onMoveToWindow: ((NSView) -> Void)?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard window != nil else { return }
+ onMoveToWindow?(self)
+ }
+ }
+
+ final class Coordinator: NSObject, NSSharingServicePickerDelegate {
+ var onFinish: (() -> Void)?
+ private var picker: NSSharingServicePicker?
+
+ func showPicker(items: [Any], from view: NSView) {
+ guard picker == nil else { return }
+ let picker = NSSharingServicePicker(items: items)
+ picker.delegate = self
+ self.picker = picker
+ // The window is still being presented when the view joins it, so wait for the next pass of
+ // the run loop before anchoring the picker to it.
+ DispatchQueue.main.async { [weak view] in
+ guard let view, view.window != nil else { return }
+ picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
+ }
+ }
+
+ func sharingServicePicker(
+ _ sharingServicePicker: NSSharingServicePicker,
+ didChoose service: NSSharingService?
+ ) {
+ // Called with nil when the picker closes without a choice; either way the picker is done.
+ onFinish?()
+ }
+ }
+}
+#else
struct ShareActivityView: UIViewControllerRepresentable {
var activityItems: [Any]
var applicationActivities: [UIActivity]? = nil
@@ -19,3 +84,8 @@ struct ShareActivityView: UIViewControllerRepresentable {
context: UIViewControllerRepresentableContext
) {}
}
+#endif
+
+#Preview {
+ ShareActivityView(activityItems: ["Home Assistant"])
+}
diff --git a/Sources/App/PermissionScreen/PermissionRequestView.swift b/Sources/App/PermissionScreen/PermissionRequestView.swift
index b8b9591df6..0843cd4635 100644
--- a/Sources/App/PermissionScreen/PermissionRequestView.swift
+++ b/Sources/App/PermissionScreen/PermissionRequestView.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
struct PermissionRequestView: View {
@Environment(\.dismiss) private var dismiss
diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings
index 14f764a92a..9a9493a51d 100644
--- a/Sources/App/Resources/en.lproj/Localizable.strings
+++ b/Sources/App/Resources/en.lproj/Localizable.strings
@@ -1197,6 +1197,7 @@ This server requires a client certificate (mTLS) but the operation was cancelled
"menu.actions.title" = "Actions";
"menu.application.about" = "About %@";
"menu.application.preferences" = "Preferences…";
+"menu.application.settings" = "Settings…";
"menu.file.update_sensors" = "Update Sensors";
"menu.help.help" = "%@ Help";
"menu.status_item.quit" = "Quit";
diff --git a/Sources/App/Scenes/AssistWindowSceneDelegate.swift b/Sources/App/Scenes/AssistWindowSceneDelegate.swift
index 4aaddc434e..bb1bae98d4 100644
--- a/Sources/App/Scenes/AssistWindowSceneDelegate.swift
+++ b/Sources/App/Scenes/AssistWindowSceneDelegate.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
/// Scene delegate for the Mac Assist window. Its only job is remembering the window's size and position
@@ -33,3 +34,4 @@ final class AssistWindowSceneDelegate: UIResponder, UIWindowSceneDelegate {
WindowScenesManager.shared.didDiscardScene(scene)
}
}
+#endif
diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift
index ebfbcab36d..2379a538cd 100644
--- a/Sources/App/Scenes/CarPlaySceneDelegate.swift
+++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Combine
import GRDB
@@ -362,3 +363,4 @@ extension CarPlaySceneDelegate: CPInterfaceControllerDelegate {
allTemplates.forEach { $0.templateWillAppear(template: aTemplate) }
}
}
+#endif
diff --git a/Sources/App/Scenes/MacBrowserModeWindowGuard.swift b/Sources/App/Scenes/MacBrowserModeWindowGuard.swift
new file mode 100644
index 0000000000..72f5eadfdf
--- /dev/null
+++ b/Sources/App/Scenes/MacBrowserModeWindowGuard.swift
@@ -0,0 +1,45 @@
+#if os(macOS)
+import AppKit
+import Shared
+import SwiftUI
+
+/// Honours "Open Home Assistant UI in browser" for the main window. With that setting on there is never an
+/// in-app frontend, so a main window that comes up is closed again, and the browser is opened in its place
+/// the first time that happens in the life of the process: the user's cold launch. Windows that appear
+/// later are closed without reopening the browser; the Dock icon and the menu bar item open it on request.
+struct MacBrowserModeWindowGuard: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSView {
+ GuardView()
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {}
+
+ private final class GuardView: NSView {
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard let window else { return }
+
+ let isInitialConnection = MacBrowserSceneLauncher.markSceneConnected()
+ let actions = MacBrowserSceneLauncher.actions(isInitialConnection: isInitialConnection)
+ guard actions.destroysEmptyWindow,
+ let url = Current.servers.all.first?.activeURLUsingLastKnownNetworkState() else { return }
+
+ if actions.opensBrowser {
+ URLOpener.shared.open(url, options: [:], completionHandler: nil)
+ }
+ // Closing a window from inside the pass that is putting it on screen is not allowed.
+ DispatchQueue.main.async { [weak window] in
+ window?.close()
+ }
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+ }
+}
+
+#Preview {
+ MacBrowserModeWindowGuard()
+}
+#endif
diff --git a/Sources/App/Scenes/MacConfirmationHUD.swift b/Sources/App/Scenes/MacConfirmationHUD.swift
new file mode 100644
index 0000000000..78ecc8c3c0
--- /dev/null
+++ b/Sources/App/Scenes/MacConfirmationHUD.swift
@@ -0,0 +1,39 @@
+#if os(macOS)
+import AppKit
+import Shared
+import SwiftUI
+
+/// A brief confirmation drawn over a window, for an action whose result is otherwise invisible: a fired
+/// event, a called service. The Mac counterpart of the `ProgressHUD` the iOS app shows.
+enum MacConfirmationHUD {
+ private static let visibleDuration: TimeInterval = 3
+ private static let fadeDuration: TimeInterval = 0.25
+
+ static func show(icon: MaterialDesignIcons, text: String, in window: NSWindow) {
+ guard let contentView = window.contentView else { return }
+
+ let hud = NSHostingView(rootView: MacConfirmationHUDView(icon: icon, text: text))
+ hud.translatesAutoresizingMaskIntoConstraints = false
+ hud.alphaValue = 0
+ contentView.addSubview(hud)
+ NSLayoutConstraint.activate([
+ hud.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
+ hud.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
+ ])
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = fadeDuration
+ hud.animator().alphaValue = 1
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + visibleDuration) {
+ NSAnimationContext.runAnimationGroup({ context in
+ context.duration = fadeDuration
+ hud.animator().alphaValue = 0
+ }, completionHandler: {
+ hud.removeFromSuperview()
+ })
+ }
+ }
+}
+#endif
diff --git a/Sources/App/Scenes/MacConfirmationHUDView.swift b/Sources/App/Scenes/MacConfirmationHUDView.swift
new file mode 100644
index 0000000000..496f4e3644
--- /dev/null
+++ b/Sources/App/Scenes/MacConfirmationHUDView.swift
@@ -0,0 +1,28 @@
+#if os(macOS)
+import Shared
+import SwiftUI
+
+struct MacConfirmationHUDView: View {
+ let icon: MaterialDesignIcons
+ let text: String
+
+ var body: some View {
+ VStack(spacing: DesignSystem.Spaces.two) {
+ Image(uiImage: icon.image(ofSize: .init(width: 64, height: 64), color: .label))
+ .renderingMode(.template)
+ .foregroundStyle(.primary)
+ Text(text)
+ .font(.headline)
+ .multilineTextAlignment(.center)
+ }
+ .padding(DesignSystem.Spaces.three)
+ .frame(minWidth: 160)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.three))
+ }
+}
+
+#Preview {
+ MacConfirmationHUDView(icon: .checkIcon, text: "Done")
+ .padding()
+}
+#endif
diff --git a/Sources/App/Scenes/MacKeyWindowObserver.swift b/Sources/App/Scenes/MacKeyWindowObserver.swift
new file mode 100644
index 0000000000..bd79fa5235
--- /dev/null
+++ b/Sources/App/Scenes/MacKeyWindowObserver.swift
@@ -0,0 +1,25 @@
+#if os(macOS)
+import AppKit
+import Combine
+
+/// The window the user is working in, published as it changes so menu commands can follow it.
+final class MacKeyWindowObserver: ObservableObject {
+ static let shared = MacKeyWindowObserver()
+
+ @Published private(set) var window: NSWindow?
+
+ private var cancellables: Set = []
+
+ private init() {
+ self.window = NSApp.keyWindow
+ for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] {
+ NotificationCenter.default.publisher(for: name)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.window = NSApp.keyWindow
+ }
+ .store(in: &cancellables)
+ }
+ }
+}
+#endif
diff --git a/Sources/App/Scenes/MacWebViewCommands.swift b/Sources/App/Scenes/MacWebViewCommands.swift
new file mode 100644
index 0000000000..b7221f2c4f
--- /dev/null
+++ b/Sources/App/Scenes/MacWebViewCommands.swift
@@ -0,0 +1,45 @@
+#if os(macOS)
+import Shared
+import SwiftUI
+
+/// The menu bar commands that act on the frontend: reloading and searching the page, the toolbar, and
+/// sending the sensors. They apply to the window the user is working in.
+struct MacWebViewCommands: Commands {
+ @ObservedObject private var keyWindow = MacKeyWindowObserver.shared
+
+ var body: some Commands {
+ CommandGroup(after: .newItem) {
+ Button(L10n.Menu.File.updateSensors) {
+ frontmostWebViewController?.updateSensors()
+ }
+ .keyboardShortcut("r", modifiers: [.command, .shift])
+ .disabled(frontmostWebViewController == nil)
+ }
+
+ CommandGroup(replacing: .toolbar) {
+ Button(L10n.Menu.View.reloadPage) {
+ frontmostWebViewController?.refresh()
+ }
+ .keyboardShortcut("r", modifiers: .command)
+ .disabled(frontmostWebViewController == nil)
+
+ Button(L10n.Menu.View.find) {
+ frontmostWebViewController?.showFindInteraction()
+ }
+ .keyboardShortcut("f", modifiers: .command)
+ .disabled(frontmostWebViewController == nil)
+
+ Button(L10n.Menu.View.customizeToolbar) {
+ frontmostWebViewController?.customizeToolbar()
+ }
+ .disabled(frontmostWebViewController == nil)
+ }
+ }
+
+ /// The frontend of the window the user is working in; a sheet counts for the window under it.
+ private var frontmostWebViewController: WebViewController? {
+ let window = keyWindow.window
+ return Current.sceneManager.webViewController(in: window?.sheetParent ?? window)
+ }
+}
+#endif
diff --git a/Sources/App/Scenes/MacWindowOpener.swift b/Sources/App/Scenes/MacWindowOpener.swift
new file mode 100644
index 0000000000..25e8dafaf2
--- /dev/null
+++ b/Sources/App/Scenes/MacWindowOpener.swift
@@ -0,0 +1,52 @@
+#if os(macOS)
+import AppKit
+import Shared
+import SwiftUI
+
+/// Opens the app's windows for code that is not inside a view: the scene manager, the menu bar item,
+/// deep links. SwiftUI only hands its `openWindow` action to the scene graph, so `HAApp` registers it here
+/// as soon as it has one.
+final class MacWindowOpener {
+ static let shared = MacWindowOpener()
+
+ private var openWindow: OpenWindowAction?
+ /// Requests that arrived before SwiftUI handed over its action, replayed once it does.
+ private var pending: [SceneActivity] = []
+
+ private init() {}
+
+ func register(_ openWindow: OpenWindowAction) {
+ self.openWindow = openWindow
+ let queued = pending
+ pending = []
+ queued.forEach(open(_:))
+ }
+
+ /// Brings the window for `activity` to the front, opening one when none is on screen.
+ func open(_ activity: SceneActivity) {
+ dispatchPrecondition(condition: .onQueue(.main))
+
+ if let window = existingWindow(for: activity) {
+ if window.isMiniaturized {
+ window.deminiaturize(nil)
+ }
+ window.makeKeyAndOrderFront(nil)
+ } else if let openWindow {
+ openWindow(id: activity.activityIdentifier)
+ } else {
+ pending.append(activity)
+ }
+
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ /// SwiftUI names a window after its scene's identifier, followed by a suffix of its own for the windows
+ /// of a `WindowGroup`.
+ func existingWindow(for activity: SceneActivity) -> NSWindow? {
+ NSApp.windows.first { window in
+ guard let identifier = window.identifier?.rawValue else { return false }
+ return identifier.hasPrefix(activity.activityIdentifier) && window.canBecomeKey && window.isVisible
+ }
+ }
+}
+#endif
diff --git a/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift b/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift
index edb6730e4d..f5d5e62456 100644
--- a/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift
+++ b/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import PromiseKit
import Shared
import UIKit
@@ -83,3 +84,4 @@ final class QuickActionWindowSceneDelegate: UIResponder, UIWindowSceneDelegate {
WindowScenesManager.shared.didDiscardScene(scene)
}
}
+#endif
diff --git a/Sources/App/Scenes/SceneActivity.swift b/Sources/App/Scenes/SceneActivity.swift
index b8fef7fd7a..375acf58bd 100644
--- a/Sources/App/Scenes/SceneActivity.swift
+++ b/Sources/App/Scenes/SceneActivity.swift
@@ -1,4 +1,8 @@
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
enum SceneActivity: CaseIterable {
case webView
@@ -53,6 +57,7 @@ enum SceneActivity: CaseIterable {
}
}
+ #if os(iOS)
var configuration: UISceneConfiguration {
switch self {
case .webView, .settings, .about, .assist, .onboarding:
@@ -62,6 +67,7 @@ enum SceneActivity: CaseIterable {
case .carPlay: return .init(name: configurationName, sessionRole: .carTemplateApplication)
}
}
+ #endif
/// The size a window of this kind opens at before the user has ever placed it. `nil` leaves the window to
/// macOS, which opens it at the frontmost window's frame.
@@ -72,13 +78,35 @@ enum SceneActivity: CaseIterable {
}
}
+ #if os(macOS)
+ /// The size a window of this kind opens at in the native Mac app before the user has ever placed it. A
+ /// SwiftUI window otherwise opens at the smallest size its content accepts.
+ var initialWindowSize: CGSize {
+ switch self {
+ case .webView, .carPlay: return .init(width: 1100, height: 760)
+ case .settings: return .init(width: 960, height: 680)
+ case .about: return .init(width: 480, height: 680)
+ case .assist: return defaultMacWindowSize ?? .init(width: 400, height: 600)
+ case .onboarding: return .init(width: 560, height: 720)
+ }
+ }
+
+ /// The smallest size a window of this kind can be resized to in the native Mac app: what the onboarding
+ /// screens shown in the main window need to lay out without overlapping.
+ var minimumWindowSize: CGSize {
+ .init(width: 480, height: 600)
+ }
+ #endif
+
/// Windows that remember their geometry need a scene delegate to receive the lifecycle callbacks
/// `WindowScenesManager` acts on. The main window's delegate is attached in `AppDelegate` instead, since it
/// also carries quick-action and browser-launch behaviour.
+ #if os(iOS)
private var sceneDelegateClass: AnyClass? {
switch self {
case .assist: return AssistWindowSceneDelegate.self
case .webView, .settings, .about, .carPlay, .onboarding: return nil
}
}
+ #endif
}
diff --git a/Sources/App/Scenes/SceneManager.swift b/Sources/App/Scenes/SceneManager.swift
index c3a6fc8560..58b33b7c61 100644
--- a/Sources/App/Scenes/SceneManager.swift
+++ b/Sources/App/Scenes/SceneManager.swift
@@ -1,8 +1,13 @@
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
+#if os(iOS)
// TODO: can i combine this with the enum?
struct SceneQuery {
@@ -17,6 +22,7 @@ extension UIWindowSceneDelegate {
pendingResolver(self)
}
}
+#endif
/// The app-level coordinator for the primary web-view window. Implemented by `HomeAssistantView`'s
/// coordinator as the web view migrates off `WebViewWindowController`; reached via `SceneManager.appCoordinator`.
@@ -48,9 +54,11 @@ enum OpenSource {
}
protocol AppCoordinator: AnyObject {
- var presentedViewController: UIViewController? { get }
- var window: UIWindow? { get }
- func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?)
+ var presentedViewController: PlatformViewController? { get }
+ var window: PlatformWindow? { get }
+ func present(_ viewController: PlatformViewController, animated: Bool, completion: (() -> Void)?)
+ /// Shows an alert over whatever this window has on screen.
+ func present(alert: AppAlert)
func show(alert: ServerAlert)
func showSettings(pushOntoNavigationStack: Bool)
func showAssistSettings()
@@ -89,7 +97,7 @@ protocol AppCoordinator: AnyObject {
}
extension AppCoordinator {
- func present(_ viewController: UIViewController) {
+ func present(_ viewController: PlatformViewController) {
present(viewController, animated: true, completion: nil)
}
@@ -162,18 +170,80 @@ final class SceneManager {
/// The current foreground `WebViewController`, published by `HomeAssistantView` (the SwiftUI web-frontend
/// host) as the web view migrates off `WebViewWindowController`. Consumers that only need the web view
/// read this instead of `webViewWindowControllerPromise.then(\.webViewControllerPromise)`.
+ #if os(macOS)
+ /// The frontend the app showed last. With every frontend window closed it is pending again, and asking
+ /// for it opens a window, whose frontend then resolves it.
+ var webViewControllerPromise: Guarantee {
+ openFrontendWindowIfNeeded()
+ return storedWebViewControllerPromise
+ }
+
+ private var storedWebViewControllerPromise: Guarantee
+ #else
private(set) var webViewControllerPromise: Guarantee
+ #endif
private var webViewControllerSeal: (WebViewController) -> Void
+ #if os(macOS)
+ private struct WeakWebViewController {
+ weak var value: WebViewController?
+ }
+
+ /// Every frontend currently alive, one per window, so a menu command can reach the one in front.
+ private var registeredWebViewControllers: [WeakWebViewController] = []
+ #endif
+
/// Called by `HomeAssistantView` whenever it creates or replaces its `WebViewController`.
func setWebViewController(_ controller: WebViewController) {
+ #if os(macOS)
+ registeredWebViewControllers.removeAll { $0.value == nil || $0.value === controller }
+ registeredWebViewControllers.append(WeakWebViewController(value: controller))
+ if storedWebViewControllerPromise.isFulfilled {
+ storedWebViewControllerPromise = .value(controller)
+ } else {
+ webViewControllerSeal(controller)
+ }
+ #else
if webViewControllerPromise.isFulfilled {
webViewControllerPromise = .value(controller)
} else {
webViewControllerSeal(controller)
}
+ #endif
}
+ #if os(macOS)
+ /// Called when a frontend's window closes. With no frontend left, whoever asks for one next opens a
+ /// window rather than reaching the one that is gone.
+ func unregisterWebViewController(_ controller: WebViewController) {
+ registeredWebViewControllers.removeAll { $0.value == nil || $0.value === controller }
+ if registeredWebViewControllers.isEmpty {
+ (storedWebViewControllerPromise, webViewControllerSeal) = Guarantee.pending()
+ }
+ }
+
+ /// Called when a coordinator's window closes; see `unregisterWebViewController(_:)`.
+ func unregisterAppCoordinator(_ coordinator: AppCoordinator) {
+ registeredAppCoordinators.removeAll { $0.value == nil || $0.value === coordinator }
+ if registeredAppCoordinators.isEmpty {
+ (appCoordinatorPromise, appCoordinatorSeal) = Guarantee.pending()
+ everyFrontendWindowClosed = true
+ }
+ }
+
+ /// Set once the user has closed the last frontend window, which is the only time a request without a
+ /// window has to open one: at launch the scene opens its own, and in browser mode there is none to open.
+ private var everyFrontendWindowClosed = false
+
+ private func openFrontendWindowIfNeeded() {
+ guard everyFrontendWindowClosed, !Current.settingsStore.macNativeFeaturesOnly else { return }
+ everyFrontendWindowClosed = false
+ DispatchQueue.main.async {
+ MacWindowOpener.shared.open(.webView)
+ }
+ }
+ #endif
+
private var appCoordinatorPromise: Guarantee
private var appCoordinatorSeal: (AppCoordinator) -> Void
@@ -189,12 +259,20 @@ final class SceneManager {
/// The app-wide coordinator, for requests that arrive without a window behind them (deep links,
/// notifications, App Intents). Anything triggered from a window should go through
/// `appCoordinator(for:)` so it stays in that window.
- var appCoordinator: Guarantee { appCoordinatorPromise }
+ var appCoordinator: Guarantee {
+ #if os(macOS)
+ openFrontendWindowIfNeeded()
+ #endif
+ return appCoordinatorPromise
+ }
/// Called by `HomeAssistantView` once its coordinator exists.
func registerAppCoordinator(_ coordinator: AppCoordinator) {
registeredAppCoordinators.removeAll { $0.value == nil || $0.value === coordinator }
registeredAppCoordinators.append(WeakAppCoordinator(value: coordinator))
+ #if os(macOS)
+ everyFrontendWindowClosed = false
+ #endif
if appCoordinatorPromise.isFulfilled {
appCoordinatorPromise = .value(coordinator)
@@ -206,8 +284,8 @@ final class SceneManager {
/// The coordinator showing `scene`, for requests that started in one window and belong there —
/// a tap in that web view, a gesture on it. Falls back to the app-wide coordinator when the scene has
/// none of its own (kiosk mode, a window still coming up). Call on the main thread.
- func appCoordinator(for scene: UIWindowScene?) -> Guarantee {
- guard let scene else { return appCoordinatorPromise }
+ func appCoordinator(for scene: PlatformWindowScene?) -> Guarantee {
+ guard let scene else { return appCoordinator }
// Newest registration first, so a scene that came back gets its current coordinator rather than one
// left over from the container it replaced.
let coordinators = registeredAppCoordinators.reversed().compactMap(\.value)
@@ -218,10 +296,45 @@ final class SceneManager {
}
init() {
+ #if os(macOS)
+ (self.storedWebViewControllerPromise, self.webViewControllerSeal) = Guarantee.pending()
+ #else
(self.webViewControllerPromise, self.webViewControllerSeal) = Guarantee.pending()
+ #endif
(self.appCoordinatorPromise, self.appCoordinatorSeal) = Guarantee.pending()
}
+ #if os(macOS)
+ /// Every window is a window of its own on the Mac.
+ public var supportsMultipleScenes: Bool { true }
+
+ /// The frontend shown in `window`, if it shows one.
+ func webViewController(in window: NSWindow?) -> WebViewController? {
+ guard let window else { return nil }
+ return registeredWebViewControllers.compactMap(\.value).last {
+ $0.isViewLoaded && $0.view.window === window
+ }
+ }
+
+ public func activateAnyScene(for activity: SceneActivity) {
+ MacWindowOpener.shared.open(activity)
+ }
+
+ public func activateAnyScene(for activity: SceneActivity, with userInfo: [AnyHashable: Any]) {
+ MacWindowOpener.shared.open(activity)
+ }
+
+ /// Confirms an action the user cannot otherwise see the result of, over the window it was started from.
+ public func showFullScreenConfirm(
+ icon: MaterialDesignIcons,
+ text: String,
+ onto window: Promise
+ ) {
+ window.done { window in
+ MacConfirmationHUD.show(icon: icon, text: text, in: window)
+ }.cauterize()
+ }
+ #else
fileprivate func pendingResolver(from activities: Set) -> (T) -> Void {
let (promise, outerResolver) = Guarantee.pending()
@@ -316,7 +429,7 @@ final class SceneManager {
// Only activate scene if the app is already in foreground or transitioning to foreground
// This prevents widgets, notifications, or background tasks from unexpectedly bringing the app to
// foreground
- let shouldActivate = UIApplication.shared.applicationState == .active ||
+ let shouldActivate = ApplicationState.current == .active ||
active.activationState == .foregroundInactive
if shouldActivate {
@@ -324,7 +437,7 @@ final class SceneManager {
// Guarantee it runs on main thread when coming from widgets
DispatchQueue.main.async {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
UIApplication.shared.activateSceneSession(for: .init(session: active.session, options: options))
} else {
UIApplication.shared.requestSceneSessionActivation(
@@ -394,4 +507,5 @@ final class SceneManager {
hud.hide(animated: true, afterDelay: 3)
}.cauterize()
}
+ #endif
}
diff --git a/Sources/App/Scenes/ScenesWindowSizeConfig.swift b/Sources/App/Scenes/ScenesWindowSizeConfig.swift
index 1fd8e9c53d..f332a19cc6 100644
--- a/Sources/App/Scenes/ScenesWindowSizeConfig.swift
+++ b/Sources/App/Scenes/ScenesWindowSizeConfig.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Stores the last known Mac window frame per window kind, so each window reopens where the user left it.
enum ScenesWindowSizeConfig {
diff --git a/Sources/App/Scenes/WindowScenesManager.swift b/Sources/App/Scenes/WindowScenesManager.swift
index 5029bd86b8..28e6db51d9 100644
--- a/Sources/App/Scenes/WindowScenesManager.swift
+++ b/Sources/App/Scenes/WindowScenesManager.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
import UIKit
@@ -138,3 +139,4 @@ final class WindowScenesManager {
)
}
}
+#endif
diff --git a/Sources/App/Scenes/WindowSizeObserver.swift b/Sources/App/Scenes/WindowSizeObserver.swift
index 172ddf4886..4e05e77612 100644
--- a/Sources/App/Scenes/WindowSizeObserver.swift
+++ b/Sources/App/Scenes/WindowSizeObserver.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
import UIKit
@@ -32,3 +33,4 @@ final class WindowSizeObserver: NSObject {
observation = nil
}
}
+#endif
diff --git a/Sources/App/ServerSwitching/LocationBasedServerSwitcher.swift b/Sources/App/ServerSwitching/LocationBasedServerSwitcher.swift
index c5d876f93c..eac3e3800d 100644
--- a/Sources/App/ServerSwitching/LocationBasedServerSwitcher.swift
+++ b/Sources/App/ServerSwitching/LocationBasedServerSwitcher.swift
@@ -1,7 +1,11 @@
import CoreLocation
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Opens the server whose home the user is at when the app becomes active — arriving at a second
/// home brings up that home's server, like Apple Home selecting the home you're at. Being at a home
@@ -37,7 +41,7 @@ final class LocationBasedServerSwitcher {
/// The app's activation state, telling a link that is opening the app from one handled while it
/// is already up. Replaceable in tests.
- var applicationStateGetter: () -> UIApplication.State = { UIApplication.shared.applicationState }
+ var applicationStateGetter: () -> ApplicationState = { ApplicationState.current }
/// Whether an evaluation is in flight. Non-private for tests.
var isEvaluating: Bool { evaluationTask != nil }
@@ -45,7 +49,7 @@ final class LocationBasedServerSwitcher {
func start() {
guard didBecomeActiveObserver == nil else { return }
didBecomeActiveObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didBecomeActiveNotification,
+ forName: AppLifecycle.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
@@ -54,7 +58,7 @@ final class LocationBasedServerSwitcher {
}
}
didEnterBackgroundObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didEnterBackgroundNotification,
+ forName: AppLifecycle.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
diff --git a/Sources/App/Servers/ServerSelectionListView.swift b/Sources/App/Servers/ServerSelectionListView.swift
index b403f1d05d..ae576ab6ac 100644
--- a/Sources/App/Servers/ServerSelectionListView.swift
+++ b/Sources/App/Servers/ServerSelectionListView.swift
@@ -27,7 +27,7 @@ struct ServerSelectionListView: View {
.environment(\.defaultMinListRowHeight, 60)
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.one)
} else {
view
@@ -42,7 +42,13 @@ struct ServerSelectionListView: View {
}
}
ToolbarItem(placement: .primaryAction) {
+ #if os(macOS)
+ // A sheet's buttons sit in a row at its bottom, where a cog would be the odd one out
+ // beside the Close button.
+ Button(L10n.Settings.NavigationBar.title, action: expandAction)
+ #else
SettingsButton(tint: Color.haPrimary, action: expandAction)
+ #endif
}
}
}
diff --git a/Sources/App/Settings/AboutView.swift b/Sources/App/Settings/AboutView.swift
index d2a037dc84..5393afcd15 100644
--- a/Sources/App/Settings/AboutView.swift
+++ b/Sources/App/Settings/AboutView.swift
@@ -12,7 +12,7 @@ struct AboutView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: nil,
headerImageAlternativeView: AnyView(
diff --git a/Sources/App/Settings/AppIcon/AppIconSelectorView.swift b/Sources/App/Settings/AppIcon/AppIconSelectorView.swift
index 591d316503..db5f7b0b3c 100644
--- a/Sources/App/Settings/AppIcon/AppIconSelectorView.swift
+++ b/Sources/App/Settings/AppIcon/AppIconSelectorView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SFSafeSymbols
import Shared
import SwiftUI
@@ -101,3 +102,4 @@ struct AppIconSelectorView: View {
AppIconSelectorView()
}
}
+#endif
diff --git a/Sources/App/Settings/AppIconShortcuts/AppIconShortcutItemsUpdater.swift b/Sources/App/Settings/AppIconShortcuts/AppIconShortcutItemsUpdater.swift
index 9d7e66c088..7d4f2d3527 100644
--- a/Sources/App/Settings/AppIconShortcuts/AppIconShortcutItemsUpdater.swift
+++ b/Sources/App/Settings/AppIconShortcuts/AppIconShortcutItemsUpdater.swift
@@ -1,11 +1,17 @@
import SFSafeSymbols
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
enum AppIconShortcutItemsUpdater {
private static let shortcutTypePrefix = "appIconShortcut."
private static let shortcutTypeSeparator: Character = "|"
+ #if !os(macOS)
private static let maximumShortcutItems = 4
+ #endif
struct ShortcutIdentifier: Equatable {
let serverId: String
@@ -14,21 +20,31 @@ enum AppIconShortcutItemsUpdater {
}
private static var databaseUpdateObserver: NSObjectProtocol?
+ #if !os(macOS)
private static var didBecomeActiveObserver: NSObjectProtocol?
private static var needsUpdateOnForeground = false
private static let generationLock = NSLock()
private static var requestedGeneration = 0
private static var publishedGeneration = 0
+ #endif
/// `.inactive` counts as not active: launches, background ones included, start there.
+ /// A Mac app publishes nothing, so it never has to wait for the foreground.
static var isAppActive: () -> Bool = {
- Current.isCatalyst || UIApplication.shared.applicationState == .active
+ #if os(macOS)
+ return true
+ #else
+ return Current.isCatalyst || UIApplication.shared.applicationState == .active
+ #endif
}
/// Publishes the configured items now and again each time the database updater finishes a
/// server, so titles resolved before the entity table was synced (a fresh install, an imported
/// configuration) catch up without waiting for the next launch.
+ ///
+ /// Does nothing on a Mac, where an app has no Home Screen quick actions to publish.
static func start() {
+ #if !os(macOS)
if databaseUpdateObserver == nil {
databaseUpdateObserver = NotificationCenter.default.addObserver(
forName: .appDatabaseUpdaterDidFinishRoutine,
@@ -50,17 +66,21 @@ enum AppIconShortcutItemsUpdater {
}
}
updateOrDeferToForeground()
+ #endif
}
static func stop() {
+ #if !os(macOS)
for observer in [databaseUpdateObserver, didBecomeActiveObserver].compactMap({ $0 }) {
NotificationCenter.default.removeObserver(observer)
}
databaseUpdateObserver = nil
didBecomeActiveObserver = nil
needsUpdateOnForeground = false
+ #endif
}
+ #if !os(macOS)
/// Background launches (WatchConnectivity, location, background refresh) don't need the
/// shortcuts, and reading every entity and area row there was the top 0xdead10cc kill.
private static func updateOrDeferToForeground() {
@@ -70,8 +90,13 @@ enum AppIconShortcutItemsUpdater {
needsUpdateOnForeground = true
}
}
+ #endif
static func update(completion: @escaping @Sendable () -> Void = {}) {
+ #if os(macOS)
+ // Nothing to publish: a Mac app has no Home Screen quick actions.
+ DispatchQueue.main.async(execute: completion)
+ #else
let generation = nextGeneration()
// `loadInformation` fetches every entity, area, and device row for every server
// synchronously on the calling thread, and `update()` runs at app launch — keep that work
@@ -110,6 +135,7 @@ enum AppIconShortcutItemsUpdater {
publish(shortcutItems: shortcutItems, generation: generation, completion: completion)
}
}
+ #endif
}
static func identifier(from shortcutType: String) -> ShortcutIdentifier? {
@@ -127,6 +153,7 @@ enum AppIconShortcutItemsUpdater {
)
}
+ #if !os(macOS)
private static func hasUnreadableServer(
for items: [MagicItem],
entitiesPerServer: [String: [HAAppEntity]]
@@ -208,4 +235,5 @@ enum AppIconShortcutItemsUpdater {
return nil
}
}
+ #endif
}
diff --git a/Sources/App/Settings/AppLabs/AppLabsFeature.swift b/Sources/App/Settings/AppLabs/AppLabsFeature.swift
index 56112122c1..5c5c158511 100644
--- a/Sources/App/Settings/AppLabs/AppLabsFeature.swift
+++ b/Sources/App/Settings/AppLabs/AppLabsFeature.swift
@@ -26,7 +26,7 @@ enum AppLabsFeature: String, CaseIterable, Identifiable {
var isAvailableOnThisDevice: Bool {
switch self {
case .iosNativeTabBar:
- if #available(iOS 26, *) {
+ if #available(iOS 26, macOS 26, *) {
return !Current.isCatalyst
}
return false
diff --git a/Sources/App/Settings/AppLabs/AppLabsView.swift b/Sources/App/Settings/AppLabs/AppLabsView.swift
index a717c11610..b951852f8a 100644
--- a/Sources/App/Settings/AppLabs/AppLabsView.swift
+++ b/Sources/App/Settings/AppLabs/AppLabsView.swift
@@ -13,7 +13,7 @@ struct AppLabsView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .flaskOutlineIcon,
title: L10n.Settings.AppLabs.title,
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationEditView.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationEditView.swift
index 743d94ad7e..af791a6251 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationEditView.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationEditView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -382,3 +383,4 @@ private struct TextAreaEditor: View {
}
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationEditViewModel.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationEditViewModel.swift
index 88a8a44f09..ead76cf591 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationEditViewModel.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationEditViewModel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import PromiseKit
import RealmSwift
@@ -357,3 +358,4 @@ extension ComplicationEditViewModel {
}
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationFamilySelectView.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationFamilySelectView.swift
index dcd9883dce..aa530bc047 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationFamilySelectView.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationFamilySelectView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -72,3 +73,4 @@ struct ComplicationFamilySelectView: View {
)
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationListView.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationListView.swift
index 2442a2f5dc..3900062b00 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationListView.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationListView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -110,3 +111,4 @@ struct ComplicationListView: View {
#Preview("Legacy complications") {
NavigationView { ComplicationListView() }
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationListViewModel.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationListViewModel.swift
index 289a9cf71d..3760bb0d89 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationListViewModel.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationListViewModel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import PromiseKit
import Shared
@@ -144,3 +145,4 @@ extension ComplicationListViewModel {
}
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootView.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootView.swift
index 5b5f0dc2f2..b9290f7c94 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootView.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
import UIKit
@@ -150,3 +151,4 @@ extension ComplicationsRootView: SettingsScreenSearchable {
]
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootViewModel.swift b/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootViewModel.swift
index 595c9b421c..6199257f91 100644
--- a/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootViewModel.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/ComplicationsRootViewModel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Combine
import Foundation
import Shared
@@ -93,3 +94,4 @@ final class ComplicationsRootViewModel: ObservableObject {
WatchMirrorPushCoordinator.schedule(reason: .complicationSaved)
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/IconSearchPicker.swift b/Sources/App/Settings/AppleWatch/Complications/IconSearchPicker.swift
index b13e492db4..41b1f8e1d9 100644
--- a/Sources/App/Settings/AppleWatch/Complications/IconSearchPicker.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/IconSearchPicker.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -98,3 +99,4 @@ private struct IconSearchSheet: View {
.navigationViewStyle(.stack)
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/LoadingButton.swift b/Sources/App/Settings/AppleWatch/Complications/LoadingButton.swift
index f6edcc774f..56ea227f13 100644
--- a/Sources/App/Settings/AppleWatch/Complications/LoadingButton.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/LoadingButton.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import SwiftUI
/// A SwiftUI replacement for `ButtonRowWithLoading`. Shows a progress indicator
@@ -39,3 +40,4 @@ private struct LoadingButtonLabel: View {
.foregroundColor(isEnabled ? .accentColor : .secondary)
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/AllFamiliesComplicationPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/AllFamiliesComplicationPreview.swift
index 6ffb93ef09..fefbded2c8 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/AllFamiliesComplicationPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/AllFamiliesComplicationPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -401,3 +402,4 @@ struct AllFamiliesComplicationPreview: View {
.padding()
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/CircularComplicationPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/CircularComplicationPreview.swift
index 30ee5e2f29..e1a9b91c71 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/CircularComplicationPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/CircularComplicationPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import HAWatchComplications
import Shared
import SwiftUI
@@ -39,3 +40,4 @@ struct CircularComplicationPreview: View {
.padding()
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/ComplicationFamilyPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/ComplicationFamilyPreview.swift
index 510fbd5f97..4cf1bb2672 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/ComplicationFamilyPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/ComplicationFamilyPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -50,3 +51,4 @@ struct ComplicationFamilyPreview: View {
.padding()
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/CornerComplicationPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/CornerComplicationPreview.swift
index 0cbf668b9b..05a0eeda6e 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/CornerComplicationPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/CornerComplicationPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import HAWatchComplications
import Shared
import SwiftUI
@@ -124,3 +125,4 @@ private struct CornerVariantsPreview: View {
}
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/InlineComplicationPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/InlineComplicationPreview.swift
index 6b886be395..3a0eac330d 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/InlineComplicationPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/InlineComplicationPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import HAWatchComplications
import Shared
import SwiftUI
@@ -28,3 +29,4 @@ struct InlineComplicationPreview: View {
.padding()
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/RectangularComplicationPreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/RectangularComplicationPreview.swift
index 72c2eb255a..886d9812ce 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/RectangularComplicationPreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/RectangularComplicationPreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import HAWatchComplications
import Shared
import SwiftUI
@@ -182,3 +183,4 @@ private func rectangularFace(_ context: ComplicationRenderContext) -> some View
.padding()
}
#endif
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/Preview/WatchComplicationLivePreview.swift b/Sources/App/Settings/AppleWatch/Complications/Preview/WatchComplicationLivePreview.swift
index a252eb9ddf..a255dad053 100644
--- a/Sources/App/Settings/AppleWatch/Complications/Preview/WatchComplicationLivePreview.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/Preview/WatchComplicationLivePreview.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Alamofire
import Foundation
import PromiseKit
@@ -346,3 +347,4 @@ struct WatchComplicationLivePreview: View {
ComplicationRenderContext.formatValue(state, unit: unit, precision: precision)
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditView.swift b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditView.swift
index 7258004d02..d071573cce 100644
--- a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditView.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
import UIKit
@@ -439,3 +440,4 @@ extension Binding where Value == String {
))
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditViewModel.swift b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditViewModel.swift
index feac08e5b0..b01447f7b4 100644
--- a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditViewModel.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationBuilderEditViewModel.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import GRDB
import Shared
@@ -178,3 +179,4 @@ final class WatchComplicationBuilderEditViewModel: ObservableObject {
config.name = "Complication-\(number)"
}
}
+#endif
diff --git a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift
index 2a64f3f169..f237066786 100644
--- a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift
+++ b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
import UIKit
@@ -301,7 +302,7 @@ struct WatchComplicationConfigurationSheet: View {
TextField(text: text) { Text(verbatim: "—") }
.multilineTextAlignment(.trailing)
.frame(maxWidth: 120)
- #if !targetEnvironment(macCatalyst)
+ #if !(targetEnvironment(macCatalyst) || os(macOS))
.keyboardType(.numberPad)
#endif
}
@@ -931,3 +932,4 @@ extension ComplicationSlot {
}
}
}
+#endif
diff --git a/Sources/App/Settings/Calendars/CalendarEventDeleteScopeAppEnum.swift b/Sources/App/Settings/Calendars/CalendarEventDeleteScopeAppEnum.swift
index e0793a9372..f1e9828608 100644
--- a/Sources/App/Settings/Calendars/CalendarEventDeleteScopeAppEnum.swift
+++ b/Sources/App/Settings/Calendars/CalendarEventDeleteScopeAppEnum.swift
@@ -3,7 +3,7 @@ import Foundation
/// How far a delete reaches into a recurring series, matching the frontend's `RecurrenceRange`
/// (`src/data/calendar.ts`). Ignored for events that do not recur.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
enum CalendarEventDeleteScopeAppEnum: String, Codable, Sendable, AppEnum {
/// `RecurrenceRange.THISEVENT`, which the frontend represents as an empty string. Nothing is
/// sent for it: `deleteCalendarEvent` omits empty values, and omitting `recurrence_range`
diff --git a/Sources/App/Settings/Calendars/CalendarEventRepeatAppEnum.swift b/Sources/App/Settings/Calendars/CalendarEventRepeatAppEnum.swift
index 9d1b50b549..bf516590cd 100644
--- a/Sources/App/Settings/Calendars/CalendarEventRepeatAppEnum.swift
+++ b/Sources/App/Settings/Calendars/CalendarEventRepeatAppEnum.swift
@@ -7,7 +7,7 @@ import Foundation
/// The editor's extra controls — interval, weekday set, end date/count — only appear once a
/// frequency is chosen and are left out here; the rule this produces is what the frontend sends for
/// a freshly picked preset.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
enum CalendarEventRepeatAppEnum: String, Codable, Sendable, AppEnum {
case none
case yearly
diff --git a/Sources/App/Settings/Calendars/CreateCalendarEventAppIntent.swift b/Sources/App/Settings/Calendars/CreateCalendarEventAppIntent.swift
index cf71170eb2..6f6de18ee4 100644
--- a/Sources/App/Settings/Calendars/CreateCalendarEventAppIntent.swift
+++ b/Sources/App/Settings/Calendars/CreateCalendarEventAppIntent.swift
@@ -5,7 +5,7 @@ import Shared
/// Adds an event to a Home Assistant calendar, offering the fields from the frontend's event editor
/// (`dialog-calendar-event-editor.ts`): summary, description, location, all-day, start/end and a
/// repeat preset. Start and end are optional and default the way the editor opens a new event.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct CreateCalendarEventAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"app_intents.calendar.create_event.title",
diff --git a/Sources/App/Settings/Calendars/DeleteCalendarEventAppIntent.swift b/Sources/App/Settings/Calendars/DeleteCalendarEventAppIntent.swift
index 67b16684a8..ff47b039b2 100644
--- a/Sources/App/Settings/Calendars/DeleteCalendarEventAppIntent.swift
+++ b/Sources/App/Settings/Calendars/DeleteCalendarEventAppIntent.swift
@@ -10,7 +10,7 @@ import Shared
///
/// For a recurring series the scope decides whether just the picked occurrence goes or everything
/// from it onwards, matching the choice the frontend prompts for before deleting.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct DeleteCalendarEventAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"app_intents.calendar.delete_event.title",
diff --git a/Sources/App/Settings/Calendars/GetCalendarEventsAppIntent.swift b/Sources/App/Settings/Calendars/GetCalendarEventsAppIntent.swift
index 7f5adb698a..da79897ddd 100644
--- a/Sources/App/Settings/Calendars/GetCalendarEventsAppIntent.swift
+++ b/Sources/App/Settings/Calendars/GetCalendarEventsAppIntent.swift
@@ -6,7 +6,7 @@ import Shared
///
/// The result is the same entity the delete intent takes, so a Shortcut can read a calendar and act
/// on what it finds without an intermediate step.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct GetCalendarEventsAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"app_intents.calendar.get_events.title",
diff --git a/Sources/App/Settings/Calendars/HACalendarEventAppEntity.swift b/Sources/App/Settings/Calendars/HACalendarEventAppEntity.swift
index 19d3acef8c..7ab46cf042 100644
--- a/Sources/App/Settings/Calendars/HACalendarEventAppEntity.swift
+++ b/Sources/App/Settings/Calendars/HACalendarEventAppEntity.swift
@@ -10,7 +10,7 @@ import Shared
/// windowed query, and a Shortcut that runs days after it was built would otherwise fail to resolve
/// an event that has scrolled out of the window it was picked from.
// iOS 17 because its query scopes options through `@IntentParameterDependency`.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct HACalendarEventAppEntity: AppEntity, Sendable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: .init(
"app_intents.calendar.event.entity.name",
diff --git a/Sources/App/Settings/Calendars/HACalendarEventAppEntityQuery.swift b/Sources/App/Settings/Calendars/HACalendarEventAppEntityQuery.swift
index 979aedb255..080bb17a1d 100644
--- a/Sources/App/Settings/Calendars/HACalendarEventAppEntityQuery.swift
+++ b/Sources/App/Settings/Calendars/HACalendarEventAppEntityQuery.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct HACalendarEventAppEntityQuery: EntityQuery, EntityStringQuery {
/// How far ahead the picker looks. Home Assistant only answers windowed queries, so the options
/// need a bound; a month matches what the debug calendar screen fetches at a time.
diff --git a/Sources/App/Settings/CarPlay/CarPlayAddItemMenu.swift b/Sources/App/Settings/CarPlay/CarPlayAddItemMenu.swift
index 3afc302f51..c25d7446f7 100644
--- a/Sources/App/Settings/CarPlay/CarPlayAddItemMenu.swift
+++ b/Sources/App/Settings/CarPlay/CarPlayAddItemMenu.swift
@@ -70,7 +70,7 @@ struct CarPlayAddItemMenu: View {
}
private var isAssistSupported: Bool {
- if #available(iOS 26.4, *) {
+ if #available(iOS 26.4, macOS 26.4, *) {
return true
} else {
return false
diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift
index 4ce79b9f23..a5af57d3e2 100644
--- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift
+++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift
@@ -2,7 +2,6 @@ import Foundation
import Shared
import StoreKit
import SwiftUI
-import UIKit
struct CarPlayConfigurationView: View {
@Environment(\.dismiss) private var dismiss
@@ -55,9 +54,11 @@ struct CarPlayConfigurationView: View {
.toolbar(content: {
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
+ #if !os(macOS)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
SKStoreReviewController.requestReview(in: windowScene)
}
+ #endif
dismiss()
}, label: {
Text(L10n.doneLabel)
@@ -183,7 +184,7 @@ struct CarPlayConfigurationView: View {
.foregroundStyle(.secondary)
}
}
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
label.selectionDisabled(isUnsupported)
} else {
label
@@ -191,7 +192,7 @@ struct CarPlayConfigurationView: View {
}
private var isGridLayoutSupported: Bool {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
return true
} else {
return false
@@ -272,12 +273,17 @@ struct CarPlayConfigurationView: View {
private var tabsSection: some View {
Section(L10n.CarPlay.Config.Tabs.title) {
+ #if canImport(CarPlay)
NavigationLink {
CarPlayTabsSelectionView(viewModel: viewModel)
} label: {
Text(viewModel.config.tabs.map { viewModel.config.name(for: $0) }.joined(separator: ", "))
.frame(maxWidth: .infinity, alignment: .leading)
}
+ #else
+ Text(viewModel.config.tabs.map { viewModel.config.name(for: $0) }.joined(separator: ", "))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ #endif
}
.animation(.bouncy, value: viewModel.config.tabs)
}
diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift
index 6e2b4b3d94..2743296289 100644
--- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift
+++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift
@@ -1,7 +1,11 @@
import Combine
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
final class CarPlayConfigurationViewModel: ObservableObject {
@Published private(set) var config = CarPlayConfig()
diff --git a/Sources/App/Settings/CarPlay/CarPlayTabsSelectionView.swift b/Sources/App/Settings/CarPlay/CarPlayTabsSelectionView.swift
index 7a325370fb..39bcbeac48 100644
--- a/Sources/App/Settings/CarPlay/CarPlayTabsSelectionView.swift
+++ b/Sources/App/Settings/CarPlay/CarPlayTabsSelectionView.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import SFSafeSymbols
import Shared
@@ -143,3 +144,4 @@ struct CarPlayTabsSelectionView: View {
#Preview {
CarPlayTabsSelectionView(viewModel: CarPlayConfigurationViewModel())
}
+#endif
diff --git a/Sources/App/Settings/ClientEventsLogView/ClientEventsLogView.swift b/Sources/App/Settings/ClientEventsLogView/ClientEventsLogView.swift
index 902009a0b0..8b71382c3f 100644
--- a/Sources/App/Settings/ClientEventsLogView/ClientEventsLogView.swift
+++ b/Sources/App/Settings/ClientEventsLogView/ClientEventsLogView.swift
@@ -114,7 +114,7 @@ struct ClientEventsLogView: View {
}
.listRowBackground(Color.clear)
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.scrollClipDisabled(true)
} else {
view
@@ -122,7 +122,7 @@ struct ClientEventsLogView: View {
}
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.one)
} else {
view
diff --git a/Sources/App/Settings/Components/YamlPreviewSection.swift b/Sources/App/Settings/Components/YamlPreviewSection.swift
index b90f2bf8c7..847d9cd447 100644
--- a/Sources/App/Settings/Components/YamlPreviewSection.swift
+++ b/Sources/App/Settings/Components/YamlPreviewSection.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
/// A reusable SwiftUI section that renders a YAML preview with a share button.
///
@@ -12,7 +11,6 @@ struct YamlPreviewSection: View {
let shareTitle: String
let yaml: String
- @State private var showShareSheet = false
@State private var showYamlSheet = false
init(
@@ -43,9 +41,7 @@ struct YamlPreviewSection: View {
}
}
- Button {
- showShareSheet = true
- } label: {
+ ShareLink(item: yaml) {
Label {
Text(shareTitle)
.foregroundColor(.primary)
@@ -54,31 +50,12 @@ struct YamlPreviewSection: View {
}
}
}
- .sheet(isPresented: $showShareSheet) {
- YamlShareSheet(activityItems: [yaml])
- }
.sheet(isPresented: $showYamlSheet) {
YamlCodePreviewView(yaml: yaml)
}
}
}
-/// A SwiftUI wrapper around `UIActivityViewController` used to share YAML or
-/// other plain text content from settings screens.
-struct YamlShareSheet: UIViewControllerRepresentable {
- let activityItems: [Any]
- var applicationActivities: [UIActivity]?
-
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(
- activityItems: activityItems,
- applicationActivities: applicationActivities
- )
- }
-
- func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
-}
-
/// Full-screen readable YAML preview, presented as a sheet from
/// `YamlPreviewSection`. Provides a copy-to-pasteboard button and close action.
struct YamlCodePreviewView: View {
diff --git a/Sources/App/Settings/ConfigurationTransfer/AppSettingsSnapshot.swift b/Sources/App/Settings/ConfigurationTransfer/AppSettingsSnapshot.swift
index 5f60d61a5e..3a681a55a2 100644
--- a/Sources/App/Settings/ConfigurationTransfer/AppSettingsSnapshot.swift
+++ b/Sources/App/Settings/ConfigurationTransfer/AppSettingsSnapshot.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// The app's own preferences, in a form that can travel inside a configuration export.
///
@@ -111,10 +115,21 @@ struct AppSettingsSnapshot: Codable, Equatable {
.map(\.rawValue)
.sorted(),
carPlayAssistDebugSettings: store.carPlayAssistDebugSettings,
- appIcon: UIApplication.shared.alternateIconName ?? AppIcon.Release.rawValue
+ appIcon: currentAppIcon
)
}
+ /// `AppIcon.rawValue` of the icon the app shows. A Mac app has no alternate icons to switch to, so
+ /// there it is always the release one.
+ @MainActor
+ private static var currentAppIcon: String {
+ #if os(macOS)
+ AppIcon.Release.rawValue
+ #else
+ UIApplication.shared.alternateIconName ?? AppIcon.Release.rawValue
+ #endif
+ }
+
/// Writes every captured setting back through `SettingsStore`, so the same change notifications
/// fire as when the user edits the setting by hand.
@MainActor
@@ -207,8 +222,10 @@ struct AppSettingsSnapshot: Codable, Equatable {
store.menuItemTemplate = (server, menuItemTemplate)
}
+ /// Does nothing on a Mac, where the app has no alternate icons to switch to.
@MainActor
private func applyAppIcon() {
+ #if !os(macOS)
guard let appIcon, AppIcon(rawValue: appIcon) != nil else { return }
let iconName: String? = appIcon == AppIcon.Release.rawValue ? nil : appIcon
guard UIApplication.shared.alternateIconName != iconName else { return }
@@ -217,5 +234,6 @@ struct AppSettingsSnapshot: Codable, Equatable {
Current.Log.error("Failed to apply imported app icon \(appIcon): \(error.localizedDescription)")
}
}
+ #endif
}
}
diff --git a/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationView.swift b/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationView.swift
index 5149c2ef29..999463c159 100644
--- a/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationView.swift
+++ b/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationView.swift
@@ -19,7 +19,7 @@ struct ImportExportConfigurationView: View {
@StateObject private var viewModel = ImportExportConfigurationViewModel()
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .swapHorizontalIcon,
title: L10n.Settings.Debugging.ConfigurationTransfer.Header.title,
diff --git a/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationViewModel.swift b/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationViewModel.swift
index 6d834b93c3..17a4cf2cf6 100644
--- a/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationViewModel.swift
+++ b/Sources/App/Settings/ConfigurationTransfer/ImportExportConfigurationViewModel.swift
@@ -2,7 +2,6 @@ import Foundation
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
@MainActor
final class ImportExportConfigurationViewModel: ObservableObject {
@@ -165,7 +164,7 @@ final class ImportExportConfigurationViewModel: ObservableObject {
message: String? = nil,
duration: TimeInterval? = nil
) {
- guard #available(iOS 18, *) else { return }
+ guard #available(iOS 18, macOS 15, *) else { return }
ToastPresenter.shared.show(
id: Self.toastID,
symbol: symbol,
diff --git a/Sources/App/Settings/Connection/ConnectionSettingsView.swift b/Sources/App/Settings/Connection/ConnectionSettingsView.swift
index 5ad02f98b0..b67bcea0a0 100644
--- a/Sources/App/Settings/Connection/ConnectionSettingsView.swift
+++ b/Sources/App/Settings/Connection/ConnectionSettingsView.swift
@@ -10,7 +10,9 @@ struct ConnectionSettingsView: View {
@Environment(\.appSettingsPresenter) private var appSettingsPresenter
@State private var showShareSheet = false
@State private var showSecurityLevelPicker = false
+ #if !os(macOS)
@State private var activityViewController: UIActivityViewController?
+ #endif
@State private var isDeleteConfirmationPresented = false
@State private var deleteError: Error?
@State private var showDeleteError = false
@@ -32,7 +34,7 @@ struct ConnectionSettingsView: View {
}
var body: some View {
- List {
+ GroupedList {
detailsSection
clientCertificateSection
privacySection
@@ -49,6 +51,14 @@ struct ConnectionSettingsView: View {
}
ToolbarItem(placement: .topBarTrailing) {
if viewModel.canShareServer {
+ #if os(macOS)
+ if let invitationURL = viewModel.invitationURL() {
+ ShareLink(item: invitationURL) {
+ Image(systemSymbol: .squareAndArrowUp)
+ }
+ .tint(.haPrimary)
+ }
+ #else
Button {
if let activityVC = viewModel.shareServer() {
activityViewController = activityVC
@@ -58,14 +68,17 @@ struct ConnectionSettingsView: View {
Image(systemSymbol: .squareAndArrowUp)
}
.tint(.haPrimary)
+ #endif
}
}
}
+ #if !os(macOS)
.sheet(isPresented: $showShareSheet) {
if let activityVC = activityViewController {
embed(activityVC)
}
}
+ #endif
.sheet(isPresented: $showInternalURLSheet) {
NavigationView {
ConnectionURLView(
@@ -276,7 +289,7 @@ struct ConnectionSettingsView: View {
)
)
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
NavigationLink {
ConnectionURLView(
server: viewModel.server,
@@ -420,7 +433,7 @@ struct ConnectionSettingsView: View {
private var privacySection: some View {
Section(header: Text(L10n.SettingsDetails.Privacy.title)) {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
NavigationLink {
PrivacyPickerView(
title: L10n.Settings.ConnectionSection.LocationSendType.title,
@@ -531,7 +544,11 @@ struct ConnectionSettingsView: View {
Task {
do {
try await viewModel.deleteServer()
+ // On the Mac this screen is the detail of the Settings window, which moves on to
+ // another entry by itself; dismissing it would close the window.
+ #if !os(macOS)
dismiss()
+ #endif
} catch {
Current.Log.error("Failed to delete server: \(error)")
deleteError = error
@@ -731,7 +748,7 @@ private struct PrivacyPickerView: View where T: RawR
@Environment(\.dismiss) private var dismiss
var body: some View {
- List {
+ GroupedList {
Section {
ForEach(options, id: \.self) { option in
Button {
diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift
index 64f54222b7..4d9ad803bd 100644
--- a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift
+++ b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift
@@ -3,7 +3,11 @@ import Foundation
import HAKit
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// ViewModel for ConnectionSettingsView, managing server connection settings and state
@MainActor
@@ -226,7 +230,7 @@ final class ConnectionSettingsViewModel: ObservableObject {
server.info.setSetting(value: privacy, for: .locationPrivacy)
locationPrivacy = privacy
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .programmatic
).cauterize()
}
@@ -237,7 +241,8 @@ final class ConnectionSettingsViewModel: ObservableObject {
Current.api(for: server)?.registerSensors().cauterize()
}
- func shareServer() -> UIActivityViewController? {
+ /// The link that invites someone to this server, or nil when there is no address to invite them to.
+ func invitationURL() -> URL? {
guard let invitationServerURL = server.info.connection.invitationURL() else {
Current.Log.error("Invitation button failed, no invitation URL found for server \(server.identifier)")
return nil
@@ -249,8 +254,15 @@ final class ConnectionSettingsViewModel: ObservableObject {
return nil
}
+ return invitationURL
+ }
+
+ #if !os(macOS)
+ func shareServer() -> UIActivityViewController? {
+ guard let invitationURL = invitationURL() else { return nil }
return UIActivityViewController(activityItems: [invitationURL], applicationActivities: nil)
}
+ #endif
func activateServer() {
if Current.isCatalyst, Current.settingsStore.macNativeFeaturesOnly {
diff --git a/Sources/App/Settings/Connection/ConnectionURLView.swift b/Sources/App/Settings/Connection/ConnectionURLView.swift
index ff74441d62..5ba4eb512b 100644
--- a/Sources/App/Settings/Connection/ConnectionURLView.swift
+++ b/Sources/App/Settings/Connection/ConnectionURLView.swift
@@ -83,7 +83,9 @@ struct ConnectionURLView: View {
private var urlTextField: some View {
TextField(viewModel.placeholder, text: $viewModel.url)
+ #if !os(macOS)
.textContentType(.URL)
+ #endif
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
diff --git a/Sources/App/Settings/Connection/ConnectionURLsHowItWorksView.swift b/Sources/App/Settings/Connection/ConnectionURLsHowItWorksView.swift
index 2dc8ea33f9..45ef0b0429 100644
--- a/Sources/App/Settings/Connection/ConnectionURLsHowItWorksView.swift
+++ b/Sources/App/Settings/Connection/ConnectionURLsHowItWorksView.swift
@@ -46,7 +46,7 @@ struct ConnectionURLsHowItWorksView: View {
]
var body: some View {
- List {
+ GroupedList {
Section {
Text(L10n.Settings.ConnectionSection.UrlsHowItWorks.intro)
.font(.body)
diff --git a/Sources/App/Settings/Connection/Webhook/WebhookDetailViewModel.swift b/Sources/App/Settings/Connection/Webhook/WebhookDetailViewModel.swift
index b5ec3ec985..d6152431ca 100644
--- a/Sources/App/Settings/Connection/Webhook/WebhookDetailViewModel.swift
+++ b/Sources/App/Settings/Connection/Webhook/WebhookDetailViewModel.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@MainActor
final class WebhookDetailViewModel: ObservableObject {
diff --git a/Sources/App/Settings/DatabaseExplorer/DatabaseTableDetailView.swift b/Sources/App/Settings/DatabaseExplorer/DatabaseTableDetailView.swift
index 3fb123e93b..b5f82b0950 100644
--- a/Sources/App/Settings/DatabaseExplorer/DatabaseTableDetailView.swift
+++ b/Sources/App/Settings/DatabaseExplorer/DatabaseTableDetailView.swift
@@ -80,7 +80,7 @@ struct DatabaseTableDetailView: View {
.listRowBackground(Color.clear)
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.one)
} else {
view
diff --git a/Sources/App/Settings/DebugDatabaseTransferSection.swift b/Sources/App/Settings/DebugDatabaseTransferSection.swift
index fbb48ecc47..99345f2558 100644
--- a/Sources/App/Settings/DebugDatabaseTransferSection.swift
+++ b/Sources/App/Settings/DebugDatabaseTransferSection.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
import UniformTypeIdentifiers
struct DebugDatabaseTransferSection: View {
@@ -189,7 +188,7 @@ struct DebugDatabaseTransferSection: View {
}
private func showProgressToast(title: String) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: "debug-database-transfer-\(part.rawValue)",
symbol: .arrowClockwise,
@@ -200,7 +199,7 @@ struct DebugDatabaseTransferSection: View {
}
private func showSuccessToast(title: String, message: String) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: "debug-database-transfer-\(part.rawValue)",
symbol: .checkmarkSealFill,
@@ -213,7 +212,7 @@ struct DebugDatabaseTransferSection: View {
}
private func showFailureToast(title: String, message: String) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: "debug-database-transfer-\(part.rawValue)",
symbol: .exclamationmarkTriangleFill,
diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift
index 609bdd852f..821af78b12 100644
--- a/Sources/App/Settings/DebugView.swift
+++ b/Sources/App/Settings/DebugView.swift
@@ -2,6 +2,7 @@ import PromiseKit
import SFSafeSymbols
import Shared
import SwiftUI
+import UserNotifications
import WebKit
import XCGLogger
@@ -48,7 +49,7 @@ struct DebugView: View {
}()
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .bugIcon,
title: L10n.Settings.Debugging.Header.title,
@@ -98,7 +99,7 @@ struct DebugView: View {
)
}
- if #available(iOS 17, *), !Current.isCatalyst {
+ if #available(iOS 17, macOS 14, *), !Current.isCatalyst {
NavigationLink {
ThreadCredentialsManagementView()
} label: {
@@ -114,7 +115,7 @@ struct DebugView: View {
}
}
} footer: {
- if #available(iOS 17, *), !Current.isCatalyst {
+ if #available(iOS 17, macOS 14, *), !Current.isCatalyst {
Text(
L10n.Settings.Debugging.Thread.footer
)
@@ -134,7 +135,17 @@ struct DebugView: View {
.frame(width: 100, height: 100, alignment: .center)
})
.frame(maxWidth: .infinity, alignment: .center)
- .listRowBackground(Color.clear)
+ .modify { button in
+ #if os(macOS)
+ // A grouped form draws every row in a card, which the logo is not meant to have; a
+ // section header is drawn outside one.
+ Section {} header: {
+ button
+ }
+ #else
+ button.listRowBackground(Color.clear)
+ #endif
+ }
} else {
developerSection
}
@@ -657,7 +668,7 @@ struct DebugView: View {
}
private func showResetAppToast(message: String) {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.show(
id: Self.resetAppToastID,
symbol: .exclamationmarkTriangle,
@@ -669,7 +680,7 @@ struct DebugView: View {
}
private func hideResetAppToast() {
- if #available(iOS 18, *) {
+ if #available(iOS 18, macOS 15, *) {
ToastPresenter.shared.hide(id: Self.resetAppToastID)
}
}
@@ -726,7 +737,7 @@ private struct MediaTypesRequiringUserActionForPlaybackView: View {
}
var body: some View {
- List {
+ GroupedList {
Section {
ForEach(SettingsStore.MediaTypeRequiringUserActionForPlayback.allCases, id: \.self) { mediaType in
Button {
@@ -777,7 +788,7 @@ private struct CarPlayDebugSettingsView: View {
}
var body: some View {
- List {
+ GroupedList {
assistSessionSection
ttsPlaybackSection
ttsSessionSection
diff --git a/Sources/App/Settings/EntityPicker/EntityFilterPickerView.swift b/Sources/App/Settings/EntityPicker/EntityFilterPickerView.swift
index 8cd98863b5..9276c364ae 100644
--- a/Sources/App/Settings/EntityPicker/EntityFilterPickerView.swift
+++ b/Sources/App/Settings/EntityPicker/EntityFilterPickerView.swift
@@ -67,7 +67,7 @@ struct EntityFilterPickerView: View {
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.half)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.glassEffect(.regular.interactive(), in: .capsule)
.contentShape(Capsule())
@@ -128,7 +128,7 @@ struct EntityFilterPickerView: View {
.padding(.vertical, DesignSystem.Spaces.one)
.frame(width: 150, alignment: .leading)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view
.glassEffect(.regular.interactive(), in: .capsule)
.contentShape(Capsule())
diff --git a/Sources/App/Settings/EntityPicker/EntityPicker.swift b/Sources/App/Settings/EntityPicker/EntityPicker.swift
index a9b37775ee..da5904fbfe 100644
--- a/Sources/App/Settings/EntityPicker/EntityPicker.swift
+++ b/Sources/App/Settings/EntityPicker/EntityPicker.swift
@@ -77,7 +77,7 @@ struct EntityPicker: View {
private var fullscreen: some View {
content
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
CloseButton {
@@ -91,7 +91,7 @@ struct EntityPicker: View {
}
#endif
.navigationViewStyle(.stack)
- #if !targetEnvironment(macCatalyst)
+ #if !(targetEnvironment(macCatalyst) || os(macOS))
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
@@ -150,7 +150,7 @@ struct EntityPicker: View {
multipleSelectionConfirmButton
}
.animation(.easeInOut(duration: 0.35), value: viewModel.isRefreshing)
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
reloadToolbarButton
@@ -174,7 +174,7 @@ struct EntityPicker: View {
@ViewBuilder
private func capsuleFieldBackground(_ content: some View) -> some View {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
content
.glassEffect(.regular.interactive(), in: .capsule)
.contentShape(Capsule())
@@ -203,7 +203,7 @@ struct EntityPicker: View {
}
}
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
@ViewBuilder
private var reloadToolbarButton: some View {
if viewModel.isRefreshing {
@@ -322,7 +322,7 @@ struct EntityPicker: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.scrollClipDisabled()
} else {
view
@@ -338,7 +338,7 @@ struct EntityPicker: View {
}
.tint(.haPrimary)
.modify { view in
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
view.buttonStyle(.bordered)
} else {
view
diff --git a/Sources/App/Settings/Focus/FocusHowItWorksView.swift b/Sources/App/Settings/Focus/FocusHowItWorksView.swift
index f2dca27778..a7ce5e4b26 100644
--- a/Sources/App/Settings/Focus/FocusHowItWorksView.swift
+++ b/Sources/App/Settings/Focus/FocusHowItWorksView.swift
@@ -48,7 +48,7 @@ struct FocusHowItWorksView: View {
]
var body: some View {
- List {
+ GroupedList {
Section {
Text(L10n.Focus.HowItWorks.intro)
.font(.body)
diff --git a/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift b/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift
index c28a05cb18..87bcf74643 100644
--- a/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift
+++ b/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift
@@ -76,6 +76,8 @@ struct FocusNameFocusFilterAppIntent: SetFocusFilterIntent {
}
}
+// The protocol does not exist on the Mac, where an app is never suspended to begin with.
+#if !os(macOS)
/// `LiveActivityIntent` is not about Live Activities here: a plain `SetFocusFilterIntent` is only
/// reliably performed while the app is in the foreground, and silently skipped when iOS would have
/// to launch the app to run it — which is every Focus that starts with the app closed, so the
@@ -86,3 +88,4 @@ struct FocusNameFocusFilterAppIntent: SetFocusFilterIntent {
/// below the one that introduced the protocol.
@available(iOS 17.0, *)
extension FocusNameFocusFilterAppIntent: LiveActivityIntent {}
+#endif
diff --git a/Sources/App/Settings/General/GeneralSettingsTemplateEditor.swift b/Sources/App/Settings/General/GeneralSettingsTemplateEditor.swift
index aba8a900db..a80f1f08dc 100644
--- a/Sources/App/Settings/General/GeneralSettingsTemplateEditor.swift
+++ b/Sources/App/Settings/General/GeneralSettingsTemplateEditor.swift
@@ -16,7 +16,7 @@ struct GeneralSettingsTemplateEditor: View {
}
var body: some View {
- List {
+ GroupedList {
Section(L10n.WebView.UniqueServerSelection.title) {
ServersPickerPillList(selectedServerId: $selectedServerId)
.padding(.vertical, DesignSystem.Spaces.half)
diff --git a/Sources/App/Settings/General/GeneralSettingsView.swift b/Sources/App/Settings/General/GeneralSettingsView.swift
index 277260bfd7..d71fdaaa78 100644
--- a/Sources/App/Settings/General/GeneralSettingsView.swift
+++ b/Sources/App/Settings/General/GeneralSettingsView.swift
@@ -12,7 +12,7 @@ struct GeneralSettingsView: View {
@State private var redrawHelper: UUID = .init()
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .cogIcon,
title: L10n.SettingsDetails.General.title,
@@ -20,7 +20,7 @@ struct GeneralSettingsView: View {
)
appIconSelection
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
Section {
launchOnLogin
showAppInPicker
@@ -56,6 +56,7 @@ struct GeneralSettingsView: View {
@ViewBuilder
private var appIconSelection: some View {
+ #if !os(macOS)
if !Current.isCatalyst {
Section {
NavigationLink(destination: AppIconSelectorView()) {
@@ -68,6 +69,7 @@ struct GeneralSettingsView: View {
}
}
}
+ #endif
}
@ViewBuilder
@@ -176,7 +178,7 @@ struct GeneralSettingsView: View {
// MARK: - Mac
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
@ViewBuilder
private var launchOnLogin: some View {
let launcherIdentifier = AppConstants.BundleID.appending(".Launcher")
diff --git a/Sources/App/Settings/Gestures/GesturesSetupView.swift b/Sources/App/Settings/Gestures/GesturesSetupView.swift
index 5a89de2bd9..20fc8281ca 100644
--- a/Sources/App/Settings/Gestures/GesturesSetupView.swift
+++ b/Sources/App/Settings/Gestures/GesturesSetupView.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import SwiftUI
@@ -141,3 +142,4 @@ extension GesturesSetupView: SettingsScreenSearchable {
]
}
}
+#endif
diff --git a/Sources/App/Settings/Kiosk/ConditionalContainerView.swift b/Sources/App/Settings/Kiosk/ConditionalContainerView.swift
index 7c62924fde..610c3fd919 100644
--- a/Sources/App/Settings/Kiosk/ConditionalContainerView.swift
+++ b/Sources/App/Settings/Kiosk/ConditionalContainerView.swift
@@ -9,6 +9,10 @@ struct ConditionalContainerView: View {
@StateObject private var appSettings: AppSettingsPresenter
@Environment(\.scenePhase) private var scenePhase
@State private var showKioskSettings = false
+ #if os(macOS)
+ /// Held for as long as the kiosk keeps the screen on; ending it lets the display sleep again.
+ @State private var displaySleepAssertion: NSObjectProtocol?
+ #endif
@Namespace private var serverSelectionNamespace
/// The scene creates its own presenter; tests pass theirs so they can drive Settings from outside.
@@ -61,16 +65,20 @@ struct ConditionalContainerView: View {
.injectingViewControllerProvider()
// A sheet's content is hosted outside this view, so it is handed the presenter of its own accord.
.environment(\.appSettingsPresenter, appSettings)
- #if !targetEnvironment(macCatalyst)
+ #if os(macOS)
+ // A Mac sheet takes its content's ideal size, and a list has none of its own: without this the
+ // picker shows up as a bare title bar.
+ .frame(minWidth: 440, idealWidth: 480, minHeight: 360, idealHeight: 420)
+ #else
.presentationDetents(sheetDetents, selection: $appSettings.detent)
.presentationDragIndicator(offersCompactDetent ? .visible : .automatic)
.modify { view in
- if #available(iOS 18.0, *), appSettings.selectionRequest?.zoomsFromStandBy == true {
+ if #available(iOS 18.0, macOS 15.0, *), appSettings.selectionRequest?.zoomsFromStandBy == true {
view.navigationTransition(.zoom(
sourceID: HomeAssistantStandByView.serverSelectionTransitionID,
in: serverSelectionNamespace
))
- } else if #available(iOS 18.0, *), let sourceID = appSettings.zoomSourceID {
+ } else if #available(iOS 18.0, macOS 15.0, *), let sourceID = appSettings.zoomSourceID {
view.navigationTransition(.zoom(sourceID: sourceID, in: serverSelectionNamespace))
} else {
view
@@ -138,7 +146,23 @@ struct ConditionalContainerView: View {
}
private func applyKeepScreenOn() {
+ #if os(macOS)
+ // A Mac has no idle timer to switch off: the display stays awake while an activity that asks
+ // for it is in progress.
+ if kiosk.shouldKeepScreenOn {
+ if displaySleepAssertion == nil {
+ displaySleepAssertion = ProcessInfo.processInfo.beginActivity(
+ options: .idleDisplaySleepDisabled,
+ reason: "Kiosk mode keeps the screen on"
+ )
+ }
+ } else if let assertion = displaySleepAssertion {
+ ProcessInfo.processInfo.endActivity(assertion)
+ displaySleepAssertion = nil
+ }
+ #else
UIApplication.shared.isIdleTimerDisabled = kiosk.shouldKeepScreenOn
+ #endif
}
private func refreshWebViewIfSettingsClosed(_ isPresented: Bool) {
diff --git a/Sources/App/Settings/Kiosk/KioskScreensaverView.swift b/Sources/App/Settings/Kiosk/KioskScreensaverView.swift
index 0e70f90e67..343b6a70bb 100644
--- a/Sources/App/Settings/Kiosk/KioskScreensaverView.swift
+++ b/Sources/App/Settings/Kiosk/KioskScreensaverView.swift
@@ -1,7 +1,6 @@
import Combine
import Shared
import SwiftUI
-import UIKit
struct KioskScreensaverView: View {
let settings: KioskScreensaverSettings
@@ -175,13 +174,13 @@ final class KioskScreensaverController: ObservableObject {
.sink { [weak self] in self?.apply($0) }
.store(in: &cancellables)
- NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
+ NotificationCenter.default.publisher(for: AppLifecycle.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.restartIdleTimer()
self?.updateBrightness()
}
.store(in: &cancellables)
- NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
+ NotificationCenter.default.publisher(for: AppLifecycle.willResignActiveNotification)
.sink { [weak self] _ in
self?.idleTimer?.invalidate()
self?.restoreBrightness()
@@ -317,7 +316,7 @@ final class KioskScreensaverController: ObservableObject {
private var shouldDimBrightness: Bool {
#if os(iOS) && !targetEnvironment(macCatalyst)
- UIApplication.shared.applicationState == .active &&
+ ApplicationState.current == .active &&
isEnabled &&
isActive &&
screensaver.dimEnabled &&
@@ -358,6 +357,47 @@ extension KioskScreensaverController: MotionDetectionObserver {
}
}
+#if os(macOS)
+/// Reports every click, drag, scroll and key press in its window: what touching the screen is on a Mac.
+struct KioskActivityDetector: NSViewRepresentable {
+ let onActivity: () -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ let view = ActivityDetectingView()
+ view.onActivity = onActivity
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ (nsView as? ActivityDetectingView)?.onActivity = onActivity
+ }
+}
+
+private final class ActivityDetectingView: NSView {
+ var onActivity: (() -> Void)?
+ private var eventMonitor: Any?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+
+ if let eventMonitor {
+ NSEvent.removeMonitor(eventMonitor)
+ self.eventMonitor = nil
+ }
+
+ guard window != nil else { return }
+ // The events are only watched on their way to whatever handles them, never consumed.
+ eventMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown, .leftMouseDragged, .scrollWheel, .keyDown]
+ ) { [weak self] event in
+ if let self, event.window === window {
+ onActivity?()
+ }
+ return event
+ }
+ }
+}
+#else
struct KioskActivityDetector: UIViewRepresentable {
let onActivity: () -> Void
@@ -416,3 +456,4 @@ private final class AnyTouchRecognizer: UIGestureRecognizer {
onTouch?()
}
}
+#endif
diff --git a/Sources/App/Settings/Kiosk/KioskSensorsViewModel.swift b/Sources/App/Settings/Kiosk/KioskSensorsViewModel.swift
index 57b0d5b37b..473bff9598 100644
--- a/Sources/App/Settings/Kiosk/KioskSensorsViewModel.swift
+++ b/Sources/App/Settings/Kiosk/KioskSensorsViewModel.swift
@@ -2,7 +2,11 @@ import Combine
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Backs the "Sensors" menu inside kiosk settings. The sensors shown here are the same
/// `WebhookSensor`s exposed in the standard sensor settings, and enabling/disabling them goes
@@ -41,7 +45,7 @@ final class KioskSensorsViewModel: ObservableObject {
func refresh() {
firstly {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .userRequested
)
}.catch { error in
diff --git a/Sources/App/Settings/Kiosk/KioskSettingsEntryCustomizationView.swift b/Sources/App/Settings/Kiosk/KioskSettingsEntryCustomizationView.swift
index ddaf87114d..7732c07282 100644
--- a/Sources/App/Settings/Kiosk/KioskSettingsEntryCustomizationView.swift
+++ b/Sources/App/Settings/Kiosk/KioskSettingsEntryCustomizationView.swift
@@ -30,7 +30,7 @@ struct KioskSettingsEntryCustomizationView: View {
Spacer()
ZStack {
RoundedRectangle(cornerRadius: 16)
- .fill(Color(uiColor: .tertiarySystemFill))
+ .fill(Color(uiColor: .tertiaryFill))
.frame(height: 140)
KioskSettingsEntryIcon(
backgroundColor: backgroundColor.wrappedValue,
diff --git a/Sources/App/Settings/Kiosk/KioskSettingsView.swift b/Sources/App/Settings/Kiosk/KioskSettingsView.swift
index a1b5b1c34d..2f393693d9 100644
--- a/Sources/App/Settings/Kiosk/KioskSettingsView.swift
+++ b/Sources/App/Settings/Kiosk/KioskSettingsView.swift
@@ -151,7 +151,7 @@ struct KioskSettingsView: View {
/// while the kiosk accepts the commands it would confirm. The screen and the settings search index
/// share this predicate so search never advertises a row that is not on screen.
static func showsCommandConfirmationRow(acceptRemoteCommands: Bool) -> Bool {
- guard #available(iOS 18, *) else { return false }
+ guard #available(iOS 18, macOS 15, *) else { return false }
return acceptRemoteCommands
}
diff --git a/Sources/App/Settings/Kiosk/KioskView.swift b/Sources/App/Settings/Kiosk/KioskView.swift
index b7f3ea7b3a..db9b00e727 100644
--- a/Sources/App/Settings/Kiosk/KioskView.swift
+++ b/Sources/App/Settings/Kiosk/KioskView.swift
@@ -1,6 +1,5 @@
import Shared
import SwiftUI
-import UIKit
struct KioskView: View {
@StateObject private var screensaver = KioskScreensaverController()
diff --git a/Sources/App/Settings/Location/LocationSettingsView.swift b/Sources/App/Settings/Location/LocationSettingsView.swift
index 88a25aec1d..fa8473048e 100644
--- a/Sources/App/Settings/Location/LocationSettingsView.swift
+++ b/Sources/App/Settings/Location/LocationSettingsView.swift
@@ -4,7 +4,6 @@ import PromiseKit
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
struct LocationSettingsView: View {
@StateObject private var viewModel = LocationSettingsViewModel()
@@ -14,7 +13,7 @@ struct LocationSettingsView: View {
@State private var showAllZones = false
var body: some View {
- Form {
+ GroupedList {
zoneSections
permissionsSection
locationHistorySection
@@ -148,7 +147,7 @@ struct LocationSettingsView: View {
.padding(.horizontal, DesignSystem.Spaces.two)
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.scrollClipDisabled()
} else {
view
@@ -182,6 +181,7 @@ struct LocationSettingsView: View {
} footer: {
Text(L10n.SettingsDetails.Location.Zones.footer)
}
+ #if !os(macOS)
.modify { view in
if #available(iOS 26.0, *) {
view.listSectionMargins(.horizontal, .zero)
@@ -189,6 +189,7 @@ struct LocationSettingsView: View {
view
}
}
+ #endif
}
}
@@ -198,7 +199,7 @@ struct LocationSettingsView: View {
isUpdatingLocation = true
firstly {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .userRequested
)
}.ensure {
@@ -252,15 +253,15 @@ struct LocationZoneMapView: View {
}
}
-private struct ZoneMapRepresentable: UIViewRepresentable {
+private struct ZoneMapRepresentable {
let coordinate: CLLocationCoordinate2D
let radius: Double
func makeCoordinator() -> Coordinator { Coordinator() }
- func makeUIView(context: Context) -> MKMapView {
+ private func makeMapView(delegate: Coordinator) -> MKMapView {
let mapView = MKMapView()
- mapView.delegate = context.coordinator
+ mapView.delegate = delegate
mapView.showsUserLocation = true
let pin = MKPointAnnotation()
@@ -282,8 +283,6 @@ private struct ZoneMapRepresentable: UIViewRepresentable {
return mapView
}
- func updateUIView(_ uiView: MKMapView, context: Context) {}
-
final class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
guard let circle = overlay as? MKCircle else {
@@ -299,6 +298,24 @@ private struct ZoneMapRepresentable: UIViewRepresentable {
}
}
+#if os(macOS)
+extension ZoneMapRepresentable: NSViewRepresentable {
+ func makeNSView(context: Context) -> MKMapView {
+ makeMapView(delegate: context.coordinator)
+ }
+
+ func updateNSView(_ nsView: MKMapView, context: Context) {}
+}
+#else
+extension ZoneMapRepresentable: UIViewRepresentable {
+ func makeUIView(context: Context) -> MKMapView {
+ makeMapView(delegate: context.coordinator)
+ }
+
+ func updateUIView(_ uiView: MKMapView, context: Context) {}
+}
+#endif
+
#Preview {
NavigationView {
LocationSettingsView()
diff --git a/Sources/App/Settings/Location/LocationSettingsViewModel.swift b/Sources/App/Settings/Location/LocationSettingsViewModel.swift
index 33af7c8693..717dbbe75a 100644
--- a/Sources/App/Settings/Location/LocationSettingsViewModel.swift
+++ b/Sources/App/Settings/Location/LocationSettingsViewModel.swift
@@ -3,7 +3,11 @@ import Foundation
import GRDB
import MapKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// View model backing `LocationSettingsView`. Tracks permission status, background refresh status,
/// the persisted location-source toggles and exposes the list of zones for display.
@@ -13,7 +17,9 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
@Published private(set) var locationAuthorizationStatus: CLAuthorizationStatus
@Published private(set) var locationAccuracyAuthorization: CLAccuracyAuthorization
+ #if !os(macOS)
@Published private(set) var backgroundRefreshStatus: UIBackgroundRefreshStatus
+ #endif
// MARK: - Location source toggles
@@ -54,7 +60,9 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
let probe = CLLocationManager()
self.locationAuthorizationStatus = probe.authorizationStatus
self.locationAccuracyAuthorization = probe.accuracyAuthorization
+ #if !os(macOS)
self.backgroundRefreshStatus = UIApplication.shared.backgroundRefreshStatus
+ #endif
self.zoneEnabled = sources.zone
self.backgroundFetchEnabled = sources.backgroundFetch
@@ -65,6 +73,7 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
locationManager.delegate = self
+ #if !os(macOS)
self.backgroundRefreshObserver = NotificationCenter.default.addObserver(
forName: UIApplication.backgroundRefreshStatusDidChangeNotification,
object: nil,
@@ -75,6 +84,7 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
self?.backgroundRefreshStatus = status
}
}
+ #endif
observeZones()
}
@@ -90,7 +100,9 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
// Pick up any changes that may have occurred while the view was off-screen.
locationAuthorizationStatus = locationManager.authorizationStatus
locationAccuracyAuthorization = locationManager.accuracyAuthorization
+ #if !os(macOS)
backgroundRefreshStatus = UIApplication.shared.backgroundRefreshStatus
+ #endif
requestCurrentLocationIfAuthorized()
}
@@ -122,7 +134,12 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
}
}
+ /// A Mac app keeps running whether or not it is on screen, so there is no background refresh to
+ /// allow or deny: it always counts as available there.
var backgroundRefreshDescription: String {
+ #if os(macOS)
+ return L10n.SettingsDetails.Location.BackgroundRefresh.enabled
+ #else
switch backgroundRefreshStatus {
case .available:
return L10n.SettingsDetails.Location.BackgroundRefresh.enabled
@@ -131,6 +148,7 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
@unknown default:
return L10n.SettingsDetails.Location.BackgroundRefresh.disabled
}
+ #endif
}
// MARK: - Disabled-state helpers
@@ -144,7 +162,11 @@ final class LocationSettingsViewModel: NSObject, ObservableObject {
}
private var isBackgroundRefreshAvailable: Bool {
+ #if os(macOS)
+ true
+ #else
backgroundRefreshStatus == .available
+ #endif
}
var isZoneToggleDisabled: Bool {
diff --git a/Sources/App/Settings/Location/Zones/ZoneMapSnapshotView.swift b/Sources/App/Settings/Location/Zones/ZoneMapSnapshotView.swift
index 4c87c33bc0..27686f4f4d 100644
--- a/Sources/App/Settings/Location/Zones/ZoneMapSnapshotView.swift
+++ b/Sources/App/Settings/Location/Zones/ZoneMapSnapshotView.swift
@@ -52,7 +52,11 @@ struct ZoneMapSnapshotView: View {
longitudinalMeters: regionMeters
)
options.size = size
+ #if os(macOS)
+ options.appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
+ #else
options.traitCollection = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
+ #endif
do {
let snapshot = try await MKMapSnapshotter(options: options).start()
@@ -63,17 +67,28 @@ struct ZoneMapSnapshotView: View {
}
private func drawZoneOverlay(on snapshot: MKMapSnapshotter.Snapshot, size: CGSize) -> UIImage {
- let center = snapshot.point(for: coordinate)
+ let center = snapshot.topLeftPoint(for: coordinate)
// Project a point on the circle's edge to know the radius in screen points.
let edgeCoordinate = coordinate.moving(
distance: .init(value: radius, unit: .meters),
direction: .init(value: 90, unit: .degrees)
)
- let pointRadius = abs(snapshot.point(for: edgeCoordinate).x - center.x)
+ let pointRadius = abs(snapshot.topLeftPoint(for: edgeCoordinate).x - center.x)
let zoneColor = UIColor(Color.haPrimary)
return UIGraphicsImageRenderer(size: size).image { _ in
+ #if os(macOS)
+ snapshot.image.draw(
+ in: CGRect(origin: .zero, size: size),
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1,
+ respectFlipped: true,
+ hints: nil
+ )
+ #else
snapshot.image.draw(at: .zero)
+ #endif
let circle = UIBezierPath(ovalIn: CGRect(
x: center.x - pointRadius,
diff --git a/Sources/App/Settings/MacToolbar/MacToolbarSettingsView.swift b/Sources/App/Settings/MacToolbar/MacToolbarSettingsView.swift
index ca4d659822..4962cdde00 100644
--- a/Sources/App/Settings/MacToolbar/MacToolbarSettingsView.swift
+++ b/Sources/App/Settings/MacToolbar/MacToolbarSettingsView.swift
@@ -5,7 +5,7 @@ struct MacToolbarSettingsView: View {
@StateObject private var viewModel = MacToolbarSettingsViewModel()
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .dockWindowIcon,
title: L10n.Settings.MacToolbar.title,
diff --git a/Sources/App/Settings/MagicItem/Add/AssistPromptMagicItemView.swift b/Sources/App/Settings/MagicItem/Add/AssistPromptMagicItemView.swift
index 30dc9c2357..cba76a26f9 100644
--- a/Sources/App/Settings/MagicItem/Add/AssistPromptMagicItemView.swift
+++ b/Sources/App/Settings/MagicItem/Add/AssistPromptMagicItemView.swift
@@ -45,7 +45,7 @@ struct AssistPromptMagicItemView: View {
}
var body: some View {
- List {
+ GroupedList {
Section {
HStack {
Text(verbatim: L10n.MagicItem.Action.Assist.Pipeline.title)
diff --git a/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift b/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift
index 1e3027f82b..dddff01136 100644
--- a/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift
+++ b/Sources/App/Settings/MagicItem/Add/MagicItemAddView.swift
@@ -41,7 +41,7 @@ struct MagicItemAddView: View {
let resolvedPickerOptions = visiblePickerOptions ?? {
var options: [PickerOption] = [.entities]
- if [.carPlay, .appIconShortcut].contains(context), #available(iOS 26.0, *) {
+ if [.carPlay, .appIconShortcut].contains(context), #available(iOS 26.0, macOS 26.0, *) {
options.append(.assistPipelines)
}
return options
@@ -98,7 +98,7 @@ struct MagicItemAddView: View {
viewModel.selectedServerId = Current.servers.all.first?.identifier.rawValue
}
}
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
.toolbar(content: {
CloseButton {
dismiss()
@@ -107,7 +107,7 @@ struct MagicItemAddView: View {
#endif
}
.navigationViewStyle(.stack)
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
.frame(minWidth: 540, minHeight: 720)
#else
.presentationDetents([.large])
diff --git a/Sources/App/Settings/MagicItem/Edit/MagicItemCustomizationView.swift b/Sources/App/Settings/MagicItem/Edit/MagicItemCustomizationView.swift
index 783eb7500b..bac34cb96d 100644
--- a/Sources/App/Settings/MagicItem/Edit/MagicItemCustomizationView.swift
+++ b/Sources/App/Settings/MagicItem/Edit/MagicItemCustomizationView.swift
@@ -30,7 +30,7 @@ struct MagicItemCustomizationView: View {
}
var body: some View {
- List {
+ GroupedList {
if let info = viewModel.info {
mainInformationView(info: info)
customizationView(info: info)
diff --git a/Sources/App/Settings/ManageStorage/ManageStorageView.swift b/Sources/App/Settings/ManageStorage/ManageStorageView.swift
index d6ffbcf01e..3dc8d8e0f2 100644
--- a/Sources/App/Settings/ManageStorage/ManageStorageView.swift
+++ b/Sources/App/Settings/ManageStorage/ManageStorageView.swift
@@ -13,7 +13,7 @@ struct ManageStorageView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: nil,
headerImageAlternativeView: AnyView(
diff --git a/Sources/App/Settings/NFC/NFCNDEFPayload+Additions.swift b/Sources/App/Settings/NFC/NFCNDEFPayload+Additions.swift
index faf6a4ca61..47c715dcb8 100644
--- a/Sources/App/Settings/NFC/NFCNDEFPayload+Additions.swift
+++ b/Sources/App/Settings/NFC/NFCNDEFPayload+Additions.swift
@@ -1,4 +1,5 @@
-#if !targetEnvironment(macCatalyst)
+#if canImport(CoreNFC)
+#if !(targetEnvironment(macCatalyst) || os(macOS))
import CoreNFC
extension NFCNDEFPayload {
@@ -21,3 +22,4 @@ extension NFCNDEFPayload {
}
}
#endif
+#endif
diff --git a/Sources/App/Settings/NFC/NFCReader.swift b/Sources/App/Settings/NFC/NFCReader.swift
index 1d6a3993d0..d577d227d0 100644
--- a/Sources/App/Settings/NFC/NFCReader.swift
+++ b/Sources/App/Settings/NFC/NFCReader.swift
@@ -1,4 +1,5 @@
-#if !targetEnvironment(macCatalyst)
+#if canImport(CoreNFC)
+#if !(targetEnvironment(macCatalyst) || os(macOS))
import CoreNFC
import Foundation
import PromiseKit
@@ -92,3 +93,4 @@ class NFCReader: NSObject, NFCTagReaderSessionDelegate {
}
}
#endif
+#endif
diff --git a/Sources/App/Settings/NFC/NFCTagView.swift b/Sources/App/Settings/NFC/NFCTagView.swift
index 7326ccaf4e..2ac7dfcdd1 100644
--- a/Sources/App/Settings/NFC/NFCTagView.swift
+++ b/Sources/App/Settings/NFC/NFCTagView.swift
@@ -1,11 +1,9 @@
import Shared
import SwiftUI
-import UIKit
struct NFCTagView: View {
let identifier: String
- @State private var showShareSheet = false
@State private var showYamlSheet = false
var body: some View {
@@ -16,9 +14,6 @@ struct NFCTagView: View {
}
.navigationTitle(L10n.Nfc.Detail.title)
.navigationBarTitleDisplayMode(.inline)
- .sheet(isPresented: $showShareSheet) {
- NFCShareSheet(activityItems: [identifier])
- }
.sheet(isPresented: $showYamlSheet) {
YamlCodeView(yaml: yamlExample)
}
@@ -46,9 +41,7 @@ struct NFCTagView: View {
}
}
- Button {
- showShareSheet = true
- } label: {
+ ShareLink(item: identifier) {
Label {
Text(L10n.Nfc.Detail.share)
.foregroundColor(.primary)
@@ -127,22 +120,6 @@ struct NFCTagView: View {
}
}
-// MARK: - Activity View Controller (Share Sheet)
-
-struct NFCShareSheet: UIViewControllerRepresentable {
- let activityItems: [Any]
- let applicationActivities: [UIActivity]? = nil
-
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(
- activityItems: activityItems,
- applicationActivities: applicationActivities
- )
- }
-
- func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
-}
-
// MARK: - YAML Code View
struct YamlCodeView: View {
diff --git a/Sources/App/Settings/NFC/NFCWriter.swift b/Sources/App/Settings/NFC/NFCWriter.swift
index 016fbe8e54..f7a28f495c 100644
--- a/Sources/App/Settings/NFC/NFCWriter.swift
+++ b/Sources/App/Settings/NFC/NFCWriter.swift
@@ -1,4 +1,5 @@
-#if !targetEnvironment(macCatalyst)
+#if canImport(CoreNFC)
+#if !(targetEnvironment(macCatalyst) || os(macOS))
import CoreNFC
import Foundation
import PromiseKit
@@ -127,3 +128,4 @@ class NFCWriter: NSObject, NFCNDEFReaderSessionDelegate {
}
}
#endif
+#endif
diff --git a/Sources/App/Settings/NFC/iOSTagManager.swift b/Sources/App/Settings/NFC/iOSTagManager.swift
index 5353a749c9..d1382f3130 100644
--- a/Sources/App/Settings/NFC/iOSTagManager.swift
+++ b/Sources/App/Settings/NFC/iOSTagManager.swift
@@ -1,7 +1,7 @@
import Foundation
import PromiseKit
import Shared
-#if !targetEnvironment(macCatalyst)
+#if !(targetEnvironment(macCatalyst) || os(macOS))
import CoreNFC
#endif
@@ -88,7 +88,7 @@ class TagActivityManager: TagManager {
}
}
-#if !targetEnvironment(macCatalyst)
+#if !(targetEnvironment(macCatalyst) || os(macOS))
class iOSTagManager: TagActivityManager {
override var isNFCAvailable: Bool {
NFCNDEFReaderSession.readingAvailable
diff --git a/Sources/App/Settings/Notifications/NotificationDebugView.swift b/Sources/App/Settings/Notifications/NotificationDebugView.swift
index 89887d0aa0..f5a86780dd 100644
--- a/Sources/App/Settings/Notifications/NotificationDebugView.swift
+++ b/Sources/App/Settings/Notifications/NotificationDebugView.swift
@@ -5,8 +5,6 @@ import SwiftUI
struct NotificationDebugView: View {
@StateObject private var viewModel = NotificationDebugViewModel()
- @State private var showShareSheet = false
- @State private var shareItems: [Any] = []
@State private var resetAlert: ResetAlertInfo?
@State private var ratePromise: Promise?
@State private var rateLimitRemaining: Int?
@@ -18,7 +16,7 @@ struct NotificationDebugView: View {
}
var body: some View {
- List {
+ GroupedList {
Section {
NavigationLink {
NotificationRateLimitView(initialPromise: ratePromise) { response in
@@ -41,11 +39,7 @@ struct NotificationDebugView: View {
Text(L10n.SettingsDetails.Location.Notifications.header)
}
- Button {
- guard let id = viewModel.pushID else { return }
- shareItems = [id]
- showShareSheet = true
- } label: {
+ ShareLink(item: viewModel.pushID ?? "") {
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(L10n.SettingsDetails.Notifications.PushIdSection.header)
.foregroundColor(.primary)
@@ -56,6 +50,7 @@ struct NotificationDebugView: View {
.truncationMode(.middle)
}
}
+ .disabled(viewModel.pushID == nil)
Button {
viewModel.resetPushID { result in
@@ -86,9 +81,6 @@ struct NotificationDebugView: View {
ratePromise = promise
}
}
- .sheet(isPresented: $showShareSheet) {
- NotificationsShareSheet(activityItems: shareItems)
- }
.alert(item: $resetAlert) { info in
Alert(
title: Text(info.title),
diff --git a/Sources/App/Settings/Notifications/NotificationHistoryView.swift b/Sources/App/Settings/Notifications/NotificationHistoryView.swift
index 77568ab995..4b903c6359 100644
--- a/Sources/App/Settings/Notifications/NotificationHistoryView.swift
+++ b/Sources/App/Settings/Notifications/NotificationHistoryView.swift
@@ -114,7 +114,7 @@ struct NotificationHistoryView: View {
}
.listRowBackground(Color.clear)
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.scrollClipDisabled(true)
} else {
view
@@ -124,7 +124,7 @@ struct NotificationHistoryView: View {
Text(L10n.SettingsDetails.Notifications.History.footer)
}
.modify { view in
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.one)
} else {
view
diff --git a/Sources/App/Settings/Notifications/NotificationRateLimitView.swift b/Sources/App/Settings/Notifications/NotificationRateLimitView.swift
index 9893f42c8c..2d910122e3 100644
--- a/Sources/App/Settings/Notifications/NotificationRateLimitView.swift
+++ b/Sources/App/Settings/Notifications/NotificationRateLimitView.swift
@@ -15,7 +15,7 @@ struct NotificationRateLimitView: View {
}
var body: some View {
- List {
+ GroupedList {
content
}
.navigationTitle(L10n.SettingsDetails.Notifications.RateLimits.header)
diff --git a/Sources/App/Settings/Notifications/NotificationSettingsView.swift b/Sources/App/Settings/Notifications/NotificationSettingsView.swift
index 4d35fcf47f..50568a09f6 100644
--- a/Sources/App/Settings/Notifications/NotificationSettingsView.swift
+++ b/Sources/App/Settings/Notifications/NotificationSettingsView.swift
@@ -11,7 +11,7 @@ struct NotificationSettingsView: View {
@StateObject private var viewModel = NotificationSettingsViewModel()
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .bellIcon,
title: L10n.SettingsDetails.Notifications.title,
@@ -40,7 +40,7 @@ struct NotificationSettingsView: View {
viewModel.refreshPermissionStatus()
}
.onReceive(
- NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
+ NotificationCenter.default.publisher(for: AppLifecycle.didBecomeActiveNotification)
) { _ in
viewModel.refreshPermissionStatus()
viewModel.refreshBadgeCount()
@@ -114,8 +114,7 @@ struct NotificationSettingsView: View {
private var badgeSection: some View {
Section {
Button {
- UIApplication.shared.applicationIconBadgeNumber = 0
- viewModel.refreshBadgeCount()
+ viewModel.clearBadge()
} label: {
HStack {
Text(L10n.SettingsDetails.Notifications.BadgeSection.Button.title)
@@ -193,13 +192,20 @@ final class NotificationSettingsViewModel: ObservableObject {
}
}
- init() {
+ private let badge: AppIconBadge
+
+ init(badge: AppIconBadge = .application) {
+ self.badge = badge
refreshBadgeCount()
}
func refreshBadgeCount() {
- let value = UIApplication.shared.applicationIconBadgeNumber
- badgeCountText = NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal)
+ badgeCountText = NumberFormatter.localizedString(from: NSNumber(value: badge.count()), number: .decimal)
+ }
+
+ func clearBadge() {
+ badge.clear()
+ refreshBadgeCount()
}
func refreshPermissionStatus() {
diff --git a/Sources/App/Settings/Notifications/NotificationsShareSheet.swift b/Sources/App/Settings/Notifications/NotificationsShareSheet.swift
deleted file mode 100644
index 3778781609..0000000000
--- a/Sources/App/Settings/Notifications/NotificationsShareSheet.swift
+++ /dev/null
@@ -1,11 +0,0 @@
-import SwiftUI
-
-struct NotificationsShareSheet: UIViewControllerRepresentable {
- let activityItems: [Any]
-
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
- }
-
- func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
-}
diff --git a/Sources/App/Settings/PrivacyView.swift b/Sources/App/Settings/PrivacyView.swift
index c3a766a5f5..afc4583960 100644
--- a/Sources/App/Settings/PrivacyView.swift
+++ b/Sources/App/Settings/PrivacyView.swift
@@ -9,7 +9,7 @@ struct PrivacyView: View {
@State private var analytics: Bool = Current.settingsStore.privacy.analytics
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .lockIcon,
title: L10n.SettingsDetails.Privacy.title,
diff --git a/Sources/App/Settings/Sensors/Details/SensorDetailView.swift b/Sources/App/Settings/Sensors/Details/SensorDetailView.swift
index 0cc259d703..085d81822d 100644
--- a/Sources/App/Settings/Sensors/Details/SensorDetailView.swift
+++ b/Sources/App/Settings/Sensors/Details/SensorDetailView.swift
@@ -10,7 +10,7 @@ struct SensorDetailView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: MaterialDesignIcons(serversideValueNamed: viewModel.sensor.Icon.orEmpty, fallback: .motionIcon),
title: viewModel.sensor.Name.orEmpty
@@ -95,6 +95,15 @@ struct SensorDetailView: View {
return rows
}
+ /// A Mac steps a value in a field that can also be typed into, rather than with a stepper alone.
+ private static var usesDecimalStepper: Bool {
+ #if os(macOS)
+ true
+ #else
+ UIDevice.current.userInterfaceIdiom == .mac
+ #endif
+ }
+
private static func makeRow(for setting: WebhookSensorSetting) -> AnyView {
{
switch setting.type {
@@ -108,7 +117,7 @@ struct SensorDetailView: View {
}
)
case let .stepper(getter, setter, minimum, maximum, step, displayValueFor):
- if UIDevice.current.userInterfaceIdiom == .mac {
+ if usesDecimalStepper {
return AnyView(
SensorDetailsDecimalStepper(
title: setting.title,
diff --git a/Sources/App/Settings/Sensors/Details/SensorDetailViewModel.swift b/Sources/App/Settings/Sensors/Details/SensorDetailViewModel.swift
index 7f7c85173a..2de03285b4 100644
--- a/Sources/App/Settings/Sensors/Details/SensorDetailViewModel.swift
+++ b/Sources/App/Settings/Sensors/Details/SensorDetailViewModel.swift
@@ -2,7 +2,6 @@ import Combine
import Foundation
import Shared
import SwiftUI
-import UIKit
class SensorDetailViewModel: ObservableObject, SensorObserver {
/// The server this sensor is being configured for. Enablement is per server, so the switch
diff --git a/Sources/App/Settings/Sensors/Health/HealthSensorListViewModel.swift b/Sources/App/Settings/Sensors/Health/HealthSensorListViewModel.swift
index bb9ed6d6e9..df587fdb9c 100644
--- a/Sources/App/Settings/Sensors/Health/HealthSensorListViewModel.swift
+++ b/Sources/App/Settings/Sensors/Health/HealthSensorListViewModel.swift
@@ -95,7 +95,7 @@ class HealthSensorListViewModel: ObservableObject {
func refreshSensors() {
firstly {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .userRequested
)
}.catch { [weak self] error in
diff --git a/Sources/App/Settings/Sensors/Health/HealthSensorRow.swift b/Sources/App/Settings/Sensors/Health/HealthSensorRow.swift
index f89b1ab7f4..90eab8b759 100644
--- a/Sources/App/Settings/Sensors/Health/HealthSensorRow.swift
+++ b/Sources/App/Settings/Sensors/Health/HealthSensorRow.swift
@@ -11,7 +11,7 @@ struct HealthSensorRow: View {
private var icon: UIImage {
MaterialDesignIcons(serversideValueNamed: metric.icon)
- .settingsIcon(for: UITraitCollection.current)
+ .settingsIcon()
}
private var subtitle: String? {
diff --git a/Sources/App/Settings/Sensors/List/SensorForegroundOnlyBadge.swift b/Sources/App/Settings/Sensors/List/SensorForegroundOnlyBadge.swift
index 3a13568a76..0bd8a052b1 100644
--- a/Sources/App/Settings/Sensors/List/SensorForegroundOnlyBadge.swift
+++ b/Sources/App/Settings/Sensors/List/SensorForegroundOnlyBadge.swift
@@ -12,7 +12,7 @@ struct SensorForegroundOnlyBadge: View {
.foregroundStyle(.secondary)
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.micro)
- .background(Color(uiColor: .tertiarySystemFill), in: Capsule())
+ .background(Color(uiColor: .tertiaryFill), in: Capsule())
.accessibilityLabel(L10n.SettingsSensors.Sensors.ForegroundOnly.accessibilityLabel)
}
}
diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift
index 815f5f81f9..2a98979252 100644
--- a/Sources/App/Settings/Sensors/List/SensorListView.swift
+++ b/Sources/App/Settings/Sensors/List/SensorListView.swift
@@ -37,7 +37,7 @@ struct SensorListView: View {
}()
var body: some View {
- List {
+ GroupedList {
if !viewModel.isSearching {
if let server = viewModel.server, isServerScoped {
AppleLikeListTopRowHeader(
@@ -190,11 +190,15 @@ struct SensorListView: View {
}
}
}
+ #if os(macOS)
+ .searchable(text: $viewModel.searchTerm, prompt: Text(searchPrompt))
+ #else
.searchable(
text: $viewModel.searchTerm,
placement: .navigationBarDrawer(displayMode: .always),
prompt: Text(searchPrompt)
)
+ #endif
.onAppear {
permissionsViewModel.update()
viewModel.refresh()
diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift
index fb744589ac..f87a0da168 100644
--- a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift
+++ b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift
@@ -140,7 +140,7 @@ class SensorListViewModel: ObservableObject {
func refresh() {
firstly {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
type: .userRequested
)
}.catch { [weak self] error in
diff --git a/Sources/App/Settings/Sensors/List/SensorRow.swift b/Sources/App/Settings/Sensors/List/SensorRow.swift
index 2a3dd22c08..da95fbda50 100644
--- a/Sources/App/Settings/Sensors/List/SensorRow.swift
+++ b/Sources/App/Settings/Sensors/List/SensorRow.swift
@@ -11,7 +11,7 @@ struct SensorRow: View {
var body: some View {
HStack(spacing: DesignSystem.Spaces.two) {
if let icon = sensor.Icon.flatMap({ MaterialDesignIcons(serversideValueNamed: $0) }) {
- Image(uiImage: icon.settingsIcon(for: UITraitCollection.current))
+ Image(uiImage: icon.settingsIcon())
.renderingMode(.template)
.foregroundColor(isEnabled ? .accentColor : .secondary)
}
diff --git a/Sources/App/Settings/Sensors/Permissions/SensorPermissionRequester.swift b/Sources/App/Settings/Sensors/Permissions/SensorPermissionRequester.swift
index 676cbc45dc..9b37c96d4c 100644
--- a/Sources/App/Settings/Sensors/Permissions/SensorPermissionRequester.swift
+++ b/Sources/App/Settings/Sensors/Permissions/SensorPermissionRequester.swift
@@ -6,7 +6,11 @@ import Foundation
import PromiseKit
import Shared
import Speech
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
/// Reads and requests the device permissions behind the sensors.
@@ -33,7 +37,12 @@ final class SensorPermissionRequester {
func isAvailable(_ permission: SensorPermission) -> Bool {
switch permission {
case .motion:
+ #if os(macOS)
+ // No Mac has the motion coprocessor this permission guards.
+ return false
+ #else
return Current.motion.isActivityAvailable()
+ #endif
case .focus:
return Current.focusStatus.isAvailable()
case .location, .notification, .camera, .microphone, .speech, .bluetooth, .localNetwork:
@@ -45,7 +54,12 @@ final class SensorPermissionRequester {
func status(for permission: SensorPermission) -> SensorPermissionStatus {
switch permission {
case .motion:
+ #if os(macOS)
+ // Never shown: the permission is not available on a Mac, so there is nothing to grant.
+ return .restricted
+ #else
return .init(CMMotionActivityManager.authorizationStatus())
+ #endif
case .focus:
return .init(Current.focusStatus.authorizationStatus())
case .location:
@@ -53,10 +67,16 @@ final class SensorPermissionRequester {
case .camera:
return .init(AVCaptureDevice.authorizationStatus(for: .video))
case .microphone:
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
return .init(AVAudioApplication.shared.recordPermission)
} else {
+ #if os(macOS)
+ // A Mac has no audio session; before macOS 14 the capture device answers for the
+ // microphone.
+ return .init(AVCaptureDevice.authorizationStatus(for: .audio))
+ #else
return .init(AVAudioSession.sharedInstance().recordPermission)
+ #endif
}
case .speech:
return .init(SFSpeechRecognizer.authorizationStatus())
@@ -118,7 +138,7 @@ final class SensorPermissionRequester {
private func refreshSensors() {
HomeAssistantAPI.manuallyUpdate(
- applicationState: UIApplication.shared.applicationState,
+ applicationState: ApplicationState.current,
// Not `.userRequested`: this follows a permission prompt on its own, and that type also
// asks for temporary full location accuracy, which has nothing to do with the sensor
// the user just switched on.
@@ -143,14 +163,20 @@ final class SensorPermissionRequester {
Task { @MainActor in completion() }
}
case .microphone:
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
AVAudioApplication.requestRecordPermission { _ in
Task { @MainActor in completion() }
}
} else {
+ #if os(macOS)
+ AVCaptureDevice.requestAccess(for: .audio) { _ in
+ Task { @MainActor in completion() }
+ }
+ #else
AVAudioSession.sharedInstance().requestRecordPermission { _ in
Task { @MainActor in completion() }
}
+ #endif
}
case .speech:
SFSpeechRecognizer.requestAuthorization { _ in
diff --git a/Sources/App/Settings/Sensors/Permissions/SensorPermissionStatus.swift b/Sources/App/Settings/Sensors/Permissions/SensorPermissionStatus.swift
index 7b3f8c8f74..ae5d8e403b 100644
--- a/Sources/App/Settings/Sensors/Permissions/SensorPermissionStatus.swift
+++ b/Sources/App/Settings/Sensors/Permissions/SensorPermissionStatus.swift
@@ -91,7 +91,7 @@ extension SensorPermissionStatus {
}
}
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
init(_ status: AVAudioApplication.recordPermission) {
switch status {
case .undetermined: self = .notDetermined
@@ -101,6 +101,7 @@ extension SensorPermissionStatus {
}
}
+ #if !os(macOS)
init(_ status: AVAudioSession.RecordPermission) {
switch status {
case .undetermined: self = .notDetermined
@@ -109,6 +110,7 @@ extension SensorPermissionStatus {
@unknown default: self = .notDetermined
}
}
+ #endif
init(_ status: SFSpeechRecognizerAuthorizationStatus) {
switch status {
diff --git a/Sources/App/Settings/Sensors/Permissions/SensorPermissionsView.swift b/Sources/App/Settings/Sensors/Permissions/SensorPermissionsView.swift
index 553e62197d..2d72886fa1 100644
--- a/Sources/App/Settings/Sensors/Permissions/SensorPermissionsView.swift
+++ b/Sources/App/Settings/Sensors/Permissions/SensorPermissionsView.swift
@@ -6,7 +6,7 @@ struct SensorPermissionsView: View {
@StateObject private var viewModel = SensorPermissionsViewModel()
var body: some View {
- List {
+ GroupedList {
ForEach(viewModel.availablePermissions) { permission in
Section {
Button {
diff --git a/Sources/App/Settings/Settings/ClosestServerSourceBadge.swift b/Sources/App/Settings/Settings/ClosestServerSourceBadge.swift
index c2bf6a87c4..9ba7284f9a 100644
--- a/Sources/App/Settings/Settings/ClosestServerSourceBadge.swift
+++ b/Sources/App/Settings/Settings/ClosestServerSourceBadge.swift
@@ -22,7 +22,7 @@ struct ClosestServerSourceBadge: View {
.foregroundStyle(.secondary)
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.micro)
- .background(Color(uiColor: .tertiarySystemFill), in: Capsule())
+ .background(Color(uiColor: .tertiaryFill), in: Capsule())
// Keeps its shape wherever it is placed, rather than being squeezed by a neighbour
// that wants the width.
.fixedSize()
diff --git a/Sources/App/Settings/Settings/MacSettingsSidebarLink.swift b/Sources/App/Settings/Settings/MacSettingsSidebarLink.swift
new file mode 100644
index 0000000000..366ba3640b
--- /dev/null
+++ b/Sources/App/Settings/Settings/MacSettingsSidebarLink.swift
@@ -0,0 +1,35 @@
+import Shared
+import SwiftUI
+
+/// A row of the settings list that opens a screen.
+///
+/// On iOS and under Mac Catalyst the row is a link that pushes its destination. In the native Mac app the
+/// sidebar's selection decides what the detail column shows, so the row is only its label: a link there
+/// would open the destination a second time, inside the sidebar column.
+struct MacSettingsSidebarLink: View {
+ private let destination: () -> Destination
+ private let label: () -> Label
+
+ init(destination: @autoclosure @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) {
+ self.destination = destination
+ self.label = label
+ }
+
+ var body: some View {
+ #if os(macOS)
+ label()
+ #else
+ NavigationLink(destination: destination()) {
+ label()
+ }
+ #endif
+ }
+}
+
+#Preview {
+ List {
+ MacSettingsSidebarLink(destination: Text(verbatim: "Destination")) {
+ Text(verbatim: "Row")
+ }
+ }
+}
diff --git a/Sources/App/Settings/Settings/MacSettingsSidebarRowStyle.swift b/Sources/App/Settings/Settings/MacSettingsSidebarRowStyle.swift
index 6101099816..e6ccd5ad18 100644
--- a/Sources/App/Settings/Settings/MacSettingsSidebarRowStyle.swift
+++ b/Sources/App/Settings/Settings/MacSettingsSidebarRowStyle.swift
@@ -12,6 +12,10 @@ struct MacSettingsSidebarRowStyle: ViewModifier {
@State private var isHovering = false
func body(content: Content) -> some View {
+ #if os(macOS)
+ // The system sidebar draws its own selection and hover states.
+ content
+ #else
if Current.isCatalyst {
content
.onHover { isHovering = $0 }
@@ -31,6 +35,7 @@ struct MacSettingsSidebarRowStyle: ViewModifier {
} else {
content
}
+ #endif
}
private var fillOpacity: CGFloat {
diff --git a/Sources/App/Settings/Settings/ServerSwitchingHowItWorksView.swift b/Sources/App/Settings/Settings/ServerSwitchingHowItWorksView.swift
index 6348f03984..577f2f0bf1 100644
--- a/Sources/App/Settings/Settings/ServerSwitchingHowItWorksView.swift
+++ b/Sources/App/Settings/Settings/ServerSwitchingHowItWorksView.swift
@@ -38,7 +38,7 @@ struct ServerSwitchingHowItWorksView: View {
]
var body: some View {
- List {
+ GroupedList {
Section {
Text(L10n.Settings.ServerSwitching.HowItWorks.intro)
.font(.body)
diff --git a/Sources/App/Settings/Settings/ServerSwitchingSettingsView.swift b/Sources/App/Settings/Settings/ServerSwitchingSettingsView.swift
index e0493d7957..0aec0b0fd3 100644
--- a/Sources/App/Settings/Settings/ServerSwitchingSettingsView.swift
+++ b/Sources/App/Settings/Settings/ServerSwitchingSettingsView.swift
@@ -13,7 +13,7 @@ struct ServerSwitchingSettingsView: View {
}
var body: some View {
- List {
+ GroupedList {
Section {
if Current.servers.all.count > 1 {
Toggle(isOn: .init(get: {
diff --git a/Sources/App/Settings/Settings/ServersListView.swift b/Sources/App/Settings/Settings/ServersListView.swift
index 8961f709f8..9c2022cb4f 100644
--- a/Sources/App/Settings/Settings/ServersListView.swift
+++ b/Sources/App/Settings/Settings/ServersListView.swift
@@ -7,11 +7,13 @@ struct ServersListView: View {
@StateObject private var observer = ServersObserver()
@State private var showAddServer = false
@State private var serverPendingDeletion: Server?
+ #if !os(macOS)
@Environment(\.editMode) private var editMode
+ #endif
var body: some View {
ForEach(observer.servers, id: \.identifier) { server in
- NavigationLink(destination: ConnectionSettingsView(server: server)) {
+ MacSettingsSidebarLink(destination: ConnectionSettingsView(server: server)) {
HomeAssistantAccountRowView(server: server, isCompact: Current.isCatalyst)
}
.tag(MacSettingsSidebarSelection.server(server.identifier))
@@ -55,14 +57,14 @@ struct ServersListView: View {
observer.moveServers(from: source, to: destination)
}
- NavigationLink(destination: ServerSwitchingSettingsView()) {
+ MacSettingsSidebarLink(destination: ServerSwitchingSettingsView()) {
Label(L10n.Settings.ServerSwitching.title, systemSymbol: .arrowLeftArrowRight)
}
.tag(MacSettingsSidebarSelection.serverSwitching)
.macSettingsSidebarRow(isSelected: macSidebarSelection == .serverSwitching)
Button {
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
Current.sceneManager.activateAnyScene(for: .onboarding)
#else
showAddServer = true
@@ -70,7 +72,10 @@ struct ServersListView: View {
} label: {
Label(L10n.Settings.ConnectionSection.addServer, systemSymbol: .plus)
}
- #if !targetEnvironment(macCatalyst)
+ #if os(macOS)
+ // A bordered button would read as a control dropped into the sidebar instead of one of its rows.
+ .buttonStyle(.plain)
+ #elseif !targetEnvironment(macCatalyst)
.fullScreenCover(isPresented: $showAddServer) {
OnboardingNavigationView(onboardingStyle: .secondary)
}
diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift
index fbb532b0af..d6276a646d 100644
--- a/Sources/App/Settings/Settings/SettingsItem.swift
+++ b/Sources/App/Settings/Settings/SettingsItem.swift
@@ -106,7 +106,7 @@ enum SettingsItem: String, Hashable, CaseIterable {
@ViewBuilder
func icon(size: CGFloat) -> some View {
- if self == .siri, #available(iOS 26.0, *) {
+ if self == .siri, #available(iOS 26.0, macOS 26.0, *) {
Image(systemSymbol: SFSymbol(rawValue: "siri"))
.resizable()
.scaledToFit()
@@ -135,7 +135,11 @@ enum SettingsItem: String, Hashable, CaseIterable {
case .macToolbar:
MacToolbarSettingsView()
case .gestures:
+ #if os(macOS)
+ EmptyView()
+ #else
GesturesSetupView()
+ #endif
case .greetings:
GreetingsSettingsView()
case .kiosk:
@@ -172,7 +176,11 @@ enum SettingsItem: String, Hashable, CaseIterable {
case .carPlay:
CarPlayConfigurationView()
case .complications:
+ #if os(macOS)
+ EmptyView()
+ #else
SettingsComplicationsView()
+ #endif
case .help:
EmptyView()
case .permissions:
@@ -190,7 +198,7 @@ enum SettingsItem: String, Hashable, CaseIterable {
var isVisible: Bool {
// Filter based on platform
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
// Kiosk mode is unsupported on macOS.
let hiddenItems: [SettingsItem] = [
.servers,
@@ -221,7 +229,7 @@ enum SettingsItem: String, Hashable, CaseIterable {
case .watch, .complications:
return Self.isWatchAvailable
case .carPlay:
- return UIDevice.current.userInterfaceIdiom == .phone
+ return Self.isPhone
case .appLabs:
// App Labs is limited to TestFlight builds while its features mature.
return AppLabsFeature.isLabsAvailable
@@ -276,7 +284,12 @@ enum SettingsItem: String, Hashable, CaseIterable {
case .servers: return SettingsServersView.settingsSearchEntries
case .general: return GeneralSettingsView.settingsSearchEntries
case .macToolbar: return MacToolbarSettingsView.settingsSearchEntries
- case .gestures: return GesturesSetupView.settingsSearchEntries
+ case .gestures:
+ #if os(macOS)
+ return []
+ #else
+ return GesturesSetupView.settingsSearchEntries
+ #endif
case .greetings: return GreetingsSettingsView.settingsSearchEntries
case .kiosk: return KioskSettingsView.settingsSearchEntries
case .location: return LocationSettingsView.settingsSearchEntries
@@ -299,7 +312,12 @@ enum SettingsItem: String, Hashable, CaseIterable {
case .siri: return SiriSettingsView.settingsSearchEntries
case .watch: return WatchConfigurationView.settingsSearchEntries
case .carPlay: return CarPlayConfigurationView.settingsSearchEntries
- case .complications: return ComplicationsRootView.settingsSearchEntries
+ case .complications:
+ #if os(macOS)
+ return []
+ #else
+ return ComplicationsRootView.settingsSearchEntries
+ #endif
case .permissions: return SensorPermissionsView.settingsSearchEntries
case .privacy: return PrivacyView.settingsSearchEntries
case .debugging: return DebugView.settingsSearchEntries
@@ -336,8 +354,17 @@ enum SettingsItem: String, Hashable, CaseIterable {
return !contentMatches(searchQuery: query).isEmpty
}
+ /// Whether this is an iPhone, the only device an Apple Watch pairs with or CarPlay runs from.
+ private static var isPhone: Bool {
+ #if os(macOS)
+ false
+ #else
+ UIDevice.current.userInterfaceIdiom == .phone
+ #endif
+ }
+
private static var isWatchAvailable: Bool {
- guard UIDevice.current.userInterfaceIdiom == .phone else { return false }
+ guard isPhone else { return false }
if Current.isDebug {
return true
} else if case .paired = Communicator.shared.lastKnownWatchState {
@@ -370,9 +397,11 @@ struct SettingsNotificationsView: View {
}
}
+#if os(iOS)
struct SettingsComplicationsView: View {
var body: some View {
ComplicationsRootView()
.navigationTitle(L10n.Settings.DetailsSection.WatchRowComplications.title)
}
}
+#endif
diff --git a/Sources/App/Settings/Settings/SettingsView.swift b/Sources/App/Settings/Settings/SettingsView.swift
index c3dae5466d..961dff514a 100644
--- a/Sources/App/Settings/Settings/SettingsView.swift
+++ b/Sources/App/Settings/Settings/SettingsView.swift
@@ -5,6 +5,11 @@ struct SettingsView: View {
private enum Constants {
static let macSidebarRowHeight: CGFloat = 32
static let macSidebarBottomPadding: CGFloat = DesignSystem.Spaces.three
+ #if os(macOS)
+ static let macSidebarMinWidth: CGFloat = 220
+ static let macSidebarIdealWidth: CGFloat = 260
+ static let macSidebarMaxWidth: CGFloat = 360
+ #endif
}
var embedInOwnNavigation: Bool = true
@@ -21,11 +26,15 @@ struct SettingsView: View {
var body: some View {
Group {
+ #if os(macOS)
+ macOSView
+ #else
if Current.isCatalyst {
macOSView
} else {
iOSView
}
+ #endif
}
.onAppear {
isShowingTranslationKeys = prefs.bool(forKey: "showTranslationKeys")
@@ -34,6 +43,30 @@ struct SettingsView: View {
// MARK: - macOS Split View
+ #if os(macOS)
+ private var macOSView: some View {
+ NavigationSplitView {
+ macOSSidebarContent
+ .navigationSplitViewColumnWidth(
+ min: Constants.macSidebarMinWidth,
+ ideal: Constants.macSidebarIdealWidth,
+ max: Constants.macSidebarMaxWidth
+ )
+ } detail: {
+ NavigationStack {
+ macOSDetail
+ }
+ // A stack per sidebar entry, so a screen pushed under one entry is not left showing under the
+ // next one the user picks.
+ .id(macSidebarSelection)
+ }
+ .onChange(of: serversObserver.servers.map(\.identifier)) { identifiers in
+ if case let .server(identifier) = macSidebarSelection, !identifiers.contains(identifier) {
+ macSidebarSelection = .item(.general)
+ }
+ }
+ }
+ #else
@ViewBuilder
private var macOSView: some View {
// Use navigation view since navigation stack has bugs on Mac Catalyst
@@ -44,6 +77,7 @@ struct SettingsView: View {
}
.navigationViewStyle(.columns)
}
+ #endif
@ViewBuilder
private var macOSDetail: some View {
@@ -63,6 +97,8 @@ struct SettingsView: View {
}
}
+ /// The native Mac app takes the system sidebar as it comes: its material, its selection and its
+ /// search field. Catalyst has no such list style, so there the sidebar is drawn by hand.
private var macOSSidebarContent: some View {
List(selection: $macSidebarSelection) {
if isSearching {
@@ -80,12 +116,20 @@ struct SettingsView: View {
// Settings items grouped by user objective
settingsSections(SettingsSection.allCases, matching: nil)
}
+ #if !os(macOS)
Color.clear
.frame(height: Constants.macSidebarBottomPadding)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
+ #endif
}
+ #if os(macOS)
+ .listStyle(.sidebar)
+ .labelStyle(MacSettingsSidebarLabelStyle())
+ .searchable(text: $searchText, placement: .sidebar, prompt: Text(L10n.Settings.Search.prompt))
+ .navigationTitle(L10n.Settings.NavigationBar.title)
+ #else
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, Constants.macSidebarRowHeight)
.labelStyle(MacSettingsSidebarLabelStyle())
@@ -100,6 +144,7 @@ struct SettingsView: View {
}
}
}
+ #endif
}
private var macOSPlaceholder: some View {
@@ -111,6 +156,7 @@ struct SettingsView: View {
// MARK: - iOS List View
+ #if !os(macOS)
// When pushed onto the container's stack (`embedInOwnNavigation == false`) items are pushed as
// `AppSettingsPushRoute.item` instead, resolved by `ContainerView`: that path must stay
// single-typed or SwiftUI's path diffing can fatally error comparing elements of different types.
@@ -230,6 +276,7 @@ struct SettingsView: View {
}
}
}
+ #endif
private var translationKeysWarningSection: some View {
Section {
@@ -311,11 +358,15 @@ struct SettingsView: View {
let serverResults = serverSearchResults
if SettingsItem.servers.matches(searchQuery: trimmedSearchQuery) || !serverResults.isEmpty {
Section {
+ // The native sidebar lists the servers themselves; the servers screen's own rows are
+ // labels there, so the screen is left out of its results.
+ #if !os(macOS)
if SettingsItem.servers.matches(searchQuery: trimmedSearchQuery) {
settingsItemRow(.servers, searchQuery: trimmedSearchQuery)
}
+ #endif
ForEach(serverResults, id: \.identifier) { server in
- NavigationLink(destination: ConnectionSettingsView(server: server)) {
+ MacSettingsSidebarLink(destination: ConnectionSettingsView(server: server)) {
serverSearchRow(server: server)
}
.tag(MacSettingsSidebarSelection.server(server.identifier))
@@ -375,6 +426,11 @@ struct SettingsView: View {
header: String,
@ViewBuilder content: () -> some View
) -> some View {
+ #if os(macOS)
+ Section(header: Text(header)) {
+ content()
+ }
+ #else
if Current.isCatalyst {
Section {
Text(header)
@@ -398,6 +454,7 @@ struct SettingsView: View {
content()
}
}
+ #endif
}
@ViewBuilder
@@ -419,7 +476,7 @@ struct SettingsView: View {
} else if Current.isCatalyst {
// The Catalyst sidebar lives in a `NavigationView`, which value-based links don't
// support, so it keeps the eager destination.
- NavigationLink(destination: item.destinationView) {
+ MacSettingsSidebarLink(destination: item.destinationView) {
settingsItemLabel(item, subtitle: subtitle)
}
.tag(MacSettingsSidebarSelection.item(item))
@@ -484,6 +541,7 @@ struct SettingsView: View {
}
}
+ #if !os(macOS)
private var aboutViewContent: some View {
AboutView()
.navigationBarTitleDisplayMode(.inline)
@@ -495,6 +553,7 @@ struct SettingsView: View {
}
}
}
+ #endif
}
#Preview {
diff --git a/Sources/App/Settings/Siri/SiriServerConfigurationView.swift b/Sources/App/Settings/Siri/SiriServerConfigurationView.swift
index d4010c35d7..d3e6b457a0 100644
--- a/Sources/App/Settings/Siri/SiriServerConfigurationView.swift
+++ b/Sources/App/Settings/Siri/SiriServerConfigurationView.swift
@@ -14,7 +14,7 @@ struct SiriServerConfigurationView: View {
}
var body: some View {
- List {
+ GroupedList {
exposureSection(
header: L10n.Settings.Siri.Configure.Calendars.header,
footer: L10n.Settings.Siri.Configure.Calendars.footer,
diff --git a/Sources/App/Settings/Siri/SiriSettingsView.swift b/Sources/App/Settings/Siri/SiriSettingsView.swift
index 7a6066c177..74f5ee7ef9 100644
--- a/Sources/App/Settings/Siri/SiriSettingsView.swift
+++ b/Sources/App/Settings/Siri/SiriSettingsView.swift
@@ -15,7 +15,7 @@ struct SiriSettingsView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: nil,
headerImageAlternativeView: AnyView(
@@ -40,7 +40,7 @@ struct SiriSettingsView: View {
)) {
Text(row.name)
}
- if #available(iOS 27.0, *), row.isExposed,
+ if #available(iOS 27.0, macOS 27.0, *), row.isExposed,
let server = Current.servers.server(for: .init(rawValue: row.id)) {
NavigationLink {
SiriServerConfigurationView(server: server)
diff --git a/Sources/App/Settings/Widgets/Builder/CustomWidgetsListView.swift b/Sources/App/Settings/Widgets/Builder/CustomWidgetsListView.swift
index 224bfeeee8..b761824a01 100644
--- a/Sources/App/Settings/Widgets/Builder/CustomWidgetsListView.swift
+++ b/Sources/App/Settings/Widgets/Builder/CustomWidgetsListView.swift
@@ -5,8 +5,8 @@ struct CustomWidgetsListView: View {
@StateObject private var viewModel = WidgetBuilderViewModel()
@State private var showDeleteConfirmation = false
var body: some View {
- List {
- if #available(iOS 17, *) {
+ GroupedList {
+ if #available(iOS 17, macOS 14, *) {
header
yourWidgetsSection
}
@@ -16,7 +16,7 @@ struct CustomWidgetsListView: View {
Text(verbatim: L10n.SettingsDetails.Widgets.ReloadAll.description)
}
- if #available(iOS 17, *) {
+ if #available(iOS 17, macOS 14, *) {
Button {
showDeleteConfirmation = true
@@ -101,7 +101,7 @@ struct CustomWidgetsListView: View {
Text(widget.name)
}
Spacer()
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
Button {
viewModel.deleteWidget(widget)
} label: {
diff --git a/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift b/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift
index 8842633805..1e25a4ce08 100644
--- a/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift
+++ b/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift
@@ -158,7 +158,7 @@ struct WidgetCreationView: View {
)
}
Spacer()
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
Button {
viewModel.deleteItem(item)
} label: {
diff --git a/Sources/App/Templating/JinjaEntitySuggestionsView.swift b/Sources/App/Templating/JinjaEntitySuggestionsView.swift
index 42d9fb8f3b..2ab41e934e 100644
--- a/Sources/App/Templating/JinjaEntitySuggestionsView.swift
+++ b/Sources/App/Templating/JinjaEntitySuggestionsView.swift
@@ -42,7 +42,7 @@ struct JinjaEntitySuggestionsView: View {
.frame(maxWidth: 220, alignment: .leading)
.padding(.horizontal, DesignSystem.Spaces.oneAndHalf)
.padding(.vertical, DesignSystem.Spaces.half)
- .background(Capsule().fill(Color(uiColor: .tertiarySystemFill)))
+ .background(Capsule().fill(Color(uiColor: .tertiaryFill)))
}
.buttonStyle(.plain)
}
diff --git a/Sources/App/Templating/JinjaSyntaxHighlighter.swift b/Sources/App/Templating/JinjaSyntaxHighlighter.swift
index 640a1e4d61..feb9b2f835 100644
--- a/Sources/App/Templating/JinjaSyntaxHighlighter.swift
+++ b/Sources/App/Templating/JinjaSyntaxHighlighter.swift
@@ -1,4 +1,9 @@
+import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Colors Jinja template source for the template editor: literal text is secondary, expression
/// bodies primary, with delimiters, keywords, numbers, and strings tinted — a lightweight take on
@@ -46,7 +51,7 @@ enum JinjaSyntaxHighlighter {
for reference in entityReferences where NSMaxRange(reference.range) <= fullRange.length {
result.addAttributes([
- .backgroundColor: UIColor.tertiarySystemFill,
+ .backgroundColor: UIColor.tertiaryFill,
.foregroundColor: UIColor.label,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor.haPrimary,
diff --git a/Sources/App/Templating/JinjaTemplateButton.swift b/Sources/App/Templating/JinjaTemplateButton.swift
index fceb97d6b1..fdbbaee487 100644
--- a/Sources/App/Templating/JinjaTemplateButton.swift
+++ b/Sources/App/Templating/JinjaTemplateButton.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
/// A form row for a Jinja template: shows the template's rendered result (falling back to the
/// template source, then the placeholder), and opens the full editor sheet when tapped.
diff --git a/Sources/App/Templating/JinjaTemplateEditorSheet.swift b/Sources/App/Templating/JinjaTemplateEditorSheet.swift
index 5801efef3a..404c57ede2 100644
--- a/Sources/App/Templating/JinjaTemplateEditorSheet.swift
+++ b/Sources/App/Templating/JinjaTemplateEditorSheet.swift
@@ -1,7 +1,6 @@
import GRDB
import Shared
import SwiftUI
-import UIKit
/// The full Jinja editor presented from `JinjaTemplateButton`: the evaluated result in its own
/// section at the top (with a placeholder until there is one), the syntax-highlighted editor below
diff --git a/Sources/App/Templating/JinjaTextEditor.swift b/Sources/App/Templating/JinjaTextEditor.swift
index 6207228128..f28362376a 100644
--- a/Sources/App/Templating/JinjaTextEditor.swift
+++ b/Sources/App/Templating/JinjaTextEditor.swift
@@ -1,7 +1,477 @@
import Shared
import SwiftUI
-import UIKit
+#if os(macOS)
+/// The text view of the Jinja editor sheet on the Mac: an `NSTextView` (SwiftUI's `TextEditor` can't do
+/// live syntax highlighting) with Jinja colors, cursor reporting for the entity suggestions below, and
+/// an insertion channel for clicked suggestions. Smart punctuation is off — smart quotes silently
+/// break Jinja.
+struct JinjaTextEditor: NSViewRepresentable {
+ @Binding var text: String
+ /// The current cursor location (UTF-16), reported for the context-aware entity suggestions.
+ @Binding var cursorLocation: Int
+ /// Set by the suggestion pills / entity picker; the editor consumes it, inserts at the cursor,
+ /// and clears it back to nil.
+ @Binding var pendingInsertion: JinjaTemplateSuggestion?
+ /// Known entity id ranges inside the current text, rendered as clickable inline pills.
+ var entityReferences: [JinjaEntityReference] = []
+ /// Called when the user clicks one of the inline entity pills.
+ var onEntityTap: (JinjaEntityReference) -> Void = { _ in }
+ /// Focuses the editor as soon as it appears.
+ var autoFocus = false
+
+ static let font = UIFont.monospacedSystemFont(
+ ofSize: UIFont.preferredFont(forTextStyle: .callout).pointSize,
+ weight: .regular
+ )
+
+ func makeNSView(context: Context) -> NSScrollView {
+ let coordinator = context.coordinator
+
+ // The text system is assembled by hand so the editor lays out with TextKit 1, which the pill
+ // attachments and the hit testing on them are written against.
+ let textStorage = NSTextStorage()
+ let layoutManager = NSLayoutManager()
+ textStorage.addLayoutManager(layoutManager)
+ let textContainer = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
+ textContainer.widthTracksTextView = true
+ textContainer.lineFragmentPadding = 0
+ layoutManager.addTextContainer(textContainer)
+
+ let textView = EditorTextView(frame: .zero, textContainer: textContainer)
+ textView.minSize = .zero
+ textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
+ textView.isVerticallyResizable = true
+ textView.isHorizontallyResizable = false
+ textView.autoresizingMask = [.width]
+ textView.drawsBackground = false
+ textView.font = Self.font
+ textView.isRichText = false
+ textView.importsGraphics = false
+ // Every change replaces the whole text with its highlighted copy, which the undo stack can't
+ // follow.
+ textView.allowsUndo = false
+ textView.isAutomaticQuoteSubstitutionEnabled = false
+ textView.isAutomaticDashSubstitutionEnabled = false
+ textView.isAutomaticTextReplacementEnabled = false
+ textView.isAutomaticSpellingCorrectionEnabled = false
+ textView.isAutomaticTextCompletionEnabled = false
+ textView.isContinuousSpellCheckingEnabled = false
+ textView.isGrammarCheckingEnabled = false
+ textView.smartInsertDeleteEnabled = false
+ textView.textContainerInset = .zero
+ textView.delegate = coordinator
+ textView.onMouseDown = { [weak coordinator] point in
+ coordinator?.handleMouseDown(at: point) ?? false
+ }
+ textView.onAppearanceChange = { [weak coordinator] in
+ coordinator?.reapplyHighlighting()
+ }
+ textView.focusesWhenShown = autoFocus
+
+ // No scroller: the editor grows with its text, so there is normally nothing to scroll, and a
+ // scroller that comes and goes would change the width the text wraps to.
+ let scrollView = EditorScrollView()
+ scrollView.drawsBackground = false
+ scrollView.borderType = .noBorder
+ scrollView.hasVerticalScroller = false
+ scrollView.hasHorizontalScroller = false
+ scrollView.documentView = textView
+
+ coordinator.textView = textView
+ coordinator.textStorage = textStorage
+ coordinator.applyHighlighting(text, entityReferences: entityReferences)
+ return scrollView
+ }
+
+ func updateNSView(_ nsView: NSScrollView, context: Context) {
+ context.coordinator.parent = self
+ if context.coordinator.appliedSourceText != text || context.coordinator
+ .appliedEntityReferences != entityReferences {
+ context.coordinator.applyHighlighting(text, entityReferences: entityReferences)
+ }
+ if let insertion = pendingInsertion {
+ context.coordinator.scheduleInsertion(insertion)
+ }
+ }
+
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? {
+ guard let width = proposal.width, width.isFinite, width > 0 else { return nil }
+ return CGSize(width: width, height: context.coordinator.fittingHeight(forWidth: width))
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ /// Hands scrolling on to the enclosing form while the text fits, which is nearly always: the editor
+ /// grows with its text and only scrolls itself when something holds it to a smaller height.
+ fileprivate final class EditorScrollView: NSScrollView {
+ override func scrollWheel(with event: NSEvent) {
+ if let documentView, documentView.frame.height > contentSize.height {
+ super.scrollWheel(with: event)
+ } else {
+ nextResponder?.scrollWheel(with: event)
+ }
+ }
+ }
+
+ /// The text view, reporting what the editor needs to hear of: clicks, to catch the ones on an entity
+ /// pill, and a change between light and dark.
+ fileprivate final class EditorTextView: NSTextView {
+ /// Asked about every click before the text view acts on it. Returns true when the click landed
+ /// on an entity pill and has been dealt with.
+ var onMouseDown: ((CGPoint) -> Bool)?
+ /// The pills are drawn as images, so they have to be drawn again when light and dark swap.
+ var onAppearanceChange: (() -> Void)?
+ var focusesWhenShown = false
+
+ override func mouseDown(with event: NSEvent) {
+ if onMouseDown?(convert(event.locationInWindow, from: nil)) == true {
+ return
+ }
+ super.mouseDown(with: event)
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard focusesWhenShown, window != nil else { return }
+ focusesWhenShown = false
+ // The window is still being put on screen when the view joins it, so wait for the next pass
+ // of the run loop before taking the focus.
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ window?.makeFirstResponder(self)
+ }
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ onAppearanceChange?()
+ }
+ }
+
+ final class Coordinator: NSObject, NSTextViewDelegate {
+ var parent: JinjaTextEditor
+ fileprivate weak var textView: EditorTextView?
+ /// Held here because a text view whose text system was assembled by hand doesn't keep its
+ /// storage alive.
+ var textStorage: NSTextStorage?
+ var appliedSourceText = ""
+ var appliedEntityReferences: [JinjaEntityReference] = []
+ private var displayEntityRanges: [(displayRange: NSRange, reference: JinjaEntityReference)] = []
+ private var scheduledInsertion: JinjaTemplateSuggestion?
+
+ init(parent: JinjaTextEditor) {
+ self.parent = parent
+ }
+
+ func textDidChange(_ notification: Notification) {
+ // Text that is still being composed (a dead key, an input method) is left alone: swapping
+ // it for its highlighted copy would end the composition.
+ guard let textView, !textView.hasMarkedText() else { return }
+ let sourceText = sourceText(from: textView.attributedString())
+ parent.text = sourceText
+ applyHighlighting(sourceText, entityReferences: parent.entityReferences)
+ reportCursor()
+ }
+
+ func textViewDidChangeSelection(_ notification: Notification) {
+ reportCursor()
+ }
+
+ /// Re-applies syntax colors, keeping the cursor where it was.
+ func applyHighlighting(_ text: String, entityReferences: [JinjaEntityReference]) {
+ guard let textView, let textStorage else { return }
+ let selectedSourceLocation = sourceLocation(forDisplayLocation: textView.selectedRange().location)
+ appliedSourceText = text
+ appliedEntityReferences = entityReferences
+ textStorage.setAttributedString(displayText(for: text, entityReferences: entityReferences))
+ // What is typed next takes these until the following pass colors it.
+ textView.typingAttributes = [
+ .font: JinjaTextEditor.font,
+ .foregroundColor: UIColor.secondaryLabel,
+ ]
+ textView.setSelectedRange(NSRange(
+ location: min(displayLocation(forSourceLocation: selectedSourceLocation), textStorage.length),
+ length: 0
+ ))
+ }
+
+ func reapplyHighlighting() {
+ applyHighlighting(appliedSourceText, entityReferences: appliedEntityReferences)
+ }
+
+ func scheduleInsertion(_ suggestion: JinjaTemplateSuggestion) {
+ guard scheduledInsertion != suggestion else { return }
+ scheduledInsertion = suggestion
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self, scheduledInsertion == suggestion else { return }
+ parent.pendingInsertion = nil
+ insert(suggestion)
+ scheduledInsertion = nil
+ }
+ }
+
+ private func insert(_ suggestion: JinjaTemplateSuggestion) {
+ guard let textView else { return }
+ let nsText = parent.text as NSString
+ let cursor = min(parent.cursorLocation, nsText.length)
+ let replaceRange: NSRange
+ if let replacementRange = suggestion.replacementRange,
+ NSMaxRange(replacementRange) <= nsText.length {
+ replaceRange = replacementRange
+ } else {
+ let replacingCount = min(suggestion.replacingCount, cursor)
+ replaceRange = NSRange(location: cursor - replacingCount, length: replacingCount)
+ }
+ let nextText = nsText.replacingCharacters(in: replaceRange, with: suggestion.insertion)
+ parent.text = nextText
+ applyHighlighting(nextText, entityReferences: parent.entityReferences)
+ let insertionEnd = replaceRange.location + (suggestion.insertion as NSString).length
+ textView.setSelectedRange(NSRange(
+ location: displayLocation(forSourceLocation: max(0, insertionEnd - suggestion.cursorOffsetFromEnd)),
+ length: 0
+ ))
+ reportCursor()
+ }
+
+ /// Selects the entity pill under a click and reports it. Returns false when the click landed
+ /// anywhere else, leaving it to the text view.
+ func handleMouseDown(at point: CGPoint) -> Bool {
+ guard let textView,
+ let location = characterLocation(for: point, in: textView),
+ let reference = reference(atDisplayLocation: location) else {
+ return false
+ }
+
+ textView.window?.makeFirstResponder(textView)
+ textView.setSelectedRange(NSRange(
+ location: displayLocation(forSourceLocation: reference.range.location),
+ length: 1
+ ))
+ reportCursor()
+ parent.onEntityTap(reference)
+ return true
+ }
+
+ /// The height the text takes when wrapped to `width`, never less than one line. Measured on a
+ /// copy, so asking doesn't disturb the layout on screen.
+ func fittingHeight(forWidth width: CGFloat) -> CGFloat {
+ let lineHeight = JinjaTextEditor.font.lineHeight
+ guard let textStorage else { return lineHeight }
+ let measuredStorage = NSTextStorage(attributedString: textStorage)
+ let layoutManager = NSLayoutManager()
+ let textContainer = NSTextContainer(size: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
+ textContainer.lineFragmentPadding = 0
+ layoutManager.addTextContainer(textContainer)
+ measuredStorage.addLayoutManager(layoutManager)
+ layoutManager.ensureLayout(for: textContainer)
+ return max(layoutManager.usedRect(for: textContainer).height.rounded(.up), lineHeight)
+ }
+
+ private func reportCursor() {
+ guard let textView else { return }
+ let location = sourceLocation(forDisplayLocation: textView.selectedRange().location)
+ DispatchQueue.main.async { [weak self] in
+ self?.parent.cursorLocation = location
+ }
+ }
+
+ private func displayText(
+ for sourceText: String,
+ entityReferences: [JinjaEntityReference]
+ ) -> NSAttributedString {
+ let highlighted = JinjaSyntaxHighlighter.highlight(sourceText, font: JinjaTextEditor.font)
+ let result = NSMutableAttributedString()
+ displayEntityRanges = []
+
+ var sourceLocation = 0
+ let sourceLength = (sourceText as NSString).length
+ let references = entityReferences
+ .filter { $0.range.location >= 0 && NSMaxRange($0.range) <= sourceLength }
+ .sorted { $0.range.location < $1.range.location }
+
+ for reference in references where reference.range.location >= sourceLocation {
+ if reference.range.location > sourceLocation {
+ result.append(highlighted.attributedSubstring(
+ from: NSRange(location: sourceLocation, length: reference.range.location - sourceLocation)
+ ))
+ }
+
+ let displayLocation = result.length
+ let attachment = NSTextAttachment()
+ attachment.image = pillImage(for: reference)
+ attachment.bounds = CGRect(
+ x: 0,
+ y: JinjaTextEditor.font.descender - 4,
+ width: attachment.image?.size.width ?? 0,
+ height: attachment.image?.size.height ?? 0
+ )
+
+ let chip = NSMutableAttributedString(attachment: attachment)
+ chip.addAttribute(
+ .jinjaEntityReference,
+ value: reference,
+ range: NSRange(location: 0, length: chip.length)
+ )
+ result.append(chip)
+ displayEntityRanges.append((NSRange(location: displayLocation, length: chip.length), reference))
+ sourceLocation = NSMaxRange(reference.range)
+ }
+
+ if sourceLocation < sourceLength {
+ result.append(highlighted.attributedSubstring(
+ from: NSRange(location: sourceLocation, length: sourceLength - sourceLocation)
+ ))
+ }
+
+ return result
+ }
+
+ private func sourceText(from attributedText: NSAttributedString) -> String {
+ let result = NSMutableString()
+ let nsString = attributedText.string as NSString
+ var location = 0
+
+ while location < attributedText.length {
+ var effectiveRange = NSRange()
+ if let reference = attributedText.attribute(
+ .jinjaEntityReference,
+ at: location,
+ effectiveRange: &effectiveRange
+ ) as? JinjaEntityReference {
+ result.append(reference.entityId)
+ location = NSMaxRange(effectiveRange)
+ } else {
+ let character = nsString.substring(with: NSRange(location: location, length: 1))
+ if character != "\u{FFFC}" {
+ result.append(character)
+ }
+ location += 1
+ }
+ }
+
+ return result as String
+ }
+
+ private func reference(atDisplayLocation location: Int) -> JinjaEntityReference? {
+ displayEntityRanges.first { NSLocationInRange(location, $0.displayRange) }?.reference
+ }
+
+ private func sourceLocation(forDisplayLocation displayLocation: Int) -> Int {
+ var adjustment = 0
+ for item in displayEntityRanges {
+ if displayLocation <= item.displayRange.location {
+ break
+ } else if NSLocationInRange(displayLocation, item.displayRange) {
+ return item.reference.range.location
+ } else {
+ adjustment += item.reference.range.length - item.displayRange.length
+ }
+ }
+ return max(0, displayLocation + adjustment)
+ }
+
+ private func displayLocation(forSourceLocation sourceLocation: Int) -> Int {
+ var adjustment = 0
+ for item in displayEntityRanges {
+ if sourceLocation <= item.reference.range.location {
+ break
+ } else if sourceLocation <= NSMaxRange(item.reference.range) {
+ return item.displayRange.location + item.displayRange.length
+ } else {
+ adjustment += item.reference.range.length - item.displayRange.length
+ }
+ }
+ return max(0, sourceLocation - adjustment)
+ }
+
+ private func pillImage(for reference: JinjaEntityReference) -> UIImage {
+ let titleFont = UIFont.systemFont(
+ ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
+ weight: .medium
+ )
+ let subtitleFont = UIFont.preferredFont(forTextStyle: .caption2)
+ let horizontalPadding: CGFloat = 12
+ let verticalPadding: CGFloat = 5
+ let maxWidth: CGFloat = 165
+ let titleWidth = (reference.name as NSString).size(withAttributes: [.font: titleFont]).width
+ let subtitleWidth = reference.subtitle.map {
+ ($0 as NSString).size(withAttributes: [.font: subtitleFont]).width
+ } ?? 0
+ let contentWidth = min(max(titleWidth, subtitleWidth), maxWidth - (horizontalPadding * 2))
+ let hasSubtitle = reference.subtitle?.isEmpty == false
+ let height: CGFloat = hasSubtitle ? 38 : 26
+ let size = CGSize(width: contentWidth + (horizontalPadding * 2), height: height)
+ let renderer = UIGraphicsImageRenderer(size: size)
+
+ // The pill is a bitmap, so its colors are fixed when it is drawn: draw it for the appearance
+ // the editor is shown in.
+ var image = UIImage(size: size)
+ (textView?.effectiveAppearance ?? NSApp.effectiveAppearance).performAsCurrentDrawingAppearance {
+ image = renderer.image { _ in
+ UIColor.tertiaryFill.setFill()
+ UIBezierPath(
+ roundedRect: CGRect(origin: .zero, size: size),
+ xRadius: height / 2,
+ yRadius: height / 2
+ ).fill()
+
+ let titleRect = CGRect(
+ x: horizontalPadding,
+ y: hasSubtitle ? verticalPadding : 5,
+ width: contentWidth,
+ height: titleFont.lineHeight
+ )
+ draw(reference.name, in: titleRect, font: titleFont, color: .label)
+
+ if let subtitle = reference.subtitle, !subtitle.isEmpty {
+ let subtitleRect = CGRect(
+ x: horizontalPadding,
+ y: verticalPadding + titleFont.lineHeight - 1,
+ width: contentWidth,
+ height: subtitleFont.lineHeight
+ )
+ draw(subtitle, in: subtitleRect, font: subtitleFont, color: .secondaryLabel)
+ }
+ }
+ }
+ return image
+ }
+
+ private func draw(_ text: String, in rect: CGRect, font: UIFont, color: UIColor) {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineBreakMode = .byTruncatingTail
+ (text as NSString).draw(in: rect, withAttributes: [
+ .font: font,
+ .foregroundColor: color,
+ .paragraphStyle: paragraphStyle,
+ ])
+ }
+
+ private func characterLocation(for point: CGPoint, in textView: NSTextView) -> Int? {
+ guard let layoutManager = textView.layoutManager,
+ let textContainer = textView.textContainer,
+ layoutManager.numberOfGlyphs > 0 else {
+ return nil
+ }
+
+ var location = point
+ location.x -= textView.textContainerOrigin.x
+ location.y -= textView.textContainerOrigin.y
+
+ let glyphIndex = layoutManager.glyphIndex(for: location, in: textContainer)
+ let glyphRange = NSRange(location: glyphIndex, length: 1)
+ let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
+ guard glyphRect.insetBy(dx: -4, dy: -6).contains(location) else { return nil }
+
+ return layoutManager.characterIndexForGlyph(at: glyphIndex)
+ }
+ }
+}
+#else
/// The text view of the Jinja editor sheet: a `UITextView` (SwiftUI's `TextEditor` can't do live
/// syntax highlighting) with Jinja colors, cursor reporting for the entity suggestions below, and
/// an insertion channel for tapped suggestions. Smart punctuation is off — smart quotes silently
@@ -349,6 +819,7 @@ struct JinjaTextEditor: UIViewRepresentable {
}
}
}
+#endif
#Preview {
JinjaTextEditor(
@@ -363,6 +834,7 @@ private extension NSAttributedString.Key {
static let jinjaEntityReference = NSAttributedString.Key("JinjaEntityReference")
}
+#if !os(macOS)
private extension UIFont {
func withWeight(_ weight: UIFont.Weight) -> UIFont {
let descriptor = fontDescriptor.addingAttributes([
@@ -371,3 +843,4 @@ private extension UIFont {
return UIFont(descriptor: descriptor, size: pointSize)
}
}
+#endif
diff --git a/Sources/App/TestFlightCommunication/TestFlightCommunicationView.swift b/Sources/App/TestFlightCommunication/TestFlightCommunicationView.swift
index 3806b7f585..d17985ef50 100644
--- a/Sources/App/TestFlightCommunication/TestFlightCommunicationView.swift
+++ b/Sources/App/TestFlightCommunication/TestFlightCommunicationView.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
struct TestFlightCommunicationView: View {
let message: TestFlightMessage
diff --git a/Sources/App/Utilities/AppAlert.swift b/Sources/App/Utilities/AppAlert.swift
new file mode 100644
index 0000000000..8d520a4831
--- /dev/null
+++ b/Sources/App/Utilities/AppAlert.swift
@@ -0,0 +1,112 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+/// An alert described once and shown with the platform's own: `UIAlertController` on iOS, `NSAlert` on
+/// the Mac. For alerts raised from outside a SwiftUI view, where `.alert` is not an option.
+struct AppAlert {
+ struct Action {
+ enum Style {
+ case `default`
+ case cancel
+ case destructive
+ }
+
+ var title: String
+ var style: Style = .default
+ /// The action Return presses on the Mac, as `UIAlertController.preferredAction` is on iOS.
+ var isPreferred = false
+ var handler: (() -> Void)?
+
+ init(title: String, style: Style = .default, isPreferred: Bool = false, handler: (() -> Void)? = nil) {
+ self.title = title
+ self.style = style
+ self.isPreferred = isPreferred
+ self.handler = handler
+ }
+ }
+
+ var title: String?
+ var message: String?
+ var actions: [Action]
+
+ init(title: String? = nil, message: String? = nil, actions: [Action]) {
+ self.title = title
+ self.message = message
+ self.actions = actions
+ }
+
+ /// The order the Mac's buttons go in. The first one is pressed by Return, so it is the preferred action,
+ /// else the cancel action, else the first ordinary one; a destructive action never comes first unless it
+ /// is all there is.
+ var macButtonOrder: [Action] {
+ let first = actions.first(where: \.isPreferred)
+ ?? actions.first(where: { $0.style == .cancel })
+ ?? actions.first(where: { $0.style == .default })
+ ?? actions.first
+ guard let first else { return [] }
+ let rest = actions.filter { $0.title != first.title || $0.style != first.style }
+ return [first] + rest
+ }
+
+ /// What the Mac shows as the bold message and the text under it: a title is the message, and without
+ /// one the message itself takes its place rather than sitting under an empty line.
+ var macTexts: (message: String, informative: String) {
+ if let title {
+ return (title, message ?? "")
+ }
+ return (message ?? "", "")
+ }
+
+ #if os(macOS)
+ /// Shows the alert as a sheet on `window`, or as an app-modal alert when there is no window to attach
+ /// it to. Call on the main thread.
+ func present(on window: NSWindow?) {
+ let alert = NSAlert()
+ let texts = macTexts
+ alert.messageText = texts.message
+ alert.informativeText = texts.informative
+
+ let ordered = macButtonOrder
+ for action in ordered {
+ let button = alert.addButton(withTitle: action.title)
+ button.hasDestructiveAction = action.style == .destructive
+ if action.style == .cancel {
+ button.keyEquivalent = "\u{1b}"
+ }
+ }
+
+ let handle: (NSApplication.ModalResponse) -> Void = { response in
+ let index = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
+ guard ordered.indices.contains(index) else { return }
+ ordered[index].handler?()
+ }
+
+ if let window {
+ alert.beginSheetModal(for: window, completionHandler: handle)
+ } else {
+ handle(alert.runModal())
+ }
+ }
+ #else
+ func makeAlertController() -> UIAlertController {
+ let controller = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ for action in actions {
+ let style: UIAlertAction.Style = switch action.style {
+ case .default: .default
+ case .cancel: .cancel
+ case .destructive: .destructive
+ }
+ let alertAction = UIAlertAction(title: action.title, style: style) { _ in action.handler?() }
+ controller.addAction(alertAction)
+ if action.isPreferred {
+ controller.preferredAction = alertAction
+ }
+ }
+ return controller
+ }
+ #endif
+}
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeSchemaEntity.swift
index 8460c3755b..db39752ab8 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeSchemaEntity.swift
@@ -3,7 +3,7 @@ import Foundation
/// Home Assistant's calendar API has no attendee concept, so this only ever appears as an empty
/// list on an event. It exists because the schema requires the property to have this shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .calendar.attendee)
struct CalendarAttendeeSchemaEntity: TransientAppEntity {
var person: IntentPerson
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeStatusSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeStatusSchemaEnum.swift
index b8d4776b80..d5b89b1323 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeStatusSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeStatusSchemaEnum.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
/// Home Assistant reports no attendees, so no case is ever produced; the schema requires the shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .calendar.attendeeStatus)
enum CalendarAttendeeStatusSchemaEnum: String {
case accepted
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeTypeSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeTypeSchemaEnum.swift
index 61a003f35d..913e80dbc7 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeTypeSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarAttendeeTypeSchemaEnum.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
/// Home Assistant reports no attendees, so no case is ever produced; the schema requires the shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .calendar.attendeeType)
enum CalendarAttendeeTypeSchemaEnum: String {
case person
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntity.swift
index b1ee5b394e..1ccfbe4a27 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntity.swift
@@ -7,7 +7,7 @@ import Shared
///
/// Home Assistant has no concept of attendees, organizers, alarms or travel time, so those are
/// empty or nil — which is the truthful answer for an event that has none, not a placeholder.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .calendar.event)
struct CalendarEventSchemaEntity: IndexedEntity {
static let defaultQuery = CalendarEventSchemaEntityQuery()
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntityQuery.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntityQuery.swift
index 6dcae70b85..e239e14697 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntityQuery.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSchemaEntityQuery.swift
@@ -5,7 +5,7 @@ import Shared
/// Answers from the cached calendar events, re-read from the server first: Home Assistant has no
/// "fetch event by id" endpoint, so the cache is what an identifier resolves against.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
struct CalendarEventSchemaEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [CalendarEventSchemaEntity] {
let wanted = Set(identifiers)
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSpotlightIndexer.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSpotlightIndexer.swift
index 6da26e9123..f2ac4ee2f8 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarEventSpotlightIndexer.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarEventSpotlightIndexer.swift
@@ -11,7 +11,7 @@ import Shared
/// Only events are indexed here. Calendars and to-do lists already reach Spotlight through
/// `HACalendarAppEntity` and `HAAppEntityAppIntentEntity`, so indexing their schema counterparts
/// would put the same thing in the index twice.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
enum CalendarEventSpotlightIndexer {
private static let stateKey = "spotlightIndexedCalendarEventIds"
private static let batchSize = 100
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarEventStatusSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarEventStatusSchemaEnum.swift
index 62e3215d0f..8973ef2f2d 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarEventStatusSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarEventStatusSchemaEnum.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
/// Home Assistant does not report an event status, so this exists only to satisfy the schema shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .calendar.eventStatus)
enum CalendarEventStatusSchemaEnum: String {
case confirmed
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntity.swift
index 6afc8ebbd1..0537894cba 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntity.swift
@@ -8,7 +8,7 @@ import Shared
/// Parallel to `HACalendarAppEntity` rather than replacing it: the schema domain is iOS 27, and
/// annotating the shipped entity would drag its query, the calendar intents and the Spotlight
/// extension to iOS 27 with it, putting calendars out of reach for everyone below that.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .calendar.calendar)
struct CalendarSchemaEntity: IndexedEntity {
static let defaultQuery = CalendarSchemaEntityQuery()
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntityQuery.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntityQuery.swift
index d48a45b640..f58733b30b 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntityQuery.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaEntityQuery.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
struct CalendarSchemaEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [CalendarSchemaEntity] {
identifiers.compactMap {
diff --git a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaSupport.swift b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaSupport.swift
index 7f0c400471..a79e5bf9ae 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaSupport.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CalendarSchemaSupport.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
/// Lookups and validation shared by the calendar schema intents.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
enum CalendarSchemaSupport {
/// The calendars Siri may offer, honouring the per-server opt-out.
///
diff --git a/Sources/App/Utilities/AppIntentSchemas/CreateEventSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/CreateEventSchemaIntent.swift
index 2005192a39..94306b2e7b 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CreateEventSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CreateEventSchemaIntent.swift
@@ -6,7 +6,7 @@ import Shared
///
/// Attendees are accepted because the schema requires the parameter, but Home Assistant's calendar
/// API has nowhere to put them, so they are ignored rather than silently half-applied.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .calendar.createEvent)
struct CreateEventSchemaIntent {
var title: String
diff --git a/Sources/App/Utilities/AppIntentSchemas/CreateReminderSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/CreateReminderSchemaIntent.swift
index 062cc870e7..3b53e498cf 100644
--- a/Sources/App/Utilities/AppIntentSchemas/CreateReminderSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/CreateReminderSchemaIntent.swift
@@ -7,7 +7,7 @@ import Shared
/// Flags, tags, URLs, images, location triggers, recurrence and sections have no equivalent in the
/// `todo` domain, so they are accepted to satisfy the schema and then ignored rather than being
/// folded into the note, which would put text on the list the user did not dictate.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .reminders.createReminder)
struct CreateReminderSchemaIntent {
var title: String
diff --git a/Sources/App/Utilities/AppIntentSchemas/DeleteEventSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/DeleteEventSchemaIntent.swift
index ed73a06af7..8eba09f8ba 100644
--- a/Sources/App/Utilities/AppIntentSchemas/DeleteEventSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/DeleteEventSchemaIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
/// Removes an event from a Home Assistant calendar.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .calendar.deleteEvent)
struct DeleteEventSchemaIntent {
var entity: CalendarEventSchemaEntity
diff --git a/Sources/App/Utilities/AppIntentSchemas/DeleteRemindersSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/DeleteRemindersSchemaIntent.swift
index 9262305557..9bba3bd447 100644
--- a/Sources/App/Utilities/AppIntentSchemas/DeleteRemindersSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/DeleteRemindersSchemaIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
/// Removes items from Home Assistant todo lists.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .reminders.deleteReminders)
struct DeleteRemindersSchemaIntent {
var entities: [ReminderSchemaEntity]
diff --git a/Sources/App/Utilities/AppIntentSchemas/EventAlarmCases.swift b/Sources/App/Utilities/AppIntentSchemas/EventAlarmCases.swift
index 0ab24b5e31..35099185a7 100644
--- a/Sources/App/Utilities/AppIntentSchemas/EventAlarmCases.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/EventAlarmCases.swift
@@ -3,7 +3,7 @@ import Foundation
/// The union the calendar schema expects for an alarm: either an offset before the event or an
/// absolute date. Home Assistant reports neither, so this stays empty.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@UnionValue
enum EventAlarmCases {
case duration(Duration)
diff --git a/Sources/App/Utilities/AppIntentSchemas/EventLocationCases.swift b/Sources/App/Utilities/AppIntentSchemas/EventLocationCases.swift
index 11152c594d..df02b00140 100644
--- a/Sources/App/Utilities/AppIntentSchemas/EventLocationCases.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/EventLocationCases.swift
@@ -4,14 +4,14 @@ import GeoToolbox
/// The union the calendar schema expects for an event's location. Home Assistant stores a plain
/// string, so `.text` is the case it ever produces; the place case exists to satisfy the shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@UnionValue
enum EventLocationCases {
case place(PlaceDescriptor)
case text(String)
}
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
extension EventLocationCases {
/// Home Assistant stores a location as a free string, so a structured place is flattened to
/// its name rather than dropped.
diff --git a/Sources/App/Utilities/AppIntentSchemas/EventSpanSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/EventSpanSchemaEnum.swift
index ff3f11587d..bed2223cab 100644
--- a/Sources/App/Utilities/AppIntentSchemas/EventSpanSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/EventSpanSchemaEnum.swift
@@ -3,7 +3,7 @@ import Foundation
/// Which occurrences of a recurring event an edit or deletion applies to. Maps onto Home
/// Assistant's `recurrence_range`, which is empty for one occurrence and `THISANDFUTURE` otherwise.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .calendar.eventSpan)
enum EventSpanSchemaEnum: String {
case this
diff --git a/Sources/App/Utilities/AppIntentSchemas/GetRemindersAppIntent.swift b/Sources/App/Utilities/AppIntentSchemas/GetRemindersAppIntent.swift
index b4954b16e9..b50b55315b 100644
--- a/Sources/App/Utilities/AppIntentSchemas/GetRemindersAppIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/GetRemindersAppIntent.swift
@@ -7,7 +7,7 @@ import Shared
/// The reminders schema has no read action — Apple only defines create, update and delete — so this
/// is a plain intent. It returns the same entity the update and delete intents take, so a Shortcut
/// can read a list and act on what it finds without an intermediate step.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
struct GetRemindersAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"app_intents.reminders.get_items.title",
diff --git a/Sources/App/Utilities/AppIntentSchemas/LocationTriggerEventSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/LocationTriggerEventSchemaEnum.swift
index aeffd3d2c0..c26fdcda45 100644
--- a/Sources/App/Utilities/AppIntentSchemas/LocationTriggerEventSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/LocationTriggerEventSchemaEnum.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
/// Home Assistant has no location triggers, so no case is ever produced; the schema names both.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .reminders.locationTriggerEvent)
enum LocationTriggerEventSchemaEnum: String {
case arrive
diff --git a/Sources/App/Utilities/AppIntentSchemas/LocationTriggerSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/LocationTriggerSchemaEntity.swift
index 5d57e9c6f5..851cd6ab82 100644
--- a/Sources/App/Utilities/AppIntentSchemas/LocationTriggerSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/LocationTriggerSchemaEntity.swift
@@ -5,7 +5,7 @@ import GeoToolbox
/// Home Assistant todo items have no location trigger, so this only ever appears as nil. It exists
/// because the schema requires the property to have this shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .reminders.locationTrigger)
struct LocationTriggerSchemaEntity: TransientAppEntity {
var place: PlaceDescriptor
diff --git a/Sources/App/Utilities/AppIntentSchemas/RecurrenceRuleRRULE.swift b/Sources/App/Utilities/AppIntentSchemas/RecurrenceRuleRRULE.swift
index ae9627a8c0..91de699919 100644
--- a/Sources/App/Utilities/AppIntentSchemas/RecurrenceRuleRRULE.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/RecurrenceRuleRRULE.swift
@@ -5,7 +5,7 @@ import Foundation
/// Only frequency, interval and how the rule ends are carried across — the parts Siri produces from
/// a spoken phrase. A rule that also narrows by weekday or month-day keeps its frequency and loses
/// the narrowing, which repeats more often than asked rather than silently dropping the event.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
extension Calendar.RecurrenceRule {
var rrule: String? {
guard let frequency = rruleFrequency else { return nil }
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderListResolution.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderListResolution.swift
index f0f566b3c8..17333be4f4 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderListResolution.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderListResolution.swift
@@ -1,6 +1,6 @@
import Foundation
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
enum ReminderListResolution {
case noLists
case only(ReminderListSchemaEntity)
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntity.swift
index 94e62b03aa..ee403415ce 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntity.swift
@@ -4,7 +4,7 @@ import Foundation
import Shared
/// A Home Assistant todo list in the shape Apple Intelligence understands.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .reminders.list)
struct ReminderListSchemaEntity: IndexedEntity {
static let defaultQuery = ReminderListSchemaEntityQuery()
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntityQuery.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntityQuery.swift
index 3a5e98ef7e..298a68ffdd 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntityQuery.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderListSchemaEntityQuery.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
struct ReminderListSchemaEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [ReminderListSchemaEntity] {
let wanted = Set(identifiers)
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderListTypeSchemaEnum.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderListTypeSchemaEnum.swift
index e408507a38..7789627d99 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderListTypeSchemaEnum.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderListTypeSchemaEnum.swift
@@ -3,7 +3,7 @@ import Foundation
/// Home Assistant has one kind of todo list, so this is always `.standard`. The schema requires
/// that case by name.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEnum(schema: .reminders.listType)
enum ReminderListTypeSchemaEnum: String {
case standard
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntity.swift
index 05bfaf805e..e636834a48 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntity.swift
@@ -6,7 +6,7 @@ import Shared
///
/// Home Assistant todo items carry a summary, a description, a due date and a status. Flags, tags,
/// URLs, location triggers and recurrence have no equivalent, so they stay empty or nil.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .reminders.reminder)
struct ReminderSchemaEntity {
static let defaultQuery = ReminderSchemaEntityQuery()
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntityQuery.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntityQuery.swift
index 3ce7fa7364..7dd53acec2 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntityQuery.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderSchemaEntityQuery.swift
@@ -4,7 +4,7 @@ import Shared
/// Todo items are not cached locally, so this reads them from the server a list at a time, the same
/// way the todo widget does.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
struct ReminderSchemaEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [ReminderSchemaEntity] {
let wanted = Set(identifiers)
diff --git a/Sources/App/Utilities/AppIntentSchemas/ReminderSectionSchemaEntity.swift b/Sources/App/Utilities/AppIntentSchemas/ReminderSectionSchemaEntity.swift
index 98fa89e646..1ec4cd6d06 100644
--- a/Sources/App/Utilities/AppIntentSchemas/ReminderSectionSchemaEntity.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/ReminderSectionSchemaEntity.swift
@@ -3,7 +3,7 @@ import Foundation
/// Home Assistant todo lists have no sections, so this only ever appears as nil on a reminder. It
/// exists because the schema requires the parameter to have this shape.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppEntity(schema: .reminders.section)
struct ReminderSectionSchemaEntity: TransientAppEntity {
var name: String
diff --git a/Sources/App/Utilities/AppIntentSchemas/RemindersSchemaSupport.swift b/Sources/App/Utilities/AppIntentSchemas/RemindersSchemaSupport.swift
index 57b67749b0..43d665c405 100644
--- a/Sources/App/Utilities/AppIntentSchemas/RemindersSchemaSupport.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/RemindersSchemaSupport.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
/// Lookups and date formatting shared by the reminders schema intents.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
enum RemindersSchemaSupport {
static func api(for list: ReminderListSchemaEntity) throws -> HomeAssistantAPI {
guard let server = Current.servers.server(for: .init(rawValue: list.serverId)),
diff --git a/Sources/App/Utilities/AppIntentSchemas/UpdateEventSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/UpdateEventSchemaIntent.swift
index 8812537fc7..66a96fd1a3 100644
--- a/Sources/App/Utilities/AppIntentSchemas/UpdateEventSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/UpdateEventSchemaIntent.swift
@@ -6,7 +6,7 @@ import Shared
///
/// `calendar/event/update` replaces the whole event, so every field the caller left out is refilled
/// from the event as it stands rather than cleared.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .calendar.updateEvent)
struct UpdateEventSchemaIntent {
var event: CalendarEventSchemaEntity
diff --git a/Sources/App/Utilities/AppIntentSchemas/UpdateReminderSchemaIntent.swift b/Sources/App/Utilities/AppIntentSchemas/UpdateReminderSchemaIntent.swift
index 687303c128..696972c25f 100644
--- a/Sources/App/Utilities/AppIntentSchemas/UpdateReminderSchemaIntent.swift
+++ b/Sources/App/Utilities/AppIntentSchemas/UpdateReminderSchemaIntent.swift
@@ -7,7 +7,7 @@ import Shared
/// `todo.update_item` replaces the fields it is given, so anything the caller left out is refilled
/// from the item as it stands. Moving an item between lists, flags, tags, URLs, recurrence and
/// location triggers have no equivalent in the `todo` domain and are ignored.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
@AppIntent(schema: .reminders.updateReminder)
struct UpdateReminderSchemaIntent {
var target: ReminderSchemaEntity
diff --git a/Sources/App/Utilities/AppIntents/AddTo/EntityAddToDestinationAppEnum.swift b/Sources/App/Utilities/AppIntents/AddTo/EntityAddToDestinationAppEnum.swift
index d604b73321..03a29f5f29 100644
--- a/Sources/App/Utilities/AppIntents/AddTo/EntityAddToDestinationAppEnum.swift
+++ b/Sources/App/Utilities/AppIntents/AddTo/EntityAddToDestinationAppEnum.swift
@@ -1,7 +1,11 @@
import AppIntents
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Where "Add entity to" puts an entity.
///
@@ -43,11 +47,15 @@ enum EntityAddToDestinationAppEnum: String, Codable, Sendable, AppEnum {
var isAvailable: Bool {
switch self {
case .appleWatch:
- !Current.isCatalyst
+ return !Current.isCatalyst
case .carPlay:
- !Current.isCatalyst && UIDevice.current.userInterfaceIdiom == .phone
+ #if os(macOS)
+ return false
+ #else
+ return !Current.isCatalyst && UIDevice.current.userInterfaceIdiom == .phone
+ #endif
case .macToolbar:
- Current.isCatalyst
+ return Current.isCatalyst
}
}
diff --git a/Sources/App/Utilities/AppMacBridgeStatusItemConfiguration.swift b/Sources/App/Utilities/AppMacBridgeStatusItemConfiguration.swift
index 23aa2dfe30..39b8ada903 100644
--- a/Sources/App/Utilities/AppMacBridgeStatusItemConfiguration.swift
+++ b/Sources/App/Utilities/AppMacBridgeStatusItemConfiguration.swift
@@ -1,5 +1,9 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
class AppMacBridgeStatusItemConfiguration: MacBridgeStatusItemConfiguration {
init(
diff --git a/Sources/App/Utilities/AppShortcuts/HomeAssistantAppShortcuts.swift b/Sources/App/Utilities/AppShortcuts/HomeAssistantAppShortcuts.swift
index 103c249218..9c826c5af0 100644
--- a/Sources/App/Utilities/AppShortcuts/HomeAssistantAppShortcuts.swift
+++ b/Sources/App/Utilities/AppShortcuts/HomeAssistantAppShortcuts.swift
@@ -16,7 +16,7 @@ import Foundation
/// command entity types deliberately set none and these symbols reach the row instead. The cover pair
/// shows the shape of it — an open curtain against a closed one — and `HomeAssistantAppShortcutsTests`
/// pins every symbol as real and distinct.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct HomeAssistantAppShortcuts: AppShortcutsProvider {
static let shortcutTileColor: ShortcutTileColor = .lightBlue
diff --git a/Sources/App/Utilities/DismissWhenModifier.swift b/Sources/App/Utilities/DismissWhenModifier.swift
new file mode 100644
index 0000000000..d3fef63fe8
--- /dev/null
+++ b/Sources/App/Utilities/DismissWhenModifier.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+/// Dismisses the view it is applied to once `trigger` turns true.
+///
+/// A view that reads `\.dismiss` itself is evaluated again whenever that action changes. In the native
+/// Mac app it changes each time the pages pushed onto the navigation stack are rebuilt, so a view that
+/// both builds a pushed page and reads the action never stops updating. Reading it here instead keeps
+/// that view out of the cycle.
+struct DismissWhenModifier: ViewModifier {
+ @Environment(\.dismiss) private var dismiss
+
+ let trigger: Bool
+
+ func body(content: Content) -> some View {
+ content
+ .onChange(of: trigger) { newValue in
+ if newValue {
+ dismiss()
+ }
+ }
+ }
+}
+
+extension View {
+ func dismiss(when trigger: Bool) -> some View {
+ modifier(DismissWhenModifier(trigger: trigger))
+ }
+}
+
+#Preview {
+ Text(verbatim: "Dismissed once the trigger turns true")
+ .dismiss(when: false)
+}
diff --git a/Sources/App/Utilities/Extensions/ApplicationState+Current.swift b/Sources/App/Utilities/Extensions/ApplicationState+Current.swift
new file mode 100644
index 0000000000..033a52aa08
--- /dev/null
+++ b/Sources/App/Utilities/Extensions/ApplicationState+Current.swift
@@ -0,0 +1,18 @@
+import Shared
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+extension ApplicationState {
+ /// The state the app is in right now. A Mac app is never in the background: it is either frontmost
+ /// or it is not. Read on the main thread.
+ static var current: ApplicationState {
+ #if os(macOS)
+ NSApplication.shared.isActive ? .active : .inactive
+ #else
+ UIApplication.shared.applicationState
+ #endif
+ }
+}
diff --git a/Sources/App/Utilities/Extensions/MKMapSnapshotter+TopLeftPoint.swift b/Sources/App/Utilities/Extensions/MKMapSnapshotter+TopLeftPoint.swift
new file mode 100644
index 0000000000..15412e0231
--- /dev/null
+++ b/Sources/App/Utilities/Extensions/MKMapSnapshotter+TopLeftPoint.swift
@@ -0,0 +1,13 @@
+import MapKit
+
+extension MKMapSnapshotter.Snapshot {
+ /// The point for `coordinate` measured from the image's top left corner, which is where the image
+ /// renderer's origin is. AppKit measures the snapshot from its bottom left corner.
+ func topLeftPoint(for coordinate: CLLocationCoordinate2D) -> CGPoint {
+ var point = point(for: coordinate)
+ #if os(macOS)
+ point.y = image.size.height - point.y
+ #endif
+ return point
+ }
+}
diff --git a/Sources/App/Utilities/Extensions/PlatformViewController+TransparentOverlay.swift b/Sources/App/Utilities/Extensions/PlatformViewController+TransparentOverlay.swift
new file mode 100644
index 0000000000..0d13181b28
--- /dev/null
+++ b/Sources/App/Utilities/Extensions/PlatformViewController+TransparentOverlay.swift
@@ -0,0 +1,37 @@
+import Shared
+#if os(macOS)
+import AppKit
+import ObjectiveC
+#else
+import UIKit
+#endif
+
+#if os(macOS)
+private var sizesSheetToContentKey: UInt8 = 0
+#endif
+
+extension PlatformViewController {
+ /// Sets the controller up to cover the screen with nothing of its own behind its content and to fade in,
+ /// which is what a SwiftUI screen that draws its own card over the frontend needs on iOS. The Mac shows
+ /// the same screen as a sheet on the window, made just large enough for that card.
+ func presentsAsTransparentOverlay() {
+ #if os(iOS)
+ view.backgroundColor = .clear
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ #else
+ sizesSheetToContent = true
+ #endif
+ }
+
+ #if os(macOS)
+ /// Whether the sheet this controller is presented in takes its size from the screen inside it, rather
+ /// than a size of the presenter's choosing.
+ private(set) var sizesSheetToContent: Bool {
+ get { objc_getAssociatedObject(self, &sizesSheetToContentKey) as? Bool ?? false }
+ set {
+ objc_setAssociatedObject(self, &sizesSheetToContentKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+ }
+ #endif
+}
diff --git a/Sources/App/Utilities/Extensions/ToolbarPlacement+PlatformNavigationBar.swift b/Sources/App/Utilities/Extensions/ToolbarPlacement+PlatformNavigationBar.swift
new file mode 100644
index 0000000000..334547baed
--- /dev/null
+++ b/Sources/App/Utilities/Extensions/ToolbarPlacement+PlatformNavigationBar.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+extension ToolbarPlacement {
+ /// The bar a navigation stack draws its title and back button in: the navigation bar on iOS, the
+ /// window's toolbar on the Mac.
+ static var platformNavigationBar: ToolbarPlacement {
+ #if os(macOS)
+ return .windowToolbar
+ #else
+ return .navigationBar
+ #endif
+ }
+}
diff --git a/Sources/App/Utilities/Extensions/UIApplication+BackgroundTask.swift b/Sources/App/Utilities/Extensions/UIApplication+BackgroundTask.swift
index 3b44f63c46..23906fd42a 100644
--- a/Sources/App/Utilities/Extensions/UIApplication+BackgroundTask.swift
+++ b/Sources/App/Utilities/Extensions/UIApplication+BackgroundTask.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import PromiseKit
import Shared
@@ -41,3 +42,4 @@ private extension UIApplication {
)
}
}
+#endif
diff --git a/Sources/App/Utilities/Extensions/UIColor+CSSRGB.swift b/Sources/App/Utilities/Extensions/UIColor+CSSRGB.swift
index 947a033ff2..1f0db480b1 100644
--- a/Sources/App/Utilities/Extensions/UIColor+CSSRGB.swift
+++ b/Sources/App/Utilities/Extensions/UIColor+CSSRGB.swift
@@ -1,5 +1,10 @@
import Foundation
+import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
public extension UIColor {
convenience init?(rgbString string: String) {
diff --git a/Sources/App/Utilities/Extensions/UIColor+TertiaryFill.swift b/Sources/App/Utilities/Extensions/UIColor+TertiaryFill.swift
new file mode 100644
index 0000000000..6d5bc5d34e
--- /dev/null
+++ b/Sources/App/Utilities/Extensions/UIColor+TertiaryFill.swift
@@ -0,0 +1,23 @@
+import Shared
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+extension UIColor {
+ /// The thin fill behind pills and badges: `tertiarySystemFill` wherever the system has it. AppKit only
+ /// gained the fill colors in macOS 14, so earlier releases get the quaternary label color, which is
+ /// what they tint thin fills with.
+ static var tertiaryFill: UIColor {
+ #if os(macOS)
+ if #available(macOS 14.0, *) {
+ return .tertiarySystemFill
+ } else {
+ return .quaternaryLabelColor
+ }
+ #else
+ return .tertiarySystemFill
+ #endif
+ }
+}
diff --git a/Sources/App/Utilities/Extensions/UIImage+Circle.swift b/Sources/App/Utilities/Extensions/UIImage+Circle.swift
index a84d25ca03..f67ad3dcd8 100644
--- a/Sources/App/Utilities/Extensions/UIImage+Circle.swift
+++ b/Sources/App/Utilities/Extensions/UIImage+Circle.swift
@@ -1,25 +1,31 @@
import CoreGraphics
import Foundation
+import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
extension UIImage {
func croppedToCircle() -> UIImage {
- UIGraphicsBeginImageContextWithOptions(size, false, scale)
-
- let circleWidth = size.width
- let radius = circleWidth / 2
-
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
- UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
- draw(in: rect)
+ let radius = size.width / 2
- let newImage: UIImage? = UIGraphicsGetImageFromCurrentImageContext()
- UIGraphicsEndImageContext()
-
- if let newImage {
- return newImage
- } else {
- return self
+ #if os(macOS)
+ return UIGraphicsImageRenderer(size: size, scale: scale).image { _ in
+ NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).addClip()
+ draw(in: rect)
+ }
+ #else
+ let format = UIGraphicsImageRendererFormat.preferred()
+ format.scale = scale
+ format.opaque = false
+ format.preferredRange = .standard
+ return UIGraphicsImageRenderer(size: size, format: format).image { _ in
+ UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
+ draw(in: rect)
}
+ #endif
}
}
diff --git a/Sources/App/Utilities/Extensions/UIImage+scaledToSize.swift b/Sources/App/Utilities/Extensions/UIImage+scaledToSize.swift
index 277a8ab02b..6cce7ea562 100644
--- a/Sources/App/Utilities/Extensions/UIImage+scaledToSize.swift
+++ b/Sources/App/Utilities/Extensions/UIImage+scaledToSize.swift
@@ -1,10 +1,19 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
extension UIImage {
func scaledToSize(_ size: CGSize) -> UIImage {
- UIGraphicsImageRenderer(
+ #if os(macOS)
+ return UIGraphicsImageRenderer(size: size).image { _ in
+ draw(in: CGRect(origin: .zero, size: size))
+ }
+ #else
+ return UIGraphicsImageRenderer(
size: size,
format: with(UIGraphicsImageRendererFormat.preferred()) {
$0.opaque = imageRendererFormat.opaque
@@ -12,5 +21,6 @@ extension UIImage {
).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
+ #endif
}
}
diff --git a/Sources/App/Utilities/FrontendThemeMode.swift b/Sources/App/Utilities/FrontendThemeMode.swift
index 5755bc1b36..ce7cc1277f 100644
--- a/Sources/App/Utilities/FrontendThemeMode.swift
+++ b/Sources/App/Utilities/FrontendThemeMode.swift
@@ -1,5 +1,9 @@
import Foundation
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// The "Theme mode" a Home Assistant user picks in their frontend profile, stored per account.
enum FrontendThemeMode: String {
@@ -16,6 +20,16 @@ enum FrontendThemeMode: String {
self = dark ? .dark : .light
}
+ #if os(macOS)
+ /// The appearance a window takes for this mode; nil lets the window follow the system.
+ var appearance: NSAppearance? {
+ switch self {
+ case .automatic: return nil
+ case .light: return NSAppearance(named: .aqua)
+ case .dark: return NSAppearance(named: .darkAqua)
+ }
+ }
+ #else
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .automatic: return .unspecified
@@ -23,4 +37,5 @@ enum FrontendThemeMode: String {
case .dark: return .dark
}
}
+ #endif
}
diff --git a/Sources/App/Utilities/FrontendThemeModeApplier.swift b/Sources/App/Utilities/FrontendThemeModeApplier.swift
index f1476dfb2a..bab1651e70 100644
--- a/Sources/App/Utilities/FrontendThemeModeApplier.swift
+++ b/Sources/App/Utilities/FrontendThemeModeApplier.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Styles each window with the theme mode of the frontend its scene is showing.
///
@@ -10,19 +14,33 @@ import UIKit
final class FrontendThemeModeApplier {
static let shared = FrontendThemeModeApplier()
- private let windowScenes: @MainActor () -> [UIWindowScene]
+ /// Posted when a scene comes to the front: on the Mac, where every window is its own scene, that is a
+ /// window becoming key.
+ static var sceneDidActivateNotification: Notification.Name {
+ #if os(macOS)
+ return NSWindow.didBecomeKeyNotification
+ #else
+ return UIScene.didActivateNotification
+ #endif
+ }
+
+ private let windowScenes: @MainActor () -> [PlatformWindowScene]
private var modes: [Identifier: FrontendThemeMode] = [:]
- private var scenes: [Identifier: @MainActor () -> UIWindowScene?] = [:]
+ private var scenes: [Identifier: @MainActor () -> PlatformWindowScene?] = [:]
private var fallback: FrontendThemeMode = .automatic
- init(windowScenes: (@MainActor () -> [UIWindowScene])? = nil) {
+ init(windowScenes: (@MainActor () -> [PlatformWindowScene])? = nil) {
self.windowScenes = windowScenes ?? {
- UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+ #if os(macOS)
+ return NSApplication.shared.windows
+ #else
+ return UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+ #endif
}
NotificationCenter.default.addObserver(
self,
selector: #selector(apply),
- name: UIScene.didActivateNotification,
+ name: Self.sceneDidActivateNotification,
object: nil
)
}
@@ -42,11 +60,43 @@ final class FrontendThemeModeApplier {
}
/// The scene is resolved on every apply, because the frontend is handed over before it has a window.
- func frontend(for server: Identifier, showingIn scene: @escaping @MainActor () -> UIWindowScene?) {
+ func frontend(
+ for server: Identifier,
+ showingIn scene: @escaping @MainActor () -> PlatformWindowScene?
+ ) {
scenes[server] = scene
apply()
}
+ #if os(macOS)
+ @objc func apply() {
+ var modeByWindow: [ObjectIdentifier: FrontendThemeMode] = [:]
+ for (server, scene) in scenes {
+ guard let window = scene(), let mode = modes[server] else { continue }
+ modeByWindow[ObjectIdentifier(window)] = mode
+ }
+
+ for window in windowScenes() {
+ // A sheet, popover or other child window belongs to the window it is attached to, the way
+ // everything a scene presents shares the scene's windows on iOS.
+ let owner = Self.owner(of: window)
+ // The status item, menus and tooltips live above the normal level and follow the system.
+ guard owner.level == .normal else { continue }
+ let appearance = (modeByWindow[ObjectIdentifier(owner)] ?? fallback).appearance
+ if window.appearance?.name != appearance?.name {
+ window.appearance = appearance
+ }
+ }
+ }
+
+ private static func owner(of window: NSWindow) -> NSWindow {
+ var owner = window
+ while let parent = owner.sheetParent ?? owner.parent {
+ owner = parent
+ }
+ return owner
+ }
+ #else
@objc func apply() {
var modeByScene: [String: FrontendThemeMode] = [:]
for (server, scene) in scenes {
@@ -61,4 +111,5 @@ final class FrontendThemeModeApplier {
}
}
}
+ #endif
}
diff --git a/Sources/App/Utilities/FrontendThemeModeObserver.swift b/Sources/App/Utilities/FrontendThemeModeObserver.swift
index 1008bc879c..1515f55f11 100644
--- a/Sources/App/Utilities/FrontendThemeModeObserver.swift
+++ b/Sources/App/Utilities/FrontendThemeModeObserver.swift
@@ -1,7 +1,11 @@
import Foundation
import HAKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Reads the theme mode of the server one frontend scene is showing, and reports it to the applier.
@MainActor
@@ -18,7 +22,7 @@ final class FrontendThemeModeObserver {
NotificationCenter.default.addObserver(
self,
selector: #selector(sceneDidActivate),
- name: UIScene.didActivateNotification,
+ name: FrontendThemeModeApplier.sceneDidActivateNotification,
object: nil
)
}
diff --git a/Sources/App/Utilities/HingeInteractionFactory.swift b/Sources/App/Utilities/HingeInteractionFactory.swift
index 3d6b330d8e..610af3e523 100644
--- a/Sources/App/Utilities/HingeInteractionFactory.swift
+++ b/Sources/App/Utilities/HingeInteractionFactory.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import Shared
import UIKit
@@ -37,3 +38,4 @@ enum HingeInteractionFactory {
return HingeState(angleRadians: angle.doubleValue, uiKitStatusRawValue: status.intValue)
}
}
+#endif
diff --git a/Sources/App/Utilities/MacSheet/MacSwiftUIScreenCarrying.swift b/Sources/App/Utilities/MacSheet/MacSwiftUIScreenCarrying.swift
new file mode 100644
index 0000000000..b7e498ed6a
--- /dev/null
+++ b/Sources/App/Utilities/MacSheet/MacSwiftUIScreenCarrying.swift
@@ -0,0 +1,19 @@
+#if os(macOS)
+import SwiftUI
+
+/// A controller whose whole content is a SwiftUI screen.
+///
+/// The app hands such controllers to AppKit code to present, the same way it hands a `UIHostingController`
+/// to UIKit on iOS. AppKit can put one in a sheet, but SwiftUI's `dismiss` action does nothing there, and
+/// most screens close themselves with it. So the presenter takes the screen back out of the controller
+/// and shows it in a sheet SwiftUI owns; see `NSViewController.presentSheet(_:)`.
+protocol MacSwiftUIScreenCarrying: NSViewController {
+ var carriedScreen: AnyView { get }
+}
+
+extension NSHostingController: MacSwiftUIScreenCarrying {
+ var carriedScreen: AnyView {
+ AnyView(rootView)
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/MacSheet/MacSwiftUISheetHostView.swift b/Sources/App/Utilities/MacSheet/MacSwiftUISheetHostView.swift
new file mode 100644
index 0000000000..fb5b398c61
--- /dev/null
+++ b/Sources/App/Utilities/MacSheet/MacSwiftUISheetHostView.swift
@@ -0,0 +1,29 @@
+#if os(macOS)
+import Shared
+import SwiftUI
+
+/// An invisible view whose only job is to present a screen in a SwiftUI sheet on the window it sits in.
+struct MacSwiftUISheetHostView: View {
+ @ObservedObject var model: MacSwiftUISheetModel
+ /// Reports the controller SwiftUI hosts the screen in, which is the one further sheets belong on.
+ let onResolveContentController: (NSViewController) -> Void
+
+ var body: some View {
+ Color.clear
+ .frame(width: 0, height: 0)
+ .sheet(isPresented: $model.isPresented, onDismiss: { model.onDismiss?() }) {
+ model.screen
+ .frame(width: model.size?.width, height: model.size?.height)
+ .environment(\.isPresentedInMacSheet, true)
+ .background(ViewControllerResolver(onResolve: onResolveContentController))
+ }
+ }
+}
+
+#Preview {
+ MacSwiftUISheetHostView(
+ model: .init(screen: AnyView(Text(verbatim: "Screen")), size: .init(width: 320, height: 200)),
+ onResolveContentController: { _ in }
+ )
+}
+#endif
diff --git a/Sources/App/Utilities/MacSheet/MacSwiftUISheetModel.swift b/Sources/App/Utilities/MacSheet/MacSwiftUISheetModel.swift
new file mode 100644
index 0000000000..f269c48b43
--- /dev/null
+++ b/Sources/App/Utilities/MacSheet/MacSwiftUISheetModel.swift
@@ -0,0 +1,20 @@
+#if os(macOS)
+import SwiftUI
+
+/// What `MacSwiftUISheetHostView` shows, and the switch that closes it from AppKit code.
+final class MacSwiftUISheetModel: ObservableObject {
+ @Published var isPresented = true
+
+ let screen: AnyView
+ /// The size the sheet takes. `nil` leaves the sheet to size itself to the screen, which suits a screen
+ /// that knows how big it is, such as a bottom sheet.
+ let size: CGSize?
+ /// Called once the sheet is gone, whether the screen dismissed itself or AppKit code closed it.
+ var onDismiss: (() -> Void)?
+
+ init(screen: AnyView, size: CGSize?) {
+ self.screen = screen
+ self.size = size
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/MacSheet/MacSwiftUISheetPresentation.swift b/Sources/App/Utilities/MacSheet/MacSwiftUISheetPresentation.swift
new file mode 100644
index 0000000000..cd18d4ee63
--- /dev/null
+++ b/Sources/App/Utilities/MacSheet/MacSwiftUISheetPresentation.swift
@@ -0,0 +1,21 @@
+#if os(macOS)
+import AppKit
+
+/// One screen shown through `NSViewController.presentSheet(_:)`: the controller it was handed over in, the
+/// hidden host that presents it and the model that closes it.
+final class MacSwiftUISheetPresentation {
+ /// The controller the screen arrived in. It is never put on screen itself; it stands for the sheet in
+ /// code that tracks what is presented.
+ let carrier: NSViewController
+ let model: MacSwiftUISheetModel
+ let host: NSViewController
+ /// The controller SwiftUI hosts the screen in, once the sheet is up.
+ weak var contentController: NSViewController?
+
+ init(carrier: NSViewController, model: MacSwiftUISheetModel, host: NSViewController) {
+ self.carrier = carrier
+ self.model = model
+ self.host = host
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/MacSheet/NSViewController+Sheets.swift b/Sources/App/Utilities/MacSheet/NSViewController+Sheets.swift
new file mode 100644
index 0000000000..7407ab385d
--- /dev/null
+++ b/Sources/App/Utilities/MacSheet/NSViewController+Sheets.swift
@@ -0,0 +1,139 @@
+#if os(macOS)
+import AppKit
+import ObjectiveC
+import SwiftUI
+
+private var swiftUISheetPresentationsKey: UInt8 = 0
+private var closingSheetPresentationsKey: UInt8 = 0
+private var sheetPresenterKey: UInt8 = 0
+
+/// Sheets on a Mac window, for code that presents controllers the way it does on iOS. A controller holding
+/// a SwiftUI screen goes in a sheet SwiftUI owns, so that the screen can dismiss itself; any other
+/// controller goes in an AppKit sheet.
+extension NSViewController {
+ private enum SheetConstants {
+ /// The size a screen opens at when its controller does not ask for one. A sheet sized to fit a
+ /// screen built for a phone would otherwise collapse to the width of its narrowest row.
+ static let defaultSize = CGSize(width: 560, height: 640)
+ }
+
+ private var swiftUISheetPresentations: [MacSwiftUISheetPresentation] {
+ get { objc_getAssociatedObject(self, &swiftUISheetPresentationsKey) as? [MacSwiftUISheetPresentation] ?? [] }
+ set {
+ objc_setAssociatedObject(self, &swiftUISheetPresentationsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+ }
+
+ /// Sheets told to close, kept until SwiftUI reports them gone so their hosts can be taken down then.
+ private var closingSheetPresentations: [MacSwiftUISheetPresentation] {
+ get { objc_getAssociatedObject(self, &closingSheetPresentationsKey) as? [MacSwiftUISheetPresentation] ?? [] }
+ set {
+ objc_setAssociatedObject(self, &closingSheetPresentationsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+ }
+
+ /// For a controller shown through `presentSheet(_:)` in a SwiftUI sheet, the controller that did it.
+ private var swiftUISheetPresenter: NSViewController? {
+ get { (objc_getAssociatedObject(self, &sheetPresenterKey) as? WeakReference)?.controller }
+ set {
+ objc_setAssociatedObject(
+ self,
+ &sheetPresenterKey,
+ newValue.map(WeakReference.init(controller:)),
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+ )
+ }
+ }
+
+ private final class WeakReference {
+ weak var controller: NSViewController?
+
+ init(controller: NSViewController) {
+ self.controller = controller
+ }
+ }
+
+ /// The controllers presented from this one as sheets, oldest first.
+ var presentedSheets: [NSViewController] {
+ (presentedViewControllers ?? []) + swiftUISheetPresentations.map(\.carrier)
+ }
+
+ /// The content of a sheet on this controller's window that is not in `presentedSheets`, such as one
+ /// SwiftUI put up itself, unless that sheet is on its way out.
+ var foreignSheetContentController: NSViewController? {
+ guard let sheet = view.window?.attachedSheet, let content = sheet.contentViewController else { return nil }
+ let closing = closingSheetPresentations.contains { $0.contentController?.view.window === sheet }
+ return closing ? nil : content
+ }
+
+ /// The controller to present from in order to land on top of this one: for a SwiftUI screen shown in a
+ /// sheet, the controller SwiftUI hosts it in; otherwise the controller itself.
+ var sheetContentController: NSViewController {
+ swiftUISheetPresenter?.swiftUISheetPresentations.first { $0.carrier === self }?.contentController ?? self
+ }
+
+ func presentSheet(_ viewController: NSViewController) {
+ guard let carrier = viewController as? MacSwiftUIScreenCarrying else {
+ presentAsSheet(viewController)
+ return
+ }
+
+ let size: CGSize? = if viewController.sizesSheetToContent {
+ nil
+ } else if viewController.preferredContentSize != .zero {
+ viewController.preferredContentSize
+ } else {
+ SheetConstants.defaultSize
+ }
+ let model = MacSwiftUISheetModel(screen: carrier.carriedScreen, size: size)
+ // Held weakly by the host it is about to own, which is created first.
+ weak var resolving: MacSwiftUISheetPresentation?
+ let host = NSHostingController(rootView: MacSwiftUISheetHostView(model: model) { controller in
+ resolving?.contentController = controller
+ })
+ let record = MacSwiftUISheetPresentation(carrier: viewController, model: model, host: host)
+ resolving = record
+ model.onDismiss = { [weak self, weak record] in
+ guard let self, let record else { return }
+ record.host.view.removeFromSuperview()
+ record.host.removeFromParent()
+ record.carrier.swiftUISheetPresenter = nil
+ swiftUISheetPresentations.removeAll { $0 === record }
+ closingSheetPresentations.removeAll { $0 === record }
+ }
+
+ viewController.swiftUISheetPresenter = self
+ swiftUISheetPresentations.append(record)
+ addChild(host)
+ host.view.frame = .zero
+ view.addSubview(host.view)
+ }
+
+ /// Closes this controller's sheet, whichever kind it was presented in. A SwiftUI sheet leaves the
+ /// stack at once, so whatever is presented next goes on what is under it rather than in it.
+ func dismissSheet() {
+ if let presenter = swiftUISheetPresenter,
+ let presentation = presenter.swiftUISheetPresentations.first(where: { $0.carrier === self }) {
+ presenter.close(presentation)
+ } else {
+ dismiss(nil)
+ }
+ }
+
+ /// Closes every sheet presented from this controller, newest first.
+ func dismissPresentedSheets() {
+ for presentation in swiftUISheetPresentations.reversed() {
+ close(presentation)
+ }
+ for presented in (presentedViewControllers ?? []).reversed() {
+ dismiss(presented)
+ }
+ }
+
+ private func close(_ presentation: MacSwiftUISheetPresentation) {
+ swiftUISheetPresentations.removeAll { $0 === presentation }
+ closingSheetPresentations.append(presentation)
+ presentation.model.isPresented = false
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/MenuManager.swift b/Sources/App/Utilities/MenuManager.swift
index c555ec6816..07a2e7d5f9 100644
--- a/Sources/App/Utilities/MenuManager.swift
+++ b/Sources/App/Utilities/MenuManager.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Foundation
import HAKit
import PromiseKit
@@ -9,138 +10,6 @@ private extension UIMenu.Identifier {
static var haFile: Self { .init(rawValue: "ha.file") }
}
-public struct MenuManagerTitleSubscription: Equatable {
- private var uuid = UUID()
- var server: Server
- var template: String
- var token: HACancellable
-
- init(server: Server, template: String, token: HACancellable) {
- self.server = server
- self.template = template
- self.token = token
- }
-
- func cancel() {
- token.cancel()
- }
-
- public static func == (lhs: MenuManagerTitleSubscription, rhs: MenuManagerTitleSubscription) -> Bool {
- lhs.uuid == rhs.uuid
- }
-}
-
-private final class CompositeHACancellable: HACancellable {
- private var cancellables: [HACancellable]
-
- init(_ cancellables: [HACancellable] = []) {
- self.cancellables = cancellables
- }
-
- func append(_ cancellable: HACancellable) {
- cancellables.append(cancellable)
- }
-
- func cancel() {
- let activeCancellables = cancellables
- cancellables.removeAll()
- activeCancellables.forEach { $0.cancel() }
- }
-}
-
-private final class StatusItemTitleState {
- var hasRenderedTitle = false
- var hasReceivedLiveUpdate = false
-}
-
-enum StatusItemTitleRenderer {
- static func subscribe(
- api: HomeAssistantAPI,
- template: String,
- update: @escaping (String) -> Void
- ) -> HACancellable {
- let state = StatusItemTitleState()
- let cancellable = CompositeHACancellable()
-
- cancellable.append(api.connection.send(.init(
- type: .rest(.post, "template"),
- data: ["template": template],
- shouldRetry: true
- )) { result in
- switch result {
- case let .success(data):
- guard !state.hasReceivedLiveUpdate else {
- return
- }
-
- state.hasRenderedTitle = true
- update(renderedTitle(from: data))
- case let .failure(error):
- Current.Log.error("Failed to render status item title via REST fallback: \(error)")
- }
- })
-
- cancellable.append(api.connection.subscribe(
- to: .renderTemplate(template),
- initiated: { result in
- guard case let .failure(error) = result else {
- return
- }
-
- Current.Log.error("Failed to subscribe to status item title updates: \(error)")
-
- if !state.hasRenderedTitle {
- update(L10n.errorLabel)
- }
- },
- handler: { _, response in
- state.hasRenderedTitle = true
- state.hasReceivedLiveUpdate = true
- update(String(describing: response.result))
- }
- ))
-
- return cancellable
- }
-
- private static func renderedTitle(from data: HAData) -> String {
- switch data {
- case let .primitive(value):
- return String(describing: value)
- case let .dictionary(value):
- return String(describing: value)
- case let .array(value):
- return String(describing: value)
- case .empty:
- return ""
- }
- }
-}
-
-/// Shared decision for what the macOS status item should do when "activated" (icon click or the
-/// "Toggle" menu item). When the user enabled "Open Home Assistant UI in browser"
-/// (`macNativeFeaturesOnly`) there is no in-app web view to toggle — it is destroyed at launch by
-/// `QuickActionWindowSceneDelegate` — so any "show me Home Assistant" affordance must open the default
-/// browser instead of spawning a web-view window. The preference is read live on every call because
-/// flipping it does not reconfigure the status item (it posts no `menuRelatedSettingDidChange`).
-enum StatusItemPrimaryAction {
- /// Opens Home Assistant in the default browser when the browser preference is on.
- /// - Returns: `true` if it handled the action (browser opened), `false` to fall through to the
- /// normal toggle/activate behaviour.
- static func openInBrowserIfNeeded() -> Bool {
- guard Current.settingsStore.macNativeFeaturesOnly else { return false }
- // Prefer the server shown in the menu-bar title; its getter already falls back to the first
- // server, so this also covers users without a configured menu-bar template.
- let server = Current.settingsStore.menuItemTemplate?.server ?? Current.servers.all.first
- // This only runs on macOS, where the last-known network information is read live from
- // macBridge, so the synchronous evaluation is current. Callers need the handled/unhandled
- // decision synchronously to fall through to the toggle behavior.
- guard let url = server?.activeURLUsingLastKnownNetworkState() else { return false }
- URLOpener.shared.open(url, options: [:], completionHandler: nil)
- return true
- }
-}
-
class MenuManager {
let builder: UIMenuBuilder
@@ -246,7 +115,7 @@ class MenuManager {
private func preferencesMenu() -> AppMacBridgeStatusItemMenuItem {
.init(
- name: L10n.Menu.Application.preferences,
+ name: L10n.Menu.Application.settings,
keyEquivalentModifier: [.command],
keyEquivalent: ","
) { callbackInfo in
@@ -373,168 +242,4 @@ class MenuManager {
@objc private func showFindInteraction() {}
@objc private func customizeToolbar() {}
}
-
-#if targetEnvironment(macCatalyst)
-final class StatusItemManager {
- private var titleSubscription: MenuManagerTitleSubscription? {
- didSet {
- if oldValue != titleSubscription {
- oldValue?.cancel()
- }
- }
- }
-
- private var shouldRefreshTitleSubscription = false
-
- private var appName: String {
- Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Home Assistant"
- }
-
- func configure() {
- onMain { [self] in
- configureStatusItem()
- refreshTitleSubscription()
- }
- }
-
- func apiDidConnect() {
- onMain { [self] in
- shouldRefreshTitleSubscription = true
- refreshTitleSubscription()
- }
- }
-
- private func onMain(_ work: @escaping () -> Void) {
- if Thread.isMainThread {
- work()
- } else {
- DispatchQueue.main.async(execute: work)
- }
- }
-
- private func refreshTitleSubscription() {
- titleSubscription = subscribeStatusItemTitle(
- existing: shouldRefreshTitleSubscription ? nil : titleSubscription,
- update: Current.macBridge.configureStatusItem(title:)
- )
- shouldRefreshTitleSubscription = false
- }
-
- private func subscribeStatusItemTitle(
- existing: MenuManagerTitleSubscription?,
- update: @escaping (String) -> Void
- ) -> MenuManagerTitleSubscription? {
- guard let (server, template) = Current.settingsStore.menuItemTemplate,
- Current.settingsStore.locationVisibility.isStatusItemVisible,
- !template.isEmpty else {
- update("")
- return nil
- }
-
- guard existing == nil || existing?.template != template || existing?.server != server else {
- return existing
- }
-
- existing?.cancel()
- update("")
-
- guard let api = Current.api(for: server) else {
- Current.Log.error("No API available to update status item title")
- return nil
- }
-
- return .init(
- server: server,
- template: template,
- token: StatusItemTitleRenderer.subscribe(api: api, template: template, update: update)
- )
- }
-
- private func aboutMenu() -> [AppMacBridgeStatusItemMenuItem] {
- [
- .init(name: L10n.About.title) { callbackInfo in
- Current.sceneManager.activateAnyScene(for: .about)
- callbackInfo.activate()
- },
- .init(name: L10n.Updater.CheckForUpdatesMenu.title) { callbackInfo in
- Current.sceneManager.activateAnyScene(for: .webView)
- callbackInfo.activate()
-
- UIApplication.shared.sendAction(
- #selector(AppDelegate.checkForUpdate(_:)),
- to: AppDelegate.shared,
- from: callbackInfo,
- for: nil
- )
- },
- ]
- }
-
- private func preferencesMenu() -> AppMacBridgeStatusItemMenuItem {
- .init(
- name: L10n.Menu.Application.preferences,
- keyEquivalentModifier: [.command],
- keyEquivalent: ","
- ) { callbackInfo in
- Current.sceneManager.activateAnyScene(for: .settings)
- callbackInfo.activate()
- }
- }
-
- private func toggleMenu() -> AppMacBridgeStatusItemMenuItem {
- .init(name: L10n.Menu.StatusItem.toggle(appName)) { callbackInfo in
- if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
- if callbackInfo.isActive {
- callbackInfo.deactivate()
- } else {
- Current.sceneManager.activateAnyScene(for: .webView)
- callbackInfo.activate()
- }
- }
- }
-
- private func quitMenu() -> AppMacBridgeStatusItemMenuItem {
- .init(
- name: L10n.Menu.StatusItem.quit,
- keyEquivalentModifier: [.command],
- keyEquivalent: "q"
- ) { callbackInfo in
- callbackInfo.terminate()
- }
- }
-
- private func configureStatusItem() {
- if Current.settingsStore.locationVisibility.isDockVisible {
- Current.macBridge.activationPolicy = .regular
- } else {
- Current.macBridge.activationPolicy = .accessory
- }
-
- var menuItems = [AppMacBridgeStatusItemMenuItem]()
- menuItems.append(toggleMenu())
- menuItems.append(.separator())
- menuItems.append(contentsOf: aboutMenu())
- menuItems.append(preferencesMenu())
- menuItems.append(quitMenu())
-
- Current.macBridge.configureStatusItem(using: AppMacBridgeStatusItemConfiguration(
- isVisible: Current.settingsStore.locationVisibility.isStatusItemVisible,
- image: Asset.statusItemIcon.image.cgImage!,
- imageSize: Asset.statusItemIcon.image.size,
- accessibilityLabel: appName,
- items: menuItems,
- primaryActionHandler: { callbackInfo in
- if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
- if callbackInfo.isActive {
- callbackInfo.deactivate()
- } else if callbackInfo.hasWindows {
- callbackInfo.activate()
- } else {
- Current.sceneManager.activateAnyScene(for: .webView)
- callbackInfo.activate()
- }
- }
- ))
- }
-}
#endif
diff --git a/Sources/App/Utilities/MenuManagerTitleSubscription.swift b/Sources/App/Utilities/MenuManagerTitleSubscription.swift
new file mode 100644
index 0000000000..22d497fddd
--- /dev/null
+++ b/Sources/App/Utilities/MenuManagerTitleSubscription.swift
@@ -0,0 +1,24 @@
+import Foundation
+import HAKit
+import Shared
+
+public struct MenuManagerTitleSubscription: Equatable {
+ private var uuid = UUID()
+ var server: Server
+ var template: String
+ var token: HACancellable
+
+ init(server: Server, template: String, token: HACancellable) {
+ self.server = server
+ self.template = template
+ self.token = token
+ }
+
+ func cancel() {
+ token.cancel()
+ }
+
+ public static func == (lhs: MenuManagerTitleSubscription, rhs: MenuManagerTitleSubscription) -> Bool {
+ lhs.uuid == rhs.uuid
+ }
+}
diff --git a/Sources/App/Utilities/NavigationView.swift b/Sources/App/Utilities/NavigationView.swift
new file mode 100644
index 0000000000..21eebd8863
--- /dev/null
+++ b/Sources/App/Utilities/NavigationView.swift
@@ -0,0 +1,30 @@
+#if os(macOS)
+import SwiftUI
+
+/// Stands in for SwiftUI's `NavigationView` in the native Mac app.
+///
+/// The app uses `NavigationView` for a single stack of screens, which is what it is on iOS with the stack
+/// style. On a Mac SwiftUI has no such style: a `NavigationView` always lays out in columns, putting a
+/// screen meant to fill a sheet or a window into a sidebar next to an empty detail area. Declared here,
+/// this type takes the name over for the app target and gives those screens the stack they were built for.
+struct NavigationView: View {
+ private let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ var body: some View {
+ NavigationStack {
+ content
+ }
+ }
+}
+
+#Preview {
+ NavigationView {
+ Text(verbatim: "Root")
+ .navigationTitle(Text(verbatim: "Title"))
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/OcclusionRegionsReader.swift b/Sources/App/Utilities/OcclusionRegionsReader.swift
index 9becfdf04e..644ca5411f 100644
--- a/Sources/App/Utilities/OcclusionRegionsReader.swift
+++ b/Sources/App/Utilities/OcclusionRegionsReader.swift
@@ -1,6 +1,16 @@
import SwiftUI
-import UIKit
+#if os(macOS)
+/// Reports how far hardware cutouts such as the camera reach into a view from its sides. A Mac window
+/// never has one reaching into it, so the insets are left as they are.
+struct OcclusionRegionsReader: View {
+ @Binding var insets: EdgeInsets
+
+ var body: some View {
+ Color.clear
+ }
+}
+#else
/// Reports how far hardware cutouts such as the camera reach into a view from its sides.
struct OcclusionRegionsReader: UIViewRepresentable {
@Binding var insets: EdgeInsets
@@ -17,6 +27,7 @@ struct OcclusionRegionsReader: UIViewRepresentable {
}
}
}
+#endif
#Preview {
Text("Steps clear of the camera")
diff --git a/Sources/App/Utilities/OcclusionRegionsReaderView.swift b/Sources/App/Utilities/OcclusionRegionsReaderView.swift
index 91f9a50ba9..50fcfcfdd1 100644
--- a/Sources/App/Utilities/OcclusionRegionsReaderView.swift
+++ b/Sources/App/Utilities/OcclusionRegionsReaderView.swift
@@ -1,5 +1,5 @@
+#if os(iOS)
import SwiftUI
-import UIKit
/// Reads the system's reserved occlusion regions and reports the horizontal insets that keep content clear of them.
final class OcclusionRegionsReaderView: UIView {
@@ -85,3 +85,4 @@ final class OcclusionRegionsReaderView: UIView {
return regions
}
}
+#endif
diff --git a/Sources/App/Utilities/OpenInChromeController.swift b/Sources/App/Utilities/OpenInChromeController.swift
index 58b64076e3..3348219d34 100644
--- a/Sources/App/Utilities/OpenInChromeController.swift
+++ b/Sources/App/Utilities/OpenInChromeController.swift
@@ -32,7 +32,11 @@
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
private let googleChromeHTTPScheme: String = "googlechrome:"
private let googleChromeHTTPSScheme: String = "googlechromes:"
diff --git a/Sources/App/Utilities/OpenInFirefoxControllerSwift.swift b/Sources/App/Utilities/OpenInFirefoxControllerSwift.swift
index 073412a51c..b2a3f3485c 100644
--- a/Sources/App/Utilities/OpenInFirefoxControllerSwift.swift
+++ b/Sources/App/Utilities/OpenInFirefoxControllerSwift.swift
@@ -5,7 +5,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
-import UIKit
open class OpenInFirefoxControllerSwift {
enum FirefoxType {
@@ -27,10 +26,6 @@ open class OpenInFirefoxControllerSwift {
let type: FirefoxType
- // This would need to be changed if used from an extension… but you
- // can't open arbitrary URLs from an extension anyway.
- let app = UIApplication.shared
-
init(type: FirefoxType = .regular) {
self.type = type
}
@@ -42,7 +37,7 @@ open class OpenInFirefoxControllerSwift {
}
open func isFirefoxInstalled() -> Bool {
- app.canOpenURL(URL(string: "\(type.urlScheme)://")!)
+ URLOpener.shared.canOpenURL(URL(string: "\(type.urlScheme)://")!)
}
open func openInFirefox(_ url: URL, privateTab: Bool = false) {
@@ -51,7 +46,7 @@ open class OpenInFirefoxControllerSwift {
let escaped = encodeByAddingPercentEscapes(url.absoluteString)
if let firefoxURL =
URL(string: "\(type.urlScheme)://open-url?\(privateTab ? "private=true&" : "")url=\(escaped)") {
- app.open(firefoxURL, options: [:], completionHandler: nil)
+ URLOpener.shared.open(firefoxURL, options: [:], completionHandler: nil)
}
}
}
diff --git a/Sources/App/Utilities/OpenSettingsDestination.swift b/Sources/App/Utilities/OpenSettingsDestination.swift
index da3bcedede..9fcf2ed19b 100644
--- a/Sources/App/Utilities/OpenSettingsDestination.swift
+++ b/Sources/App/Utilities/OpenSettingsDestination.swift
@@ -1,5 +1,8 @@
import Foundation
import Shared
+#if os(iOS)
+import UIKit
+#endif
enum OpenSettingsDestination {
case location
@@ -40,7 +43,11 @@ enum OpenSettingsDestination {
return nil
}
} else {
+ #if os(macOS)
+ return nil
+ #else
return URL(string: UIApplication.openSettingsURLString)!
+ #endif
}
}
}
diff --git a/Sources/App/Utilities/Permissions.swift b/Sources/App/Utilities/Permissions.swift
index 9cf979afd9..57f5fcd230 100644
--- a/Sources/App/Utilities/Permissions.swift
+++ b/Sources/App/Utilities/Permissions.swift
@@ -2,7 +2,11 @@ import CoreLocation
import CoreMotion
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
public enum PermissionStatus {
@@ -185,7 +189,12 @@ public enum PermissionType {
let locationManager = CLLocationManager()
return locationManager.authorizationStatus.genericStatus
case .motion:
+ #if os(macOS)
+ // No Mac has the motion coprocessor this permission guards, so there is nothing to grant.
+ return .restricted
+ #else
return CMMotionActivityManager.authorizationStatus().genericStatus
+ #endif
case .notification:
guard let authorizationStatus = fetchNotificationsAuthorizationStatus() else { return .denied }
return authorizationStatus.genericStatus
@@ -250,6 +259,9 @@ public enum PermissionType {
}
}
case .motion:
+ #if os(macOS)
+ completionHandler(false, .restricted)
+ #else
let manager = CMMotionActivityManager()
let now = Date()
@@ -262,13 +274,18 @@ public enum PermissionType {
}
completionHandler(true, .authorized)
})
+ #endif
case .notification:
UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { granted, error in
if let error {
Current.Log.error("Error when requesting notifications permissions: \(error)")
}
DispatchQueue.main.async {
+ #if os(macOS)
+ NSApplication.shared.registerForRemoteNotifications()
+ #else
UIApplication.shared.registerForRemoteNotifications()
+ #endif
let status: PermissionStatus = granted ? .authorized : .denied
completionHandler(granted, status)
}
diff --git a/Sources/App/Utilities/ProgressHUD.swift b/Sources/App/Utilities/ProgressHUD.swift
index 8961601180..001bb31f23 100644
--- a/Sources/App/Utilities/ProgressHUD.swift
+++ b/Sources/App/Utilities/ProgressHUD.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import Shared
import UIKit
@@ -180,3 +181,4 @@ final class HUDLabel: UILabel {
didSet { onTextChange?() }
}
}
+#endif
diff --git a/Sources/App/Utilities/RemindersSync/RemindersSyncBackgroundRefresher.swift b/Sources/App/Utilities/RemindersSync/RemindersSyncBackgroundRefresher.swift
index 326bb7da0e..d443ea06f5 100644
--- a/Sources/App/Utilities/RemindersSync/RemindersSyncBackgroundRefresher.swift
+++ b/Sources/App/Utilities/RemindersSync/RemindersSyncBackgroundRefresher.swift
@@ -1,13 +1,37 @@
+#if os(iOS)
import BackgroundTasks
+#endif
import Foundation
import Shared
-/// Schedules background Reminders syncs through `BGTaskScheduler` at the frequency the user
-/// picked in the sync settings. The frequency is only the earliest allowed start; iOS decides
-/// when (and whether) the refresh actually runs.
+/// Schedules background Reminders syncs at the frequency the user picked in the sync settings.
+///
+/// On iOS that goes through `BGTaskScheduler`, where the frequency is only the earliest allowed start and
+/// the system decides when (and whether) the refresh actually runs. A Mac app is never suspended, so
+/// there it is a plain repeating timer that runs for as long as the app does.
enum RemindersSyncBackgroundRefresher {
static let taskIdentifier = "io.robbie.homeassistant.reminderssync"
+ #if os(macOS)
+ private static var timer: Timer?
+
+ static func register() {}
+
+ /// (Re)starts the timer, or stops it when background refresh is off or there is nothing to sync.
+ static func schedule() {
+ timer?.invalidate()
+ timer = nil
+
+ let interval = RemindersSyncSettings.current.backgroundRefreshInterval
+ guard interval > 0, !RemindersSyncConfig.all().isEmpty else { return }
+
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
+ Task { @MainActor in
+ await RemindersSyncManager.shared.syncAll()
+ }
+ }
+ }
+ #else
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: .main) { task in
handleAppRefresh(task: task)
@@ -57,4 +81,5 @@ enum RemindersSyncBackgroundRefresher {
complete(false)
}
}
+ #endif
}
diff --git a/Sources/App/Utilities/RemindersSync/RemindersSyncManager.swift b/Sources/App/Utilities/RemindersSync/RemindersSyncManager.swift
index 4d7b053c8f..841e74bb36 100644
--- a/Sources/App/Utilities/RemindersSync/RemindersSyncManager.swift
+++ b/Sources/App/Utilities/RemindersSync/RemindersSyncManager.swift
@@ -2,7 +2,11 @@ import EventKit
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Orchestrates syncing between Apple Reminders lists and Home Assistant todo lists for every
/// stored `RemindersSyncConfig`. Fetches both sides, diffs them via `RemindersSyncPlanner` and
@@ -49,7 +53,7 @@ final class RemindersSyncManager: ObservableObject {
})
notificationObservers.append(NotificationCenter.default.addObserver(
- forName: UIApplication.willEnterForegroundNotification,
+ forName: AppLifecycle.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
@@ -60,7 +64,7 @@ final class RemindersSyncManager: ObservableObject {
})
notificationObservers.append(NotificationCenter.default.addObserver(
- forName: UIApplication.didEnterBackgroundNotification,
+ forName: AppLifecycle.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
@@ -131,7 +135,7 @@ final class RemindersSyncManager: ObservableObject {
func requestAccess() async -> Bool {
do {
let granted: Bool
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
granted = try await eventStore.requestFullAccessToReminders()
} else {
granted = try await eventStore.requestAccess(to: .reminder)
@@ -311,7 +315,7 @@ final class RemindersSyncManager: ObservableObject {
/// database while backgrounded, GRDB goes back to the suspended state so nothing can hold the
/// app-group SQLite lock when the process is frozen.
private func suspendDatabaseIfBackgrounded() {
- if UIApplication.shared.applicationState == .background {
+ if ApplicationState.current == .background {
AppDatabaseSuspension.suspend()
}
}
diff --git a/Sources/App/Utilities/RemindersSync/RemindersSyncSettingsView.swift b/Sources/App/Utilities/RemindersSync/RemindersSyncSettingsView.swift
index fd1454a6df..1d12bba3f5 100644
--- a/Sources/App/Utilities/RemindersSync/RemindersSyncSettingsView.swift
+++ b/Sources/App/Utilities/RemindersSync/RemindersSyncSettingsView.swift
@@ -19,9 +19,17 @@ struct RemindersSyncSettingsView: View {
Text(L10n.RemindersSync.Settings.AccessDenied.body)
.foregroundStyle(.secondary)
Button(L10n.RemindersSync.Settings.openSettings) {
+ #if os(macOS)
+ // A Mac keeps the switch for this under Privacy & Security in System Settings.
+ let settingsURL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders"
+ if let url = URL(string: settingsURL) {
+ URLOpener.shared.open(url, options: [:], completionHandler: nil)
+ }
+ #else
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
+ #endif
}
}
} else {
diff --git a/Sources/App/Utilities/SoftwareKeyboardObserver.swift b/Sources/App/Utilities/SoftwareKeyboardObserver.swift
index 210ae260ed..89a14cd4f8 100644
--- a/Sources/App/Utilities/SoftwareKeyboardObserver.swift
+++ b/Sources/App/Utilities/SoftwareKeyboardObserver.swift
@@ -1,6 +1,10 @@
import Combine
import Foundation
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Whether an on-screen keyboard is up, as opposed to a hardware keyboard driving a focused field.
@MainActor
@@ -9,8 +13,14 @@ final class SoftwareKeyboardObserver: ObservableObject {
@Published private(set) var isShown = false
+ #if !os(macOS)
private var cancellables = Set()
+ #endif
+ #if os(macOS)
+ /// A Mac has no on-screen keyboard, so there is nothing to observe and `isShown` stays false.
+ init() {}
+ #else
/// Keyboard notifications carry the screen the keyboard appears on as their object, which is also the
/// coordinate space the end frame is expressed in. UIKit posts them on the main actor.
init(
@@ -34,6 +44,7 @@ final class SoftwareKeyboardObserver: ObservableObject {
}
.store(in: &cancellables)
}
+ #endif
static func isShown(endFrame: CGRect?, screenHeight: CGFloat?) -> Bool {
guard let endFrame, let screenHeight, endFrame.height >= minimumHeight else { return false }
diff --git a/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntity+IndexedEntity.swift b/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntity+IndexedEntity.swift
index ebb9266b99..234ce18ae8 100644
--- a/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntity+IndexedEntity.swift
+++ b/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntity+IndexedEntity.swift
@@ -3,7 +3,7 @@ import CoreSpotlight
import Foundation
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
extension HAAppEntityAppIntentEntity: IndexedEntity {
/// Augments what App Intents derives from `displayRepresentation` with everything a person might
/// type, and with the context line, which a search result needs as `contentDescription`: Spotlight
diff --git a/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntityQuery+IndexedEntityQuery.swift b/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntityQuery+IndexedEntityQuery.swift
index 8853f53f6c..347520f26c 100644
--- a/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntityQuery+IndexedEntityQuery.swift
+++ b/Sources/App/Utilities/Spotlight/HAAppEntityAppIntentEntityQuery+IndexedEntityQuery.swift
@@ -5,7 +5,7 @@ import Shared
/// Lets the system ask for a Spotlight refresh instead of waiting for the app's own pass, which only
/// runs when the database changes or the app comes to the foreground.
-@available(iOS 27.0, *)
+@available(iOS 27.0, macOS 27.0, *)
extension HAAppEntityAppIntentEntityQuery: IndexedEntityQuery {
func reindexEntities(
for identifiers: [String],
diff --git a/Sources/App/Utilities/Spotlight/HACalendarAppEntity+IndexedEntity.swift b/Sources/App/Utilities/Spotlight/HACalendarAppEntity+IndexedEntity.swift
index 4b7ffc1db4..3893975144 100644
--- a/Sources/App/Utilities/Spotlight/HACalendarAppEntity+IndexedEntity.swift
+++ b/Sources/App/Utilities/Spotlight/HACalendarAppEntity+IndexedEntity.swift
@@ -3,7 +3,7 @@ import CoreSpotlight
import Foundation
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
extension HACalendarAppEntity: IndexedEntity {
/// Mirrors `HAAppEntityAppIntentEntity`: Spotlight renders `contentDescription` as the second row
/// and does not fall back to the display representation's subtitle, so the entity id is set there
diff --git a/Sources/App/Utilities/Spotlight/SpotlightEntityIndexer.swift b/Sources/App/Utilities/Spotlight/SpotlightEntityIndexer.swift
index d4f47f644f..a823fcc98a 100644
--- a/Sources/App/Utilities/Spotlight/SpotlightEntityIndexer.swift
+++ b/Sources/App/Utilities/Spotlight/SpotlightEntityIndexer.swift
@@ -3,7 +3,11 @@ import CoreSpotlight
import CryptoKit
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Publishes the entities and calendars cached in the local database to Spotlight, so searching the
/// system for an entity's name (or its area, device or server) finds it and opens its more-info
@@ -12,7 +16,7 @@ import UIKit
/// The index is rebuilt from the database rather than kept in sync incrementally: a full snapshot is
/// cheap to derive, and comparing its signature against the last indexed one means a refresh that
/// changed nothing costs a single hash instead of thousands of Spotlight writes.
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
@MainActor
final class SpotlightEntityIndexer: ServerObserver {
static let shared = SpotlightEntityIndexer()
@@ -73,7 +77,7 @@ final class SpotlightEntityIndexer: ServerObserver {
// without a foreground transition ever following, which would defer the reindex forever.
if !Current.isCatalyst {
backgroundObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didEnterBackgroundNotification,
+ forName: AppLifecycle.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
@@ -82,7 +86,7 @@ final class SpotlightEntityIndexer: ServerObserver {
}
}
foregroundObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.willEnterForegroundNotification,
+ forName: AppLifecycle.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
@@ -118,7 +122,7 @@ final class SpotlightEntityIndexer: ServerObserver {
/// Reindexes the named entities on request from the system, which asks when it notices the index
/// may be stale rather than waiting for the next database change.
- @available(iOS 27.0, *)
+ @available(iOS 27.0, macOS 27.0, *)
func reindex(entityIds: [String]) async throws {
guard let snapshot = await Task.detached(priority: .utility) { Self.makeSnapshot() }.value else {
return
@@ -143,7 +147,7 @@ final class SpotlightEntityIndexer: ServerObserver {
}
/// Rebuilds the whole index on request from the system.
- @available(iOS 27.0, *)
+ @available(iOS 27.0, macOS 27.0, *)
func reindexEverything() async {
await reindex(reason: "system asked for a reindex")
}
@@ -180,7 +184,7 @@ final class SpotlightEntityIndexer: ServerObserver {
/// Events are indexed on their own because the entity is iOS 27; see
/// `CalendarEventSpotlightIndexer` for why it isn't part of the snapshot.
private func reindexCalendarEvents() async {
- if #available(iOS 27.0, *) {
+ if #available(iOS 27.0, macOS 27.0, *) {
await CalendarEventSpotlightIndexer.reindex(index: index, defaults: defaults)
}
}
@@ -189,7 +193,7 @@ final class SpotlightEntityIndexer: ServerObserver {
// A trigger fired while backgrounded (background refresh, servers changing) lands here with
// the coalescing delay already spent; defer it to the foreground instead of reading the
// database from an unprotected background process.
- if !Current.isCatalyst, UIApplication.shared.applicationState == .background {
+ if !Current.isCatalyst, ApplicationState.current == .background {
needsReindexOnForeground = true
return
}
diff --git a/Sources/App/Utilities/StatusItemManager.swift b/Sources/App/Utilities/StatusItemManager.swift
new file mode 100644
index 0000000000..388298bd80
--- /dev/null
+++ b/Sources/App/Utilities/StatusItemManager.swift
@@ -0,0 +1,163 @@
+#if targetEnvironment(macCatalyst) || os(macOS)
+import Foundation
+import Shared
+
+/// Owns the Mac menu bar item: its icon, its templated title and the menu behind it.
+final class StatusItemManager {
+ private var titleSubscription: MenuManagerTitleSubscription? {
+ didSet {
+ if oldValue != titleSubscription {
+ oldValue?.cancel()
+ }
+ }
+ }
+
+ private var shouldRefreshTitleSubscription = false
+
+ private var appName: String {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Home Assistant"
+ }
+
+ func configure() {
+ onMain { [self] in
+ configureStatusItem()
+ refreshTitleSubscription()
+ }
+ }
+
+ func apiDidConnect() {
+ onMain { [self] in
+ shouldRefreshTitleSubscription = true
+ refreshTitleSubscription()
+ }
+ }
+
+ private func onMain(_ work: @escaping () -> Void) {
+ if Thread.isMainThread {
+ work()
+ } else {
+ DispatchQueue.main.async(execute: work)
+ }
+ }
+
+ private func refreshTitleSubscription() {
+ titleSubscription = subscribeStatusItemTitle(
+ existing: shouldRefreshTitleSubscription ? nil : titleSubscription,
+ update: Current.macBridge.configureStatusItem(title:)
+ )
+ shouldRefreshTitleSubscription = false
+ }
+
+ private func subscribeStatusItemTitle(
+ existing: MenuManagerTitleSubscription?,
+ update: @escaping (String) -> Void
+ ) -> MenuManagerTitleSubscription? {
+ guard let (server, template) = Current.settingsStore.menuItemTemplate,
+ Current.settingsStore.locationVisibility.isStatusItemVisible,
+ !template.isEmpty else {
+ update("")
+ return nil
+ }
+
+ guard existing == nil || existing?.template != template || existing?.server != server else {
+ return existing
+ }
+
+ existing?.cancel()
+ update("")
+
+ guard let api = Current.api(for: server) else {
+ Current.Log.error("No API available to update status item title")
+ return nil
+ }
+
+ return .init(
+ server: server,
+ template: template,
+ token: StatusItemTitleRenderer.subscribe(api: api, template: template, update: update)
+ )
+ }
+
+ private func aboutMenu() -> [AppMacBridgeStatusItemMenuItem] {
+ [
+ .init(name: L10n.About.title) { callbackInfo in
+ Current.sceneManager.activateAnyScene(for: .about)
+ callbackInfo.activate()
+ },
+ .init(name: L10n.Updater.CheckForUpdatesMenu.title) { callbackInfo in
+ Current.sceneManager.activateAnyScene(for: .webView)
+ callbackInfo.activate()
+
+ AppDelegate.shared?.checkForUpdate(callbackInfo)
+ },
+ ]
+ }
+
+ private func preferencesMenu() -> AppMacBridgeStatusItemMenuItem {
+ .init(
+ name: L10n.Menu.Application.settings,
+ keyEquivalentModifier: [.command],
+ keyEquivalent: ","
+ ) { callbackInfo in
+ Current.sceneManager.activateAnyScene(for: .settings)
+ callbackInfo.activate()
+ }
+ }
+
+ private func toggleMenu() -> AppMacBridgeStatusItemMenuItem {
+ .init(name: L10n.Menu.StatusItem.toggle(appName)) { callbackInfo in
+ if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
+ if callbackInfo.isActive {
+ callbackInfo.deactivate()
+ } else {
+ Current.sceneManager.activateAnyScene(for: .webView)
+ callbackInfo.activate()
+ }
+ }
+ }
+
+ private func quitMenu() -> AppMacBridgeStatusItemMenuItem {
+ .init(
+ name: L10n.Menu.StatusItem.quit,
+ keyEquivalentModifier: [.command],
+ keyEquivalent: "q"
+ ) { callbackInfo in
+ callbackInfo.terminate()
+ }
+ }
+
+ private func configureStatusItem() {
+ if Current.settingsStore.locationVisibility.isDockVisible {
+ Current.macBridge.activationPolicy = .regular
+ } else {
+ Current.macBridge.activationPolicy = .accessory
+ }
+
+ var menuItems = [AppMacBridgeStatusItemMenuItem]()
+ menuItems.append(toggleMenu())
+ menuItems.append(.separator())
+ menuItems.append(contentsOf: aboutMenu())
+ menuItems.append(preferencesMenu())
+ menuItems.append(quitMenu())
+
+ Current.macBridge.configureStatusItem(using: AppMacBridgeStatusItemConfiguration(
+ isVisible: Current.settingsStore.locationVisibility.isStatusItemVisible,
+ image: Asset.statusItemIcon.image.cgImage!,
+ imageSize: Asset.statusItemIcon.image.size,
+ accessibilityLabel: appName,
+ items: menuItems,
+ primaryActionHandler: { callbackInfo in
+ if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
+ if callbackInfo.isActive {
+ callbackInfo.deactivate()
+ } else if callbackInfo.hasWindows {
+ callbackInfo.activate()
+ } else {
+ Current.sceneManager.activateAnyScene(for: .webView)
+ callbackInfo.activate()
+ }
+ }
+ ))
+ }
+}
+#endif
diff --git a/Sources/App/Utilities/StatusItemPrimaryAction.swift b/Sources/App/Utilities/StatusItemPrimaryAction.swift
new file mode 100644
index 0000000000..e7b2dbcd58
--- /dev/null
+++ b/Sources/App/Utilities/StatusItemPrimaryAction.swift
@@ -0,0 +1,26 @@
+import Foundation
+import Shared
+
+/// Shared decision for what the macOS status item should do when "activated" (icon click or the
+/// "Toggle" menu item). When the user enabled "Open Home Assistant UI in browser"
+/// (`macNativeFeaturesOnly`) there is no in-app web view to toggle — it is destroyed at launch by
+/// `QuickActionWindowSceneDelegate` — so any "show me Home Assistant" affordance must open the default
+/// browser instead of spawning a web-view window. The preference is read live on every call because
+/// flipping it does not reconfigure the status item (it posts no `menuRelatedSettingDidChange`).
+enum StatusItemPrimaryAction {
+ /// Opens Home Assistant in the default browser when the browser preference is on.
+ /// - Returns: `true` if it handled the action (browser opened), `false` to fall through to the
+ /// normal toggle/activate behaviour.
+ static func openInBrowserIfNeeded() -> Bool {
+ guard Current.settingsStore.macNativeFeaturesOnly else { return false }
+ // Prefer the server shown in the menu-bar title; its getter already falls back to the first
+ // server, so this also covers users without a configured menu-bar template.
+ let server = Current.settingsStore.menuItemTemplate?.server ?? Current.servers.all.first
+ // This only runs on macOS, where the last-known network information is read live from
+ // macBridge, so the synchronous evaluation is current. Callers need the handled/unhandled
+ // decision synchronously to fall through to the toggle behavior.
+ guard let url = server?.activeURLUsingLastKnownNetworkState() else { return false }
+ URLOpener.shared.open(url, options: [:], completionHandler: nil)
+ return true
+ }
+}
diff --git a/Sources/App/Utilities/StatusItemTitleRenderer.swift b/Sources/App/Utilities/StatusItemTitleRenderer.swift
new file mode 100644
index 0000000000..94eb5441dd
--- /dev/null
+++ b/Sources/App/Utilities/StatusItemTitleRenderer.swift
@@ -0,0 +1,90 @@
+import Foundation
+import HAKit
+import Shared
+
+enum StatusItemTitleRenderer {
+ static func subscribe(
+ api: HomeAssistantAPI,
+ template: String,
+ update: @escaping (String) -> Void
+ ) -> HACancellable {
+ let state = TitleState()
+ let cancellable = CompositeHACancellable()
+
+ cancellable.append(api.connection.send(.init(
+ type: .rest(.post, "template"),
+ data: ["template": template],
+ shouldRetry: true
+ )) { result in
+ switch result {
+ case let .success(data):
+ guard !state.hasReceivedLiveUpdate else {
+ return
+ }
+
+ state.hasRenderedTitle = true
+ update(renderedTitle(from: data))
+ case let .failure(error):
+ Current.Log.error("Failed to render status item title via REST fallback: \(error)")
+ }
+ })
+
+ cancellable.append(api.connection.subscribe(
+ to: .renderTemplate(template),
+ initiated: { result in
+ guard case let .failure(error) = result else {
+ return
+ }
+
+ Current.Log.error("Failed to subscribe to status item title updates: \(error)")
+
+ if !state.hasRenderedTitle {
+ update(L10n.errorLabel)
+ }
+ },
+ handler: { _, response in
+ state.hasRenderedTitle = true
+ state.hasReceivedLiveUpdate = true
+ update(String(describing: response.result))
+ }
+ ))
+
+ return cancellable
+ }
+
+ private static func renderedTitle(from data: HAData) -> String {
+ switch data {
+ case let .primitive(value):
+ return String(describing: value)
+ case let .dictionary(value):
+ return String(describing: value)
+ case let .array(value):
+ return String(describing: value)
+ case .empty:
+ return ""
+ }
+ }
+
+ private final class CompositeHACancellable: HACancellable {
+ private var cancellables: [HACancellable]
+
+ init(_ cancellables: [HACancellable] = []) {
+ self.cancellables = cancellables
+ }
+
+ func append(_ cancellable: HACancellable) {
+ cancellables.append(cancellable)
+ }
+
+ func cancel() {
+ let activeCancellables = cancellables
+ cancellables.removeAll()
+ activeCancellables.forEach { $0.cancel() }
+ }
+ }
+
+ private final class TitleState {
+ var hasRenderedTitle = false
+ var hasReceivedLiveUpdate = false
+ }
+}
diff --git a/Sources/App/Utilities/ThemeColors.swift b/Sources/App/Utilities/ThemeColors.swift
index 9ba94fc76a..aa68c04fed 100644
--- a/Sources/App/Utilities/ThemeColors.swift
+++ b/Sources/App/Utilities/ThemeColors.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
public struct ThemeColors: Codable {
public enum Color: String, CaseIterable {
@@ -35,6 +39,11 @@ public struct ThemeColors: Codable {
}
}
+ #if os(macOS)
+ init(appearance: NSAppearance) {
+ self = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .dark : .light
+ }
+ #else
init(traitCollection: UITraitCollection) {
switch traitCollection.userInterfaceStyle {
case .dark: self = .dark
@@ -42,19 +51,37 @@ public struct ThemeColors: Codable {
default: self = .light
}
}
+ #endif
+ }
+
+ #if os(macOS)
+ static func cachedThemeColors(for appearance: NSAppearance) -> ThemeColors {
+ cachedThemeColors(for: InterfaceStyle(appearance: appearance))
}
+ static func updateCache(with messageBody: [String: Any], for appearance: NSAppearance) {
+ updateCache(with: messageBody, for: InterfaceStyle(appearance: appearance))
+ }
+ #else
static func cachedThemeColors(for traitCollection: UITraitCollection) -> ThemeColors {
- let style = InterfaceStyle(traitCollection: traitCollection)
- let cached = prefs.object(forKey: style.userDefaultsKey) as? [Color.RawValue: String] ?? [:]
- Current.Log.verbose("loaded cached colors \(cached)")
- return ThemeColors(values: cached)
+ cachedThemeColors(for: InterfaceStyle(traitCollection: traitCollection))
}
static func updateCache(
with messageBody: [String: Any],
for traitCollection: UITraitCollection
) {
+ updateCache(with: messageBody, for: InterfaceStyle(traitCollection: traitCollection))
+ }
+ #endif
+
+ private static func cachedThemeColors(for style: InterfaceStyle) -> ThemeColors {
+ let cached = prefs.object(forKey: style.userDefaultsKey) as? [Color.RawValue: String] ?? [:]
+ Current.Log.verbose("loaded cached colors \(cached)")
+ return ThemeColors(values: cached)
+ }
+
+ private static func updateCache(with messageBody: [String: Any], for style: InterfaceStyle) {
func rawValue(for key: Color) -> String? {
messageBody[key.rawValue]
.flatMap { $0 as? String }?
@@ -66,7 +93,6 @@ public struct ThemeColors: Codable {
dictionary[color.rawValue] = rawValue(for: color)
}
Current.Log.verbose("caching color values \(dictionary)")
- let style = InterfaceStyle(traitCollection: traitCollection)
prefs.set(dictionary, forKey: style.userDefaultsKey)
}
}
diff --git a/Sources/App/Utilities/URLOpener.swift b/Sources/App/Utilities/URLOpener.swift
index fef117a475..9908454a4f 100644
--- a/Sources/App/Utilities/URLOpener.swift
+++ b/Sources/App/Utilities/URLOpener.swift
@@ -1,5 +1,16 @@
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
+
+#if os(macOS)
+/// AppKit opens a URL without options, so the dictionary is only ever empty on the Mac.
+typealias URLOpeningOptions = [String: Any]
+#else
+typealias URLOpeningOptions = [UIApplication.OpenExternalURLOptionsKey: Any]
+#endif
/// A protocol for opening URLs, allowing for easy mocking and testing.
protocol URLOpening {
@@ -10,7 +21,7 @@ protocol URLOpening {
/// - completion: An optional completion handler to call when the operation completes.
func open(
_ url: URL,
- options: [UIApplication.OpenExternalURLOptionsKey: Any],
+ options: URLOpeningOptions,
completionHandler completion: ((Bool) -> Void)?
)
@@ -47,12 +58,42 @@ final class URLOpener: URLOpening {
/// - completion: An optional completion handler to call when the operation completes.
func open(
_ url: URL,
- options: [UIApplication.OpenExternalURLOptionsKey: Any] = [:],
+ options: URLOpeningOptions = [:],
completionHandler completion: ((Bool) -> Void)? = nil
) {
Current.Log.verbose("Opening URL: \(url.absoluteString)")
+ #if os(macOS)
+ if url.isFileURL {
+ completion?(Self.revealInFinder(url))
+ return
+ }
+ completion?(NSWorkspace.shared.open(url))
+ #else
UIApplication.shared.open(url, options: options, completionHandler: completion)
+ #endif
+ }
+
+ #if os(macOS)
+ /// Shows a file selected in Finder, or a folder's contents; Launch Services would hand a file to the app
+ /// that owns it instead.
+ private static func revealInFinder(_ url: URL) -> Bool {
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return false }
+ var selection = [url]
+ if isDirectory.boolValue {
+ let contents = try? FileManager.default.contentsOfDirectory(
+ at: url,
+ includingPropertiesForKeys: nil,
+ options: [.skipsHiddenFiles]
+ )
+ if let contents, !contents.isEmpty {
+ selection = contents
+ }
+ }
+ NSWorkspace.shared.activateFileViewerSelecting(selection)
+ return true
}
+ #endif
/// Returns a Boolean value indicating whether an app is available to handle a URL scheme.
/// - Parameter url: A URL (Universal Resource Locator). The URL's scheme is used to identify the app that can open
@@ -60,7 +101,11 @@ final class URLOpener: URLOpening {
/// - Returns: false if there is no app installed for handling the URL's scheme, or if you have not declared the
/// URL's scheme in your Info.plist; otherwise, true.
func canOpenURL(_ url: URL) -> Bool {
+ #if os(macOS)
+ NSWorkspace.shared.urlForApplication(toOpen: url) != nil
+ #else
UIApplication.shared.canOpenURL(url)
+ #endif
}
func openSettings(destination: OpenSettingsDestination, completionHandler: ((Bool) -> Void)? = nil) {
@@ -78,7 +123,7 @@ final class URLOpener: URLOpening {
#if DEBUG
/// A mock URL opener for testing purposes.
final class MockURLOpener: URLOpening {
- var openedURLs: [(url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any])] = []
+ var openedURLs: [(url: URL, options: URLOpeningOptions)] = []
/// Called after a URL is recorded, so a test can wait for an open that happens asynchronously.
var onOpen: ((URL) -> Void)?
var canOpenURLResult: Bool = true
@@ -87,7 +132,7 @@ final class MockURLOpener: URLOpening {
func open(
_ url: URL,
- options: [UIApplication.OpenExternalURLOptionsKey: Any] = [:],
+ options: URLOpeningOptions = [:],
completionHandler completion: ((Bool) -> Void)? = nil
) {
openedURLs.append((url, options))
diff --git a/Sources/App/Utilities/Utils.swift b/Sources/App/Utilities/Utils.swift
index e197538a54..ab221ce3c9 100644
--- a/Sources/App/Utilities/Utils.swift
+++ b/Sources/App/Utilities/Utils.swift
@@ -1,7 +1,9 @@
import Foundation
import GRDB
import KeychainAccess
+#if os(iOS)
import SafariServices
+#endif
import Security
import Shared
@@ -90,7 +92,7 @@ func deleteKeychainCompletely() throws {
// survive the forced restart so startup can recover the server list.
}
-func openURLInBrowser(_ urlToOpen: URL, _ sender: UIViewController?) {
+func openURLInBrowser(_ urlToOpen: URL, _ sender: PlatformViewController?) {
guard ["http", "https"].contains(urlToOpen.scheme?.lowercased()) else {
URLOpener.shared.open(urlToOpen, options: [:], completionHandler: nil)
return
@@ -109,9 +111,11 @@ func openURLInBrowser(_ urlToOpen: URL, _ sender: UIViewController?) {
OpenInFirefoxControllerSwift(type: .focus).openInFirefox(urlToOpen)
case .FirefoxKlar where OpenInFirefoxControllerSwift(type: .klar).isFirefoxInstalled():
OpenInFirefoxControllerSwift(type: .klar).openInFirefox(urlToOpen)
+ #if os(iOS)
case .SafariInApp where sender != nil:
let sfv = SFSafariViewController(url: urlToOpen)
sender!.present(sfv, animated: true)
+ #endif
default:
URLOpener.shared.open(urlToOpen, options: [:], completionHandler: nil)
}
diff --git a/Sources/App/Utilities/VerticalBarObserver.swift b/Sources/App/Utilities/VerticalBarObserver.swift
index 7e3a0374ab..dfa92ec2b7 100644
--- a/Sources/App/Utilities/VerticalBarObserver.swift
+++ b/Sources/App/Utilities/VerticalBarObserver.swift
@@ -1,6 +1,16 @@
import SwiftUI
-import UIKit
+#if os(macOS)
+/// Follows whether the system lays the bars out in a vertical column, as on an unfolded iPhone. A Mac
+/// never does, so the binding is left as it is.
+struct VerticalBarObserver: View {
+ @Binding var hasVerticalBar: Bool
+
+ var body: some View {
+ Color.clear
+ }
+}
+#else
/// Follows whether the system lays the bars out in a vertical column, as on an unfolded iPhone.
struct VerticalBarObserver: UIViewControllerRepresentable {
@Binding var hasVerticalBar: Bool
@@ -17,6 +27,7 @@ struct VerticalBarObserver: UIViewControllerRepresentable {
}
}
}
+#endif
#Preview {
Text("Follows the bar layout")
diff --git a/Sources/App/Utilities/VerticalBarObserverViewController.swift b/Sources/App/Utilities/VerticalBarObserverViewController.swift
index 870b957818..1e5a078f4c 100644
--- a/Sources/App/Utilities/VerticalBarObserverViewController.swift
+++ b/Sources/App/Utilities/VerticalBarObserverViewController.swift
@@ -1,3 +1,4 @@
+#if os(iOS)
import UIKit
/// Reports whether its traits put the system bars in a vertical column, once per change.
@@ -38,3 +39,4 @@ final class VerticalBarObserverViewController: UIViewController {
onChange?(value)
}
}
+#endif
diff --git a/Sources/App/Utilities/View+ZoomTransition.swift b/Sources/App/Utilities/View+ZoomTransition.swift
index 74483daf03..bd8916d8e1 100644
--- a/Sources/App/Utilities/View+ZoomTransition.swift
+++ b/Sources/App/Utilities/View+ZoomTransition.swift
@@ -1,25 +1,33 @@
import SwiftUI
-/// The zoom transition pair, usable from code that still deploys below iOS 18: before it, both
-/// are no-ops and the presentation uses the default transition.
+/// The zoom transition pair, usable from code that still deploys below iOS 18: before it, and on the
+/// Mac, which has no zoom transition, both are no-ops and the presentation uses the default transition.
extension View {
/// Marks this view as what a presentation with the same `id` zooms out of and back into.
@ViewBuilder
func zoomTransitionSource(id: some Hashable, in namespace: Namespace.ID) -> some View {
+ #if os(macOS)
+ self
+ #else
if #available(iOS 18.0, *) {
matchedTransitionSource(id: id, in: namespace)
} else {
self
}
+ #endif
}
/// Zooms this presented content out of the view marked with `zoomTransitionSource` for `sourceID`.
@ViewBuilder
func zoomNavigationTransition(sourceID: some Hashable, in namespace: Namespace.ID) -> some View {
+ #if os(macOS)
+ self
+ #else
if #available(iOS 18.0, *) {
navigationTransition(.zoom(sourceID: sourceID, in: namespace))
} else {
self
}
+ #endif
}
}
diff --git a/Sources/App/VoiceToolsServer/VoiceToolsServerLanguagesView.swift b/Sources/App/VoiceToolsServer/VoiceToolsServerLanguagesView.swift
index fb1651ee65..580dada79c 100644
--- a/Sources/App/VoiceToolsServer/VoiceToolsServerLanguagesView.swift
+++ b/Sources/App/VoiceToolsServer/VoiceToolsServerLanguagesView.swift
@@ -22,7 +22,7 @@ struct VoiceToolsServerLanguagesView: View {
}
var body: some View {
- List {
+ GroupedList {
if let locales {
if locales.isEmpty {
Section {
diff --git a/Sources/App/VoiceToolsServer/VoiceToolsServerSettingsView.swift b/Sources/App/VoiceToolsServer/VoiceToolsServerSettingsView.swift
index c7c986dd68..46b7abfacb 100644
--- a/Sources/App/VoiceToolsServer/VoiceToolsServerSettingsView.swift
+++ b/Sources/App/VoiceToolsServer/VoiceToolsServerSettingsView.swift
@@ -34,7 +34,7 @@ struct VoiceToolsServerSettingsView: View {
}
var body: some View {
- List {
+ GroupedList {
AppleLikeListTopRowHeader(
image: .accountVoiceIcon,
title: L10n.Settings.VoiceToolsServer.title,
diff --git a/Sources/App/WhatsNew/WhatsNewIconView.swift b/Sources/App/WhatsNew/WhatsNewIconView.swift
index 1428323401..9228a4695d 100644
--- a/Sources/App/WhatsNew/WhatsNewIconView.swift
+++ b/Sources/App/WhatsNew/WhatsNewIconView.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
/// Renders a What's New icon, whether it is an SF Symbol or a Material Design glyph, in one colour.
struct WhatsNewIconView: View {
diff --git a/Sources/App/WhatsNew/WhatsNewModels.swift b/Sources/App/WhatsNew/WhatsNewModels.swift
index e7c0bdf9eb..375469cbfd 100644
--- a/Sources/App/WhatsNew/WhatsNewModels.swift
+++ b/Sources/App/WhatsNew/WhatsNewModels.swift
@@ -1,7 +1,11 @@
import Foundation
import SFSafeSymbols
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
typealias MaterialDesignIcon = MaterialDesignIcons
@@ -37,7 +41,7 @@ enum WhatsNewTargetPlatform: String, Hashable {
case mac
static var current: WhatsNewTargetPlatform {
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
return .mac
#else
switch UIDevice.current.userInterfaceIdiom {
diff --git a/Sources/App/WhatsNew/WhatsNewView.swift b/Sources/App/WhatsNew/WhatsNewView.swift
index 6e0453301f..322b41c2d9 100644
--- a/Sources/App/WhatsNew/WhatsNewView.swift
+++ b/Sources/App/WhatsNew/WhatsNewView.swift
@@ -1,7 +1,6 @@
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
struct WhatsNewView: View {
let release: WhatsNewRelease
diff --git a/Sources/App/ZoneManager/ZoneManager.swift b/Sources/App/ZoneManager/ZoneManager.swift
index 03ff964b10..935dc0e44d 100644
--- a/Sources/App/ZoneManager/ZoneManager.swift
+++ b/Sources/App/ZoneManager/ZoneManager.swift
@@ -3,7 +3,11 @@ import Foundation
import GRDB
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
class ZoneManager {
let locationManager: CLLocationManager
@@ -60,7 +64,11 @@ class ZoneManager {
private func updateLocationManager(isInitial: Bool) {
with(locationManager) {
$0.delegate = collector
+ // A Mac app is never suspended, and Core Location raises an exception when an app without
+ // a location background mode asks for background updates there.
+ #if !os(macOS)
$0.allowsBackgroundLocationUpdates = true
+ #endif
$0.pausesLocationUpdatesAutomatically = false
if Current.settingsStore.locationSources.significantLocationChange {
diff --git a/Sources/CarPlay/Operations/CarPlayAlertPresenting.swift b/Sources/CarPlay/Operations/CarPlayAlertPresenting.swift
index 77bf1953b8..50fd0dd18f 100644
--- a/Sources/CarPlay/Operations/CarPlayAlertPresenting.swift
+++ b/Sources/CarPlay/Operations/CarPlayAlertPresenting.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
@@ -12,3 +13,4 @@ protocol CarPlayAlertPresenting: AnyObject {
}
extension CPInterfaceController: CarPlayAlertPresenting {}
+#endif
diff --git a/Sources/CarPlay/Operations/CarPlayOperationAlert.swift b/Sources/CarPlay/Operations/CarPlayOperationAlert.swift
index fdbf66a584..1dbf1672de 100644
--- a/Sources/CarPlay/Operations/CarPlayOperationAlert.swift
+++ b/Sources/CarPlay/Operations/CarPlayOperationAlert.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import Shared
@@ -34,3 +35,4 @@ enum CarPlayOperationAlert {
)
}
}
+#endif
diff --git a/Sources/CarPlay/Operations/CarPlayOperationDeadline.swift b/Sources/CarPlay/Operations/CarPlayOperationDeadline.swift
index cd3a8d23b0..7aef7fc5e3 100644
--- a/Sources/CarPlay/Operations/CarPlayOperationDeadline.swift
+++ b/Sources/CarPlay/Operations/CarPlayOperationDeadline.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import Shared
@@ -87,3 +88,4 @@ final class CarPlayOperationDeadline {
report = nil
}
}
+#endif
diff --git a/Sources/CarPlay/Operations/CarPlayOperationError.swift b/Sources/CarPlay/Operations/CarPlayOperationError.swift
index 6db5de6106..6cc25f8692 100644
--- a/Sources/CarPlay/Operations/CarPlayOperationError.swift
+++ b/Sources/CarPlay/Operations/CarPlayOperationError.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import Shared
@@ -85,3 +86,4 @@ enum CarPlayOperationError: Error {
return false
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Areas/CarPlayAreasViewModel.swift b/Sources/CarPlay/Templates/Areas/CarPlayAreasViewModel.swift
index 23a9a2cc7e..ca42c0b386 100644
--- a/Sources/CarPlay/Templates/Areas/CarPlayAreasViewModel.swift
+++ b/Sources/CarPlay/Templates/Areas/CarPlayAreasViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -123,3 +124,4 @@ final class CarPlayAreasViewModel {
templateProvider?.presentEntitiesList(template: entitiesListTemplate)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate+Build.swift b/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate+Build.swift
index 6d4c8afd41..d81d007df0 100644
--- a/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate+Build.swift
+++ b/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate+Build.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
extension CarPlayAreasZonesTemplate {
@@ -5,3 +6,4 @@ extension CarPlayAreasZonesTemplate {
CarPlayAreasZonesTemplate(viewModel: .init())
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate.swift b/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate.swift
index 3ce5eddd25..b7d684d2df 100644
--- a/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate.swift
+++ b/Sources/CarPlay/Templates/Areas/CarPlayAreasZonesTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -63,3 +64,4 @@ final class CarPlayAreasZonesTemplate: CarPlayTemplateProvider {
)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/CarPlayListItemProvider.swift b/Sources/CarPlay/Templates/CarPlayListItemProvider.swift
index 4f9aaffe0f..cc5dfc9e32 100644
--- a/Sources/CarPlay/Templates/CarPlayListItemProvider.swift
+++ b/Sources/CarPlay/Templates/CarPlayListItemProvider.swift
@@ -1,6 +1,8 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
protocol CarPlayListItemProvider {
var template: CPListItem { get set }
}
+#endif
diff --git a/Sources/CarPlay/Templates/CarPlayNoServerAlert.swift b/Sources/CarPlay/Templates/CarPlayNoServerAlert.swift
index 68fe98128e..94d28477ae 100644
--- a/Sources/CarPlay/Templates/CarPlayNoServerAlert.swift
+++ b/Sources/CarPlay/Templates/CarPlayNoServerAlert.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import Shared
@@ -19,3 +20,4 @@ final class CarPlayNoServerAlert {
interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/CarPlayPaginatedListTemplate.swift b/Sources/CarPlay/Templates/CarPlayPaginatedListTemplate.swift
index 064ac01b74..141561df49 100644
--- a/Sources/CarPlay/Templates/CarPlayPaginatedListTemplate.swift
+++ b/Sources/CarPlay/Templates/CarPlayPaginatedListTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import SFSafeSymbols
@@ -298,3 +299,4 @@ final class CarPlayPaginatedListTemplate {
updateTemplate()
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/CarPlayTemplateProvider.swift b/Sources/CarPlay/Templates/CarPlayTemplateProvider.swift
index 26cf7e309b..1ba2c34fec 100644
--- a/Sources/CarPlay/Templates/CarPlayTemplateProvider.swift
+++ b/Sources/CarPlay/Templates/CarPlayTemplateProvider.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -25,3 +26,4 @@ extension CarPlayTemplateProvider {
CarPlayOperationAlert.present(error, on: alertPresenter)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate+Build.swift b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate+Build.swift
index fb612b22d3..2331405535 100644
--- a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate+Build.swift
+++ b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate+Build.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
extension CarPlayDomainsListTemplate {
@@ -5,3 +6,4 @@ extension CarPlayDomainsListTemplate {
CarPlayDomainsListTemplate(viewModel: .init())
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate.swift b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate.swift
index 9478495481..c242e2c845 100644
--- a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate.swift
+++ b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -66,3 +67,4 @@ class CarPlayDomainsListTemplate: CarPlayTemplateProvider {
)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListViewModel.swift b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListViewModel.swift
index e5ddf4e462..2b5381a09f 100644
--- a/Sources/CarPlay/Templates/Domains/CarPlayDomainsListViewModel.swift
+++ b/Sources/CarPlay/Templates/Domains/CarPlayDomainsListViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -112,3 +113,4 @@ final class CarPlayDomainsListViewModel {
domain == .cover ? overrideCoverIcon : domain.icon()
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayControlScreenFactory.swift b/Sources/CarPlay/Templates/Entities/CarPlayControlScreenFactory.swift
index 185234c5f8..5780b4dd78 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayControlScreenFactory.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayControlScreenFactory.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -18,3 +19,4 @@ enum CarPlayControlScreenFactory {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate+Build.swift b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate+Build.swift
index 967dfd2d02..fab9ffda38 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate+Build.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate+Build.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import Shared
@@ -18,3 +19,4 @@ extension CarPlayEntitiesListTemplate {
return CarPlayEntitiesListTemplate(viewModel: viewModel, title: title)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate.swift b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate.swift
index e72ded4a06..5b42fd26f2 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -169,3 +170,4 @@ final class CarPlayEntitiesListTemplate: CarPlayTemplateProvider {
updateItems(entityProviders: entityProviders)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListViewModel.swift b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListViewModel.swift
index 8f32825ae0..f3a295cf13 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListViewModel.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayEntitiesListViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import PromiseKit
@@ -226,3 +227,4 @@ final class CarPlayEntitiesListViewModel {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayEntityListItem.swift b/Sources/CarPlay/Templates/Entities/CarPlayEntityListItem.swift
index d11644add8..4eb3437c97 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayEntityListItem.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayEntityListItem.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -213,3 +214,4 @@ final class CarPlayEntityListItem: CarPlayListItemProvider {
return baseState
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/CarPlayLockConfirmation.swift b/Sources/CarPlay/Templates/Entities/CarPlayLockConfirmation.swift
index e8d89f1279..eb693c6234 100644
--- a/Sources/CarPlay/Templates/Entities/CarPlayLockConfirmation.swift
+++ b/Sources/CarPlay/Templates/Entities/CarPlayLockConfirmation.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -114,3 +115,4 @@ enum CarPlayLockConfirmation {
interfaceController?.presentTemplate(alert, animated: true, completion: nil)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlTemplate.swift b/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlTemplate.swift
index 2042f3c229..de1155cdfd 100644
--- a/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlTemplate.swift
+++ b/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -404,3 +405,4 @@ final class CarPlayClimateControlTemplate: CarPlayTemplateProvider {
interfaceController?.pushTemplate(selectionTemplate, animated: true, completion: nil)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlViewModel.swift b/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlViewModel.swift
index a0b4eb68d1..0a8b6a908e 100644
--- a/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlViewModel.swift
+++ b/Sources/CarPlay/Templates/Entities/Climate/CarPlayClimateControlViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import PromiseKit
@@ -193,3 +194,4 @@ final class CarPlayClimateControlViewModel {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlTemplate.swift b/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlTemplate.swift
index b2e1aec9fa..091a35ad64 100644
--- a/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlTemplate.swift
+++ b/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -273,3 +274,4 @@ final class CarPlayVacuumControlTemplate: CarPlayTemplateProvider {
interfaceController?.pushTemplate(selectionTemplate, animated: true, completion: nil)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlViewModel.swift b/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlViewModel.swift
index cb589fb5ae..79c9096286 100644
--- a/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlViewModel.swift
+++ b/Sources/CarPlay/Templates/Entities/Vacuum/CarPlayVacuumControlViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import PromiseKit
@@ -170,3 +171,4 @@ final class CarPlayVacuumControlViewModel {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemFlow.swift b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemFlow.swift
index 969e4306fc..bea5e08837 100644
--- a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemFlow.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemFlow.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import Shared
@@ -337,3 +338,4 @@ final class CarPlayAddItemFlow {
return item
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemViewModel.swift b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemViewModel.swift
index 809ce40abc..d635b98956 100644
--- a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemViewModel.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayAddItemViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import Shared
@@ -326,3 +327,4 @@ private extension Pipeline {
supportsSpeechToText && supportsTextToSpeech
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayEditItemFlow.swift b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayEditItemFlow.swift
index 02c7204278..daa6fba517 100644
--- a/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayEditItemFlow.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/AddItem/CarPlayEditItemFlow.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import Shared
@@ -124,3 +125,4 @@ final class CarPlayEditItemFlow {
onFinish()
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayActionsTemplate+Build.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayActionsTemplate+Build.swift
index 3ad780e464..56389d8ea9 100644
--- a/Sources/CarPlay/Templates/QuickAccess/CarPlayActionsTemplate+Build.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayActionsTemplate+Build.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import Shared
@@ -14,3 +15,4 @@ extension CarPlayQuickAccessTemplate {
)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift
index ee2e99204e..6aa5d2599f 100644
--- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import AVFoundation
import CarPlay
import Foundation
@@ -1158,3 +1159,4 @@ extension CarPlayAssistSession: AssistServiceDelegate {
enterErrorState(message: message)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistTonePlayer.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistTonePlayer.swift
index 5148f09745..8030010e09 100644
--- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistTonePlayer.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistTonePlayer.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import AVFoundation
import Foundation
import Shared
@@ -184,3 +185,4 @@ extension CarPlayAssistTonePlayer: AVAudioPlayerDelegate {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift
index b4db46dc95..912334e14d 100644
--- a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -1117,3 +1118,4 @@ final class CarPlayQuickAccessTemplate: CarPlayTemplateProvider {
session.start()
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessViewModel.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessViewModel.swift
index 516349458f..7a1e3da294 100644
--- a/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessViewModel.swift
+++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayQuickAccessViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import HAKit
import Shared
@@ -72,3 +73,4 @@ final class CarPlayQuickAccessViewModel {
}
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Servers/CarPlayAssistSettingsTemplate.swift b/Sources/CarPlay/Templates/Servers/CarPlayAssistSettingsTemplate.swift
index e638faa0b3..e3162cdb79 100644
--- a/Sources/CarPlay/Templates/Servers/CarPlayAssistSettingsTemplate.swift
+++ b/Sources/CarPlay/Templates/Servers/CarPlayAssistSettingsTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import Shared
@@ -235,3 +236,4 @@ final class CarPlayAssistSettingsTemplate {
return Locale.current.localizedString(forIdentifier: identifier)?.capitalizedFirst ?? identifier
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Servers/CarPlayPreferredServer.swift b/Sources/CarPlay/Templates/Servers/CarPlayPreferredServer.swift
index 6d480ca251..4229b64a6d 100644
--- a/Sources/CarPlay/Templates/Servers/CarPlayPreferredServer.swift
+++ b/Sources/CarPlay/Templates/Servers/CarPlayPreferredServer.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
import Shared
@@ -19,3 +20,4 @@ enum CarPlayPreferredServer {
prefs.set(server.identifier.rawValue, forKey: preferenceKey)
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate+Build.swift b/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate+Build.swift
index 70e5ecf180..aa2fb15c50 100644
--- a/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate+Build.swift
+++ b/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate+Build.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import Foundation
extension CarPlayServersListTemplate {
@@ -5,3 +6,4 @@ extension CarPlayServersListTemplate {
CarPlayServersListTemplate(viewModel: .init())
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate.swift b/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate.swift
index a76121b9c6..55909b5ff3 100644
--- a/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate.swift
+++ b/Sources/CarPlay/Templates/Servers/CarPlayServerListTemplate.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -348,3 +349,4 @@ final class CarPlayServersListTemplate: CarPlayTemplateProvider {
return item
}
}
+#endif
diff --git a/Sources/CarPlay/Templates/Servers/CarPlayServerListViewModel.swift b/Sources/CarPlay/Templates/Servers/CarPlayServerListViewModel.swift
index a82d9dddc9..34575b6c68 100644
--- a/Sources/CarPlay/Templates/Servers/CarPlayServerListViewModel.swift
+++ b/Sources/CarPlay/Templates/Servers/CarPlayServerListViewModel.swift
@@ -1,3 +1,4 @@
+#if canImport(CarPlay)
import CarPlay
import Foundation
import HAKit
@@ -230,3 +231,4 @@ extension CarPlayServerListViewModel: ServerObserver {
setServer(server: server)
}
}
+#endif
diff --git a/Sources/Extensions/AppIntents/Control/ControlResultSnippetView.swift b/Sources/Extensions/AppIntents/Control/ControlResultSnippetView.swift
index d740588a2b..5d08612b1c 100644
--- a/Sources/Extensions/AppIntents/Control/ControlResultSnippetView.swift
+++ b/Sources/Extensions/AppIntents/Control/ControlResultSnippetView.swift
@@ -2,7 +2,6 @@ import HADesignSystem
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
/// The card Siri shows after a control command has changed something.
///
diff --git a/Sources/Extensions/AppIntents/EntityState/EntityStateSnippetView.swift b/Sources/Extensions/AppIntents/EntityState/EntityStateSnippetView.swift
index 4d1a7efb4d..a133e58175 100644
--- a/Sources/Extensions/AppIntents/EntityState/EntityStateSnippetView.swift
+++ b/Sources/Extensions/AppIntents/EntityState/EntityStateSnippetView.swift
@@ -2,7 +2,6 @@ import HADesignSystem
import SFSafeSymbols
import Shared
import SwiftUI
-import UIKit
/// The card Siri and the Shortcuts app show under the spoken answer of `GetEntityStateAppIntent`.
@available(macOS 13.0, *)
diff --git a/Sources/Extensions/AppIntents/FireEventAppIntent.swift b/Sources/Extensions/AppIntents/FireEventAppIntent.swift
index 4443d86351..dcb235261f 100644
--- a/Sources/Extensions/AppIntents/FireEventAppIntent.swift
+++ b/Sources/Extensions/AppIntents/FireEventAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct FireEventAppIntent: AppIntent, CustomIntentMigratedAppIntent {
// Carries over shortcuts built with the deprecated SiriKit FireEventIntent
static let intentClassName = "FireEventIntent"
diff --git a/Sources/Extensions/AppIntents/GetCameraSnapshotAppIntent.swift b/Sources/Extensions/AppIntents/GetCameraSnapshotAppIntent.swift
index d64f6d2413..c4a7b9a48d 100644
--- a/Sources/Extensions/AppIntents/GetCameraSnapshotAppIntent.swift
+++ b/Sources/Extensions/AppIntents/GetCameraSnapshotAppIntent.swift
@@ -1,9 +1,13 @@
import AppIntents
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UniformTypeIdentifiers
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct GetCameraSnapshotAppIntent: AppIntent, CustomIntentMigratedAppIntent {
// Carries over shortcuts built with the deprecated SiriKit GetCameraImageIntent
static let intentClassName = "GetCameraImageIntent"
diff --git a/Sources/Extensions/AppIntents/IntentActionEntity.swift b/Sources/Extensions/AppIntents/IntentActionEntity.swift
index 24c353929e..06fd6eda3d 100644
--- a/Sources/Extensions/AppIntents/IntentActionEntity.swift
+++ b/Sources/Extensions/AppIntents/IntentActionEntity.swift
@@ -2,7 +2,7 @@ import AppIntents
import SFSafeSymbols
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct IntentActionEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Action")
static let defaultQuery = IntentActionEntityQuery()
diff --git a/Sources/Extensions/AppIntents/IntentActionEntityQuery.swift b/Sources/Extensions/AppIntents/IntentActionEntityQuery.swift
index ea2f0947ee..d479d55bff 100644
--- a/Sources/Extensions/AppIntents/IntentActionEntityQuery.swift
+++ b/Sources/Extensions/AppIntents/IntentActionEntityQuery.swift
@@ -1,7 +1,7 @@
import AppIntents
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct IntentActionEntityQuery: EntityQuery, EntityStringQuery {
@IntentParameterDependency(\.$server)
var intent
diff --git a/Sources/Extensions/AppIntents/IntentCameraEntity.swift b/Sources/Extensions/AppIntents/IntentCameraEntity.swift
index 20a2212094..e7d5a9cf89 100644
--- a/Sources/Extensions/AppIntents/IntentCameraEntity.swift
+++ b/Sources/Extensions/AppIntents/IntentCameraEntity.swift
@@ -1,7 +1,7 @@
import AppIntents
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct IntentCameraEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Camera")
static let defaultQuery = IntentCameraEntityQuery()
diff --git a/Sources/Extensions/AppIntents/IntentCameraEntityQuery.swift b/Sources/Extensions/AppIntents/IntentCameraEntityQuery.swift
index 10dabcd10a..3e9454d2c0 100644
--- a/Sources/Extensions/AppIntents/IntentCameraEntityQuery.swift
+++ b/Sources/Extensions/AppIntents/IntentCameraEntityQuery.swift
@@ -2,7 +2,7 @@ import AppIntents
import HAKit
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct IntentCameraEntityQuery: EntityQuery, EntityStringQuery {
@IntentParameterDependency(\.$server)
var intent
diff --git a/Sources/Extensions/AppIntents/OpenAppSettingsAppIntent.swift b/Sources/Extensions/AppIntents/OpenAppSettingsAppIntent.swift
index f880874f5b..e202ca8562 100644
--- a/Sources/Extensions/AppIntents/OpenAppSettingsAppIntent.swift
+++ b/Sources/Extensions/AppIntents/OpenAppSettingsAppIntent.swift
@@ -14,7 +14,7 @@ struct OpenAppSettingsAppIntent: AppIntent {
static var openAppWhenRun: Bool = true
// `openAppWhenRun` is deprecated from iOS 26; both stay until the deployment target passes 26.
- @available(iOS 26.0, watchOS 26.0, *)
+ @available(iOS 26.0, macOS 26.0, watchOS 26.0, *)
static var supportedModes: IntentModes { .foreground }
@MainActor
diff --git a/Sources/Extensions/AppIntents/PerformActionAppIntent.swift b/Sources/Extensions/AppIntents/PerformActionAppIntent.swift
index 6c3f9a1779..81a8476066 100644
--- a/Sources/Extensions/AppIntents/PerformActionAppIntent.swift
+++ b/Sources/Extensions/AppIntents/PerformActionAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, watchOS 10.0, *)
+@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct PerformActionAppIntent: AppIntent, CustomIntentMigratedAppIntent {
// Carries over shortcuts built with the deprecated SiriKit CallServiceIntent
static let intentClassName = "CallServiceIntent"
diff --git a/Sources/Extensions/AppIntents/Sensor/IntentSensorsAppEntity.swift b/Sources/Extensions/AppIntents/Sensor/IntentSensorsAppEntity.swift
index d0bc661ecd..f96b9fff79 100644
--- a/Sources/Extensions/AppIntents/Sensor/IntentSensorsAppEntity.swift
+++ b/Sources/Extensions/AppIntents/Sensor/IntentSensorsAppEntity.swift
@@ -3,7 +3,7 @@ import Foundation
import SFSafeSymbols
import Shared
-@available(iOS 17.0, macOS 13.0, tvOS 16.0, *)
+@available(iOS 17.0, macOS 14.0, tvOS 16.0, *)
struct IntentSensorsAppEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Sensor")
@@ -58,7 +58,7 @@ struct IntentSensorsAppEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 17.0, macOS 13.0, tvOS 16.0, *)
+@available(iOS 17.0, macOS 14.0, tvOS 16.0, *)
struct IntentSensorsAppEntityQuery: EntityQuery {
@IntentParameterDependency(\.$server)
var config
diff --git a/Sources/Extensions/AppIntents/Widget/Complication/ComplicationRenderContext+WidgetRenderModels.swift b/Sources/Extensions/AppIntents/Widget/Complication/ComplicationRenderContext+WidgetRenderModels.swift
index 493f4cdcc9..63331ab8e0 100644
--- a/Sources/Extensions/AppIntents/Widget/Complication/ComplicationRenderContext+WidgetRenderModels.swift
+++ b/Sources/Extensions/AppIntents/Widget/Complication/ComplicationRenderContext+WidgetRenderModels.swift
@@ -1,3 +1,6 @@
+// The complication render models, and the content views that draw them, are not built for the Mac, where
+// no widget mirrors a watch complication.
+#if !os(macOS)
import HAWatchComplications
import Shared
@@ -50,3 +53,4 @@ extension ComplicationRenderContext {
)
}
}
+#endif
diff --git a/Sources/Extensions/AppIntents/Widget/Complication/WidgetComplicationResolver.swift b/Sources/Extensions/AppIntents/Widget/Complication/WidgetComplicationResolver.swift
index e64d80f4ea..5c80b46fb9 100644
--- a/Sources/Extensions/AppIntents/Widget/Complication/WidgetComplicationResolver.swift
+++ b/Sources/Extensions/AppIntents/Widget/Complication/WidgetComplicationResolver.swift
@@ -5,7 +5,7 @@ import Shared
/// Loads a watch complication the user already built and resolves it into the shared
/// `ComplicationRenderContext`, so a lock-screen widget can render it through the very same content
/// views the watch and the complication editor use.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
enum WidgetComplicationResolver {
enum ResolveError: Error {
case noComplication
diff --git a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift
index 5fb3f79521..ad5c560881 100644
--- a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift
+++ b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift
@@ -1,3 +1,6 @@
+// The details widget exists only in the lock screen's accessory families, which a Mac does not have, so the
+// widget and everything that configures and feeds it are left out of the Mac build.
+#if !os(macOS)
import AppIntents
import AudioToolbox
import Foundation
@@ -170,3 +173,4 @@ struct WidgetDetailsAppIntent: WidgetConfigurationIntent {
}
}
}
+#endif
diff --git a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift
index 597555c4b0..aadf69ec50 100644
--- a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift
@@ -1,3 +1,6 @@
+// The details widget exists only in the lock screen's accessory families, which a Mac does not have, so the
+// widget and everything that configures and feeds it are left out of the Mac build.
+#if !os(macOS)
import AppIntents
import HAKit
import HAWatchComplications
@@ -235,3 +238,4 @@ enum WidgetDetailsDataError: Error {
case apiError
case badResponse
}
+#endif
diff --git a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAttributeAppEntity.swift b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAttributeAppEntity.swift
index 284410d5ed..d3be6d74ab 100644
--- a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAttributeAppEntity.swift
+++ b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAttributeAppEntity.swift
@@ -1,3 +1,6 @@
+// The details widget exists only in the lock screen's accessory families, which a Mac does not have, so the
+// widget and everything that configures and feeds it are left out of the Mac build.
+#if !os(macOS)
import AppIntents
import Foundation
import Shared
@@ -39,3 +42,4 @@ struct WidgetDetailsAttributeAppEntityQuery: EntityQuery {
)
}
}
+#endif
diff --git a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyAppIntentTimelineProvider.swift
index a2da25f35c..080b44de43 100644
--- a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyAppIntentTimelineProvider.swift
@@ -3,7 +3,7 @@ import HAKit
import Shared
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetEnergyEntry
typealias Intent = WidgetEnergyAppIntent
diff --git a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyChartSample.swift b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyChartSample.swift
index 094534d8a7..c0ad53ab02 100644
--- a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyChartSample.swift
+++ b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyChartSample.swift
@@ -7,7 +7,7 @@ import Foundation
/// grid whatever solar can't cover, and returns whatever solar it can't use. Keeping them consistent
/// matters because the chart splits generation into the part used at home and the part exported —
/// sample data that ignored the relationship would render a stack no home could produce.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
enum WidgetEnergyChartSample {
/// Hourly buckets covering the 24 hours from `dayStart`.
static func day(startingAt dayStart: Date) -> [WidgetEnergyEntry.ChartPoint] {
diff --git a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyEntry.swift b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyEntry.swift
index 4da54888a6..b3cf668b20 100644
--- a/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyEntry.swift
+++ b/Sources/Extensions/AppIntents/Widget/Energy/WidgetEnergyEntry.swift
@@ -2,7 +2,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyEntry: TimelineEntry {
var date = Date()
var period: WidgetEnergyPeriod = .today
@@ -129,7 +129,7 @@ struct WidgetEnergyEntry: TimelineEntry {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
extension WidgetEnergyEntry.ChartPoint {
/// The drawing half of the bucket, for the design system's energy chart.
var designSystemModel: WidgetEnergyChartPoint {
diff --git a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift
index 0feae3eb42..d5caa4e62e 100644
--- a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift
+++ b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift
@@ -171,6 +171,7 @@ struct WidgetGaugeAppIntent: WidgetConfigurationIntent {
}
}
}
+ #if !os(macOS)
Case(.complication) {
When(\WidgetGaugeAppIntent.$runScript, .equalTo, true) {
Summary {
@@ -190,6 +191,7 @@ struct WidgetGaugeAppIntent: WidgetConfigurationIntent {
}
}
}
+ #endif
DefaultCase {
When(\WidgetGaugeAppIntent.$runScript, .equalTo, true) {
Switch(\.$gaugeType) {
diff --git a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift
index 74f9666b53..5cc52fe648 100644
--- a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift
@@ -1,10 +1,12 @@
import AppIntents
import HAKit
+#if !os(macOS)
import HAWatchComplications
+#endif
import Shared
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetGaugeAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetGaugeEntry
typealias Intent = WidgetGaugeAppIntent
@@ -84,13 +86,16 @@ struct WidgetGaugeAppIntentTimelineProvider: AppIntentTimelineProvider {
switch configuration.source {
case .entity:
return try await entityEntry(for: configuration)
+ #if !os(macOS)
case .complication:
return try await complicationEntry(for: configuration)
+ #endif
case .template:
return try await templateEntry(for: configuration)
}
}
+ #if !os(macOS)
/// Mirrors one of the user's circular watch complications: the entry carries the resolved render
/// model and the view draws it through the shared complication content view, so the complication's
/// own gauge style, colors and slots decide how it looks — not the widget's `gaugeType`.
@@ -114,6 +119,7 @@ struct WidgetGaugeAppIntentTimelineProvider: AppIntentTimelineProvider {
showConfirmationNotification: configuration.showConfirmationNotification
)
}
+ #endif
/// Builds the gauge from a single picked entity's live state, fetched over the REST `/states`
/// endpoint (no admin required). The 0…1 fill maps the numeric state across the configured
@@ -246,7 +252,7 @@ enum WidgetGaugeDataSource {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetGaugeEntry: TimelineEntry {
var date = Date()
@@ -258,9 +264,11 @@ struct WidgetGaugeEntry: TimelineEntry {
var label: String?
var min: String?
var max: String?
+ #if !os(macOS)
/// Set only by the complication source: every family renders this through the shared circular
/// watch complication content view instead of the widget's own gauge.
var complicationModel: CircularComplicationRenderModel?
+ #endif
var runScript: Bool
var script: IntentScriptEntity?
diff --git a/Sources/Extensions/AppIntents/Widget/ReloadWidgetsAppIntent.swift b/Sources/Extensions/AppIntents/Widget/ReloadWidgetsAppIntent.swift
index e371472d1b..670c99b93f 100644
--- a/Sources/Extensions/AppIntents/Widget/ReloadWidgetsAppIntent.swift
+++ b/Sources/Extensions/AppIntents/Widget/ReloadWidgetsAppIntent.swift
@@ -15,7 +15,7 @@ struct ReloadWidgetsAppIntent: AppIntent {
))
static var openAppWhenRun: Bool = false
// `openAppWhenRun` is deprecated from iOS 26; both stay until the deployment target passes 26.
- @available(iOS 26.0, watchOS 26.0, *)
+ @available(iOS 26.0, macOS 26.0, watchOS 26.0, *)
static var supportedModes: IntentModes { .background }
func perform() async throws -> some IntentResult {
diff --git a/Sources/Extensions/AppIntents/Widget/Script/WidgetScriptsAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Script/WidgetScriptsAppIntentTimelineProvider.swift
index 300f0899f4..d42c7106d6 100644
--- a/Sources/Extensions/AppIntents/Widget/Script/WidgetScriptsAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/AppIntents/Widget/Script/WidgetScriptsAppIntentTimelineProvider.swift
@@ -20,7 +20,7 @@ struct WidgetScriptsEntry: TimelineEntry {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetScriptsAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetScriptsEntry
typealias Intent = WidgetScriptsAppIntent
diff --git a/Sources/Extensions/AppIntents/Widget/Sensor/WidgetSensorsAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Sensor/WidgetSensorsAppIntentTimelineProvider.swift
index ba26c02eee..3215e7c4a8 100644
--- a/Sources/Extensions/AppIntents/Widget/Sensor/WidgetSensorsAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/AppIntents/Widget/Sensor/WidgetSensorsAppIntentTimelineProvider.swift
@@ -5,7 +5,7 @@ import PromiseKit
import Shared
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetSensorsAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetSensorsEntry
typealias Intent = WidgetSensorsAppIntent
@@ -136,7 +136,7 @@ enum WidgetDetailsTableDataSource {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetSensorsEntry: TimelineEntry {
var date = Date()
diff --git a/Sources/Extensions/AppIntents/Widget/WidgetContentSourceAppEnum.swift b/Sources/Extensions/AppIntents/Widget/WidgetContentSourceAppEnum.swift
index 1c87d8b69a..5eed169fa2 100644
--- a/Sources/Extensions/AppIntents/Widget/WidgetContentSourceAppEnum.swift
+++ b/Sources/Extensions/AppIntents/Widget/WidgetContentSourceAppEnum.swift
@@ -6,9 +6,11 @@ enum WidgetContentSourceAppEnum: String, Codable, Sendable, AppEnum {
/// The widget picks a single entity and its value is generated automatically from the entity's state.
case entity
+ #if !os(macOS)
/// The widget mirrors one of the watch complications the user already built, rendered by the very
- /// same views the watch and the complication editor use.
+ /// same views the watch and the complication editor use. A Mac has no watch complications to mirror.
case complication
+ #endif
/// The user provides Jinja templates rendered by the server (requires an admin user).
case template
@@ -16,6 +18,12 @@ enum WidgetContentSourceAppEnum: String, Codable, Sendable, AppEnum {
static let typeDisplayRepresentation = TypeDisplayRepresentation(
name: .init("widgets.content_source.title", defaultValue: "Source")
)
+ #if os(macOS)
+ static var caseDisplayRepresentations: [WidgetContentSourceAppEnum: DisplayRepresentation] = [
+ .entity: DisplayRepresentation(title: .init("widgets.content_source.entity", defaultValue: "Entity")),
+ .template: DisplayRepresentation(title: .init("widgets.content_source.template", defaultValue: "Template")),
+ ]
+ #else
static var caseDisplayRepresentations: [WidgetContentSourceAppEnum: DisplayRepresentation] = [
.entity: DisplayRepresentation(title: .init("widgets.content_source.entity", defaultValue: "Entity")),
.complication: DisplayRepresentation(title: .init(
@@ -24,4 +32,5 @@ enum WidgetContentSourceAppEnum: String, Codable, Sendable, AppEnum {
)),
.template: DisplayRepresentation(title: .init("widgets.content_source.template", defaultValue: "Template")),
]
+ #endif
}
diff --git a/Sources/Extensions/NotificationContent/CameraStreamHLSViewController.swift b/Sources/Extensions/NotificationContent/CameraStreamHLSViewController.swift
index fb8712cf86..5b52ae615d 100644
--- a/Sources/Extensions/NotificationContent/CameraStreamHLSViewController.swift
+++ b/Sources/Extensions/NotificationContent/CameraStreamHLSViewController.swift
@@ -2,12 +2,20 @@ import AVKit
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
-class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
+class CameraStreamHLSViewController: PlatformViewController, CameraStreamHandler {
let api: HomeAssistantAPI
let url: URL
+ #if os(macOS)
+ let playerView: AVPlayerView
+ #else
let playerViewController: AVPlayerViewController
+ #endif
let promise: Promise
var didUpdateState: (CameraStreamHandlerState) -> Void = { _ in }
private let seal: Resolver
@@ -38,11 +46,17 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
init(api: HomeAssistantAPI, url: URL) {
self.api = api
self.url = url
+ #if os(macOS)
+ self.playerView = AVPlayerView()
+ #else
self.playerViewController = AVPlayerViewController()
+ #endif
(self.promise, self.seal) = Promise.pending()
super.init(nibName: nil, bundle: nil)
+ #if !os(macOS)
addChild(playerViewController)
+ #endif
}
@available(*, unavailable)
@@ -52,29 +66,64 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
deinit {
observationTokens.forEach { $0.invalidate() }
- playerViewController.player?.pause()
- playerViewController.player = nil
+ player?.pause()
+ player = nil
}
+ /// The player behind whichever AVKit view the platform shows the stream in.
+ private var player: AVPlayer? {
+ get {
+ #if os(macOS)
+ playerView.player
+ #else
+ playerViewController.player
+ #endif
+ }
+ set {
+ #if os(macOS)
+ playerView.player = newValue
+ #else
+ playerViewController.player = newValue
+ #endif
+ }
+ }
+
+ /// The view the stream is drawn in, which is what the aspect ratio is applied to.
+ private var playerContentView: PlatformView {
+ #if os(macOS)
+ playerView
+ #else
+ playerViewController.view
+ #endif
+ }
+
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
- view.addSubview(playerViewController.view)
- playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(playerContentView)
+ playerContentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
- playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
- playerViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- playerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ playerContentView.topAnchor.constraint(equalTo: view.topAnchor),
+ playerContentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ playerContentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ playerContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
+ #if !os(macOS)
playerViewController.didMove(toParent: self)
+ #endif
setupVideo()
}
func pause() {
- playerViewController.player?.pause()
+ player?.pause()
}
func play() {
@@ -83,10 +132,10 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
var hasAudio: Bool { true }
- var isMuted: Bool { playerViewController.player?.isMuted ?? true }
+ var isMuted: Bool { player?.isMuted ?? true }
func setMuted(_ muted: Bool) {
- playerViewController.player?.isMuted = muted
+ player?.isMuted = muted
}
private var aspectRatioConstraint: NSLayoutConstraint? {
@@ -102,7 +151,7 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
didSet {
if oldValue != lastSize, let size = lastSize {
aspectRatioConstraint = NSLayoutConstraint.aspectRatioConstraint(
- on: playerViewController.view,
+ on: playerContentView,
size: size
)
}
@@ -110,7 +159,10 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
}
private func setupVideo() {
+ // A Mac has no audio session: the player goes straight to the system output.
+ #if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback)
+ #endif
let asset: AVURLAsset
@@ -136,7 +188,7 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
let playerItem = AVPlayerItem(asset: asset)
let videoPlayer = AVPlayer(playerItem: playerItem)
- playerViewController.player = videoPlayer
+ player = videoPlayer
// assume 16:9
lastSize = CGSize(width: 16, height: 9)
diff --git a/Sources/Extensions/NotificationContent/CameraStreamMJPEGViewController.swift b/Sources/Extensions/NotificationContent/CameraStreamMJPEGViewController.swift
index 3fd96e2a47..0b6060c12a 100644
--- a/Sources/Extensions/NotificationContent/CameraStreamMJPEGViewController.swift
+++ b/Sources/Extensions/NotificationContent/CameraStreamMJPEGViewController.swift
@@ -2,13 +2,21 @@ import Alamofire
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
-class CameraStreamMJPEGViewController: UIViewController, CameraStreamHandler {
+class CameraStreamMJPEGViewController: PlatformViewController, CameraStreamHandler {
let api: HomeAssistantAPI
let response: StreamCameraResponse
let baseURL: URL
+ #if os(macOS)
+ let imageView: NSImageView
+ #else
let imageView: UIImageView
+ #endif
let streamer: MJPEGStreamer
let promise: Promise
var didUpdateState: (CameraStreamHandlerState) -> Void = { _ in }
@@ -48,7 +56,11 @@ class CameraStreamMJPEGViewController: UIViewController, CameraStreamHandler {
self.response = response
self.baseURL = baseURL
self.streamer = api.VideoStreamer()
+ #if os(macOS)
+ self.imageView = NSImageView()
+ #else
self.imageView = UIImageView()
+ #endif
(self.promise, self.seal) = Promise.pending()
super.init(nibName: nil, bundle: nil)
}
@@ -62,10 +74,25 @@ class CameraStreamMJPEGViewController: UIViewController, CameraStreamHandler {
streamer.cancel()
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if os(macOS)
+ imageView.imageScaling = .scaleProportionallyUpOrDown
+ // An image view asks for its image's own size; the stream fits the notification, not the reverse.
+ for orientation in [NSLayoutConstraint.Orientation.horizontal, .vertical] {
+ imageView.setContentHuggingPriority(.defaultLow, for: orientation)
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: orientation)
+ }
+ #else
imageView.contentMode = .scaleAspectFit
+ #endif
view.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
diff --git a/Sources/Extensions/NotificationContent/CameraStreamWebRTCViewController.swift b/Sources/Extensions/NotificationContent/CameraStreamWebRTCViewController.swift
index 5d09b6f14f..1faceaa99b 100644
--- a/Sources/Extensions/NotificationContent/CameraStreamWebRTCViewController.swift
+++ b/Sources/Extensions/NotificationContent/CameraStreamWebRTCViewController.swift
@@ -1,12 +1,16 @@
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import WebRTC
/// Hosts the in-app WebRTC player as a `CameraStreamHandler` so live camera notifications prefer
/// WebRTC, falling back to HLS/MJPEG. Resolves `promise` on first frame; rejects on failure/timeout.
-final class CameraStreamWebRTCViewController: UIViewController, CameraStreamHandler {
+final class CameraStreamWebRTCViewController: PlatformViewController, CameraStreamHandler {
enum WebRTCError: LocalizedError {
/// WebRTC needs the entity id, which isn't in `StreamCameraResponse`; use `init(api:cameraEntityId:)`.
case requiresEntityID
@@ -72,9 +76,20 @@ final class CameraStreamWebRTCViewController: UIViewController, CameraStreamHand
viewModel.stop()
}
+ #if os(macOS)
+ override func loadView() {
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.backgroundColor = NSColor.black.cgColor
+ view = container
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if !os(macOS)
view.backgroundColor = .black
+ #endif
attachPlayer()
startTimeout()
}
@@ -121,7 +136,9 @@ final class CameraStreamWebRTCViewController: UIViewController, CameraStreamHand
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
+ #if !os(macOS)
controller.didMove(toParent: self)
+ #endif
playerViewController = controller
lastSize = CGSize(width: 16, height: 9)
}
@@ -130,7 +147,9 @@ final class CameraStreamWebRTCViewController: UIViewController, CameraStreamHand
timeoutWorkItem?.cancel()
timeoutWorkItem = nil
viewModel.stop()
+ #if !os(macOS)
playerViewController?.willMove(toParent: nil)
+ #endif
playerViewController?.view.removeFromSuperview()
playerViewController?.removeFromParent()
playerViewController = nil
diff --git a/Sources/Extensions/NotificationContent/CameraViewController.swift b/Sources/Extensions/NotificationContent/CameraViewController.swift
index bb0b07b307..25972e726c 100644
--- a/Sources/Extensions/NotificationContent/CameraViewController.swift
+++ b/Sources/Extensions/NotificationContent/CameraViewController.swift
@@ -5,11 +5,15 @@ import KeychainAccess
import PromiseKit
import SFSafeSymbols
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
import UserNotificationsUI
-class CameraViewController: UIViewController, NotificationCategory {
+class CameraViewController: PlatformViewController, NotificationCategory {
enum CameraError: LocalizedError {
case missingEntityId
case missingAPI
@@ -24,11 +28,58 @@ class CameraViewController: UIViewController, NotificationCategory {
}
}
+ /// A stream controller: the view controller drawing one kind of stream and the handler that drives it.
+ typealias StreamController = CameraStreamHandler & PlatformViewController
+
let entityId: String
let api: HomeAssistantAPI
private var isMuted = true
+ #if os(macOS)
+ private lazy var muteButton: NSButton = {
+ let button = NSButton()
+ button.isBordered = false
+ button.imagePosition = .imageOnly
+ button.contentTintColor = .white
+ button.symbolConfiguration = .init(pointSize: 15, weight: .semibold)
+ button.wantsLayer = true
+ button.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.4).cgColor
+ button.layer?.cornerRadius = 18
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.target = self
+ button.action = #selector(toggleMute)
+ return button
+ }()
+
+ private lazy var loadingIndicator: NSProgressIndicator = {
+ let indicator = NSProgressIndicator()
+ indicator.style = .spinning
+ indicator.controlSize = .regular
+ indicator.isIndeterminate = true
+ indicator.isDisplayedWhenStopped = false
+ // Drawn as it would be on a dark background so it shows over the stream, like the white one on iOS.
+ indicator.appearance = NSAppearance(named: .darkAqua)
+ indicator.translatesAutoresizingMaskIntoConstraints = false
+ return indicator
+ }()
+
+ #if DEBUG
+ private lazy var streamTypeLabel: NSTextField = {
+ let label = NSTextField(labelWithString: "")
+ label.textColor = .white
+ label.font = .systemFont(ofSize: 11, weight: .semibold)
+ label.drawsBackground = true
+ label.backgroundColor = NSColor.black.withAlphaComponent(0.4)
+ label.alignment = .center
+ label.wantsLayer = true
+ label.layer?.cornerRadius = 4
+ label.layer?.masksToBounds = true
+ label.translatesAutoresizingMaskIntoConstraints = false
+ return label
+ }()
+ #endif
+ #else
private lazy var muteButton: UIButton = {
let button = UIButton(type: .system)
button.tintColor = .white
@@ -61,6 +112,7 @@ class CameraViewController: UIViewController, NotificationCategory {
return label
}()
#endif
+ #endif
required init(api: HomeAssistantAPI, notification: UNNotification, attachmentURL: URL?) throws {
guard let entityId = notification.request.content.userInfo["entity_id"] as? String,
@@ -82,6 +134,12 @@ class CameraViewController: UIViewController, NotificationCategory {
activeViewController?.pause()
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
@@ -90,7 +148,13 @@ class CameraViewController: UIViewController, NotificationCategory {
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
- loadingIndicator.startAnimating()
+ #if os(macOS)
+ NSLayoutConstraint.activate([
+ loadingIndicator.widthAnchor.constraint(equalToConstant: 32),
+ loadingIndicator.heightAnchor.constraint(equalToConstant: 32),
+ ])
+ #endif
+ setLoading(true)
view.addSubview(muteButton)
muteButton.isHidden = true
@@ -111,9 +175,11 @@ class CameraViewController: UIViewController, NotificationCategory {
#endif
}
- var activeViewController: (UIViewController & CameraStreamHandler)? {
+ var activeViewController: StreamController? {
willSet {
+ #if !os(macOS)
activeViewController?.willMove(toParent: nil)
+ #endif
newValue.flatMap { addChild($0) }
}
didSet {
@@ -121,7 +187,12 @@ class CameraViewController: UIViewController, NotificationCategory {
oldValue?.removeFromParent()
if let viewController = activeViewController {
+ #if os(macOS)
+ // Inserted beneath the overlays, which keeps them in front without re-adding them.
+ view.addSubview(viewController.view, positioned: .below, relativeTo: nil)
+ #else
view.addSubview(viewController.view)
+ #endif
viewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
@@ -130,6 +201,7 @@ class CameraViewController: UIViewController, NotificationCategory {
viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
+ #if !os(macOS)
viewController.didMove(toParent: self)
view.bringSubviewToFront(loadingIndicator)
@@ -137,6 +209,7 @@ class CameraViewController: UIViewController, NotificationCategory {
#if DEBUG
view.bringSubviewToFront(streamTypeLabel)
#endif
+ #endif
updateOverlays()
}
}
@@ -161,7 +234,7 @@ class CameraViewController: UIViewController, NotificationCategory {
}.then { [weak self, api, entityId] resultAndBaseURL -> Promise in
let (result, baseURL) = resultAndBaseURL
var controllers = Self.possibleControllers
- .compactMap { controllerClass -> () -> Promise in
+ .compactMap { controllerClass -> () -> Promise in
{
do {
return try .value(controllerClass.init(api: api, response: result, baseURL: baseURL))
@@ -172,7 +245,7 @@ class CameraViewController: UIViewController, NotificationCategory {
}
// Prefer WebRTC; it rejects when unsupported so the chain falls through to HLS then MJPEG.
- controllers.insert({ () -> Promise in
+ controllers.insert({ () -> Promise in
.value(CameraStreamWebRTCViewController(api: api, cameraEntityId: entityId))
}, at: 0)
@@ -209,24 +282,43 @@ class CameraViewController: UIViewController, NotificationCategory {
updateMuteIcon()
#if DEBUG
+ #if os(macOS)
+ streamTypeLabel.stringValue = " \(debugStreamName(for: active)) "
+ #else
streamTypeLabel.text = " \(debugStreamName(for: active)) "
#endif
+ #endif
}
private func updateMuteIcon() {
- muteButton.setImage(UIImage(systemSymbol: isMuted ? .speakerSlashFill : .speakerWave3), for: .normal)
+ let image = UIImage(systemSymbol: isMuted ? .speakerSlashFill : .speakerWave3)
// Label reflects the action the button performs, so VoiceOver conveys both purpose and state.
- muteButton.accessibilityLabel = isMuted
+ let accessibilityLabel = isMuted
? L10n.Extensions.NotificationContent.Camera.unmute
: L10n.Extensions.NotificationContent.Camera.mute
+ #if os(macOS)
+ muteButton.image = image
+ muteButton.setAccessibilityLabel(accessibilityLabel)
+ #else
+ muteButton.setImage(image, for: .normal)
+ muteButton.accessibilityLabel = accessibilityLabel
+ #endif
}
private func setLoading(_ loading: Bool) {
+ #if os(macOS)
+ if loading {
+ loadingIndicator.startAnimation(nil)
+ } else {
+ loadingIndicator.stopAnimation(nil)
+ }
+ #else
if loading {
loadingIndicator.startAnimating()
} else {
loadingIndicator.stopAnimating()
}
+ #endif
}
@objc private func toggleMute() {
@@ -236,7 +328,7 @@ class CameraViewController: UIViewController, NotificationCategory {
}
#if DEBUG
- private func debugStreamName(for controller: UIViewController & CameraStreamHandler) -> String {
+ private func debugStreamName(for controller: StreamController) -> String {
if controller is CameraStreamWebRTCViewController {
return "WebRTC"
}
@@ -267,22 +359,22 @@ class CameraViewController: UIViewController, NotificationCategory {
}
}
- private static var possibleControllers: [(UIViewController & CameraStreamHandler).Type] { [
+ private static var possibleControllers: [StreamController.Type] { [
CameraStreamHLSViewController.self,
CameraStreamMJPEGViewController.self,
] }
private func viewController(
- from controllerPromises: [() -> Promise]
- ) -> Promise {
+ from controllerPromises: [() -> Promise]
+ ) -> Promise {
var accumulatedErrors = [Error]()
- var promise: Promise = .init(
+ var promise: Promise = .init(
error: CameraViewControllerError.noControllers
)
for nextPromise in controllerPromises {
promise = promise
- .recover { [weak self, extensionContext] error -> Promise in
+ .recover { [weak self, extensionContext] error -> Promise in
// always tell the extension context the previous one failed, aka go back to showing pause
extensionContext?.mediaPlayingPaused()
// accumulate the error
@@ -324,7 +416,7 @@ class CameraViewController: UIViewController, NotificationCategory {
}
}
- return promise.recover { nextError -> Promise in
+ return promise.recover { nextError -> Promise in
throw CameraViewControllerError.accumulated(accumulatedErrors + [nextError])
}
}
diff --git a/Sources/Extensions/NotificationContent/ImageAttachmentViewController.swift b/Sources/Extensions/NotificationContent/ImageAttachmentViewController.swift
index df8ce8391f..6f0fa09c00 100644
--- a/Sources/Extensions/NotificationContent/ImageAttachmentViewController.swift
+++ b/Sources/Extensions/NotificationContent/ImageAttachmentViewController.swift
@@ -1,18 +1,35 @@
+import ImageIO
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+import WebKit
+#endif
import UniformTypeIdentifiers
import UserNotifications
import UserNotificationsUI
-import WebKit
-class ImageAttachmentViewController: UIViewController, NotificationCategory {
+class ImageAttachmentViewController: PlatformViewController, NotificationCategory {
let attachmentURL: URL
let needsEndSecurityScoped: Bool
let image: UIImage
let imageData: Data
let imageUTI: UTType
+ #if os(macOS)
+ // An AppKit image view plays an animated GIF itself, so every image type is shown the same way.
+ enum ImageViewType {
+ case imageView(NSImageView)
+
+ var view: NSView {
+ switch self {
+ case let .imageView(imageView): return imageView
+ }
+ }
+ }
+ #else
enum ImageViewType {
case imageView(UIImageView)
case webView(WKWebView)
@@ -24,6 +41,7 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
}
}
}
+ #endif
let visibleView: ImageViewType
@@ -56,6 +74,17 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
self.imageUTI = .jpeg
}
+ #if os(macOS)
+ self.visibleView = .imageView(with(NSImageView()) {
+ $0.imageScaling = .scaleProportionallyUpOrDown
+ $0.animates = true
+ // An image view asks for its image's own size; the image fits the notification, not the reverse.
+ for orientation in [NSLayoutConstraint.Orientation.horizontal, .vertical] {
+ $0.setContentHuggingPriority(.defaultLow, for: orientation)
+ $0.setContentCompressionResistancePriority(.defaultLow, for: orientation)
+ }
+ })
+ #else
if imageUTI.conforms(to: .gif) {
// use a WebView for gif so we can animate without pulling in a third party library
let config = with(WKWebViewConfiguration()) {
@@ -84,6 +113,7 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
$0.contentMode = .scaleAspectFit
})
}
+ #endif
} catch {
attachmentURL.stopAccessingSecurityScopedResource()
@@ -130,6 +160,7 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
lastAttachmentURL = attachmentURL
switch visibleView {
+ #if !os(macOS)
case let .webView(webView):
let mime = imageUTI.preferredMIMEType
webView.load(
@@ -138,6 +169,7 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
characterEncodingName: "UTF-8",
baseURL: attachmentURL
)
+ #endif
case let .imageView(imageView):
imageView.image = image
}
@@ -148,6 +180,9 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
}
override func loadView() {
+ #if os(macOS)
+ view = NSView()
+ #else
class UnanimatingView: UIView {
override func layoutSubviews() {
// avoids the image view sizing up from nothing when initially displaying
@@ -159,6 +194,7 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
}
view = UnanimatingView()
+ #endif
}
override func viewDidLoad() {
diff --git a/Sources/Extensions/NotificationContent/MapViewController.swift b/Sources/Extensions/NotificationContent/MapViewController.swift
index f9b77141d9..e33a631b36 100644
--- a/Sources/Extensions/NotificationContent/MapViewController.swift
+++ b/Sources/Extensions/NotificationContent/MapViewController.swift
@@ -1,11 +1,15 @@
import MapKit
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
import UserNotificationsUI
-class MapViewController: UIViewController, NotificationCategory, MKMapViewDelegate {
+class MapViewController: PlatformViewController, NotificationCategory, MKMapViewDelegate {
let api: HomeAssistantAPI
let location: CLLocationCoordinate2D
let haDict: [String: Any]
@@ -49,6 +53,12 @@ class MapViewController: UIViewController, NotificationCategory, MKMapViewDelega
}
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
@@ -83,7 +93,11 @@ class MapViewController: UIViewController, NotificationCategory, MKMapViewDelega
mapView.showsScale = (haDict["shows_scale"] != nil)
mapView.showsTraffic = (haDict["shows_traffic"] != nil)
+ #if os(macOS)
+ mapView.setAccessibilityIdentifier("notification_map")
+ #else
mapView.accessibilityIdentifier = "notification_map"
+ #endif
let span = MKCoordinateSpan(
latitudeDelta: CLLocationDegrees(templateValue: haDict["latitude_delta"]) ?? 0.1,
diff --git a/Sources/Extensions/NotificationContent/NotificationErrorViewController.swift b/Sources/Extensions/NotificationContent/NotificationErrorViewController.swift
index 526313cecb..fc0e97bd1d 100644
--- a/Sources/Extensions/NotificationContent/NotificationErrorViewController.swift
+++ b/Sources/Extensions/NotificationContent/NotificationErrorViewController.swift
@@ -1,11 +1,20 @@
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
+import UserNotifications
import UserNotificationsUI
-class NotificationErrorViewController: UIViewController, NotificationCategory {
+class NotificationErrorViewController: PlatformViewController, NotificationCategory {
+ #if os(macOS)
+ let label = NSTextField(wrappingLabelWithString: "")
+ #else
let label = UILabel()
+ #endif
required init(api: HomeAssistantAPI, notification: UNNotification, attachmentURL: URL?) throws {
fatalError("not meant to be used in the list of potentials, just directly set")
@@ -13,7 +22,11 @@ class NotificationErrorViewController: UIViewController, NotificationCategory {
init(error: Error) {
super.init(nibName: nil, bundle: nil)
+ #if os(macOS)
+ label.stringValue = error.localizedDescription
+ #else
label.text = error.localizedDescription
+ #endif
}
@available(*, unavailable)
@@ -21,12 +34,26 @@ class NotificationErrorViewController: UIViewController, NotificationCategory {
fatalError("init(coder:) has not been implemented")
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if os(macOS)
+ label.isSelectable = false
+ label.alignment = .center
+ label.textColor = .systemRed
+ // The message wraps to the width the notification has rather than widening the notification.
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ #else
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .systemRed
+ #endif
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
diff --git a/Sources/Extensions/NotificationContent/NotificationLoadingViewController.swift b/Sources/Extensions/NotificationContent/NotificationLoadingViewController.swift
index d2a3de5f53..662ef73bc9 100644
--- a/Sources/Extensions/NotificationContent/NotificationLoadingViewController.swift
+++ b/Sources/Extensions/NotificationContent/NotificationLoadingViewController.swift
@@ -1,10 +1,15 @@
import Foundation
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
+import UserNotifications
import UserNotificationsUI
-class NotificationLoadingViewController: UIViewController, NotificationCategory {
+class NotificationLoadingViewController: PlatformViewController, NotificationCategory {
required init(api: HomeAssistantAPI, notification: UNNotification, attachmentURL: URL?) throws {
super.init(nibName: nil, bundle: nil)
}
@@ -18,9 +23,35 @@ class NotificationLoadingViewController: UIViewController, NotificationCategory
fatalError("init(coder:) has not been implemented")
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
override func viewDidLoad() {
super.viewDidLoad()
+ #if os(macOS)
+ let activityIndicator = NSProgressIndicator()
+ activityIndicator.style = .spinning
+ activityIndicator.controlSize = .small
+ activityIndicator.isIndeterminate = true
+
+ // The spinner sets the height of the notification while it loads, so it is pinned top to bottom
+ // and centred rather than stretched across the width.
+ view.addSubview(activityIndicator)
+ activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ activityIndicator.topAnchor.constraint(equalTo: view.topAnchor),
+ activityIndicator.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ activityIndicator.widthAnchor.constraint(equalToConstant: 16),
+ activityIndicator.heightAnchor.constraint(equalToConstant: 16),
+ ])
+
+ activityIndicator.startAnimation(nil)
+ #else
let activityIndicator: UIActivityIndicatorView
activityIndicator = UIActivityIndicatorView(style: .medium)
@@ -35,6 +66,7 @@ class NotificationLoadingViewController: UIViewController, NotificationCategory
])
activityIndicator.startAnimating()
+ #endif
}
func start() -> Promise {
diff --git a/Sources/Extensions/NotificationContent/NotificationViewController.swift b/Sources/Extensions/NotificationContent/NotificationViewController.swift
index 6eff385289..28b35465ee 100644
--- a/Sources/Extensions/NotificationContent/NotificationViewController.swift
+++ b/Sources/Extensions/NotificationContent/NotificationViewController.swift
@@ -2,14 +2,20 @@ import Alamofire
import KeychainAccess
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
import UserNotifications
import UserNotificationsUI
-class NotificationViewController: UIViewController, UNNotificationContentExtension {
- var activeViewController: (UIViewController & NotificationCategory)? {
+class NotificationViewController: PlatformViewController, UNNotificationContentExtension {
+ var activeViewController: (PlatformViewController & NotificationCategory)? {
willSet {
+ #if !os(macOS)
activeViewController?.willMove(toParent: nil)
+ #endif
newValue.flatMap { addChild($0) }
}
didSet {
@@ -19,14 +25,25 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
if let viewController = activeViewController {
view.addSubview(viewController.view)
viewController.view.translatesAutoresizingMaskIntoConstraints = false
+ #if os(macOS)
+ // The content decides its own height from its constraints and the notification is told
+ // that height through `preferredContentSize`. Until the system applies it the two
+ // disagree, so the bottom edge yields rather than fighting the frame the system set.
+ let bottom = viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ bottom.priority = .defaultLow
+ #else
+ let bottom = viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ #endif
NSLayoutConstraint.activate([
viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ bottom,
])
+ #if !os(macOS)
viewController.didMove(toParent: self)
+ #endif
} else {
// 0 doesn't adjust size, must be a > check
preferredContentSize.height = .leastNonzeroMagnitude
@@ -34,7 +51,34 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
}
}
- private static var possibleControllers: [(UIViewController & NotificationCategory).Type] { [
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+
+ override func viewDidLayout() {
+ super.viewDidLayout()
+ updatePreferredContentSize()
+ }
+
+ /// Passes the height the content laid itself out to on to the system, which sizes the notification
+ /// from `preferredContentSize` rather than from the view's constraints. Only the height matters to
+ /// the system; the width is whatever it gave the notification.
+ private func updatePreferredContentSize() {
+ guard let contentView = activeViewController?.view, view.bounds.width > 0 else {
+ return
+ }
+
+ let height = contentView.frame.height
+ guard abs(preferredContentSize.height - height) > 0.5 else {
+ return
+ }
+
+ preferredContentSize = CGSize(width: view.bounds.width, height: height)
+ }
+ #endif
+
+ private static var possibleControllers: [(PlatformViewController & NotificationCategory).Type] { [
CameraViewController.self,
MapViewController.self,
ImageAttachmentViewController.self,
@@ -46,7 +90,7 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
api: HomeAssistantAPI,
attachmentURL: URL?,
allowDownloads: Bool = true
- ) -> Guarantee<(UIViewController & NotificationCategory)?> {
+ ) -> Guarantee<(PlatformViewController & NotificationCategory)?> {
// Try based on current info (e.g. entity_id or attached via service extension)
for controllerType in Self.possibleControllers {
@@ -109,7 +153,11 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
activeViewController = NotificationLoadingViewController()
+ #if os(macOS)
+ var indicator: NSProgressIndicator?
+ #else
var indicator: UIActivityIndicatorView?
+ #endif
viewController(
for: notification,
@@ -126,21 +174,40 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
let view = self?.view {
// don't show the HUD for a screen that has pause/play because it already acts like a loading indicator
indicator = {
+ #if os(macOS)
+ let indicator = NSProgressIndicator()
+ indicator.style = .spinning
+ indicator.controlSize = .small
+ indicator.isIndeterminate = true
+ #else
let indicator = UIActivityIndicatorView(style: .medium)
+ #endif
indicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
indicator.topAnchor.constraint(equalTo: view.topAnchor, constant: 50),
])
+ #if os(macOS)
+ NSLayoutConstraint.activate([
+ indicator.widthAnchor.constraint(equalToConstant: 16),
+ indicator.heightAnchor.constraint(equalToConstant: 16),
+ ])
+ indicator.startAnimation(nil)
+ #else
indicator.startAnimating()
+ #endif
return indicator
}()
}
return controller.start()
}.ensure {
+ #if os(macOS)
+ indicator?.stopAnimation(nil)
+ #else
indicator?.stopAnimating()
+ #endif
indicator?.removeFromSuperview()
}.catch { [weak self] error in
Current.Log.error("finally failed: \(error)")
diff --git a/Sources/Extensions/NotificationContent/Resources/Info-macOS.plist b/Sources/Extensions/NotificationContent/Resources/Info-macOS.plist
new file mode 100644
index 0000000000..c54a164372
--- /dev/null
+++ b/Sources/Extensions/NotificationContent/Resources/Info-macOS.plist
@@ -0,0 +1,71 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ NotificationContentExtension
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSExtension
+
+ NSExtensionAttributes
+
+ UNNotificationExtensionCategory
+
+ camera
+ camera1
+ camera2
+ camera3
+ camera4
+ map
+ map1
+ map2
+ map3
+ map4
+ map5
+ CAMERA
+ CAMERA1
+ CAMERA2
+ CAMERA3
+ CAMERA4
+ MAP
+ MAP1
+ MAP2
+ MAP3
+ MAP4
+ MAP5
+ DYNAMIC
+
+ UNNotificationExtensionInitialContentSizeRatio
+ 0
+ UNNotificationExtensionUserInteractionEnabled
+
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.content-extension
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationViewController
+
+
+
diff --git a/Sources/Extensions/NotificationContent/VideoAudioAttachmentViewController.swift b/Sources/Extensions/NotificationContent/VideoAudioAttachmentViewController.swift
index d3b9a87522..6cb0ad15f9 100644
--- a/Sources/Extensions/NotificationContent/VideoAudioAttachmentViewController.swift
+++ b/Sources/Extensions/NotificationContent/VideoAudioAttachmentViewController.swift
@@ -1,11 +1,15 @@
-import MobileCoreServices
import PromiseKit
import Shared
+#if os(macOS)
+import AppKit
+#else
+import MobileCoreServices
import UIKit
+#endif
import UserNotifications
import UserNotificationsUI
-class PlayerAttachmentViewController: UIViewController, NotificationCategory {
+class PlayerAttachmentViewController: PlatformViewController, NotificationCategory {
enum PlayerAttachmentError: Error {
case noAttachment
}
@@ -45,10 +49,18 @@ class PlayerAttachmentViewController: UIViewController, NotificationCategory {
}
}
+ #if os(macOS)
+ override func loadView() {
+ view = NSView()
+ }
+ #endif
+
var videoViewController: CameraStreamHLSViewController? {
willSet {
videoViewController?.url.stopAccessingSecurityScopedResource()
+ #if !os(macOS)
videoViewController?.willMove(toParent: nil)
+ #endif
newValue.flatMap { addChild($0) }
}
didSet {
@@ -65,7 +77,9 @@ class PlayerAttachmentViewController: UIViewController, NotificationCategory {
videoViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
+ #if !os(macOS)
videoViewController.didMove(toParent: self)
+ #endif
}
}
}
diff --git a/Sources/Extensions/Share/ShareViewController.swift b/Sources/Extensions/Share/ShareViewController.swift
index c07cc2b002..0d54125461 100644
--- a/Sources/Extensions/Share/ShareViewController.swift
+++ b/Sources/Extensions/Share/ShareViewController.swift
@@ -2,7 +2,11 @@ import CoreServices
import PromiseKit
import Shared
import Social
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
@objc(HAShareViewController)
class ShareViewController: SLComposeServiceViewController {
@@ -36,9 +40,12 @@ class ShareViewController: SLComposeServiceViewController {
}
}
+ // The Mac compose sheet has no preview and no configuration rows to customise.
+ #if os(iOS)
override func loadPreviewView() -> UIView! {
nil
}
+ #endif
override func didSelectPost() {
Current.Log.info("starting to post")
@@ -57,21 +64,44 @@ class ShareViewController: SLComposeServiceViewController {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}.catch { [weak self] error in
Current.Log.error("failed to post: \(error)")
- let alert = UIAlertController(
- title: L10n.ShareExtension.Error.title,
- message: error.localizedDescription,
- preferredStyle: .alert
- )
- alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: { _ in
- self?.extensionContext?.cancelRequest(withError: error)
- }))
- self?.present(alert, animated: true, completion: nil)
+ self?.showFailure(error)
+ }
+ }
+
+ /// Tells the user the event was not sent, then hands the request back as cancelled.
+ private func showFailure(_ error: Error) {
+ #if os(macOS)
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = L10n.ShareExtension.Error.title
+ alert.informativeText = error.localizedDescription
+ alert.addButton(withTitle: L10n.okLabel)
+ let cancelRequest: (NSApplication.ModalResponse) -> Void = { [weak self] _ in
+ self?.extensionContext?.cancelRequest(withError: error)
+ }
+ if let window = view.window {
+ alert.beginSheetModal(for: window, completionHandler: cancelRequest)
+ } else {
+ cancelRequest(alert.runModal())
}
+ #else
+ let alert = UIAlertController(
+ title: L10n.ShareExtension.Error.title,
+ message: error.localizedDescription,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: { [weak self] _ in
+ self?.extensionContext?.cancelRequest(withError: error)
+ }))
+ present(alert, animated: true, completion: nil)
+ #endif
}
+ #if os(iOS)
override func configurationItems() -> [Any]! {
[]
}
+ #endif
override func viewDidLoad() {
super.viewDidLoad()
diff --git a/Sources/Extensions/Widgets/Areas/WidgetAreas.swift b/Sources/Extensions/Widgets/Areas/WidgetAreas.swift
index 3b2805a516..0420b09371 100644
--- a/Sources/Extensions/Widgets/Areas/WidgetAreas.swift
+++ b/Sources/Extensions/Widgets/Areas/WidgetAreas.swift
@@ -5,7 +5,7 @@ import WidgetKit
/// The areas of a server, floor by floor, in the order Home Assistant lists them — and a page at a
/// time, since no widget family holds a whole home.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetAreas: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -23,7 +23,7 @@ struct WidgetAreas: Widget {
}
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
#Preview("Large", as: .systemLarge) {
WidgetAreas()
} timeline: {
@@ -31,7 +31,7 @@ struct WidgetAreas: Widget {
WidgetAreasEntry.preview(family: .systemLarge, page: 1)
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
#Preview("Small", as: .systemSmall) {
WidgetAreas()
} timeline: {
diff --git a/Sources/Extensions/Widgets/Areas/WidgetAreasPageAppIntent.swift b/Sources/Extensions/Widgets/Areas/WidgetAreasPageAppIntent.swift
index a6e3540e84..f8c67ca22a 100644
--- a/Sources/Extensions/Widgets/Areas/WidgetAreasPageAppIntent.swift
+++ b/Sources/Extensions/Widgets/Areas/WidgetAreasPageAppIntent.swift
@@ -7,7 +7,7 @@ import WidgetKit
///
/// Not discoverable: it exists to be the intent behind the widget's own arrows, and carries the
/// widget's server, family and page count because the intent runs outside the view that knows them.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetAreasPageAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"widgets.areas.page.title",
diff --git a/Sources/Extensions/Widgets/Areas/WidgetAreasTimelineProvider.swift b/Sources/Extensions/Widgets/Areas/WidgetAreasTimelineProvider.swift
index 88974cb803..30a6b08b3c 100644
--- a/Sources/Extensions/Widgets/Areas/WidgetAreasTimelineProvider.swift
+++ b/Sources/Extensions/Widgets/Areas/WidgetAreasTimelineProvider.swift
@@ -6,7 +6,7 @@ import WidgetKit
/// Builds the Areas widget's timeline out of the areas the app has already stored for the server,
/// so the widget needs no connection of its own — the app's database is the same one the areas
/// screen and the watch read.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetAreasTimelineProvider: WidgetSingleEntryTimelineProvider {
typealias Entry = WidgetAreasEntry
typealias Intent = WidgetAreasAppIntent
diff --git a/Sources/Extensions/Widgets/Areas/WidgetAreasView.swift b/Sources/Extensions/Widgets/Areas/WidgetAreasView.swift
index 03e47aa631..8a664d67ea 100644
--- a/Sources/Extensions/Widgets/Areas/WidgetAreasView.swift
+++ b/Sources/Extensions/Widgets/Areas/WidgetAreasView.swift
@@ -5,7 +5,7 @@ import WidgetKit
/// The Areas widget: the design system draws the floors, the area tiles and the paging arrows; this
/// adds the deep link each tile opens and the intents the arrows run.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetAreasView: View {
@Environment(\.widgetFamily) private var widgetFamily
@@ -77,7 +77,7 @@ struct WidgetAreasView: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview {
WidgetAreasView(entry: .preview(family: .systemMedium))
.frame(width: 338, height: 158)
diff --git a/Sources/Extensions/Widgets/Assist/AssistAppIntent.swift b/Sources/Extensions/Widgets/Assist/AssistAppIntent.swift
index 1cf6bfeae6..c467d2d3fd 100644
--- a/Sources/Extensions/Widgets/Assist/AssistAppIntent.swift
+++ b/Sources/Extensions/Widgets/Assist/AssistAppIntent.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
// AppIntent that open app needs to have it's target the widget extension AND app target!
-@available(iOS 18, watchOS 10, *)
+@available(iOS 18, macOS 15, watchOS 10, *)
struct AssistAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"app_intents.controls.assist.title",
@@ -13,7 +13,7 @@ struct AssistAppIntent: AppIntent {
static var openAppWhenRun: Bool = true
// `openAppWhenRun` is deprecated from iOS 26; both stay until the deployment target passes 26.
- @available(iOS 26.0, watchOS 26.0, *)
+ @available(iOS 26.0, macOS 26.0, watchOS 26.0, *)
static var supportedModes: IntentModes { .foreground }
@Parameter(title: .init("app_intents.assist.pipeline.title", defaultValue: "Pipeline"))
diff --git a/Sources/Extensions/Widgets/Assist/Control/ControlAssist.swift b/Sources/Extensions/Widgets/Assist/Control/ControlAssist.swift
index 91605759cf..b6d133ed23 100644
--- a/Sources/Extensions/Widgets/Assist/Control/ControlAssist.swift
+++ b/Sources/Extensions/Widgets/Assist/Control/ControlAssist.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAssist: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Assist/Control/ControlAssistValueProvider.swift b/Sources/Extensions/Widgets/Assist/Control/ControlAssistValueProvider.swift
index 66e2d115b6..67b2d7ba6b 100644
--- a/Sources/Extensions/Widgets/Assist/Control/ControlAssistValueProvider.swift
+++ b/Sources/Extensions/Widgets/Assist/Control/ControlAssistValueProvider.swift
@@ -3,13 +3,13 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAssistItem {
let pipeline: AssistPipelineEntity
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAssistValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlAssistConfiguration) async throws -> ControlAssistItem {
.init(pipeline: configuration.pipeline ?? placeholder(), displayText: configuration.displayText)
@@ -28,7 +28,7 @@ struct ControlAssistValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlAssistConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = "Assist"
diff --git a/Sources/Extensions/Widgets/Assist/WidgetAssist.swift b/Sources/Extensions/Widgets/Assist/WidgetAssist.swift
index 4ef8dd70da..3b9bedbcca 100644
--- a/Sources/Extensions/Widgets/Assist/WidgetAssist.swift
+++ b/Sources/Extensions/Widgets/Assist/WidgetAssist.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetAssist: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -13,7 +13,7 @@ struct WidgetAssist: Widget {
content: { entry in
// Widget background and tap destinations are family dependent,
// so `WidgetAssistView` applies them per branch.
- if #available(iOS 18.0, *) {
+ if #available(iOS 18.0, macOS 15.0, *) {
WidgetAssistViewTintedWrapper(entry: entry)
} else {
WidgetAssistView(entry: entry, tinted: false)
@@ -28,6 +28,11 @@ struct WidgetAssist: Widget {
}
private var supportedFamilies: [WidgetFamily] {
- [.systemSmall, .systemMedium, .accessoryCircular]
+ #if os(macOS)
+ // A Mac has no lock screen for the accessory family to appear on.
+ return [.systemSmall, .systemMedium]
+ #else
+ return [.systemSmall, .systemMedium, .accessoryCircular]
+ #endif
}
}
diff --git a/Sources/Extensions/Widgets/Assist/WidgetAssistAppIntent.swift b/Sources/Extensions/Widgets/Assist/WidgetAssistAppIntent.swift
index 1b2f962ab4..cb72a589f5 100644
--- a/Sources/Extensions/Widgets/Assist/WidgetAssistAppIntent.swift
+++ b/Sources/Extensions/Widgets/Assist/WidgetAssistAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetAssistAppIntent: WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
// Carries over configurations from the deprecated SiriKit widget intent
static let intentClassName = "AssistInAppIntent"
diff --git a/Sources/Extensions/Widgets/Assist/WidgetAssistProvider.swift b/Sources/Extensions/Widgets/Assist/WidgetAssistProvider.swift
index e145357b04..8020e6e70c 100644
--- a/Sources/Extensions/Widgets/Assist/WidgetAssistProvider.swift
+++ b/Sources/Extensions/Widgets/Assist/WidgetAssistProvider.swift
@@ -28,7 +28,7 @@ struct WidgetAssistEntry: TimelineEntry {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetAssistProvider: AppIntentTimelineProvider {
typealias Entry = WidgetAssistEntry
typealias Intent = WidgetAssistAppIntent
diff --git a/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift b/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift
index a25e56c8ff..ac4384f5d4 100644
--- a/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift
+++ b/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift
@@ -27,10 +27,12 @@ struct WidgetAssistView: View {
@ViewBuilder
private var content: some View {
switch widgetFamily {
+ #if !os(macOS)
case .accessoryCircular:
accessoryCircular
.widgetBackground(Color.clear)
.widgetURL(entry.widgetURL)
+ #endif
case .systemSmall:
singleHomeScreenItem
.widgetBackground(Color.clear)
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendar.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendar.swift
index 3b97a2cef7..839987cda8 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendar.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendar.swift
@@ -5,7 +5,7 @@ import SwiftUI
import WidgetKit
/// Upcoming events from any number of Home Assistant calendars, merged into one list.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCalendar: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -32,7 +32,7 @@ struct WidgetCalendar: Widget {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetCalendar()
}, timeline: {
@@ -48,7 +48,7 @@ struct WidgetCalendar: Widget {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemMedium, widget: {
WidgetCalendar()
}, timeline: {
@@ -64,7 +64,7 @@ struct WidgetCalendar: Widget {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemLarge, widget: {
WidgetCalendar()
}, timeline: {
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntity.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntity.swift
index 15e8e0337c..3b52a9e6fc 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntity.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntity.swift
@@ -7,7 +7,7 @@ import Shared
/// Distinct from the app target's `HACalendarAppEntity`, which the widget extension cannot see: the
/// calendar Shortcuts entities are compiled into the app only, the same way `TodoListAppEntity` is
/// the widget's own copy of a to-do list.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetCalendarAppEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: .init(
"app_intents.calendar.entity.name",
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntityQuery.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntityQuery.swift
index d57a131436..b019acf46b 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntityQuery.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppEntityQuery.swift
@@ -7,7 +7,7 @@ import Shared
/// Deliberately not scoped to a server parameter: the widget merges events from any number of
/// calendars, and a household with two servers onboarded has no reason to be forced into picking
/// one of them first.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetCalendarAppEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [WidgetCalendarAppEntity] {
identifiers.compactMap { identifier in
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntent.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntent.swift
index 23dc6661b4..42ddf5a047 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntent.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetCalendarAppIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = .init("widgets.calendar.title", defaultValue: "Calendar")
static let description = IntentDescription(
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntentTimelineProvider.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntentTimelineProvider.swift
index e1b165e64f..904ba71418 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntentTimelineProvider.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarAppIntentTimelineProvider.swift
@@ -9,7 +9,7 @@ import WidgetKit
/// The events are read through `Current.calendarsModel()`, which fetches from the server, refreshes
/// the cache, and falls back to the cache when the server can't be reached — so the widget keeps
/// showing the last known schedule while the phone is off the network.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCalendarAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetCalendarEntry
typealias Intent = WidgetCalendarAppIntent
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshAppIntent.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshAppIntent.swift
index 584469059b..adba9d4181 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshAppIntent.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshAppIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetCalendarRefreshAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"widgets.calendar.refresh_title",
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshButton.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshButton.swift
index fe93b669b0..92702dcbc7 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshButton.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarRefreshButton.swift
@@ -4,7 +4,7 @@ import SwiftUI
/// The time the entry was last refreshed, preceded by a small reload glyph, mirroring the energy
/// widget. The pair is one control: tapping either part reloads the widget's timeline.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCalendarRefreshButton: View {
let date: Date
@@ -20,7 +20,7 @@ struct WidgetCalendarRefreshButton: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview {
WidgetCalendarRefreshButton(date: Date())
.padding()
diff --git a/Sources/Extensions/Widgets/Calendar/WidgetCalendarView.swift b/Sources/Extensions/Widgets/Calendar/WidgetCalendarView.swift
index 77dcb69f7d..87c26cd5fd 100644
--- a/Sources/Extensions/Widgets/Calendar/WidgetCalendarView.swift
+++ b/Sources/Extensions/Widgets/Calendar/WidgetCalendarView.swift
@@ -8,7 +8,7 @@ import WidgetKit
///
/// Everything is rendered against `referenceDate` rather than the clock, so a timeline entry always
/// draws the moment it was built for — and so the view can be snapshotted.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCalendarView: View {
@Environment(\.widgetFamily) private var widgetFamily
@@ -69,7 +69,7 @@ struct WidgetCalendarView: View {
// The family-specific previews live on `WidgetCalendar`, through WidgetKit's own preview macro:
// `\.widgetFamily` is read-only inside the widget extension, so a plain preview here cannot choose
// a family. These two cover the states that do not depend on one.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Events") {
WidgetCalendarView(
referenceDate: WidgetCalendarPreviewSample.referenceDate,
@@ -86,7 +86,7 @@ struct WidgetCalendarView: View {
.frame(width: 338, height: 158)
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Not configured") {
WidgetCalendarView(
referenceDate: WidgetCalendarPreviewSample.referenceDate,
diff --git a/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift b/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift
index 58b600ec2f..8dc79f0872 100644
--- a/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift
@@ -46,7 +46,7 @@ struct WidgetBasicContainerView: View {
}
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct WidgetBasicContainerView_Previews: PreviewProvider {
struct WidgetBasicContainerViewPreviewData {
let modelsCount: Int
@@ -271,7 +271,7 @@ struct WidgetBasicContainerWrapperView: View {
/// glyph or the time reloads this widget's timeline. Only offered where App Intents exist.
private var refreshControl: (() -> AnyView)? {
guard showLastUpdate else { return nil }
- if #available(iOS 17, *) {
+ if #available(iOS 17, macOS 14, *) {
let kind = widgetKind
return { AnyView(WidgetRefreshButton(kind: kind, date: Current.date())) }
}
diff --git a/Sources/Extensions/Widgets/Common/WidgetFamilySizes.swift b/Sources/Extensions/Widgets/Common/WidgetFamilySizes.swift
index a22a7cd915..d1d1b5659d 100644
--- a/Sources/Extensions/Widgets/Common/WidgetFamilySizes.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetFamilySizes.swift
@@ -36,8 +36,10 @@ enum WidgetFamilySizes {
case .systemMedium: return 6
case .systemLarge: return 12
case .systemExtraLarge, .systemExtraLargePortrait: return 20
+ #if !os(macOS)
case .accessoryRectangular, .accessoryCircular, .accessoryInline:
return 1
+ #endif
@unknown default:
return 1
}
diff --git a/Sources/Extensions/Widgets/Common/WidgetRefreshAppIntent.swift b/Sources/Extensions/Widgets/Common/WidgetRefreshAppIntent.swift
index c73cc6b3c7..ae3d5a3b80 100644
--- a/Sources/Extensions/Widgets/Common/WidgetRefreshAppIntent.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetRefreshAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetRefreshAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"widgets.refresh_title",
diff --git a/Sources/Extensions/Widgets/Common/WidgetRefreshButton.swift b/Sources/Extensions/Widgets/Common/WidgetRefreshButton.swift
index ee9adc18e7..1ed4126be9 100644
--- a/Sources/Extensions/Widgets/Common/WidgetRefreshButton.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetRefreshButton.swift
@@ -3,7 +3,7 @@ import SwiftUI
/// The time the entry was last refreshed, preceded by a small reload glyph, mirroring the energy
/// widget. The pair is one control: tapping either part reloads the widget's timeline.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetRefreshButton: View {
let kind: WidgetsKind
let date: Date
@@ -26,7 +26,7 @@ struct WidgetRefreshButton: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview {
WidgetRefreshButton(kind: .custom, date: Date())
.padding()
diff --git a/Sources/Extensions/Widgets/Common/WidgetTileInteraction.swift b/Sources/Extensions/Widgets/Common/WidgetTileInteraction.swift
index 37271ad073..b54ec23b9a 100644
--- a/Sources/Extensions/Widgets/Common/WidgetTileInteraction.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetTileInteraction.swift
@@ -17,7 +17,7 @@ struct WidgetTileInteraction {
let family: WidgetFamily
func content(model: WidgetBasicViewModel, sizeStyle: WidgetTileSizeStyle, tile: AnyView) -> AnyView {
- if #available(iOS 17, *) {
+ if #available(iOS 17, macOS 14, *) {
if model.showConfirmation {
return AnyView(confirmationContent(model: model, sizeStyle: sizeStyle))
} else if isSplit(model) {
@@ -48,7 +48,8 @@ struct WidgetTileInteraction {
/// opens the item. `nil` for every tile that is a single control, which is all of them before
/// iOS 17 and all of them while a confirmation is pending.
func regions(model: WidgetBasicViewModel) -> WidgetTileRegions? {
- guard #available(iOS 17, *), !model.showConfirmation, let iconInteractionType = model.iconInteractionType else {
+ guard #available(iOS 17, macOS 14, *), !model.showConfirmation,
+ let iconInteractionType = model.iconInteractionType else {
return nil
}
return WidgetTileRegions(
@@ -78,7 +79,7 @@ struct WidgetTileInteraction {
/// One half of a split tile, wrapped in whatever runs it. Either half asks for confirmation when
/// the item requires it, except a tap that only opens the entity's more-info dialog: that
/// changes nothing, so there is nothing to confirm first.
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
@ViewBuilder
private func control(
model: WidgetBasicViewModel,
@@ -115,7 +116,7 @@ struct WidgetTileInteraction {
/// drawn around the link, not inside it — so redrawing it as a tile would paint a second one.
@ViewBuilder
private func linkTile(model: WidgetBasicViewModel, sizeStyle: WidgetTileSizeStyle, tile: AnyView) -> some View {
- if #available(iOS 18.0, *) {
+ if #available(iOS 18.0, macOS 15.0, *) {
tile
} else if family.isLockScreenAccessory {
tile
@@ -132,7 +133,7 @@ struct WidgetTileInteraction {
/// The intent that flips the tile into its confirmation state. `confirmsTapAction` records which
/// half asked, so confirming runs that half's behavior.
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
private func confirmationStateIntent(
for model: WidgetBasicViewModel,
confirmsTapAction: Bool = false
@@ -144,7 +145,7 @@ struct WidgetTileInteraction {
return intent
}
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
private func intent(for model: WidgetBasicViewModel, isConfirmationDone: Bool = true) -> (any AppIntent)? {
intent(
for: actionInteractionType(for: model),
@@ -153,7 +154,7 @@ struct WidgetTileInteraction {
)
}
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
private func intent(
for interactionType: WidgetInteractionType,
model: WidgetBasicViewModel,
@@ -207,7 +208,7 @@ struct WidgetTileInteraction {
}
/// The confirmation a tile turns into once it has been asked to run something that requires one.
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
private func confirmationContent(
model: WidgetBasicViewModel,
sizeStyle: WidgetTileSizeStyle
@@ -244,7 +245,7 @@ struct WidgetTileInteraction {
/// A deep link that has to be confirmed first: tapping it only flips the widget into its
/// confirmation state, and the form is what actually follows the link.
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
private func linkThatRequiresConfirmation(
model: WidgetBasicViewModel,
sizeStyle: WidgetTileSizeStyle,
diff --git a/Sources/Extensions/Widgets/Common/WidgetTimelineProviderSupport.swift b/Sources/Extensions/Widgets/Common/WidgetTimelineProviderSupport.swift
index babd93d3aa..21b00ef274 100644
--- a/Sources/Extensions/Widgets/Common/WidgetTimelineProviderSupport.swift
+++ b/Sources/Extensions/Widgets/Common/WidgetTimelineProviderSupport.swift
@@ -34,7 +34,7 @@ struct WidgetEntitiesStateCache: Codable {
let states: [MagicItem: WidgetEntityState]
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
protocol WidgetSingleEntryTimelineProvider: AppIntentTimelineProvider {
var expiration: Measurement { get }
/// What the widget shows before it has an entry. Defaults to the gallery mock, which is what
@@ -47,7 +47,7 @@ protocol WidgetSingleEntryTimelineProvider: AppIntentTimelineProvider {
func makeTimelineEntry(for configuration: Intent, in context: Context) async -> Entry
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
extension WidgetSingleEntryTimelineProvider {
/// The gallery renders the placeholder, redacted, until the snapshot arrives. Serving the same
/// mock keeps the card from flipping from one shape to another as it loads, and keeps the
@@ -93,7 +93,7 @@ enum WidgetMagicItemInfoProvider {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEntityStateProvider {
/// How long the whole batch of state fetches gets before the entry is built from whatever
/// arrived. WidgetKit budgets timeline generation, so a request that stalls — a server that
diff --git a/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntities.swift b/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntities.swift
index 3180d8dcda..33358c93b8 100644
--- a/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntities.swift
+++ b/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntities.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCommonlyUsedEntities: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
diff --git a/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntitiesTimelineProvider.swift b/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntitiesTimelineProvider.swift
index 314755fba5..ff7b15f260 100644
--- a/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntitiesTimelineProvider.swift
+++ b/Sources/Extensions/Widgets/CommonlyUsedEntities/WidgetCommonlyUsedEntitiesTimelineProvider.swift
@@ -13,7 +13,7 @@ struct WidgetCommonlyUsedEntitiesEntry: TimelineEntry {
var serverName: String?
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCommonlyUsedEntitiesTimelineProvider: WidgetSingleEntryTimelineProvider {
typealias Entry = WidgetCommonlyUsedEntitiesEntry
typealias Intent = WidgetCommonlyUsedEntitiesAppIntent
diff --git a/Sources/Extensions/Widgets/Controls/Automation/ControlAutomation.swift b/Sources/Extensions/Widgets/Controls/Automation/ControlAutomation.swift
index 6bc1a03b54..737e862fe5 100644
--- a/Sources/Extensions/Widgets/Controls/Automation/ControlAutomation.swift
+++ b/Sources/Extensions/Widgets/Controls/Automation/ControlAutomation.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAutomation: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Automation/ControlAutomationsValueProvider.swift b/Sources/Extensions/Widgets/Controls/Automation/ControlAutomationsValueProvider.swift
index 74c65ef186..1c08e7c618 100644
--- a/Sources/Extensions/Widgets/Controls/Automation/ControlAutomationsValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Automation/ControlAutomationsValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAutomationItem {
let intentAutomationEntity: IntentAutomationEntity
let icon: SFSymbolEntity
@@ -12,7 +12,7 @@ struct ControlAutomationItem {
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlAutomationsValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlAutomationConfiguration) async throws -> ControlAutomationItem {
.init(
@@ -57,7 +57,7 @@ struct ControlAutomationsValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlAutomationConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init("widgets.automations.description", defaultValue: "Run Automation")
diff --git a/Sources/Extensions/Widgets/Controls/Button/ButtonIntent.swift b/Sources/Extensions/Widgets/Controls/Button/ButtonIntent.swift
index 4dbbdf5fd5..ac56dbc5f0 100644
--- a/Sources/Extensions/Widgets/Controls/Button/ButtonIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/Button/ButtonIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct ButtonIntent: AppIntent {
static var title: LocalizedStringResource = .init("app_intents.intent.button.title", defaultValue: "Press button")
diff --git a/Sources/Extensions/Widgets/Controls/Button/ControlButton.swift b/Sources/Extensions/Widgets/Controls/Button/ControlButton.swift
index ec26774dfb..c3b895c66c 100644
--- a/Sources/Extensions/Widgets/Controls/Button/ControlButton.swift
+++ b/Sources/Extensions/Widgets/Controls/Button/ControlButton.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlButton: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Button/ControlButtonValueProvider.swift b/Sources/Extensions/Widgets/Controls/Button/ControlButtonValueProvider.swift
index 1efaf3549a..773babed14 100644
--- a/Sources/Extensions/Widgets/Controls/Button/ControlButtonValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Button/ControlButtonValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlButtonValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlButtonConfiguration) async throws -> ControlEntityItem {
item(entity: configuration.entity, iconName: configuration.icon, displayText: configuration.displayText)
@@ -56,7 +56,7 @@ struct ControlButtonValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlButtonConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.button.title",
diff --git a/Sources/Extensions/Widgets/Controls/Button/IntentButtonEntity.swift b/Sources/Extensions/Widgets/Controls/Button/IntentButtonEntity.swift
index 788196896f..0043c007b0 100644
--- a/Sources/Extensions/Widgets/Controls/Button/IntentButtonEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/Button/IntentButtonEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentButtonEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Button")
@@ -58,9 +58,11 @@ struct IntentButtonEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentButtonAppEntityQuery: EntityQuery, EntityStringQuery {
- #if WIDGET_EXTENSION
+ // The control's configuration reached the Mac in macOS 26, later than this query, which cannot depend
+ // on a type newer than itself: on the Mac the list is always grouped by server.
+ #if WIDGET_EXTENSION && !os(macOS)
@IntentParameterDependency(\.$server)
var config
#endif
@@ -83,7 +85,7 @@ struct IntentButtonAppEntityQuery: EntityQuery, EntityStringQuery {
private func collection(
for entitiesPerServer: [(Server, [IntentButtonEntity])]
) -> IntentItemCollection {
- #if WIDGET_EXTENSION
+ #if WIDGET_EXTENSION && !os(macOS)
if let server = config?.server {
let items = entitiesPerServer.first { $0.0.identifier.rawValue == server.id }?.1 ?? []
return .init(items: items)
diff --git a/Sources/Extensions/Widgets/Controls/Cover/ControlCover.swift b/Sources/Extensions/Widgets/Controls/Cover/ControlCover.swift
index daf85f4628..2d61674538 100644
--- a/Sources/Extensions/Widgets/Controls/Cover/ControlCover.swift
+++ b/Sources/Extensions/Widgets/Controls/Cover/ControlCover.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlCover: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Cover/ControlCoverValueProvider.swift b/Sources/Extensions/Widgets/Controls/Cover/ControlCoverValueProvider.swift
index de51301f0e..3231ff4716 100644
--- a/Sources/Extensions/Widgets/Controls/Cover/ControlCoverValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Cover/ControlCoverValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlCoverValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlCoverConfiguration) async throws -> ControlEntityItem {
try await ControlRefreshDelay.wait()
@@ -81,7 +81,7 @@ struct ControlCoverValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlCoverConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.cover.description",
diff --git a/Sources/Extensions/Widgets/Controls/Cover/CoverIntent.swift b/Sources/Extensions/Widgets/Controls/Cover/CoverIntent.swift
index f0c11e8937..6ed53b52e9 100644
--- a/Sources/Extensions/Widgets/Controls/Cover/CoverIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/Cover/CoverIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct CoverIntent: SetValueIntent {
static var title: LocalizedStringResource = .init("app_intents.intent.cover.title", defaultValue: "Control cover")
diff --git a/Sources/Extensions/Widgets/Controls/Cover/IntentCoverEntity.swift b/Sources/Extensions/Widgets/Controls/Cover/IntentCoverEntity.swift
index ef44a67b17..8101a02333 100644
--- a/Sources/Extensions/Widgets/Controls/Cover/IntentCoverEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/Cover/IntentCoverEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentCoverEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Cover")
@@ -58,9 +58,11 @@ struct IntentCoverEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentCoverAppEntityQuery: EntityQuery, EntityStringQuery {
- #if WIDGET_EXTENSION
+ // The control's configuration reached the Mac in macOS 26, later than this query, which cannot depend
+ // on a type newer than itself: on the Mac the list is always grouped by server.
+ #if WIDGET_EXTENSION && !os(macOS)
@IntentParameterDependency(\.$server)
var config
#endif
@@ -83,7 +85,7 @@ struct IntentCoverAppEntityQuery: EntityQuery, EntityStringQuery {
private func collection(
for entitiesPerServer: [(Server, [IntentCoverEntity])]
) -> IntentItemCollection {
- #if WIDGET_EXTENSION
+ #if WIDGET_EXTENSION && !os(macOS)
if let server = config?.server {
let items = entitiesPerServer.first { $0.0.identifier.rawValue == server.id }?.1 ?? []
return .init(items: items)
diff --git a/Sources/Extensions/Widgets/Controls/Fan/ControlFan.swift b/Sources/Extensions/Widgets/Controls/Fan/ControlFan.swift
index 6ec991e277..0a23d909ef 100644
--- a/Sources/Extensions/Widgets/Controls/Fan/ControlFan.swift
+++ b/Sources/Extensions/Widgets/Controls/Fan/ControlFan.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlFan: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Fan/ControlFanValueProvider.swift b/Sources/Extensions/Widgets/Controls/Fan/ControlFanValueProvider.swift
index 0f7c0569cc..87416f1823 100644
--- a/Sources/Extensions/Widgets/Controls/Fan/ControlFanValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Fan/ControlFanValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlFanValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlFanConfiguration) async throws -> ControlEntityItem {
try await ControlRefreshDelay.wait()
@@ -83,7 +83,7 @@ struct ControlFanValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlFanConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.fan.description",
diff --git a/Sources/Extensions/Widgets/Controls/Fan/FanIntent.swift b/Sources/Extensions/Widgets/Controls/Fan/FanIntent.swift
index 049d56928b..a6a92401c1 100644
--- a/Sources/Extensions/Widgets/Controls/Fan/FanIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/Fan/FanIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct FanIntent: SetValueIntent {
static var title: LocalizedStringResource = .init("app_intents.intent.fan.title", defaultValue: "Control fan")
diff --git a/Sources/Extensions/Widgets/Controls/Fan/IntentFanEntity.swift b/Sources/Extensions/Widgets/Controls/Fan/IntentFanEntity.swift
index b6e6ffa735..83a9a124ec 100644
--- a/Sources/Extensions/Widgets/Controls/Fan/IntentFanEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/Fan/IntentFanEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentFanEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Fan")
@@ -58,9 +58,11 @@ struct IntentFanEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentFanAppEntityQuery: EntityQuery, EntityStringQuery {
- #if WIDGET_EXTENSION
+ // The control's configuration reached the Mac in macOS 26, later than this query, which cannot depend
+ // on a type newer than itself: on the Mac the list is always grouped by server.
+ #if WIDGET_EXTENSION && !os(macOS)
@IntentParameterDependency(\.$server)
var config
#endif
@@ -83,7 +85,7 @@ struct IntentFanAppEntityQuery: EntityQuery, EntityStringQuery {
private func collection(
for entitiesPerServer: [(Server, [IntentFanEntity])]
) -> IntentItemCollection {
- #if WIDGET_EXTENSION
+ #if WIDGET_EXTENSION && !os(macOS)
if let server = config?.server {
let items = entitiesPerServer.first { $0.0.identifier.rawValue == server.id }?.1 ?? []
return .init(items: items)
diff --git a/Sources/Extensions/Widgets/Controls/Light/ControlLight.swift b/Sources/Extensions/Widgets/Controls/Light/ControlLight.swift
index b6701943a2..68ab4ac4dd 100644
--- a/Sources/Extensions/Widgets/Controls/Light/ControlLight.swift
+++ b/Sources/Extensions/Widgets/Controls/Light/ControlLight.swift
@@ -5,7 +5,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlLight: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Light/ControlLightsValueProvider.swift b/Sources/Extensions/Widgets/Controls/Light/ControlLightsValueProvider.swift
index 8e3312e384..9b864cb69b 100644
--- a/Sources/Extensions/Widgets/Controls/Light/ControlLightsValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Light/ControlLightsValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlEntityItem {
let id: String
let entityId: String
@@ -14,7 +14,7 @@ struct ControlEntityItem {
let value: Bool
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlLightsValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlLightsConfiguration) async throws -> ControlEntityItem {
try await ControlRefreshDelay.wait()
@@ -93,7 +93,7 @@ struct ControlLightsValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlLightsConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init("widgets.lights.description", defaultValue: "Turn on/off Light")
diff --git a/Sources/Extensions/Widgets/Controls/Light/IntentLightEntity.swift b/Sources/Extensions/Widgets/Controls/Light/IntentLightEntity.swift
index 3ccad07feb..5b534f2cbf 100644
--- a/Sources/Extensions/Widgets/Controls/Light/IntentLightEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/Light/IntentLightEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentLightEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Light")
@@ -58,9 +58,11 @@ struct IntentLightEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentLightAppEntityQuery: EntityQuery, EntityStringQuery {
- #if WIDGET_EXTENSION
+ // The control's configuration reached the Mac in macOS 26, later than this query, which cannot depend
+ // on a type newer than itself: on the Mac the list is always grouped by server.
+ #if WIDGET_EXTENSION && !os(macOS)
@IntentParameterDependency(\.$server)
var config
#endif
@@ -83,7 +85,7 @@ struct IntentLightAppEntityQuery: EntityQuery, EntityStringQuery {
private func collection(
for entitiesPerServer: [(Server, [IntentLightEntity])]
) -> IntentItemCollection {
- #if WIDGET_EXTENSION
+ #if WIDGET_EXTENSION && !os(macOS)
if let server = config?.server {
let items = entitiesPerServer.first { $0.0.identifier.rawValue == server.id }?.1 ?? []
return .init(items: items)
diff --git a/Sources/Extensions/Widgets/Controls/Light/LightIntent.swift b/Sources/Extensions/Widgets/Controls/Light/LightIntent.swift
index 965681fde9..580f0fe370 100644
--- a/Sources/Extensions/Widgets/Controls/Light/LightIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/Light/LightIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct LightIntent: SetValueIntent {
static var title: LocalizedStringResource = .init("app_intents.intent.light.title", defaultValue: "Control light")
diff --git a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCamera.swift b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCamera.swift
index 81788a5b60..e43466ee10 100644
--- a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCamera.swift
+++ b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCamera.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenCamera: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCameraValueProvider.swift b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCameraValueProvider.swift
index 36f02197e4..c693a6579f 100644
--- a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCameraValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenCamera/ControlOpenCameraValueProvider.swift
@@ -4,14 +4,14 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenCameraItem {
let entity: HAAppEntityAppIntentEntity
let icon: SFSymbolEntity
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenCameraValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlOpenCameraConfiguration) async throws -> ControlOpenCameraItem {
item(configuration: configuration)
@@ -56,7 +56,7 @@ struct ControlOpenCameraValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlOpenCameraConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.open_camera.configuration.title",
@@ -79,7 +79,7 @@ struct ControlOpenCameraConfiguration: ControlConfigurationIntent {
var displayText: String?
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct CameraEntityOptionsProvider: DynamicOptionsProvider {
func results() async throws -> IntentItemCollection {
let entities = ControlEntityProvider(domains: [.camera]).getEntities()
diff --git a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntity.swift b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntity.swift
index 7866fa098b..02a2526dcf 100644
--- a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntity.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenEntity: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntityValueProvider.swift b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntityValueProvider.swift
index b6ba9b8e64..d9838ddc09 100644
--- a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntityValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/ControlOpenEntityValueProvider.swift
@@ -4,14 +4,14 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenEntityItem {
let entity: HAAppEntityAppIntentEntity
let icon: SFSymbolEntity
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenEntityValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlOpenEntityConfiguration) async throws -> ControlOpenEntityItem {
item(configuration: configuration)
@@ -56,7 +56,7 @@ struct ControlOpenEntityValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlOpenEntityConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.open_entity.configuration.title",
diff --git a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift
index 7a75a28a84..3b4245a73a 100644
--- a/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
// AppIntent that open app needs to have it's target the widget extension AND app target!
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct OpenEntityAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.open_entity.configuration.title",
@@ -13,7 +13,7 @@ struct OpenEntityAppIntent: AppIntent {
static var openAppWhenRun: Bool = true
// `openAppWhenRun` is deprecated from iOS 26; both stay until the deployment target passes 26.
- @available(iOS 26.0, watchOS 26.0, *)
+ @available(iOS 26.0, macOS 26.0, watchOS 26.0, *)
static var supportedModes: IntentModes { .foreground }
@Parameter(
diff --git a/Sources/Extensions/Widgets/Controls/Switch/ControlSwitch.swift b/Sources/Extensions/Widgets/Controls/Switch/ControlSwitch.swift
index e254fdac34..88c4fea2d4 100644
--- a/Sources/Extensions/Widgets/Controls/Switch/ControlSwitch.swift
+++ b/Sources/Extensions/Widgets/Controls/Switch/ControlSwitch.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlSwitch: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Controls/Switch/ControlSwitchValueProvider.swift b/Sources/Extensions/Widgets/Controls/Switch/ControlSwitchValueProvider.swift
index 83435bcde7..549a811713 100644
--- a/Sources/Extensions/Widgets/Controls/Switch/ControlSwitchValueProvider.swift
+++ b/Sources/Extensions/Widgets/Controls/Switch/ControlSwitchValueProvider.swift
@@ -4,7 +4,7 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlSwitchValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlSwitchConfiguration) async throws -> ControlEntityItem {
try await ControlRefreshDelay.wait()
@@ -84,7 +84,7 @@ struct ControlSwitchValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlSwitchConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.switch.description",
diff --git a/Sources/Extensions/Widgets/Controls/Switch/IntentSwitchEntity.swift b/Sources/Extensions/Widgets/Controls/Switch/IntentSwitchEntity.swift
index ff3afc06e8..85a2a2fed3 100644
--- a/Sources/Extensions/Widgets/Controls/Switch/IntentSwitchEntity.swift
+++ b/Sources/Extensions/Widgets/Controls/Switch/IntentSwitchEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentSwitchEntity: AppEntity, EntityContextRepresentable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Switch")
@@ -58,9 +58,11 @@ struct IntentSwitchEntity: AppEntity, EntityContextRepresentable {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 15.0, *)
struct IntentSwitchAppEntityQuery: EntityQuery, EntityStringQuery {
- #if WIDGET_EXTENSION
+ // The control's configuration reached the Mac in macOS 26, later than this query, which cannot depend
+ // on a type newer than itself: on the Mac the list is always grouped by server.
+ #if WIDGET_EXTENSION && !os(macOS)
@IntentParameterDependency(\.$server)
var config
#endif
@@ -83,7 +85,7 @@ struct IntentSwitchAppEntityQuery: EntityQuery, EntityStringQuery {
private func collection(
for entitiesPerServer: [(Server, [IntentSwitchEntity])]
) -> IntentItemCollection {
- #if WIDGET_EXTENSION
+ #if WIDGET_EXTENSION && !os(macOS)
if let server = config?.server {
let items = entitiesPerServer.first { $0.0.identifier.rawValue == server.id }?.1 ?? []
return .init(items: items)
diff --git a/Sources/Extensions/Widgets/Controls/Switch/SwitchIntent.swift b/Sources/Extensions/Widgets/Controls/Switch/SwitchIntent.swift
index b57b5ed8ac..09f74aec7b 100644
--- a/Sources/Extensions/Widgets/Controls/Switch/SwitchIntent.swift
+++ b/Sources/Extensions/Widgets/Controls/Switch/SwitchIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct SwitchIntent: SetValueIntent {
static var title: LocalizedStringResource = .init("app_intents.intent.switch.title", defaultValue: "Control switch")
diff --git a/Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetPerformActionAppIntent.swift b/Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetPerformActionAppIntent.swift
index f91c891862..a19607f656 100644
--- a/Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetPerformActionAppIntent.swift
+++ b/Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetPerformActionAppIntent.swift
@@ -9,7 +9,7 @@ import SwiftUI
/// Shortcuts action — already does, transport choice included, so this hands the item's stored
/// action over to it and only adds what a tile needs around the call: haptics, a notification when
/// it fails, and clearing the tile's pending confirmation afterwards.
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct CustomWidgetPerformActionAppIntent: AppIntent {
static var title: LocalizedStringResource = "Perform action"
static var isDiscoverable: Bool = false
diff --git a/Sources/Extensions/Widgets/Custom/WidgetCustom.swift b/Sources/Extensions/Widgets/Custom/WidgetCustom.swift
index ab5ae9f7c0..33ccdfb67c 100644
--- a/Sources/Extensions/Widgets/Custom/WidgetCustom.swift
+++ b/Sources/Extensions/Widgets/Custom/WidgetCustom.swift
@@ -5,7 +5,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCustom: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -161,7 +161,7 @@ enum WidgetCustomSupportedFamilies {
// UNCOMMENT ON DEMAND: If all previews are uncommented, Xcode will, most probably, fail to render them all at once.
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Small empty", as: .systemSmall) {
// WidgetCustom()
// } timeline: {
@@ -175,7 +175,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Small 1 Item", as: .systemSmall) {
// WidgetCustom()
// } timeline: {
@@ -191,7 +191,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Small 2 Items", as: .systemSmall) {
// WidgetCustom()
// } timeline: {
@@ -208,7 +208,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Small 3 Items", as: .systemSmall) {
// WidgetCustom()
// } timeline: {
@@ -226,7 +226,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 1 item", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -242,7 +242,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 2 items", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -259,7 +259,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 3 items", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -277,7 +277,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 4 items", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -296,7 +296,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 5 items", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -316,7 +316,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium 6 items", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -337,7 +337,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Medium empty", as: .systemMedium) {
// WidgetCustom()
// } timeline: {
@@ -351,7 +351,7 @@ enum WidgetCustomSupportedFamilies {
// )
// }
//
-// @available(iOS 17, *)
+// @available(iOS 17, macOS 14, *)
// #Preview("Large empty", as: .systemLarge) {
// WidgetCustom()
// } timeline: {
diff --git a/Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift b/Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift
index 355d1c8955..02695df1d1 100644
--- a/Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift
+++ b/Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift
@@ -13,7 +13,7 @@ struct WidgetCustomEntry: TimelineEntry {
var showStates: Bool
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetCustomTimelineProvider: WidgetSingleEntryTimelineProvider {
typealias Entry = WidgetCustomEntry
typealias Intent = WidgetCustomAppIntent
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergy.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergy.swift
index 32187650e1..ce766b7d4b 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergy.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergy.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergy: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -23,23 +23,34 @@ struct WidgetEnergy: Widget {
}
enum WidgetEnergySupportedFamilies {
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
static var families: [WidgetFamily] {
- [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [
+ #if os(macOS)
+ // A Mac has no lock screen for the accessory families to appear on.
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges
+ #else
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
]
+ #endif
}
/// Families that lead with a single headline figure, where the instantaneous power reads better
/// than the period total. Resolving it costs one REST call per power sensor, so the families
/// that show the chart and totals instead skip it.
- @available(iOS 17.0, *)
- static let livePowerFamilies: [WidgetFamily] = [
- .systemSmall,
- .accessoryCircular,
- .accessoryRectangular,
- .accessoryInline,
- ]
+ @available(iOS 17.0, macOS 14.0, *)
+ static let livePowerFamilies: [WidgetFamily] = {
+ #if os(macOS)
+ return [.systemSmall]
+ #else
+ return [
+ .systemSmall,
+ .accessoryCircular,
+ .accessoryRectangular,
+ .accessoryInline,
+ ]
+ #endif
+ }()
}
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryCircularView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryCircularView.swift
index 11d2b90d20..8e74a7e270 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryCircularView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryCircularView.swift
@@ -5,7 +5,7 @@ import WidgetKit
/// Lock screen circular layout. A circular accessory only has room for one figure, so it shows the
/// headline series — the grid flow when the source preference includes it and the server reports
/// it, otherwise whichever series the home does have.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyAccessoryCircularView: View {
let entry: WidgetEnergyEntry
@@ -23,7 +23,9 @@ struct WidgetEnergyAccessoryCircularView: View {
}
}
-@available(iOS 17, *)
+// The accessory families do not exist on the Mac, so there is nothing to preview the layout in there.
+#if !os(macOS)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryCircular) {
WidgetEnergy()
} timeline: {
@@ -31,3 +33,4 @@ struct WidgetEnergyAccessoryCircularView: View {
WidgetEnergyEntry(isConfigured: true, solarGenerated: 12.4, livePowerSolar: 1450)
WidgetEnergyEntry(period: .today, isConfigured: false)
}
+#endif
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryInlineView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryInlineView.swift
index c17e0096b8..35ca5950bf 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryInlineView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryInlineView.swift
@@ -3,7 +3,7 @@ import SwiftUI
import WidgetKit
/// Lock screen inline layout: a single line combining the configured series, e.g. "↑12,4 ↓6,2 kWh".
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyAccessoryInlineView: View {
let entry: WidgetEnergyEntry
@@ -22,7 +22,9 @@ struct WidgetEnergyAccessoryInlineView: View {
}
}
-@available(iOS 17, *)
+// The accessory families do not exist on the Mac, so there is nothing to preview the layout in there.
+#if !os(macOS)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryInline) {
WidgetEnergy()
} timeline: {
@@ -35,3 +37,4 @@ struct WidgetEnergyAccessoryInlineView: View {
WidgetEnergyEntry(period: .today, isConfigured: false)
WidgetEnergyEntry(period: .today, isConfigured: false, noConnection: true)
}
+#endif
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryRectangularView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryRectangularView.swift
index 60cd5f1600..6755acfc9a 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryRectangularView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyAccessoryRectangularView.swift
@@ -3,7 +3,7 @@ import SwiftUI
import WidgetKit
/// Lock screen rectangular layout: the period title, then one row per configured energy series.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyAccessoryRectangularView: View {
let entry: WidgetEnergyEntry
@@ -17,7 +17,9 @@ struct WidgetEnergyAccessoryRectangularView: View {
}
}
-@available(iOS 17, *)
+// The accessory families do not exist on the Mac, so there is nothing to preview the layout in there.
+#if !os(macOS)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryRectangular) {
WidgetEnergy()
} timeline: {
@@ -36,3 +38,4 @@ struct WidgetEnergyAccessoryRectangularView: View {
WidgetEnergyEntry(period: .today, isConfigured: false)
WidgetEnergyEntry(period: .today, isConfigured: false, noConnection: true)
}
+#endif
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyControls.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyControls.swift
index bc419b52d4..21f8deae39 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyControls.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyControls.swift
@@ -6,7 +6,7 @@ import SwiftUI
///
/// The design system draws the period and the timestamp; both are the same control — a tap on either
/// reloads the widget's timeline — so this is what wraps them.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
enum WidgetEnergyControls {
/// Wraps the period label. The action, not the period, is what the control does, so that is what
/// VoiceOver announces.
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyMediumView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyMediumView.swift
index 29d3611328..4386e35de3 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyMediumView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyMediumView.swift
@@ -3,7 +3,7 @@ import SwiftUI
import WidgetKit
/// Medium/large layout: period totals for solar and grid, monetary cost, and the net-grid chart.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyMediumView: View {
let entry: WidgetEnergyEntry
@@ -45,7 +45,7 @@ struct WidgetEnergyMediumView: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemMedium) {
WidgetEnergy()
} timeline: {
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyMetric.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyMetric.swift
index ec6aff8e04..b38e516f3e 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyMetric.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyMetric.swift
@@ -8,7 +8,7 @@ import SwiftUI
/// Shared by every home screen and lock screen layout, so they all derive the same numbers from an
/// entry — and drop the same series when the server doesn't report one. The wide cards ask for
/// `Figure.totals`, which pins them to the period's energy regardless of live power.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyMetric: Identifiable, Equatable {
/// Which energy series the figure describes. Carries the presentation that never varies with
/// the entry: icon, label and colour.
@@ -253,7 +253,7 @@ struct WidgetEnergyMetric: Identifiable, Equatable {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
extension WidgetEnergyMetric {
/// The drawing half of the metric, for the design system's energy components.
///
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyRefreshAppIntent.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyRefreshAppIntent.swift
index f44e916f4b..70d5ed4e46 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyRefreshAppIntent.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyRefreshAppIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetEnergyRefreshAppIntent: AppIntent {
static var title: LocalizedStringResource = .init(
"widgets.energy.refresh_title",
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergySmallView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergySmallView.swift
index ae510c37c6..4f43792926 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergySmallView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergySmallView.swift
@@ -4,7 +4,7 @@ import WidgetKit
/// Compact card showing solar generation and grid flow. Prefers live instantaneous power (W) when
/// power sensors are configured, otherwise falls back to the period's energy totals (kWh).
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergySmallView: View {
let entry: WidgetEnergyEntry
@@ -22,7 +22,7 @@ struct WidgetEnergySmallView: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall) {
WidgetEnergy()
} timeline: {
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyStyle.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyStyle.swift
index 4791bb7bc3..6c253a3a28 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyStyle.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyStyle.swift
@@ -51,7 +51,7 @@ enum WidgetEnergyStyle {
/// home screen card does: a server the app has no URL to reach points at the URL configuration,
/// an entry that was never set up says so, and one that is configured but came back without
/// usable data — or failed to load — reports missing data instead.
- @available(iOS 17, *)
+ @available(iOS 17, macOS 14, *)
static func emptyStateText(for entry: WidgetEnergyEntry) -> String {
if entry.noConnection {
return L10n.Widgets.Energy.noConnection
diff --git a/Sources/Extensions/Widgets/Energy/WidgetEnergyView.swift b/Sources/Extensions/Widgets/Energy/WidgetEnergyView.swift
index 9919b64790..783093d448 100644
--- a/Sources/Extensions/Widgets/Energy/WidgetEnergyView.swift
+++ b/Sources/Extensions/Widgets/Energy/WidgetEnergyView.swift
@@ -3,7 +3,7 @@ import SwiftUI
import WidgetKit
/// Routes the Energy widget entry to the layout for the current widget family.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEnergyView: View {
@Environment(\.widgetFamily) private var family
@@ -13,12 +13,14 @@ struct WidgetEnergyView: View {
// The accessory families own their empty state: they render on the lock screen, where the
// home screen card's background and prose don't fit.
switch family {
+ #if !os(macOS)
case .accessoryCircular:
WidgetEnergyAccessoryCircularView(entry: entry)
case .accessoryRectangular:
WidgetEnergyAccessoryRectangularView(entry: entry)
case .accessoryInline:
WidgetEnergyAccessoryInlineView(entry: entry)
+ #endif
default:
// A configured entry always draws its layout, even with nothing to show yet: the figures
// and the chart come back empty rather than being replaced by prose. Only an entry the
@@ -58,7 +60,7 @@ struct WidgetEnergyView: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall) {
WidgetEnergy()
} timeline: {
@@ -72,7 +74,7 @@ struct WidgetEnergyView: View {
WidgetEnergyEntry(period: .today, isConfigured: true)
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Empty medium", as: .systemMedium) {
WidgetEnergy()
} timeline: {
diff --git a/Sources/Extensions/Widgets/Entities/WidgetEntities.swift b/Sources/Extensions/Widgets/Entities/WidgetEntities.swift
index e97e38900d..c77a4b4905 100644
--- a/Sources/Extensions/Widgets/Entities/WidgetEntities.swift
+++ b/Sources/Extensions/Widgets/Entities/WidgetEntities.swift
@@ -11,7 +11,7 @@ import WidgetKit
/// rest of the tile opens the entity in the app. Entities with no single main action — sensors,
/// locks, media players — open in the app from both halves. States are always shown; the only
/// display option the configuration offers is the last update time.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEntities: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
diff --git a/Sources/Extensions/Widgets/Entities/WidgetEntitiesTimelineProvider.swift b/Sources/Extensions/Widgets/Entities/WidgetEntitiesTimelineProvider.swift
index d87e0762b0..f6173451db 100644
--- a/Sources/Extensions/Widgets/Entities/WidgetEntitiesTimelineProvider.swift
+++ b/Sources/Extensions/Widgets/Entities/WidgetEntitiesTimelineProvider.swift
@@ -3,7 +3,7 @@ import CryptoKit
import Shared
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetEntitiesTimelineProvider: WidgetSingleEntryTimelineProvider {
typealias Entry = WidgetEntitiesEntry
typealias Intent = WidgetEntitiesAppIntent
diff --git a/Sources/Extensions/Widgets/EntityIconRenderer.swift b/Sources/Extensions/Widgets/EntityIconRenderer.swift
index 636eeba3cb..f2a339267e 100644
--- a/Sources/Extensions/Widgets/EntityIconRenderer.swift
+++ b/Sources/Extensions/Widgets/EntityIconRenderer.swift
@@ -1,6 +1,10 @@
import Foundation
import Shared
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Renders the Material Design icon an entity resolves to as PNG data, for Spotlight results and
/// for the entity's App Intents display representation.
diff --git a/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetails.swift b/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetails.swift
index 6fd1d9d5da..dae1cd81b5 100644
--- a/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetails.swift
+++ b/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetails.swift
@@ -1,3 +1,6 @@
+// The details widget exists only in the lock screen's accessory families, which a Mac does not have, so the
+// widget and everything that configures and feeds it are left out of the Mac build.
+#if !os(macOS)
import HAWatchComplications
import Intents
import Shared
@@ -73,3 +76,4 @@ enum WidgetDetailsSupportedFamilies {
showConfirmationNotification: true
)
})
+#endif
diff --git a/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetailsView.swift b/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetailsView.swift
index 1763afd5d4..833cc60ad3 100644
--- a/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetailsView.swift
+++ b/Sources/Extensions/Widgets/Lockscreen/Details/WidgetDetailsView.swift
@@ -1,3 +1,6 @@
+// The details widget exists only in the lock screen's accessory families, which a Mac does not have, so the
+// widget and everything that configures and feeds it are left out of the Mac build.
+#if !os(macOS)
import HAWatchComplications
import Shared
import SwiftUI
@@ -52,3 +55,4 @@ struct WidgetDetailsView: View {
showConfirmationNotification: true
)
})
+#endif
diff --git a/Sources/Extensions/Widgets/Lockscreen/Gauge/GaugeTypeAppEnum+DesignSystem.swift b/Sources/Extensions/Widgets/Lockscreen/Gauge/GaugeTypeAppEnum+DesignSystem.swift
index 8f6184b823..38bb377cf6 100644
--- a/Sources/Extensions/Widgets/Lockscreen/Gauge/GaugeTypeAppEnum+DesignSystem.swift
+++ b/Sources/Extensions/Widgets/Lockscreen/Gauge/GaugeTypeAppEnum+DesignSystem.swift
@@ -1,7 +1,7 @@
import Foundation
import Shared
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
extension GaugeTypeAppEnum {
/// The design system's own gauge shapes, which is what its gauge components are built around.
var designSystemType: WidgetGaugeType {
diff --git a/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGauge.swift b/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGauge.swift
index e905236792..af34b56d61 100644
--- a/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGauge.swift
+++ b/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGauge.swift
@@ -1,10 +1,12 @@
+#if !os(macOS)
import HAWatchComplications
+#endif
import Intents
import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetGauge: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -39,12 +41,17 @@ struct WidgetGauge: Widget {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
enum WidgetGaugeSupportedFamilies {
+ #if os(macOS)
+ /// A Mac has no lock screen, so the gauge is offered on the desktop and in Notification Center only.
+ static let families: [WidgetFamily] = [.systemSmall]
+ #else
static let families: [WidgetFamily] = [.accessoryCircular, .systemSmall]
+ #endif
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetGauge()
}, timeline: {
@@ -61,7 +68,7 @@ enum WidgetGaugeSupportedFamilies {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetGauge()
}, timeline: {
@@ -78,7 +85,8 @@ enum WidgetGaugeSupportedFamilies {
)
})
-@available(iOS 17, *)
+#if !os(macOS)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryCircular, widget: {
WidgetGauge()
}, timeline: {
@@ -97,7 +105,7 @@ enum WidgetGaugeSupportedFamilies {
// A mirrored watch complication: the entry carries the render model and the widget draws it through
// the shared circular complication content view.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryCircular, widget: {
WidgetGauge()
}, timeline: {
@@ -120,3 +128,4 @@ enum WidgetGaugeSupportedFamilies {
showConfirmationNotification: true
)
})
+#endif
diff --git a/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGaugeView.swift b/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGaugeView.swift
index bd01745537..f87ec22ae9 100644
--- a/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGaugeView.swift
+++ b/Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGaugeView.swift
@@ -1,9 +1,11 @@
+#if !os(macOS)
import HAWatchComplications
+#endif
import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetGaugeView: View {
@Environment(\.widgetFamily) private var family
var entry: WidgetGaugeEntry
@@ -12,6 +14,19 @@ struct WidgetGaugeView: View {
private static let systemSmallPadding: CGFloat = 10
var body: some View {
+ #if os(macOS)
+ // No complication is mirrored on the Mac, so an entry is always drawn as the widget's own gauge.
+ WidgetGaugeContentView(
+ gaugeType: entry.gaugeType.designSystemType,
+ value: entry.value,
+ valueLabel: entry.valueLabel,
+ label: entry.label,
+ min: entry.min,
+ max: entry.max,
+ family: family,
+ logo: Image(.logo)
+ )
+ #else
// A mirrored complication renders through the very same content view the watch and the
// complication editor use, so it carries its own gauge style, colors and slots.
if let model = entry.complicationModel {
@@ -28,8 +43,10 @@ struct WidgetGaugeView: View {
logo: Image(.logo)
)
}
+ #endif
}
+ #if !os(macOS)
/// The complication content view. The lock screen renders it bare, the way the watch face does.
/// The Home Screen is full-color, so the complication gets the black face it was designed for —
/// its white text and icon would otherwise be invisible on a light tile.
@@ -45,9 +62,10 @@ struct WidgetGaugeView: View {
CircularComplicationContentView(model: model)
}
}
+ #endif
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetGauge()
}, timeline: {
@@ -64,7 +82,7 @@ struct WidgetGaugeView: View {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetGauge()
}, timeline: {
@@ -81,7 +99,7 @@ struct WidgetGaugeView: View {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetGauge()
}, timeline: {
@@ -98,7 +116,8 @@ struct WidgetGaugeView: View {
)
})
-@available(iOS 17, *)
+#if !os(macOS)
+@available(iOS 17, macOS 14, *)
#Preview(as: .accessoryCircular, widget: {
WidgetGauge()
}, timeline: {
@@ -114,3 +133,4 @@ struct WidgetGaugeView: View {
showConfirmationNotification: true
)
})
+#endif
diff --git a/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPage.swift b/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPage.swift
index 7dce8b798b..73423ee498 100644
--- a/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPage.swift
+++ b/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPage.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenPage: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPageValueProvider.swift b/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPageValueProvider.swift
index 23bd99ecda..cf6e895317 100644
--- a/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPageValueProvider.swift
+++ b/Sources/Extensions/Widgets/OpenPage/Control/ControlOpenPageValueProvider.swift
@@ -4,14 +4,14 @@ import SFSafeSymbols
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenPageItem {
let page: PageAppEntity
let icon: SFSymbolEntity
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlOpenPageValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlOpenPageConfiguration) async throws -> ControlOpenPageItem {
item(configuration: configuration)
@@ -52,7 +52,7 @@ struct ControlOpenPageValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlOpenPageConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init(
"widgets.controls.open_page.configuration.title",
diff --git a/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift b/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift
index 2f54b1647e..5de19ebede 100644
--- a/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift
+++ b/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
// AppIntent that open app needs to have it's target the widget extension AND app target!
-@available(iOS 18, *)
+@available(iOS 18, macOS 15, *)
struct OpenPageAppIntent: AppIntent, CustomIntentMigratedAppIntent {
// Carries over shortcuts built with the deprecated SiriKit OpenPageIntent
static let intentClassName = "OpenPageIntent"
@@ -16,7 +16,7 @@ struct OpenPageAppIntent: AppIntent, CustomIntentMigratedAppIntent {
static var openAppWhenRun: Bool = true
// `openAppWhenRun` is deprecated from iOS 26; both stay until the deployment target passes 26.
- @available(iOS 26.0, watchOS 26.0, *)
+ @available(iOS 26.0, macOS 26.0, watchOS 26.0, *)
static var supportedModes: IntentModes { .foreground }
@Parameter(
diff --git a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift
index a01a7cde7b..c7ef5adc25 100644
--- a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift
+++ b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetOpenPage: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -62,6 +62,11 @@ struct WidgetOpenPage: Widget {
enum WidgetOpenPageSupportedFamilies {
static var families: [WidgetFamily] {
- [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [.accessoryCircular]
+ #if os(macOS)
+ // A Mac has no lock screen for the accessory family to appear on.
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges
+ #else
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [.accessoryCircular]
+ #endif
}
}
diff --git a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageAppIntent.swift b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageAppIntent.swift
index 864ac64a2f..a35d21a319 100644
--- a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageAppIntent.swift
+++ b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageAppIntent.swift
@@ -2,7 +2,7 @@ import AppIntents
import Foundation
import Shared
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetOpenPageAppIntent: WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
// Carries over configurations from the deprecated SiriKit widget intent
static let intentClassName = "WidgetOpenPageIntent"
diff --git a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageProvider.swift b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageProvider.swift
index 0a59b968a4..5900ea59e3 100644
--- a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageProvider.swift
+++ b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPageProvider.swift
@@ -11,7 +11,7 @@ struct WidgetOpenPageEntry: TimelineEntry {
var isPreview = false
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetOpenPageProvider: AppIntentTimelineProvider {
typealias Entry = WidgetOpenPageEntry
typealias Intent = WidgetOpenPageAppIntent
diff --git a/Sources/Extensions/Widgets/Scene/Control/ControlScene.swift b/Sources/Extensions/Widgets/Scene/Control/ControlScene.swift
index 5702ee7bad..6d4c76b8b1 100644
--- a/Sources/Extensions/Widgets/Scene/Control/ControlScene.swift
+++ b/Sources/Extensions/Widgets/Scene/Control/ControlScene.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlScene: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Scene/Control/ControlScenesValueProvider.swift b/Sources/Extensions/Widgets/Scene/Control/ControlScenesValueProvider.swift
index 59b9f2c24b..7a7ac7d6e4 100644
--- a/Sources/Extensions/Widgets/Scene/Control/ControlScenesValueProvider.swift
+++ b/Sources/Extensions/Widgets/Scene/Control/ControlScenesValueProvider.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlSceneItem {
let intentSceneEntity: IntentSceneEntity
let icon: SFSymbolEntity
@@ -11,7 +11,7 @@ struct ControlSceneItem {
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlScenesValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlSceneConfiguration) async throws -> ControlSceneItem {
.init(
@@ -56,7 +56,7 @@ struct ControlScenesValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlSceneConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init("widgets.scripts.description", defaultValue: "Run Scene")
diff --git a/Sources/Extensions/Widgets/Script/Control/ControlScript.swift b/Sources/Extensions/Widgets/Script/Control/ControlScript.swift
index e71a7e1598..9c83adabd7 100644
--- a/Sources/Extensions/Widgets/Script/Control/ControlScript.swift
+++ b/Sources/Extensions/Widgets/Script/Control/ControlScript.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlScript: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
diff --git a/Sources/Extensions/Widgets/Script/Control/ControlScriptsValueProvider.swift b/Sources/Extensions/Widgets/Script/Control/ControlScriptsValueProvider.swift
index adab77d3ce..94bb01b22a 100644
--- a/Sources/Extensions/Widgets/Script/Control/ControlScriptsValueProvider.swift
+++ b/Sources/Extensions/Widgets/Script/Control/ControlScriptsValueProvider.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlScriptItem {
let intentScriptEntity: IntentScriptEntity
let icon: SFSymbolEntity
@@ -11,7 +11,7 @@ struct ControlScriptItem {
let displayText: String?
}
-@available(iOS 18, *)
+@available(iOS 18, macOS 26, *)
struct ControlScriptsValueProvider: AppIntentControlValueProvider {
func currentValue(configuration: ControlScriptsConfiguration) async throws -> ControlScriptItem {
.init(
@@ -56,7 +56,7 @@ struct ControlScriptsValueProvider: AppIntentControlValueProvider {
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct ControlScriptsConfiguration: ControlConfigurationIntent {
static var title: LocalizedStringResource = .init("widgets.scripts.description", defaultValue: "Run Script")
diff --git a/Sources/Extensions/Widgets/Script/WidgetScripts.swift b/Sources/Extensions/Widgets/Script/WidgetScripts.swift
index 9404ecc554..d91f685cb4 100644
--- a/Sources/Extensions/Widgets/Script/WidgetScripts.swift
+++ b/Sources/Extensions/Widgets/Script/WidgetScripts.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetScripts: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -48,6 +48,11 @@ struct WidgetScripts: Widget {
enum WidgetScriptsSupportedFamilies {
static var families: [WidgetFamily] {
- [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [.accessoryCircular]
+ #if os(macOS)
+ // A Mac has no lock screen for the accessory family to appear on.
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges
+ #else
+ return [.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges + [.accessoryCircular]
+ #endif
}
}
diff --git a/Sources/Extensions/Widgets/Sensor/WidgetSensors.swift b/Sources/Extensions/Widgets/Sensor/WidgetSensors.swift
index 1028bb69fb..86c5f5e199 100644
--- a/Sources/Extensions/Widgets/Sensor/WidgetSensors.swift
+++ b/Sources/Extensions/Widgets/Sensor/WidgetSensors.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetSensors: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -57,7 +57,7 @@ struct WidgetSensors: Widget {
}
enum WidgetDetailsTableSupportedFamilies {
- @available(iOS 17.0, *)
+ @available(iOS 17.0, macOS 14.0, *)
static var families: [WidgetFamily] {
[.systemSmall, .systemMedium, .systemLarge] + WidgetFamily.extraLarges
}
diff --git a/Sources/Extensions/Widgets/TodoList/TodoItemCompleteAppIntent.swift b/Sources/Extensions/Widgets/TodoList/TodoItemCompleteAppIntent.swift
index fb1e42b7e2..9d5d8f95f4 100644
--- a/Sources/Extensions/Widgets/TodoList/TodoItemCompleteAppIntent.swift
+++ b/Sources/Extensions/Widgets/TodoList/TodoItemCompleteAppIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct TodoItemCompleteAppIntent: AppIntent {
static var title: LocalizedStringResource = "widgets.todo_list.complete_item_title"
static var isDiscoverable: Bool = false
diff --git a/Sources/Extensions/Widgets/TodoList/TodoListAppEntity.swift b/Sources/Extensions/Widgets/TodoList/TodoListAppEntity.swift
index 88dde7f8f8..eb529c50e8 100644
--- a/Sources/Extensions/Widgets/TodoList/TodoListAppEntity.swift
+++ b/Sources/Extensions/Widgets/TodoList/TodoListAppEntity.swift
@@ -4,7 +4,7 @@ import GRDB
import SFSafeSymbols
import Shared
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct TodoListAppEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "widgets.todo_list.title")
@@ -38,7 +38,7 @@ struct TodoListAppEntity: AppEntity {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct TodoListAppEntityQuery: EntityQuery, EntityStringQuery {
@IntentParameterDependency(\.$server)
var requirement
diff --git a/Sources/Extensions/Widgets/TodoList/TodoListRefreshAppIntent.swift b/Sources/Extensions/Widgets/TodoList/TodoListRefreshAppIntent.swift
index 6825349a9e..4c2211dace 100644
--- a/Sources/Extensions/Widgets/TodoList/TodoListRefreshAppIntent.swift
+++ b/Sources/Extensions/Widgets/TodoList/TodoListRefreshAppIntent.swift
@@ -3,7 +3,7 @@ import Foundation
import Shared
import WidgetKit
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct TodoListRefreshAppIntent: AppIntent {
static var title: LocalizedStringResource = "widgets.todo_list.refresh_title"
static var isDiscoverable: Bool = false
diff --git a/Sources/Extensions/Widgets/TodoList/WidgetTodoList.swift b/Sources/Extensions/Widgets/TodoList/WidgetTodoList.swift
index 8281652155..53113e3b07 100644
--- a/Sources/Extensions/Widgets/TodoList/WidgetTodoList.swift
+++ b/Sources/Extensions/Widgets/TodoList/WidgetTodoList.swift
@@ -4,7 +4,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetTodoList: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
@@ -30,7 +30,7 @@ struct WidgetTodoList: Widget {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemMedium, widget: {
WidgetTodoList()
}, timeline: {
@@ -60,7 +60,7 @@ struct WidgetTodoList: Widget {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetTodoList()
}, timeline: {
@@ -90,7 +90,7 @@ struct WidgetTodoList: Widget {
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemLarge, widget: {
WidgetTodoList()
}, timeline: {
@@ -119,7 +119,7 @@ struct WidgetTodoList: Widget {
family: .systemMedium
)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview(as: .systemSmall, widget: {
WidgetTodoList()
}, timeline: {
diff --git a/Sources/Extensions/Widgets/TodoList/WidgetTodoListProvider.swift b/Sources/Extensions/Widgets/TodoList/WidgetTodoListProvider.swift
index 5647bb5983..2977b1fc1d 100644
--- a/Sources/Extensions/Widgets/TodoList/WidgetTodoListProvider.swift
+++ b/Sources/Extensions/Widgets/TodoList/WidgetTodoListProvider.swift
@@ -18,7 +18,7 @@ enum WidgetTodoListAppIntentTimelineProviderError: Error {
case noListSelected
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetTodoListAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetTodoListEntry
typealias Intent = WidgetTodoListAppIntent
diff --git a/Sources/Extensions/Widgets/TodoList/WidgetTodoListView.swift b/Sources/Extensions/Widgets/TodoList/WidgetTodoListView.swift
index 765ca15e00..9b6bc4deb6 100644
--- a/Sources/Extensions/Widgets/TodoList/WidgetTodoListView.swift
+++ b/Sources/Extensions/Widgets/TodoList/WidgetTodoListView.swift
@@ -3,7 +3,7 @@ import Shared
import SwiftUI
import WidgetKit
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
struct WidgetTodoListView: View {
@Environment(\.widgetFamily) private var widgetFamily
private static let minuteFormatter: DateComponentsFormatter = {
@@ -151,7 +151,7 @@ struct WidgetTodoListView: View {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
private enum WidgetTodoListPreviewSample {
/// A mix of the three row shapes: no due date, one still to come, and one already past — which
/// is the only one drawn in orange.
@@ -189,14 +189,14 @@ private enum WidgetTodoListPreviewSample {
}
}
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Medium", as: .systemMedium, widget: {
WidgetTodoList()
}, timeline: {
WidgetTodoListPreviewSample.entry(family: .systemMedium)
})
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Small", as: .systemSmall, widget: {
WidgetTodoList()
}, timeline: {
@@ -204,7 +204,7 @@ private enum WidgetTodoListPreviewSample {
})
// No list picked yet: the prompt rather than the list.
-@available(iOS 17, *)
+@available(iOS 17, macOS 14, *)
#Preview("Not configured", as: .systemMedium, widget: {
WidgetTodoList()
}, timeline: {
diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift
index d3362d4a11..f6eb3613c9 100644
--- a/Sources/Extensions/Widgets/Widgets.swift
+++ b/Sources/Extensions/Widgets/Widgets.swift
@@ -5,7 +5,8 @@ import WidgetKit
@main
enum WidgetLauncher {
static func main() {
- if #available(iOSApplicationExtension 18.0, *) {
+ // Control Center controls, which the newer bundle adds, reached the Mac in macOS 26.
+ if #available(iOSApplicationExtension 18.0, macOSApplicationExtension 26.0, *) {
WidgetsBundle18.main()
} else {
WidgetsBundle17.main()
@@ -13,7 +14,7 @@ enum WidgetLauncher {
}
}
-@available(iOS 17.0, *)
+@available(iOS 17.0, macOS 14.0, *)
struct WidgetsBundle17: WidgetBundle {
init() {
MaterialDesignIcons.register()
@@ -35,13 +36,15 @@ struct WidgetsBundle17: WidgetBundle {
WidgetTodoList()
WidgetAssist()
WidgetGauge()
+ #if !os(macOS)
WidgetDetails()
+ #endif
WidgetSensors()
WidgetScripts()
}
}
-@available(iOS 18.0, *)
+@available(iOS 18.0, macOS 26.0, *)
struct WidgetsBundle18: WidgetBundle {
init() {
MaterialDesignIcons.register()
@@ -76,7 +79,9 @@ struct WidgetsBundle18: WidgetBundle {
WidgetTodoList()
WidgetAssist()
WidgetGauge()
+ #if !os(macOS)
WidgetDetails()
+ #endif
WidgetSensors()
WidgetScripts()
}
diff --git a/Sources/HADesignSystem/Package.swift b/Sources/HADesignSystem/Package.swift
index a443ad7dbc..b43e2d3412 100644
--- a/Sources/HADesignSystem/Package.swift
+++ b/Sources/HADesignSystem/Package.swift
@@ -5,6 +5,7 @@ let package = Package(
name: "HADesignSystem",
platforms: [
.iOS(.v16),
+ .macOS(.v13),
.watchOS(.v9),
],
products: [
@@ -22,7 +23,7 @@ let package = Package(
),
.testTarget(
name: "HADesignSystemTests",
- dependencies: ["HADesignSystem"],
+ dependencies: ["HADesignSystem", "HAIconic"],
path: "Tests"
),
]
diff --git a/Sources/HADesignSystem/Sources/Colors/Color+Hex.swift b/Sources/HADesignSystem/Sources/Colors/Color+Hex.swift
index 09d5ee4fe2..c4f90af5cb 100644
--- a/Sources/HADesignSystem/Sources/Colors/Color+Hex.swift
+++ b/Sources/HADesignSystem/Sources/Colors/Color+Hex.swift
@@ -1,6 +1,5 @@
import Foundation
import SwiftUI
-import UIKit
/// Builds a colour from the hex notation the palette is written in.
///
@@ -71,19 +70,17 @@ public extension UIColor {
}
convenience init(_ rgba: String, defaultColor: UIColor = .clear) {
- if let color = UIColor(rgbaString: rgba) {
- self.init(cgColor: color.cgColor)
- } else {
- self.init(cgColor: defaultColor.cgColor)
- }
+ let color = UIColor(rgbaString: rgba) ?? defaultColor
+ #if os(macOS)
+ // `NSColor(cgColor:)` is failable, where going through Core Image is not.
+ self.init(ciColor: CIColor(cgColor: color.cgColor))
+ #else
+ self.init(cgColor: color.cgColor)
+ #endif
}
func hexString(_ includeAlpha: Bool = true) -> String {
- var red: CGFloat = 0
- var green: CGFloat = 0
- var blue: CGFloat = 0
- var alpha: CGFloat = 0
- guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return "" }
+ guard let (red, green, blue, alpha) = rgbaComponents else { return "" }
guard red >= 0, red <= 1, green >= 0, green <= 1, blue >= 0, blue <= 1 else {
return ""
diff --git a/Sources/HADesignSystem/Sources/Colors/Color+Semantic.swift b/Sources/HADesignSystem/Sources/Colors/Color+Semantic.swift
index 6d5aa88973..fb7812ecbe 100644
--- a/Sources/HADesignSystem/Sources/Colors/Color+Semantic.swift
+++ b/Sources/HADesignSystem/Sources/Colors/Color+Semantic.swift
@@ -1,7 +1,5 @@
+import HAIconic
import SwiftUI
-#if canImport(UIKit)
-import UIKit
-#endif
public extension ShapeStyle where Self == Color {
static var haPrimary: Color { srgb(0x00, 0x9A, 0xC7, opacity: 1) }
@@ -141,17 +139,11 @@ private func displayP3(_ red: Double, _ green: Double, _ blue: Double, opacity:
private func adaptive(light: Color, dark: Color) -> Color {
#if os(watchOS)
return dark
- #elseif canImport(UIKit)
- return Color(UIColor { traits in
- traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
- })
#else
- return light
+ return Color(UIColor.dynamic(light: UIColor(light), dark: UIColor(dark)))
#endif
}
-#if canImport(UIKit)
public extension UIColor {
static let haPrimary = UIColor(red: 0x00 / 255.0, green: 0x9A / 255.0, blue: 0xC7 / 255.0, alpha: 1)
}
-#endif
diff --git a/Sources/HADesignSystem/Sources/Colors/ColorContrast.swift b/Sources/HADesignSystem/Sources/Colors/ColorContrast.swift
index ffdd5a15ea..53791fd186 100644
--- a/Sources/HADesignSystem/Sources/Colors/ColorContrast.swift
+++ b/Sources/HADesignSystem/Sources/Colors/ColorContrast.swift
@@ -1,6 +1,5 @@
#if !os(watchOS)
import SwiftUI
-import UIKit
/// The frontend's colour-contrast maths, from `common/color/rgb.ts`.
///
@@ -56,11 +55,10 @@ public enum ColorContrast {
}
private static func components(of color: Color) -> (Double, Double, Double)? {
- var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
- guard UIColor(color).getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
+ guard let components = UIColor(color).rgbaComponents else {
return nil
}
- return (Double(red), Double(green), Double(blue))
+ return (Double(components.red), Double(components.green), Double(components.blue))
}
}
#endif
diff --git a/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift b/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift
index 820127817a..8b6fc229eb 100644
--- a/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift
+++ b/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift
@@ -1,6 +1,5 @@
import Foundation
import SwiftUI
-import UIKit
public extension FrontendColors {
/// The resolved color for the light (default) theme, if the CSS value can be parsed.
@@ -132,9 +131,7 @@ private extension FrontendColors {
#else
let lightColor = light ?? dark ?? .clear
let darkColor = dark ?? light ?? .clear
- return Color(UIColor { traits in
- traits.userInterfaceStyle == .dark ? UIColor(darkColor) : UIColor(lightColor)
- })
+ return Color(UIColor.dynamic(light: UIColor(lightColor), dark: UIColor(darkColor)))
#endif
}
diff --git a/Sources/HADesignSystem/Sources/Colors/UIColor+CSSColor.swift b/Sources/HADesignSystem/Sources/Colors/UIColor+CSSColor.swift
index d3d383660f..6a5b64d8bf 100644
--- a/Sources/HADesignSystem/Sources/Colors/UIColor+CSSColor.swift
+++ b/Sources/HADesignSystem/Sources/Colors/UIColor+CSSColor.swift
@@ -1,5 +1,10 @@
import Foundation
+import HAIconic
+#if os(macOS)
+import AppKit
+#else
import UIKit
+#endif
/// Parses the colour notations the Home Assistant frontend hands back.
///
diff --git a/Sources/HADesignSystem/Sources/Components/AppleLikeBottomSheet.swift b/Sources/HADesignSystem/Sources/Components/AppleLikeBottomSheet.swift
index b7104d3d31..8cb14c02f7 100644
--- a/Sources/HADesignSystem/Sources/Components/AppleLikeBottomSheet.swift
+++ b/Sources/HADesignSystem/Sources/Components/AppleLikeBottomSheet.swift
@@ -9,6 +9,7 @@ public enum AppleLikeBottomSheetViewState {
private enum AppleLikeBottomSheetConstants {
static let closebuttonSize: CGFloat = 30
+ static let regularWidth: CGFloat = 400
}
/// A sheet that rises from the bottom of the screen, drawn the way iOS draws one.
@@ -19,6 +20,9 @@ private enum AppleLikeBottomSheetConstants {
public struct AppleLikeBottomSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
+ #if os(macOS)
+ @Environment(\.isPresentedInMacSheet) private var isPresentedInMacSheet
+ #endif
/// Used for appear and disappear bottom sheet animation
@State private var displayBottomSheet = false
private let title: String?
@@ -57,6 +61,47 @@ public struct AppleLikeBottomSheet: View {
}
public var body: some View {
+ #if os(macOS)
+ if isPresentedInMacSheet {
+ macSheetCard
+ } else {
+ overlay
+ }
+ #else
+ overlay
+ #endif
+ }
+
+ #if os(macOS)
+ /// The card alone, for when a Mac sheet is the panel around it: no dimming, no slide-in, and dismissed
+ /// as soon as it is asked to be.
+ private var macSheetCard: some View {
+ card
+ .frame(width: AppleLikeBottomSheetConstants.regularWidth)
+ .fixedSize(horizontal: false, vertical: true)
+ .onAppear {
+ state = .initial
+ }
+ .onChange(of: state) { newValue in
+ if newValue == .dismiss {
+ performDismiss()
+ }
+ }
+ }
+ #endif
+
+ private var card: some View {
+ VStack(spacing: .zero) {
+ header
+ content
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(contentInsets)
+ }
+ .padding(.horizontal)
+ .frame(minHeight: bottomSheetMinHeight)
+ }
+
+ private var overlay: some View {
VStack {
Spacer()
bottomSheet
@@ -73,7 +118,7 @@ public struct AppleLikeBottomSheet: View {
}
.onChange(of: state) { newValue in
if newValue == .dismiss {
- if #available(iOS 17.0, *) {
+ if #available(iOS 17.0, macOS 14.0, *) {
withAnimation(.bouncy) {
displayBottomSheet = false
} completion: {
@@ -92,27 +137,20 @@ public struct AppleLikeBottomSheet: View {
}
private var bottomSheet: some View {
- VStack(spacing: .zero) {
- header
- content
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(contentInsets)
- }
- .padding(.horizontal)
- .frame(minHeight: bottomSheetMinHeight)
- .frame(maxWidth: maxWidth, alignment: .center)
- .background(Color(uiColor: .systemBackground))
- .clipShape(sheetShape)
- .shadow(color: .black.opacity(0.2), radius: 20)
- .padding(DesignSystem.Spaces.one)
- .fixedSize(horizontal: false, vertical: true)
- .offset(y: displayBottomSheet ? 0 : bottomSheetMinHeight)
- .onAppear {
- state = .initial
- withAnimation(.bouncy) {
- displayBottomSheet = true
+ card
+ .frame(maxWidth: maxWidth, alignment: .center)
+ .background(Color(uiColor: .systemBackground))
+ .clipShape(sheetShape)
+ .shadow(color: .black.opacity(0.2), radius: 20)
+ .padding(DesignSystem.Spaces.one)
+ .fixedSize(horizontal: false, vertical: true)
+ .offset(y: displayBottomSheet ? 0 : bottomSheetMinHeight)
+ .onAppear {
+ state = .initial
+ withAnimation(.bouncy) {
+ displayBottomSheet = true
+ }
}
- }
}
private func performDismiss() {
@@ -128,14 +166,14 @@ public struct AppleLikeBottomSheet: View {
if horizontalSizeClass == .compact {
.infinity
} else {
- 400
+ AppleLikeBottomSheetConstants.regularWidth
}
}
/// From iOS 26 the corners follow the container the sheet actually sits in, which the screen-radius
/// arithmetic in `perfectCornerRadius` only approximates for the built-in display.
private var sheetShape: AnyShape {
- if #available(iOS 26, *) {
+ if #available(iOS 26, macOS 26, *) {
let minimumCornerRadius: CGFloat = horizontalSizeClass == .compact ? DesignSystem.CornerRadius.one : 50
return AnyShape(ConcentricRectangle(
corners: .concentric(minimum: .fixed(minimumCornerRadius)),
@@ -148,6 +186,10 @@ public struct AppleLikeBottomSheet: View {
private var perfectCornerRadius: CGFloat {
let cornerRadius: CGFloat = {
+ #if os(macOS)
+ // A Mac sheet floats inside its window rather than hugging a curved display edge.
+ return 50
+ #else
if horizontalSizeClass == .compact {
// TODO: Modernization - `UIScreen.main` is deprecated and only describes the built-in display.
// Below iOS 26 the screen has to come from the hosting window (`view.window?.windowScene?.screen`),
@@ -156,6 +198,7 @@ public struct AppleLikeBottomSheet: View {
} else {
return 50
}
+ #endif
}()
let minimumCornerRadius = DesignSystem.CornerRadius.one
return cornerRadius > minimumCornerRadius ? cornerRadius : minimumCornerRadius
diff --git a/Sources/HADesignSystem/Sources/Components/AssistVoiceOrbView.swift b/Sources/HADesignSystem/Sources/Components/AssistVoiceOrbView.swift
index 69258515df..457fc7e096 100644
--- a/Sources/HADesignSystem/Sources/Components/AssistVoiceOrbView.swift
+++ b/Sources/HADesignSystem/Sources/Components/AssistVoiceOrbView.swift
@@ -1,6 +1,5 @@
import HAIconic
import SwiftUI
-import UIKit
/// Animated orb shown while Assist is listening. Voice activity is shown by a plain circle behind the
/// orb that grows and shrinks with the microphone input level (0...1), no glow or shadow involved.
@@ -194,7 +193,7 @@ public struct AssistVoiceOrbView: View {
.frame(width: metrics.orbSize, height: metrics.orbSize)
.clipShape(Circle())
.modify { view in
- if #available(iOS 26.0, watchOS 26.0, *), !forcesLegacyAppearance {
+ if #available(iOS 26.0, macOS 26.0, watchOS 26.0, *), !forcesLegacyAppearance {
view.glassEffect(
.regular.tint(Color.haPrimary.opacity(appearance.orbGlassTintOpacity)),
in: .circle
diff --git a/Sources/HADesignSystem/Sources/Components/CloseButton.swift b/Sources/HADesignSystem/Sources/Components/CloseButton.swift
index 6d867bf4ac..9791aa16ee 100644
--- a/Sources/HADesignSystem/Sources/Components/CloseButton.swift
+++ b/Sources/HADesignSystem/Sources/Components/CloseButton.swift
@@ -13,7 +13,7 @@ public struct CloseButton: View {
case large
var size: CGFloat {
- #if targetEnvironment(macCatalyst)
+ #if targetEnvironment(macCatalyst) || os(macOS)
switch self {
case .small:
return 24
@@ -52,7 +52,7 @@ public struct CloseButton: View {
}
public var body: some View {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
Button(role: .close) {
tapAction()
}
diff --git a/Sources/HADesignSystem/Sources/Components/FullScreenLoaderView.swift b/Sources/HADesignSystem/Sources/Components/FullScreenLoaderView.swift
index 6127c829f8..530148611b 100644
--- a/Sources/HADesignSystem/Sources/Components/FullScreenLoaderView.swift
+++ b/Sources/HADesignSystem/Sources/Components/FullScreenLoaderView.swift
@@ -53,7 +53,7 @@ public struct FullScreenLoaderView: View {
)
if showsDelayedControls {
Group {
- if #available(iOS 26.0, *) {
+ if #available(iOS 26.0, macOS 26.0, *) {
SettingsButton(action: settingsAction)
.padding(DesignSystem.Spaces.one)
.glassEffect(.regular.interactive(), in: Circle())
diff --git a/Sources/HADesignSystem/Sources/Components/GroupedList.swift b/Sources/HADesignSystem/Sources/Components/GroupedList.swift
new file mode 100644
index 0000000000..f3761e4009
--- /dev/null
+++ b/Sources/HADesignSystem/Sources/Components/GroupedList.swift
@@ -0,0 +1,49 @@
+#if !os(watchOS)
+import SwiftUI
+
+/// A screen of settings: sections of rows, each with an optional header and an explanatory footer.
+///
+/// On iOS this is a `List`, which draws such a screen as inset grouped cards. A `List` on a Mac is a
+/// table: its sections run together, and a footer is cut off at one line however long its text is. There
+/// the screen is a grouped `Form` instead, which is how System Settings draws the same thing.
+///
+/// Use it for screens made of a known handful of rows. A list of data stays a `List`: a form builds every
+/// row up front, and cannot reorder or swipe rows.
+public struct GroupedList: View {
+ private let content: Content
+
+ public init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ public var body: some View {
+ #if os(macOS)
+ Form {
+ content
+ }
+ .formStyle(.grouped)
+ // A button in a row is the row's action, drawn as text the way a list draws it on iOS, rather than
+ // a push button with a bezel of its own sitting inside the row.
+ .buttonStyle(.borderless)
+ #else
+ List {
+ content
+ }
+ #endif
+ }
+}
+
+#Preview {
+ GroupedList {
+ Section {
+ Toggle(isOn: .constant(true)) {
+ Text(verbatim: "Setting")
+ }
+ } header: {
+ Text(verbatim: "Header")
+ } footer: {
+ Text(verbatim: "A footer that explains, at some length, what turning the setting on will do.")
+ }
+ }
+}
+#endif
diff --git a/Sources/HADesignSystem/Sources/Components/HACameraPreview.swift b/Sources/HADesignSystem/Sources/Components/HACameraPreview.swift
index 76e42cee29..15fb3312d0 100644
--- a/Sources/HADesignSystem/Sources/Components/HACameraPreview.swift
+++ b/Sources/HADesignSystem/Sources/Components/HACameraPreview.swift
@@ -1,7 +1,6 @@
#if !os(watchOS)
import AVFoundation
import SwiftUI
-import UIKit
/// A live camera feed that reports the barcodes it sees. The capture half of the frontend's
/// `ha-qr-scanner`, which does the same job with `getUserMedia` and a `