diff --git a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationView.swift b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationView.swift index 886513b36..39e75dbd2 100644 --- a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationView.swift +++ b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationView.swift @@ -88,9 +88,9 @@ struct OnboardingPermissionsNavigationView: View { } private var homeNetworkInput: some View { - HomeNetworkInputView(onNext: { networkSSID in - if let networkSSID { - viewModel.saveNetworkSSID(networkSSID) + HomeNetworkInputView(onNext: { context in + if context.networkName != nil || context.hardwareAddress != nil { + viewModel.saveHomeNetwork(context) } }) .onChange(of: viewModel.storedSSIDSuccessfully) { newValue in diff --git a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift index 5ba9da717..ad1fea92b 100644 --- a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift +++ b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift @@ -141,10 +141,14 @@ final class OnboardingPermissionsNavigationViewModel: NSObject, ObservableObject /// Saves the home network SSID to the onboarding server configuration /// - Parameter ssid: The network SSID to save for trusted local connections /// - Note: This is used in the homeNetwork step to configure secure local access - func saveNetworkSSID(_ ssid: String) { + func saveHomeNetwork(_ context: HomeNetworkInputView.SubmitContext) { onboardingServer.update { [weak self] info in - info.connection.internalSSIDs = [ssid] - + if let ssid = context.networkName { + info.connection.internalSSIDs = [ssid] + } + if let hardwareAddress = context.hardwareAddress { + info.connection.internalHardwareAddresses = [hardwareAddress] + } DispatchQueue.main.async { self?.storedSSIDSuccessfully = true } diff --git a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift index e6d1d7d40..b26a05804 100644 --- a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift +++ b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift @@ -2,11 +2,17 @@ import Shared import SwiftUI struct HomeNetworkInputView: View { + struct SubmitContext { + let networkName: String? + let hardwareAddress: String? + } + @State private var networkName: String = "" + @State private var hardwareAddress: String = "" @State private var showingEmptyNetworkAlert = false @StateObject private var viewModel = HomeNetworkInputViewModel() - let onNext: (String?) -> Void + let onNext: (SubmitContext) -> Void var body: some View { BaseOnboardingView( @@ -16,54 +22,10 @@ struct HomeNetworkInputView: View { title: L10n.Onboarding.NetworkInput.title, primaryDescription: L10n.Onboarding.NetworkInput.primaryDescription, content: { - VStack(spacing: DesignSystem.Spaces.two) { - // Network input field - VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { - Text(L10n.Onboarding.NetworkInput.InputField.title) - .font(DesignSystem.Font.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - HATextField( - placeholder: L10n.Onboarding.NetworkInput.InputField.placeholder, - text: $networkName - ) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } - - HStack(alignment: .top, spacing: DesignSystem.Spaces.one) { - Image(systemSymbol: .infoCircleFill) - .foregroundStyle(.blue) - .font(.system(size: 20)) - - VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { - Text(L10n.Onboarding.NetworkInput.Disclaimer.title) - .font(DesignSystem.Font.body.weight(.medium)) - - Text( - L10n.Onboarding.NetworkInput.Disclaimer.body - ) - .font(DesignSystem.Font.caption) - .foregroundStyle(.secondary) - } - } - .padding(DesignSystem.Spaces.two) - .background(.blue.opacity(0.1)) - .cornerRadius(DesignSystem.CornerRadius.three) - } - .frame(maxWidth: DesignSystem.List.rowMaxWidth) - .padding(.top) + networkInputContent }, primaryActionTitle: L10n.Onboarding.NetworkInput.PrimaryButton.title, - primaryAction: { - let trimmedNetworkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedNetworkName.isEmpty { - showingEmptyNetworkAlert = true - } else { - onNext(trimmedNetworkName) - } - } + primaryAction: handlePrimaryAction ) .alert(L10n.Onboarding.NetworkInput.NoNetwork.Alert.title, isPresented: $showingEmptyNetworkAlert) { Button(L10n.okLabel) {} @@ -75,7 +37,97 @@ struct HomeNetworkInputView: View { } .onChange(of: viewModel.shouldComplete) { shouldComplete in if shouldComplete { - onNext(networkName) + onNext(.init(networkName: networkName, hardwareAddress: hardwareAddress)) + } + } + } + + // MARK: - Content Views + + private var networkInputContent: some View { + VStack(spacing: DesignSystem.Spaces.two) { + networkInputField + if Current.isCatalyst { + hardwareAddressField + } + networkDisclaimer + } + .frame(maxWidth: DesignSystem.List.rowMaxWidth) + .padding(.top) + } + + private var networkInputField: some View { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { + Text(L10n.Onboarding.NetworkInput.InputField.title) + .font(DesignSystem.Font.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HATextField( + placeholder: L10n.Onboarding.NetworkInput.InputField.placeholder, + text: $networkName + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + + @ViewBuilder + private var hardwareAddressField: some View { + if !hardwareAddress.isEmpty { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { + Text(L10n.Onboarding.NetworkInput.Hardware.InputField.title) + .font(DesignSystem.Font.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HATextField( + placeholder: "00:00:00:00:00:00", + text: $hardwareAddress + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + } + + private var networkDisclaimer: some View { + HStack(alignment: .top, spacing: DesignSystem.Spaces.one) { + Image(systemSymbol: .infoCircleFill) + .foregroundStyle(.blue) + .font(.system(size: 20)) + + VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { + Text(L10n.Onboarding.NetworkInput.Disclaimer.title) + .font(DesignSystem.Font.body.weight(.medium)) + + Text(L10n.Onboarding.NetworkInput.Disclaimer.body) + .font(DesignSystem.Font.caption) + .foregroundStyle(.secondary) + } + } + .padding(DesignSystem.Spaces.two) + .background(.blue.opacity(0.1)) + .cornerRadius(DesignSystem.CornerRadius.three) + } + + // MARK: - Actions + + private func handlePrimaryAction() { + let trimmedNetworkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines) + if Current.isCatalyst { + let trimmedHardwareAddress = hardwareAddress.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmedNetworkName.isEmpty, trimmedHardwareAddress.isEmpty { + showingEmptyNetworkAlert = true + } else { + onNext(.init(networkName: trimmedNetworkName, hardwareAddress: trimmedHardwareAddress)) + } + } else { + if trimmedNetworkName.isEmpty { + showingEmptyNetworkAlert = true + } else { + onNext(.init(networkName: trimmedNetworkName, hardwareAddress: hardwareAddress)) } } } @@ -85,14 +137,15 @@ struct HomeNetworkInputView: View { let networkInfo = await Current.networkInformation networkName = networkInfo?.ssid ?? "" } + hardwareAddress = Current.connectivity.currentNetworkHardwareAddress() ?? "" } } #Preview { NavigationView { HomeNetworkInputView( - onNext: { networkName in - print("Next tapped with network: \(networkName ?? "nil")") + onNext: { context in + print("Next tapped with network: \(context.networkName ?? "nil")") } ) .navigationBarTitleDisplayMode(.inline) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index d9a72b821..6b4987ed8 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -514,6 +514,7 @@ Remote when you're ready."; "onboarding.network_input.disclaimer.title" = "Make sure to set up your home network correctly."; "onboarding.network_input.input_field.placeholder" = "Network name"; "onboarding.network_input.input_field.title" = "Wi-Fi network connected"; +"onboarding.network_input.hardware.input_field.title" = "Hardware Address"; "onboarding.network_input.no_network.alert.body" = "Please enter a network name to continue."; "onboarding.network_input.no_network.alert.title" = "Network name required"; "onboarding.network_input.no_network.skip.alert.body" = "You haven't set a home network. You can set it up later in the app settings, until that we will only use your remote connection (if it exists) to access Home Assistant."; @@ -1334,4 +1335,4 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "widgets.sensors.description" = "Display state of sensors"; "widgets.sensors.not_configured" = "No Sensors Configured"; "widgets.sensors.title" = "Sensors"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift index c99006a0b..37d4655d7 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift @@ -190,13 +190,6 @@ class ConnectionSettingsViewController: HAFormViewController, RowControllerType <<< ButtonRow { row in row.cellStyle = .value1 - row.hidden = .function([ - RowTag.externalURL.rawValue, - RowTag.internalURL.rawValue, - ], { [weak self] _ in - // We only display this section is user has non-HTTPS URL configured as internal or external - !(self?.server.info.connection.hasNonHTTPSURLOption ?? false) - }) row.title = L10n.Settings.ConnectionSection.ConnectionAccessSecurityLevel.title row.displayValueFor = { [server] _ in server.info.connection.connectionAccessSecurityLevel.description @@ -217,8 +210,6 @@ class ConnectionSettingsViewController: HAFormViewController, RowControllerType animated: true ) } - - row.evaluateHidden() } +++ Section(L10n.SettingsDetails.Privacy.title) diff --git a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift index 89517c68e..dc29faedf 100644 --- a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift +++ b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift @@ -47,40 +47,56 @@ struct ConnectionSecurityLevelBlockView: View { .onAppear { viewModel.loadRequirements() } - .sheet(isPresented: $showSettings) { - embed(UINavigationController(rootViewController: SettingsViewController())) - .onDisappear { - Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) - .done { webView in - dismiss() - webView.refresh() - } - } - } - .sheet(isPresented: $showHomeNetworkSettings) { + #if targetEnvironment(macCatalyst) + .fullScreenCover(isPresented: $showHomeNetworkSettings) { homeNetworkView } - .sheet(isPresented: $showConnectionSecurityPreferences) { + .fullScreenCover(isPresented: $showConnectionSecurityPreferences) { connectionPreferencesView } - .onReceive(NotificationCenter.default.publisher(for: .locationPermissionDidChange)) { notification in - if let userInfo = notification.userInfo { - let state = LocationPermissionState(userInfo: userInfo) - switch state { - case .notDetermined: - Current.Log.info("Location permission not determined") - case .denied, .restricted: - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - case .authorizedWhenInUse, .authorizedAlways: - // Handle permission change - reload requirements to update UI - viewModel.loadRequirements() + .fullScreenCover(isPresented: $showSettings) { + settingsView + } + #else + .sheet(isPresented: $showHomeNetworkSettings) { + homeNetworkView + } + .sheet(isPresented: $showConnectionSecurityPreferences) { + connectionPreferencesView + } + .sheet(isPresented: $showSettings) { + settingsView + } + #endif + .onReceive(NotificationCenter.default.publisher(for: .locationPermissionDidChange)) { notification in + if let userInfo = notification.userInfo { + let state = LocationPermissionState(userInfo: userInfo) + switch state { + case .notDetermined: + Current.Log.info("Location permission not determined") + case .denied, .restricted: + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + case .authorizedWhenInUse, .authorizedAlways: + // Handle permission change - reload requirements to update UI + viewModel.loadRequirements() + } } } - } } .navigationViewStyle(.stack) } + private var settingsView: some View { + embed(UINavigationController(rootViewController: SettingsViewController())) + .onDisappear { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webView in + dismiss() + webView.refresh() + } + } + } + private var content: some View { VStack(spacing: DesignSystem.Spaces.two) { Image(systemSymbol: .lockFill) @@ -121,6 +137,7 @@ struct ConnectionSecurityLevelBlockView: View { } } } + .frame(maxWidth: DesignSystem.Button.maxWidth) .padding(.top) } } @@ -129,6 +146,14 @@ struct ConnectionSecurityLevelBlockView: View { .background(Color(uiColor: .systemBackground)) } + private func openSettings() { + if Current.isCatalyst { + Current.sceneManager.activateAnyScene(for: .settings) + } else { + showSettings = true + } + } + private func requirementItem(systemSymbol: SFSymbol, title: String) -> some View { HStack { Spacer() @@ -149,7 +174,7 @@ struct ConnectionSecurityLevelBlockView: View { private var bottomButtons: some View { VStack(spacing: DesignSystem.Spaces.one) { Button(action: { - showSettings = true + openSettings() }) { Text(L10n.ConnectionSecurityLevelBlock.OpenSettings.title) } @@ -168,10 +193,15 @@ struct ConnectionSecurityLevelBlockView: View { private var homeNetworkView: some View { NavigationView(content: { - HomeNetworkInputView(onNext: { ssid in - guard let ssid else { return } + HomeNetworkInputView(onNext: { context in server.update { info in - info.connection.internalSSIDs = [ssid] + if let ssid = context.networkName { + info.connection.internalSSIDs = [ssid] + } + if let hardwareAddress = context.hardwareAddress { + info.connection.internalHardwareAddresses = [hardwareAddress] + } + showHomeNetworkSettings = false } }) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 034c6678b..ae34b7270 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1935,6 +1935,12 @@ public enum L10n { /// Make sure to set up your home network correctly. public static var title: String { return L10n.tr("Localizable", "onboarding.network_input.disclaimer.title") } } + public enum Hardware { + public enum InputField { + /// Hardware Address + public static var title: String { return L10n.tr("Localizable", "onboarding.network_input.hardware.input_field.title") } + } + } public enum InputField { /// Network name public static var placeholder: String { return L10n.tr("Localizable", "onboarding.network_input.input_field.placeholder") } diff --git a/Tests/App/Onboarding/OnboardingPermissionsNavigationViewModelTests.swift b/Tests/App/Onboarding/OnboardingPermissionsNavigationViewModelTests.swift index 658d3772a..623aea3e1 100644 --- a/Tests/App/Onboarding/OnboardingPermissionsNavigationViewModelTests.swift +++ b/Tests/App/Onboarding/OnboardingPermissionsNavigationViewModelTests.swift @@ -167,13 +167,13 @@ struct OnboardingPermissionsNavigationViewModelTests { // MARK: - Network SSID Tests - @Test("Save network SSID") - func saveNetworkSSID() async throws { + @Test("Save Home Network") + func saveHomeNetwork() async throws { let server = ServerFixture.standard let viewModel = OnboardingPermissionsNavigationViewModel(onboardingServer: server) let testSSID = "TestNetwork" - viewModel.saveNetworkSSID(testSSID) + viewModel.saveHomeNetwork(.init(networkName: testSSID, hardwareAddress: nil)) #expect(server.info.connection.internalSSIDs == [testSSID]) }