Files
iOS/Sources/Extensions/Watch/WatchSettingsView.swift
Bruno Pantaleão Gonçalves 9c9e544e62 Add Apple Watch mTLS compatibility and settings screen (#4753)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
This PR bumps HAKit to 0.4.15 which allows mTLS on Apple Watch.
It also includes connectivity changes + a settings screen to visualize
connectivity information and define where to execute scripts and scenes
from.

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->
<img width="742" height="864" alt="CleanShot 2026-06-16 at 16 39 30@2x"
src="https://github.com/user-attachments/assets/3b9ec512-76f4-4a2f-a677-710c7f37bef4"
/>


## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 17:57:46 +02:00

352 lines
15 KiB
Swift

import Communicator
import SFSafeSymbols
import Shared
import SwiftUI
/// Watch settings. Lists servers synchronized from the paired iPhone and shows connectivity details
/// (including mTLS client-certificate status). It also provides watch-local preferences like where
/// actions run (iPhone vs Watch) and per-server URL overrides; server configuration itself remains
/// managed on the iPhone.
struct WatchSettingsView: View {
@StateObject private var viewModel = WatchSettingsViewModel()
@State private var performActionTarget = WatchUserDefaults.shared.performActionTarget
var body: some View {
NavigationView {
List {
serversSection
performActionSection
}
.navigationTitle(Text(verbatim: L10n.Watch.Settings.title))
}
}
private var performActionSection: some View {
Section {
Picker(L10n.Watch.Settings.PerformAction.title, selection: $performActionTarget) {
Text(verbatim: L10n.Watch.Settings.auto).tag(WatchActionTarget.auto)
Text(verbatim: L10n.Watch.Settings.PerformAction.iphone).tag(WatchActionTarget.iPhone)
Text(verbatim: L10n.Watch.Settings.PerformAction.appleWatch).tag(WatchActionTarget.appleWatch)
}
.onChange(of: performActionTarget) { newValue in
WatchUserDefaults.shared.performActionTarget = newValue
}
} footer: {
Text(verbatim: L10n.Watch.Settings.PerformAction.footer)
}
}
@ViewBuilder
private var serversSection: some View {
Section {
if viewModel.servers.isEmpty {
Text(verbatim: L10n.Watch.Settings.noServers)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.servers, id: \.identifier.rawValue) { server in
NavigationLink {
WatchServerDetailView(server: server)
} label: {
Label {
Text(verbatim: server.info.name)
} icon: {
Image(systemSymbol: .network)
}
}
}
}
} header: {
Text(verbatim: L10n.Watch.Settings.Servers.header)
} footer: {
// When the synchronized data is from refreshed via the Home screen's reload button.
if let lastUpdated = viewModel.lastUpdated {
Text(verbatim: L10n.Watch.Settings.lastUpdated(
lastUpdated.formatted(date: .abbreviated, time: .shortened)
))
}
}
}
}
/// Read-only detail of a single synchronized server.
struct WatchServerDetailView: View {
let server: Server
private var connection: ConnectionInfo { server.info.connection }
/// Bumped when client certificates are imported so the "Available on this Watch" row re-reads
/// the Keychain while this screen is open (the import arrives asynchronously).
@State private var certRefreshToken = UUID()
@State private var showImportInstructions = false
@State private var showRemoveFromWatchConfirmation = false
/// `nil` = automatic; otherwise the URL the Watch is forced to use for this server.
@State private var urlOverride: ConnectionInfo.URLType?
init(server: Server) {
self.server = server
let raw = WatchUserDefaults.shared.urlOverrideRawValue(forServerId: server.identifier.rawValue)
_urlOverride = State(initialValue: raw.flatMap(ConnectionInfo.URLType.init(rawValue:)))
}
var body: some View {
List {
urlOverrideSection
connectionSection
statusSection
clientCertificateSection
}
.navigationTitle(Text(verbatim: server.info.name))
.id(certRefreshToken)
.onReceive(NotificationCenter.default.publisher(for: .clientCertificatesImported)) { _ in
certRefreshToken = UUID()
}
.alert(
Text(verbatim: L10n.Settings.ConnectionSection.ClientCertificate.import),
isPresented: $showImportInstructions
) {
Button(L10n.Watch.Settings.refresh) {
WatchServerSync.request()
}
} message: {
Text(verbatim: L10n.Watch.Settings.ClientCertificate.importInstructions)
}
}
/// Number of distinct URLs configured for this server (internal / external / remote-UI).
private var configuredURLCount: Int {
[ConnectionInfo.URLType.internal, .external, .remoteUI]
.compactMap { connection.address(for: $0) }
.count
}
@ViewBuilder
private var urlOverrideSection: some View {
// Only meaningful when there's a choice of URLs to force.
if configuredURLCount > 1 {
Section {
Picker(L10n.Watch.Settings.UrlOverride.title, selection: $urlOverride) {
Text(verbatim: L10n.Watch.Settings.auto)
.tag(ConnectionInfo.URLType?.none)
Text(verbatim: L10n.Settings.ConnectionSection.InternalBaseUrl.title)
.tag(ConnectionInfo.URLType?.some(.internal))
Text(verbatim: L10n.Settings.ConnectionSection.ExternalBaseUrl.title)
.tag(ConnectionInfo.URLType?.some(.external))
}
.onChange(of: urlOverride) { newValue in
WatchUserDefaults.shared.setURLOverrideRawValue(
newValue?.rawValue,
forServerId: server.identifier.rawValue
)
WatchServerSync.applyURLOverrides()
}
} footer: {
Text(verbatim: L10n.Watch.Settings.UrlOverride.footer)
}
}
}
private var connectionSection: some View {
Section(header: Text(verbatim: L10n.Settings.ConnectionSection.details)) {
infoRow(L10n.Settings.ConnectionSection.InternalBaseUrl.title, connection.internalURL?.absoluteString)
infoRow(
L10n.Settings.ConnectionSection.ExternalBaseUrl.title,
connection.address(for: .external)?.absoluteString
)
infoRow(
L10n.Settings.ConnectionSection.RemoteUiUrl.title,
connection.address(for: .remoteUI)?.absoluteString
)
}
}
private var statusSection: some View {
Section(header: Text(verbatim: L10n.Settings.StatusSection.header)) {
infoRow(L10n.Settings.StatusSection.VersionRow.title, server.info.version.description)
}
}
@ViewBuilder
private var clientCertificateSection: some View {
Section(header: Text(verbatim: L10n.Settings.ConnectionSection.ClientCertificate.header)) {
if let certificate = connection.clientCertificate {
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(verbatim: certificate.displayName)
if certificate.isExpired {
Text(verbatim: L10n.Settings.ConnectionSection.ClientCertificate.expired)
.font(.caption)
.foregroundStyle(.red)
} else if let expiresAt = certificate.expiresAt {
Text(verbatim: L10n.Settings.ConnectionSection.ClientCertificate.expiresAt(
expiresAt.formatted(date: .abbreviated, time: .omitted)
))
.font(.caption)
.foregroundStyle(.secondary)
}
}
// Whether the identity actually made it into this Watch's Keychain the key signal
// for debugging mTLS sync, since the Watch's Keychain is separate from the iPhone's.
availabilityRow(for: certificate)
} else {
Text(verbatim: L10n.Watch.Settings.ClientCertificate.none)
.foregroundStyle(.secondary)
}
importButton
}
}
@ViewBuilder
private func availabilityRow(for certificate: ClientCertificate) -> some View {
if ClientCertificateManager.shared.hasIdentity(for: certificate) {
// Tappable: offer to remove the identity from THIS Watch's Keychain (the iPhone keeps it).
Button {
showRemoveFromWatchConfirmation = true
} label: {
HStack {
infoRow(L10n.Watch.Settings.ClientCertificate.availableOnWatch, L10n.yesLabel)
Spacer()
Image(systemSymbol: .trash)
.foregroundStyle(.red)
}
}
.buttonStyle(.plain)
.alert(
Text(verbatim: L10n.Watch.Settings.ClientCertificate.RemoveFromWatch.title),
isPresented: $showRemoveFromWatchConfirmation
) {
Button(L10n.Watch.Settings.ClientCertificate.RemoveFromWatch.remove, role: .destructive) {
removeCertificateFromWatch(certificate)
}
Button(L10n.cancelLabel, role: .cancel) {}
} message: {
Text(verbatim: L10n.Watch.Settings.ClientCertificate.RemoveFromWatch.message)
}
} else {
infoRow(L10n.Watch.Settings.ClientCertificate.availableOnWatch, L10n.noLabel)
}
}
/// Delete the certificate's identity from THIS Watch's Keychain only. The iPhone's copy and the
/// server configuration are untouched, so a later refresh re-delivers and re-imports it.
private func removeCertificateFromWatch(_ certificate: ClientCertificate) {
do {
try ClientCertificateManager.shared.delete(certificate: certificate)
Current.Log.info("[mTLS] Removed client certificate from this Watch: \(certificate.displayName)")
} catch {
Current.Log.error("[mTLS] Failed to remove client certificate from Watch: \(error)")
}
Current.resetAPICache(for: [server.identifier])
certRefreshToken = UUID()
}
private var importButton: some View {
Button {
requestCertificateImport()
} label: {
Label(L10n.Settings.ConnectionSection.ClientCertificate.import, systemSymbol: .squareAndArrowDown)
}
}
/// Ask the iPhone to present its certificate import screen for this server. iOS can't foreground
/// the phone app from here, so the screen appears the next time the user opens Home Assistant on
/// the phone; once imported, the next watch refresh delivers it inline.
private func requestCertificateImport() {
if Communicator.shared.currentReachability == .immediatelyReachable {
Communicator.shared.send(.init(
identifier: InteractiveImmediateMessages.clientCertImportRequest.rawValue,
content: ["serverId": server.identifier.rawValue],
reply: { _ in }
), errorHandler: { error in
Current.Log.error("[mTLS] Failed to request certificate import: \(error)")
})
}
showImportInstructions = true
}
@ViewBuilder
private func infoRow(_ title: String, _ value: String?) -> some View {
if let value, !value.isEmpty {
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(verbatim: title)
.font(.caption)
.foregroundStyle(.secondary)
Text(verbatim: value)
.font(.footnote)
}
}
}
}
extension Notification.Name {
/// Posted on the watch once client certificate(s) received from the paired iPhone have been
/// imported into the local Keychain, so any visible mTLS status can refresh.
static let clientCertificatesImported = Notification.Name("clientCertificatesImported")
}
/// The shared "pull latest servers + mTLS certificates from the phone" routine, used by both the
/// Home refresh button and the Settings screens. The phone replies to `serversConfigSync` with the
/// encoded servers and any client certificate bundles inline; both are applied to the local Keychain.
enum WatchServerSync {
static func request() {
guard Communicator.shared.currentReachability == .immediatelyReachable else {
Current.Log.info("[Watch] Skipping server sync, iPhone not immediately reachable")
return
}
Communicator.shared.send(.init(
identifier: InteractiveImmediateMessages.serversConfigSync.rawValue,
reply: { message in
DispatchQueue.main.async {
apply(message)
}
}
), errorHandler: { error in
Current.Log.error("[Watch] Failed to request servers sync: \(error)")
})
}
private static func apply(_ message: ImmediateMessage) {
if let serversData = message.content["servers"] as? Data {
WatchUserDefaults.shared.set(Date(), key: .serversUpdatedAt)
Current.servers.restoreState(serversData)
applyURLOverrides()
}
if let certificatesData = message.content["clientCertificates"] as? Data {
importCertificates(certificatesData)
}
}
/// Re-apply each server's watch-local "Always use" URL choice. `ConnectionInfo` is overwritten on
/// every sync, so the override (stored in `WatchUserDefaults`) must be re-applied to the live
/// servers. Run on launch, after each sync, and whenever the picker changes.
static func applyURLOverrides() {
var changed: [Identifier<Server>] = []
for server in Current.servers.all {
let desired = WatchUserDefaults.shared
.urlOverrideRawValue(forServerId: server.identifier.rawValue)
.flatMap(ConnectionInfo.URLType.init(rawValue:))
guard server.info.connection.overrideActiveURLType != desired else { continue }
server.update { $0.connection.overrideActiveURLType = desired }
changed.append(server.identifier)
}
if !changed.isEmpty {
Current.resetAPICache(for: changed)
}
}
/// Import inline client certificate bundle(s) into the watch Keychain, rebuild any affected API
/// (session delegates are configured at init time), and refresh any visible mTLS status.
private static func importCertificates(_ data: Data) {
let imported = ClientCertificateManager.shared.importTransferPayload(data)
guard !imported.isEmpty else { return }
let affected = Current.servers.all.filter {
guard let id = $0.info.connection.clientCertificate?.keychainIdentifier else { return false }
return imported.contains(id)
}.map(\.identifier)
Current.resetAPICache(for: affected)
NotificationCenter.default.post(name: .clientCertificatesImported, object: nil)
}
}