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 `