Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ custom_rules:
severity: error
excluded:
- "Tests"
sf_safe_symbol:
explicit_safe_symbol_usage:
name: "Safe SFSymbol"
message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety."
regex: >
Expand Down
81 changes: 46 additions & 35 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ SPEC CHECKSUMS:
GoogleDataTransport: ea169759df570f4e37bdee1623ec32a7e64e67c4
GoogleUtilities: c2bdc4cf2ce786c4d2e6b3bcfd599a25ca78f06f
GRDB.swift: 682e07f771a9100f0bdf40fd0bed57b83ca08e29
HAKit: 2e0570970efe11fa54ad5cceb5d4c4c3fca4c603
HAKit: 8628e7a8f87fc30e2b3b67b10920dc846f33a77b
Improv-iOS: 8973990c1b1f3e3aed7fc600c8efce95359cadd0
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406
Expand All @@ -297,4 +297,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 3e51f6f88d22cb69fd187779f567da9019ee707a

COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
6 changes: 5 additions & 1 deletion Sources/App/Onboarding/API/OnboardingAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ class OnboardingAuth {
promise = promise.recover { [self] originalError -> Promise<HomeAssistantAPI> in
let authDetails = try OnboardingAuthDetails(baseURL: url)

// Note: Certificate selection happens during mTLS challenge handling
// User must explicitly import and select certificates in settings

return firstly {
performPreSteps(checkPoint: .beforeAuth, authDetails: authDetails, sender: sender)
}.then { [self] in
Expand Down Expand Up @@ -200,7 +203,8 @@ private extension ConnectionInfo {
internalHardwareAddresses: nil,
isLocalPushEnabled: false,
securityExceptions: authDetails.exceptions,
connectionAccessSecurityLevel: .undefined
connectionAccessSecurityLevel: .undefined,
clientCertificate: authDetails.clientCertificate
)

// default cloud to on
Expand Down
3 changes: 2 additions & 1 deletion Sources/App/Onboarding/API/OnboardingAuthDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class OnboardingAuthDetails: Equatable {
var url: URL
var scheme: String
var exceptions: SecurityExceptions = .init()
var clientCertificate: ClientCertificate?

init(baseURL: URL) throws {
guard var components = URLComponents(url: baseURL.sanitized(), resolvingAgainstBaseURL: false) else {
Expand Down Expand Up @@ -47,6 +48,6 @@ class OnboardingAuthDetails: Equatable {
}

static func == (lhs: OnboardingAuthDetails, rhs: OnboardingAuthDetails) -> Bool {
lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions
lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions && lhs.clientCertificate == rhs.clientCertificate
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ class OnboardingAuthLoginViewControllerImpl: UIViewController, OnboardingAuthLog
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
if let cert = authDetails.clientCertificate,
let credential = ClientCertificateManager.shared.credential(for: cert) {
completionHandler(.useCredential, credential)
return
}
}

let result = authDetails.exceptions.evaluate(challenge)
completionHandler(result.0, result.1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let pendingResolver = taskIdentifierToResolver[task.taskIdentifier] else {
completionHandler(.cancelAuthenticationChallenge, nil)
Expand All @@ -164,8 +164,13 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio
pendingResolver.reject(OnboardingAuthError(kind: .basicAuth))
completionHandler(.cancelAuthenticationChallenge, nil)
case NSURLAuthenticationMethodClientCertificate:
clientCertificateErrorOccurred[task.taskIdentifier] = true
completionHandler(.performDefaultHandling, nil)
if let cert = authDetails.clientCertificate,
let credential = ClientCertificateManager.shared.credential(for: cert) {
completionHandler(.useCredential, credential)
} else {
clientCertificateErrorOccurred[task.taskIdentifier] = true
completionHandler(.performDefaultHandling, nil)
}
default:
pendingResolver
.reject(OnboardingAuthError(kind: .authenticationUnsupported(
Expand Down
127 changes: 127 additions & 0 deletions Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Combine
import Shared
import SwiftUI
import UniformTypeIdentifiers

struct OnboardingServersListView: View {
enum Constants {
Expand All @@ -17,6 +18,13 @@ struct OnboardingServersListView: View {

@State private var showDocumentation = false
@State private var showManualInput = false
@State private var showCertificateImport = false
@State private var certificateImportData: Data?
@State private var certificateImportName = ""
@State private var certificateImportPassword = ""
@State private var certificateImportError: String?
@State private var certificateImportSuccessMessage: String?
@State private var showCertificatePasswordSheet = false
@State private var screenLoaded = false
@State private var autoConnectWorkItem: DispatchWorkItem?
@State private var autoConnectInstance: DiscoveredHomeAssistant?
Expand Down Expand Up @@ -97,6 +105,26 @@ struct OnboardingServersListView: View {
viewModel.selectInstance(.init(manualURL: connectURL), presentingController: presentingViewController)
}
}
.fileImporter(
isPresented: $showCertificateImport,
allowedContentTypes: [.pkcs12],
onCompletion: handleCertificateFileImport
)
.sheet(isPresented: $showCertificatePasswordSheet) {
certificatePasswordSheet
}
.alert(
item: Binding<AlertItem?>(
get: {
certificateImportSuccessMessage.map { AlertItem(id: $0, title: $0) }
},
set: {
certificateImportSuccessMessage = $0?.title
}
)
) { item in
Alert(title: Text(item.title), dismissButton: .default(Text(L10n.okLabel)))
}
.fullScreenCover(isPresented: .init(get: {
viewModel.showPermissionsFlow && viewModel.onboardingServer != nil
}, set: { newValue in
Expand Down Expand Up @@ -182,7 +210,16 @@ struct OnboardingServersListView: View {
}
}

@ToolbarContentBuilder
private var toolbarItems: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
showCertificateImport = true
} label: {
Image(systemSymbol: .lock)
}
.accessibilityLabel(L10n.Settings.ConnectionSection.ClientCertificate.title)
}
ToolbarItem(placement: .topBarTrailing) {
if prefillURL != nil {
CloseButton {
Expand Down Expand Up @@ -389,10 +426,100 @@ struct OnboardingServersListView: View {
.frame(height: 1)
.foregroundStyle(Color(uiColor: .secondaryLabel))
}

// MARK: - Certificate Import

private var certificatePasswordSheet: some View {
NavigationView {
Form {
Section {
TextField(
L10n.Settings.ConnectionSection.ClientCertificate.Import.name,
text: $certificateImportName
)
SecureField(
L10n.Settings.ConnectionSection.ClientCertificate.Import.password,
text: $certificateImportPassword
)
} footer: {
if let error = certificateImportError {
Text(error)
.foregroundStyle(.red)
}
}

Section {
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) {
performCertificateImport()
}
.disabled(certificateImportName.isEmpty)
}
}
.navigationTitle(L10n.Settings.ConnectionSection.ClientCertificate.Import.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.cancel) {
resetCertificateImportState()
}
}
}
}
}

private func handleCertificateFileImport(_ result: Result<URL, Error>) {
do {
let selectedFile = try result.get()
guard selectedFile.startAccessingSecurityScopedResource() else {
Current.Log.error("Cannot access security scoped resource for certificate")
return
}
defer { selectedFile.stopAccessingSecurityScopedResource() }

certificateImportData = try Data(contentsOf: selectedFile)
certificateImportName = selectedFile.deletingPathExtension().lastPathComponent
certificateImportPassword = ""
certificateImportError = nil
showCertificatePasswordSheet = true
} catch {
Current.Log.error("Error importing certificate file: \(error)")
}
}

private func performCertificateImport() {
guard let data = certificateImportData else { return }

do {
try ClientCertificateManager.shared.importP12(
data: data,
password: certificateImportPassword,
name: certificateImportName
)
resetCertificateImportState()
certificateImportSuccessMessage = "Imported \(certificateImportName)"
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success message is hard-coded and not localized. Please add a localized string (with a placeholder for the certificate name) and use that instead.

Suggested change
certificateImportSuccessMessage = "Imported \(certificateImportName)"
let successFormat = NSLocalizedString(
"client_certificate_import_success_format",
tableName: "Localizable",
bundle: .main,
value: "Imported %@",
comment: "Success message after importing a client certificate. Placeholder is the certificate name."
)
certificateImportSuccessMessage = String(format: successFormat, certificateImportName)

Copilot uses AI. Check for mistakes.
} catch let error as ClientCertificateError {
certificateImportError = error.localizedDescription
} catch {
certificateImportError = error.localizedDescription
}
}

private func resetCertificateImportState() {
showCertificatePasswordSheet = false
certificateImportData = nil
certificateImportName = ""
certificateImportPassword = ""
certificateImportError = nil
}
}

#Preview {
NavigationView {
OnboardingServersListView(prefillURL: nil, onboardingStyle: .secondary)
}
}

struct AlertItem: Identifiable {
var id: String
var title: String
}
14 changes: 13 additions & 1 deletion Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ Tags will work on any device with Home Assistant installed which has hardware su
"onboarding.connection_test_result.certificate_error.action_dont_trust" = "Don't Trust";
"onboarding.connection_test_result.certificate_error.action_trust" = "Trust Certificate";
"onboarding.connection_test_result.certificate_error.title" = "Failed to connect securely";
"onboarding.connection_test_result.client_certificate.description" = "Client Certificate Authentication is not supported.";
"onboarding.connection_test_result.client_certificate.description" = "This server requires a client certificate. Select a certificate installed on this device in Settings.";
"onboarding.connection_test_result.error_code" = "Error Code:";
"onboarding.connection_test_result.local_network_permission.description" = "\"Local Network\" privacy permission may have been denied. You can change this in the system Settings app.";
"onboarding.device_name_check.error.prompt" = "What device name should be used instead?";
Expand Down Expand Up @@ -728,6 +728,18 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho
"settings.connection_section.websocket.status.disconnected.title" = "Disconnected";
"settings.connection_section.websocket.status.rejected.title" = "Rejected";
"settings.connection_section.websocket.title" = "WebSocket";
"settings.connection_section.client_certificate.title" = "Client Certificate";
"settings.connection_section.client_certificate.none" = "None";
"settings.connection_section.client_certificate.configured" = "Configured";
"settings.connection_section.client_certificate.no_certificates" = "No certificates imported";
"settings.connection_section.client_certificate.import_instructions" = "Tap + to import a .p12 certificate file";
"settings.connection_section.client_certificate.details.header" = "Selected Certificate";
"settings.connection_section.client_certificate.details.name" = "Name";
"settings.connection_section.client_certificate.import.title" = "Import Certificate";
"settings.connection_section.client_certificate.import.name" = "Name";
"settings.connection_section.client_certificate.import.password" = "Password";
"settings.connection_section.client_certificate.import.import_button" = "Import";
"settings.connection_section.client_certificate.import.cancel" = "Cancel";
"settings.database_explorer.more_fields" = "+%li more fields";
"settings.database_explorer.no_entries" = "No entries found";
"settings.database_explorer.row_detail" = "Row Details";
Expand Down
Loading
Loading