Fix mtls profile info request on macos (#4555)

<!-- 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 -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## 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. -->
This commit is contained in:
Bruno Pantaleão Gonçalves
2026-04-24 16:00:37 +02:00
committed by GitHub
parent 1ec9940ad1
commit 3310bb6ada
8 changed files with 439 additions and 92 deletions

View File

@@ -104,6 +104,7 @@
1130A5742751B29E00640E38 /* PerServerContainer.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5732751B29E00640E38 /* PerServerContainer.test.swift */; };
1130A5762751BA1800640E38 /* Server.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5752751BA1800640E38 /* Server.test.swift */; };
1130A5782751BDD900640E38 /* ServerManager.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5772751BDD900640E38 /* ServerManager.test.swift */; };
42F1C0013140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */; };
1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130F531253A1E7400F371BE /* ComplicationListViewController.swift */; };
1130F57E253A2ED500F371BE /* ComplicationFamilySelectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130F57D253A2ED500F371BE /* ComplicationFamilySelectViewController.swift */; };
113199DC28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 113199DB28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift */; };
@@ -1949,6 +1950,7 @@
1130A5732751B29E00640E38 /* PerServerContainer.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerServerContainer.test.swift; sourceTree = "<group>"; };
1130A5752751BA1800640E38 /* Server.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.test.swift; sourceTree = "<group>"; };
1130A5772751BDD900640E38 /* ServerManager.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.test.swift; sourceTree = "<group>"; };
42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantAPIIdentity.test.swift; sourceTree = "<group>"; };
1130F531253A1E7400F371BE /* ComplicationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationListViewController.swift; sourceTree = "<group>"; };
1130F57D253A2ED500F371BE /* ComplicationFamilySelectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationFamilySelectViewController.swift; sourceTree = "<group>"; };
113199DB28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthLoginViewController.swift; sourceTree = "<group>"; };
@@ -7309,6 +7311,7 @@
1130A5732751B29E00640E38 /* PerServerContainer.test.swift */,
1130A5752751BA1800640E38 /* Server.test.swift */,
1130A5772751BDD900640E38 /* ServerManager.test.swift */,
42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */,
11B38EDE275BE29F00205C7B /* ConnectionInfo.test.swift */,
480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */,
114CBAEA2839FC2500A9BAFF /* SecurityExceptions.test.swift */,
@@ -10464,6 +10467,7 @@
11CD94B924B2D16F00BA801D /* WebhookResponseServiceCall.test.swift in Sources */,
11CD94B524B2C06700BA801D /* WebhookResponseUpdateSensors.test.swift in Sources */,
1130A5782751BDD900640E38 /* ServerManager.test.swift in Sources */,
42F1C0013140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift in Sources */,
11BC9E5724FDC1C900B9FBF7 /* ActiveSensor.test.swift in Sources */,
11F2F2A92587288200F61F7C /* NotificationAttachmentParserCamera.test.swift in Sources */,
1104FCCF253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift in Sources */,

View File

@@ -140,12 +140,15 @@ struct ServerSelectViewRow: View {
}
private func loadUserNameAndProfilePicture() {
Current.api(for: server)?.connection.caches.user.once { user in
userName = user.name.orEmpty
}
guard let api = Current.api(for: server) else { return }
Current.api(for: server)?.profilePicture { image in
profilePictureImage = image
api.currentUser { user in
userName = user?.name ?? ""
guard let user else { return }
api.profilePicture(for: user) { image in
profilePictureImage = image
}
}
}
}

View File

