diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 4b054f7e9..1ba5b3a0e 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -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 = ""; }; 1130A5752751BA1800640E38 /* Server.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.test.swift; sourceTree = ""; }; 1130A5772751BDD900640E38 /* ServerManager.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.test.swift; sourceTree = ""; }; + 42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantAPIIdentity.test.swift; sourceTree = ""; }; 1130F531253A1E7400F371BE /* ComplicationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationListViewController.swift; sourceTree = ""; }; 1130F57D253A2ED500F371BE /* ComplicationFamilySelectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationFamilySelectViewController.swift; sourceTree = ""; }; 113199DB28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthLoginViewController.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Sources/App/Servers/ServerSelectView.swift b/Sources/App/Servers/ServerSelectView.swift index e9fd937fa..f234b32fa 100644 --- a/Sources/App/Servers/ServerSelectView.swift +++ b/Sources/App/Servers/ServerSelectView.swift @@ -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 + } } } } diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift index fbba22378..f108e32aa 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift @@ -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) { diff --git a/Sources/App/Settings/Eureka/AccountRow.swift b/Sources/App/Settings/Eureka/AccountRow.swift index 3ce75146e..0436a5201 100644 --- a/Sources/App/Settings/Eureka/AccountRow.swift +++ b/Sources/App/Settings/Eureka/AccountRow.swift @@ -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, 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, 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, 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> 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 in - Promise { 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() } } } diff --git a/Sources/App/Settings/Settings/HomeAssistantAccountRowView.swift b/Sources/App/Settings/Settings/HomeAssistantAccountRowView.swift index 3779a3383..dd4ebd928 100644 --- a/Sources/App/Settings/Settings/HomeAssistantAccountRowView.swift +++ b/Sources/App/Settings/Settings/HomeAssistantAccountRowView.swift @@ -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 + } } } } diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 7c04e2668..e2740db84 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -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.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 } } } diff --git a/Sources/Shared/HATypedRequest+App.swift b/Sources/Shared/HATypedRequest+App.swift index 31688e527..30fa86644 100644 --- a/Sources/Shared/HATypedRequest+App.swift +++ b/Sources/Shared/HATypedRequest+App.swift @@ -146,6 +146,10 @@ public extension HATypedRequest { )) } + static func fetchCurrentUser() -> HATypedRequest { + HATypedRequest.currentUser() + } + static func fetchStates() -> HATypedRequest<[HAEntity]> { HATypedRequest<[HAEntity]>(request: .init( type: .rest(.get, "states") diff --git a/Tests/Shared/HomeAssistantAPIIdentity.test.swift b/Tests/Shared/HomeAssistantAPIIdentity.test.swift new file mode 100644 index 000000000..08a614cd1 --- /dev/null +++ b/Tests/Shared/HomeAssistantAPIIdentity.test.swift @@ -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( + _ request: HATypedRequest, + completion: @escaping (Result) -> 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( + to request: HATypedSubscription, + handler: @escaping (HACancellable, T) -> Void + ) -> HACancellable { + sentRequests.append(request.request) + return HANoopCancellable() + } + + @discardableResult + func subscribe( + to request: HATypedSubscription, + initiated: @escaping SubscriptionInitiatedHandler, + handler: @escaping (HACancellable, T) -> Void + ) -> HACancellable { + sentRequests.append(request.request) + initiated(.failure(.internal(debugDescription: "Subscriptions not mocked"))) + return HANoopCancellable() + } +}