@@ -124,6 +124,11 @@ final class ConnectionSettingsViewModel: ObservableObject {
updateFromServerInfo(server.info)
updateURLs()
clientCertificate = server.info.connection.clientCertificate
Current.api(for: server)?.currentUser { [weak self] user in
Task { @MainActor [weak self] in
self?.loggedInUser = user?.name ?? ""
}
}
}
private func updateFromServerInfo(_ info: ServerInfo) {

View File

@@ -1,8 +1,6 @@
import Alamofire
import Eureka
import Foundation
import HAKit
import PromiseKit
import Shared
enum AccountRowValue: Equatable, CustomStringConvertible {
@@ -135,19 +133,19 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
}
deinit {
accountSubscription?.cancel()
avatarSubscription?.cancel()
currentUserRequest?.cancel()
avatarRequest?.cancel()
}
fileprivate var cachedImage: UIImage?
fileprivate var cachedUserName: String?
private var accountSubscription: HACancellable? {
private var currentUserRequest: HACancellable? {
didSet {
oldValue?.cancel()
}
}
private var avatarSubscription: HACancellable? {
private var avatarRequest: HACancellable? {
didSet {
oldValue?.cancel()
}
@@ -161,22 +159,10 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
}
}
enum FetchAvatarError: Error, CancellableError {
case missingPerson
case missingURLForUserEntityPicture
case alreadySet
case couldntDecode
var isCancelled: Bool {
if self == .alreadySet {
return true
} else {
return false
}
}
}
private func fetchAvatar() {
currentUserRequest = nil
avatarRequest = nil
guard let server = value?.server else {
cachedImage = nil
cachedUserName = nil
@@ -184,64 +170,32 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
return
}
cachedImage = nil
cachedUserName = nil
updateCell()
guard let api = Current.api(for: server) else {
Current.Log.error("No API available to fetch avatar")
return
}
accountSubscription = api.connection.caches.user.once { [weak self] user in
currentUserRequest = api.currentUser { [weak self] user in
guard let self else { return }
Current.Log.verbose("got user from user \(user)")
cachedUserName = user.name
guard value?.server?.identifier == server.identifier else { return }
cachedUserName = user?.name
updateCell()
var lastTask: Request? {
didSet {
oldValue?.cancel()
lastTask?.resume()
}
guard let user else {
return
}
avatarSubscription = api.connection.caches.states().once { [weak self] states in
firstly { () -> Guarantee<Set<HAEntity>> in
Guarantee.value(states.all)
}.map { states throws -> HAEntity in
if let person = states.first(where: { $0.attributes["user_id"] as? String == user.id }) {
return person
} else {
throw FetchAvatarError.missingPerson
}
}.map { entity -> String in
if let urlString = entity.attributes["entity_picture"] as? String {
return urlString
} else {
throw FetchAvatarError.missingURLForUserEntityPicture
}
}.map { path throws -> URL in
guard let url = server.info.connection.activeURL()?.appendingPathComponent(path) else {
throw ServerConnectionError.noActiveURL(server.info.name)
}
if let lastTask, lastTask.error == nil, lastTask.request?.url == url {
throw FetchAvatarError.alreadySet
}
return url
}.then { url -> Promise<Data> in
Promise<Data> { seal in
lastTask = api.manager.download(url).validate().responseData { result in
seal.resolve(result.result)
}
}
}.map { data throws -> UIImage in
if let image = UIImage(data: data) {
return image
} else {
throw FetchAvatarError.couldntDecode
}
}.done { [weak self] image in
Current.Log.verbose("got image \(image.size)")
self?.cachedImage = image
self?.updateCell()
}.cauterize()
avatarRequest = api.profilePicture(for: user) { [weak self] image in
guard let self else { return }
guard value?.server?.identifier == server.identifier else { return }
cachedImage = image
updateCell()
}
}
}

View File

@@ -62,12 +62,15 @@ struct HomeAssistantAccountRowView: View {
}
private func loadUserNameAndProfilePicture() {
Current.api(for: server)?.connection.caches.user.once { user in
userName = user.name.orEmpty
}
guard let api = Current.api(for: server) else { return }
Current.api(for: server)?.profilePicture { image in
profilePicture = image
api.currentUser { user in
userName = user?.name ?? ""
guard let user else { return }
api.profilePicture(for: user) { image in
profilePicture = image
}
}
}
}

View File

@@ -1069,15 +1069,59 @@ public class HomeAssistantAPI {
}
#endif
public func profilePictureURL(completion: @escaping (URL?) -> Void) {
connection.caches.user.once { [weak self] user in
guard let self else {
Current.Log.error("Failed to retrieve profile picture URL: self is nil")
completion(nil)
return
private final class ProfilePictureCancellable: HACancellable {
private(set) var isCancelled = false
private var cancellables = [HACancellable]()
private var downloadRequest: Request?
func add(_ cancellable: HACancellable) {
if isCancelled {
cancellable.cancel()
} else {
cancellables.append(cancellable)
}
connection.caches.states().once { [weak self] states in
let states = states.all
}
func setDownloadRequest(_ request: Request) {
if isCancelled {
request.cancel()
} else {
downloadRequest = request
}
}
func cancel() {
guard !isCancelled else { return }
isCancelled = true
cancellables.forEach { $0.cancel() }
downloadRequest?.cancel()
cancellables.removeAll()
downloadRequest = nil
}
}
@discardableResult
public func currentUser(completion: @escaping (HAResponseCurrentUser?) -> Void) -> HACancellable {
connection.send(HATypedRequest<HAResponseCurrentUser>.fetchCurrentUser()) { result in
switch result {
case let .success(user):
completion(user)
case let .failure(error):
Current.Log.error("Failed to retrieve current user: \(error)")
completion(nil)
}
}
}
@discardableResult
public func profilePictureURL(
for user: HAResponseCurrentUser,
completion: @escaping (URL?) -> Void
) -> HACancellable {
connection.send(HATypedRequest<[HAEntity]>.fetchStates()) { [weak self] result in
switch result {
case let .success(states):
guard let person = states.first(where: { $0.attributes["user_id"] as? String == user.id }) else {
Current.Log.error("Profile picture: No person found for user \(user.id)")
completion(nil)
@@ -1090,25 +1134,58 @@ public class HomeAssistantAPI {
return
}
guard let url = self?.server.info.connection.activeURL()?.appendingPathComponent(path) else {
Current.Log.error("Profile picture: Missing active URL for user entity picture, user id \(user.id)")
guard let url = self?.resolvedProfilePictureURL(from: path) else {
Current.Log.error("Profile picture: Invalid URL for user entity picture, user id \(user.id)")
completion(nil)
return
}
completion(url)
case let .failure(error):
Current.Log.error("Failed to retrieve states for profile picture: \(error)")
completion(nil)
}
}
}
public func profilePicture(completion: @escaping (UIImage?) -> Void) {
profilePictureURL { [weak self] url in
@discardableResult
public func profilePictureURL(completion: @escaping (URL?) -> Void) -> HACancellable {
let cancellable = ProfilePictureCancellable()
cancellable.add(currentUser { [weak self] user in
guard !cancellable.isCancelled else { return }
guard let self, let user else {
completion(nil)
return
}
cancellable.add(profilePictureURL(for: user) { url in
guard !cancellable.isCancelled else { return }
completion(url)
})
})
return cancellable
}
@discardableResult
public func profilePicture(
for user: HAResponseCurrentUser,
completion: @escaping (UIImage?) -> Void
) -> HACancellable {
let cancellable = ProfilePictureCancellable()
cancellable.add(profilePictureURL(for: user) { [weak self] url in
guard !cancellable.isCancelled else { return }
guard let self, let url else {
completion(nil)
return
}
manager.download(url).validate().responseData { response in
let request = manager.download(url).validate()
cancellable.setDownloadRequest(request)
request.responseData { response in
guard !cancellable.isCancelled else { return }
switch response.result {
case let .success(data):
completion(UIImage(data: data))
@@ -1117,6 +1194,74 @@ public class HomeAssistantAPI {
completion(nil)
}
}
})
return cancellable
}
@discardableResult
public func profilePicture(completion: @escaping (UIImage?) -> Void) -> HACancellable {
let cancellable = ProfilePictureCancellable()
cancellable.add(currentUser { [weak self] user in
guard !cancellable.isCancelled else { return }
guard let self, let user else {
completion(nil)
return
}
cancellable.add(profilePicture(for: user) { image in
guard !cancellable.isCancelled else { return }
completion(image)
})
})
return cancellable
}
private func resolvedProfilePictureURL(from path: String) -> URL? {
guard let activeURL = server.info.connection.activeURL() else {
return nil
}
guard let url = URL(string: path, relativeTo: activeURL)?.absoluteURL else {
return nil
}
guard url.hasSameOrigin(as: activeURL) else {
return nil
}
return url
}
}
private extension URL {
func hasSameOrigin(as other: URL) -> Bool {
guard let scheme = scheme?.lowercased(),
let otherScheme = other.scheme?.lowercased(),
let host = host?.lowercased(),
let otherHost = other.host?.lowercased() else {
return false
}
return scheme == otherScheme &&
host == otherHost &&
normalizedOriginPort == other.normalizedOriginPort
}
private var normalizedOriginPort: Int? {
if let port {
return port
}
switch scheme?.lowercased() {
case "http":
return 80
case "https":
return 443
default:
return nil
}
}
}

View File

@@ -146,6 +146,10 @@ public extension HATypedRequest {
))
}
static func fetchCurrentUser() -> HATypedRequest<HAResponseCurrentUser> {
HATypedRequest<HAResponseCurrentUser>.currentUser()
}
static func fetchStates() -> HATypedRequest<[HAEntity]> {
HATypedRequest<[HAEntity]>(request: .init(
type: .rest(.get, "states")

View File

@@ -0,0 +1,229 @@
import HAKit
@testable import Shared
import XCTest
final class HomeAssistantAPIIdentityTests: XCTestCase {
override func tearDown() {
ServerFixture.reset()
super.tearDown()
}
func testCurrentUserUsesWebSocketEndpoint() {
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
let connection = FakeHAConnection()
connection.mockResponses["auth/current_user"] = .dictionary([
"id": "user-id",
"name": "cepresso",
"is_owner": false,
"is_admin": true,
"credentials": [],
"mfa_modules": [],
])
api.connection = connection
let expectation = expectation(description: "current user")
api.currentUser { user in
XCTAssertEqual(user?.id, "user-id")
XCTAssertEqual(user?.name, "cepresso")
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
XCTAssertEqual(connection.sentRequests.count, 1)
guard case let .webSocket(command) = connection.sentRequests[0].type else {
XCTFail("Expected WebSocket request")
return
}
XCTAssertEqual(command, "auth/current_user")
}
func testProfilePictureURLUsesWebSocketCurrentUserAndRestStates() {
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
let connection = FakeHAConnection()
connection.mockResponses["auth/current_user"] = .dictionary([
"id": "user-id",
"name": "cepresso",
"is_owner": false,
"is_admin": true,
"credentials": [],
"mfa_modules": [],
])
connection.mockResponses["states"] = .array([
.dictionary([
"entity_id": "person.cepresso",
"state": "home",
"last_changed": "2026-04-23T10:00:00Z",
"last_updated": "2026-04-23T10:00:00Z",
"attributes": [
"user_id": "user-id",
"entity_picture": "/api/image/serve/abc/original?token=123",
],
"context": [
"id": "context-id",
],
]),
])
api.connection = connection
let expectation = expectation(description: "profile picture URL")
api.profilePictureURL { url in
XCTAssertEqual(
url?.absoluteString,
"https://external.example.com/api/image/serve/abc/original?token=123"
)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
XCTAssertEqual(connection.sentRequests.count, 2)
guard case let .webSocket(firstCommand) = connection.sentRequests[0].type else {
XCTFail("Expected first request to use WebSocket")
return
}
XCTAssertEqual(firstCommand, "auth/current_user")
guard case let .rest(secondMethod, secondCommand) = connection.sentRequests[1].type else {
XCTFail("Expected second request to use REST")
return
}
XCTAssertEqual(secondMethod, .get)
XCTAssertEqual(secondCommand, "states")
}
func testProfilePictureURLRejectsExternalEntityPictureURL() {
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
let connection = FakeHAConnection()
connection.mockResponses["auth/current_user"] = .dictionary([
"id": "user-id",
"name": "cepresso",
"is_owner": false,
"is_admin": true,
"credentials": [],
"mfa_modules": [],
])
connection.mockResponses["states"] = .array([
.dictionary([
"entity_id": "person.cepresso",
"state": "home",
"last_changed": "2026-04-23T10:00:00Z",
"last_updated": "2026-04-23T10:00:00Z",
"attributes": [
"user_id": "user-id",
"entity_picture": "https://attacker.example.com/avatar.png",
],
"context": [
"id": "context-id",
],
]),
])
api.connection = connection
let expectation = expectation(description: "profile picture URL")
api.profilePictureURL { url in
XCTAssertNil(url)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
XCTAssertEqual(connection.sentRequests.count, 2)
}
}
private final class FakeHAConnection: HAConnection {
weak var delegate: HAConnectionDelegate?
var configuration = HAConnectionConfiguration(
connectionInfo: { nil },
fetchAuthToken: { completion in completion(.success("token")) }
)
var state: HAConnectionState = .disconnected(reason: .disconnected)
lazy var caches: HACachesContainer = .init(connection: self)
var callbackQueue: DispatchQueue = .main
var sentRequests = [HARequest]()
var mockResponses = [String: HAData]()
func connect() {}
func disconnect() {}
@discardableResult
func send(
_ request: HARequest,
completion: @escaping RequestCompletion
) -> HACancellable {
sentRequests.append(request)
completion(.failure(.internal(debugDescription: "Raw request not mocked")))
return HANoopCancellable()
}
@discardableResult
func send<T>(
_ request: HATypedRequest<T>,
completion: @escaping (Result<T, HAError>) -> Void
) -> HACancellable where T: HADataDecodable {
sentRequests.append(request.request)
let command = request.request.type.command
guard let data = mockResponses[command] else {
completion(.failure(.internal(debugDescription: "Missing mock response for \(command)")))
return HANoopCancellable()
}
do {
try completion(.success(T(data: data)))
} catch {
completion(.failure(.underlying(error as NSError)))
}
return HANoopCancellable()
}
@discardableResult
func subscribe(
to request: HARequest,
handler: @escaping SubscriptionHandler
) -> HACancellable {
sentRequests.append(request)
return HANoopCancellable()
}
@discardableResult
func subscribe(
to request: HARequest,
initiated: @escaping SubscriptionInitiatedHandler,
handler: @escaping SubscriptionHandler
) -> HACancellable {
sentRequests.append(request)
initiated(.failure(.internal(debugDescription: "Subscriptions not mocked")))
return HANoopCancellable()
}
@discardableResult
func subscribe<T>(
to request: HATypedSubscription<T>,
handler: @escaping (HACancellable, T) -> Void
) -> HACancellable {
sentRequests.append(request.request)
return HANoopCancellable()
}
@discardableResult
func subscribe<T>(
to request: HATypedSubscription<T>,
initiated: @escaping SubscriptionInitiatedHandler,
handler: @escaping (HACancellable, T) -> Void
) -> HACancellable {
sentRequests.append(request.request)
initiated(.failure(.internal(debugDescription: "Subscriptions not mocked")))
return HANoopCancellable()
}
}