From b1b8daf3ac0c99d499d378e539ded765196cdd72 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Wed, 27 May 2026 13:36:05 +0200 Subject: [PATCH 1/5] Improve URL handling and user proper realm when building API URL for ESPProvisionProvider --- ORLib.xcodeproj/project.pbxproj | 8 ++ .../ESPProvisionURLResolver.swift | 85 +++++++++++++++++ ORLib/UI/ORViewController.swift | 8 +- Tests/ESPProvisionURLResolverTest.swift | 91 +++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift create mode 100644 Tests/ESPProvisionURLResolverTest.swift diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index d27d36c..9ecd0d2 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 914F7D4429215D3500655A22 /* test12.json in Resources */ = {isa = PBXBuildFile; fileRef = 914F7D4329215D3500655A22 /* test12.json */; }; 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */; }; 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9154E2A12D9EB3220055E565 /* URLTest.swift */; }; + 91E8A0032F07700000E5BF01 /* ESPProvisionURLResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E8A0022F07700000E5BF01 /* ESPProvisionURLResolverTest.swift */; }; 9156512A28FC6D6700062E16 /* test9.json in Resources */ = {isa = PBXBuildFile; fileRef = 9156512928FC6D6700062E16 /* test9.json */; }; 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */; }; 915CCC972EF16456006B65AA /* DeviceProvision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915CCC962EF16456006B65AA /* DeviceProvision.swift */; }; @@ -71,6 +72,7 @@ 91F2165C2D887BCF008F9CA7 /* ORConfigChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */; }; 91F2165E2D887BCF008F9CA7 /* DeviceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */; }; 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */; }; + 91E8A0012F07700000E5BF01 /* ESPProvisionURLResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E8A0002F07700000E5BF01 /* ESPProvisionURLResolver.swift */; }; 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */; }; 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */; }; 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; @@ -145,6 +147,7 @@ 914F7D4329215D3500655A22 /* test12.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = test12.json; sourceTree = ""; }; 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringUtilsTest.swift; sourceTree = ""; }; 9154E2A12D9EB3220055E565 /* URLTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTest.swift; sourceTree = ""; }; + 91E8A0022F07700000E5BF01 /* ESPProvisionURLResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionURLResolverTest.swift; sourceTree = ""; }; 9156512928FC6D6700062E16 /* test9.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = test9.json; sourceTree = ""; }; 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPI.swift; sourceTree = ""; }; 915CCC962EF16456006B65AA /* DeviceProvision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvision.swift; sourceTree = ""; }; @@ -181,6 +184,7 @@ 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; + 91E8A0002F07700000E5BF01 /* ESPProvisionURLResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionURLResolver.swift; sourceTree = ""; }; 91F216712D887BCF008F9CA7 /* TimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSource.swift; sourceTree = ""; }; 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; @@ -359,6 +363,7 @@ 91F216732D887C53008F9CA7 /* TestTimeSource.swift */, 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, 9154E2A12D9EB3220055E565 /* URLTest.swift */, + 91E8A0022F07700000E5BF01 /* ESPProvisionURLResolverTest.swift */, ); path = Tests; sourceTree = ""; @@ -375,6 +380,7 @@ 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */, 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */, 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */, + 91E8A0002F07700000E5BF01 /* ESPProvisionURLResolver.swift */, 91F216712D887BCF008F9CA7 /* TimeSource.swift */, 91DD55A02DE7306100957233 /* BLEPermissionsChecker.swift */, 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */, @@ -562,6 +568,7 @@ 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */, 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */, 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */, + 91E8A0012F07700000E5BF01 /* ESPProvisionURLResolver.swift in Sources */, 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */, 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */, 4CBDF2CA2AE2869D00C7D94C /* ApiManager.swift in Sources */, @@ -594,6 +601,7 @@ 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */, 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */, + 91E8A0032F07700000E5BF01 /* ESPProvisionURLResolverTest.swift in Sources */, 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */, 91A9A8FB28BF6A4900DF8928 /* ConfigManagerTest.swift in Sources */, 91AA79F328D628E9005B9913 /* Fixture.swift in Sources */, diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift new file mode 100644 index 0000000..0ebb171 --- /dev/null +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift @@ -0,0 +1,85 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation + +struct ESPProvisionURLResolver { + private static let realmQueryKey = "realm" + private static let realmPathAllowedCharacters: CharacterSet = { + var allowedCharacters = CharacterSet.urlPathAllowed + allowedCharacters.remove(charactersIn: "/%") + return allowedCharacters + }() + + private let userDefaults: UserDefaults? + + init(userDefaults: UserDefaults? = UserDefaults(suiteName: DefaultsKey.groupEntitlement)) { + self.userDefaults = userDefaults + } + + func realm(currentURL: URL?, targetUrl: String?, baseUrl: String?) -> String { + if let realm = realmFromURLs(currentURL: currentURL, targetUrl: targetUrl, baseUrl: baseUrl) { + return realm + } + + if let realm = userDefaults?.string(forKey: DefaultsKey.realmKey), + !realm.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return realm + } + + return "master" + } + + func apiURL(baseUrl: String, realm: String) -> URL? { + guard let appUrl = makeURL(from: baseUrl), + var components = URLComponents(url: appUrl, resolvingAgainstBaseURL: false), + let encodedRealm = realm.addingPercentEncoding(withAllowedCharacters: Self.realmPathAllowedCharacters) else { + return nil + } + + components.percentEncodedPath = "/api/\(encodedRealm)" + components.query = nil + components.fragment = nil + return components.url + } + + private func realmFromURLs(currentURL: URL?, targetUrl: String?, baseUrl: String?) -> String? { + let urls = [ + currentURL, + targetUrl.flatMap { makeURL(from: $0) }, + baseUrl.flatMap { makeURL(from: $0) } + ] + + for url in urls { + guard let url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let realm = components.queryItems?.first(where: { $0.name == Self.realmQueryKey })?.value, + !realm.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + return realm + } + + return nil + } + + private func makeURL(from urlString: String) -> URL? { + URL(string: urlString) ?? urlString.stringByURLEncoding().flatMap { URL(string: $0) } + } +} diff --git a/ORLib/UI/ORViewController.swift b/ORLib/UI/ORViewController.swift index 37d9608..6eed5fb 100644 --- a/ORLib/UI/ORViewController.swift +++ b/ORLib/UI/ORViewController.swift @@ -396,10 +396,10 @@ extension ORViewcontroller: WKScriptMessageHandler { case Providers.espprovision: switch action { case Actions.providerInit: - if let baseUrl, let appUrl = URL(string: baseUrl), - - // TODO: should use realm, not always master - let apiUrl = URL(string: "\(appUrl.scheme ?? "https")://\(appUrl.host ?? "localhost")\(appUrl.port != nil ? ":\(appUrl.port!)" : "")/api/master") { + let urlResolver = ESPProvisionURLResolver() + let realm = urlResolver.realm(currentURL: myWebView?.url, targetUrl: targetUrl, baseUrl: baseUrl) + if let baseUrl, + let apiUrl = urlResolver.apiURL(baseUrl: baseUrl, realm: realm) { espProvisionProvider = ESPProvisionProvider(apiURL: apiUrl) } else { espProvisionProvider = ESPProvisionProvider() diff --git a/Tests/ESPProvisionURLResolverTest.swift b/Tests/ESPProvisionURLResolverTest.swift new file mode 100644 index 0000000..9d143b7 --- /dev/null +++ b/Tests/ESPProvisionURLResolverTest.swift @@ -0,0 +1,91 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation +import Testing +@testable import ORLib + +struct ESPProvisionURLResolverTest { + + @Test func urlRealmWinsOverStoredRealm() throws { + let (suiteName, userDefaults) = try makeUserDefaults() + userDefaults.set("master", forKey: DefaultsKey.realmKey) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) + + #expect(resolver.realm(currentURL: nil, + targetUrl: "https://example.com/manager/?realm=tenantA", + baseUrl: "https://example.com") == "tenantA") + } + + @Test func currentURLWinsOverTargetUrlAndBaseUrl() throws { + let (suiteName, userDefaults) = try makeUserDefaults() + userDefaults.set("stored", forKey: DefaultsKey.realmKey) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) + + #expect(resolver.realm(currentURL: URL(string: "https://example.com/manager/?realm=current"), + targetUrl: "https://example.com/manager/?realm=target", + baseUrl: "https://example.com/?realm=base") == "current") + } + + @Test func blankUrlRealmFallsBackToStoredRealm() throws { + let (suiteName, userDefaults) = try makeUserDefaults() + userDefaults.set("stored", forKey: DefaultsKey.realmKey) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) + + #expect(resolver.realm(currentURL: URL(string: "https://example.com/manager/?realm=%20"), + targetUrl: "https://example.com/manager/?realm=", + baseUrl: "https://example.com") == "stored") + } + + @Test func missingUrlAndStoredRealmFallsBackToMaster() { + let resolver = ESPProvisionURLResolver(userDefaults: nil) + + #expect(resolver.realm(currentURL: nil, targetUrl: nil, baseUrl: nil) == "master") + } + + @Test func apiURLPreservesOriginAndRemovesAppPathQueryAndFragment() throws { + let resolver = ESPProvisionURLResolver(userDefaults: nil) + + let apiURL = try #require(resolver.apiURL(baseUrl: "https://example.com:8443/manager/?realm=tenantA#fragment", + realm: "tenantA")) + + #expect(apiURL.absoluteString == "https://example.com:8443/api/tenantA") + } + + @Test func apiURLEncodesRealmAsSinglePathSegment() throws { + let resolver = ESPProvisionURLResolver(userDefaults: nil) + + let apiURL = try #require(resolver.apiURL(baseUrl: "https://example.com", realm: "tenant/a b%")) + + #expect(apiURL.absoluteString == "https://example.com/api/tenant%2Fa%20b%25") + } + + private func makeUserDefaults() throws -> (suiteName: String, userDefaults: UserDefaults) { + let suiteName = "ESPProvisionURLResolverTest.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + return (suiteName, userDefaults) + } +} From 7287623f1bef787af86bdd9b4208a4f8c6333823 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Wed, 27 May 2026 13:52:24 +0200 Subject: [PATCH 2/5] Don't init ESPProvisionProvider with an apiURL, pass it in provision call, it's the only place it's used. --- .../ESPProvision/DeviceProvision.swift | 16 ++++++------- .../ESPProvision/DeviceProvisionAPI.swift | 8 +------ .../ESPProvision/ESPProvisionProvider.swift | 16 ++++--------- ORLib/UI/ORViewController.swift | 24 ++++++++++++------- Tests/DeviceProvisionAPIMock.swift | 4 +++- Tests/ESPProvisionProviderTest.swift | 14 ++++++----- 6 files changed, 38 insertions(+), 44 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index 1429476..d9c5326 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -22,7 +22,7 @@ import os import RandomPasswordGenerator protocol DeviceProvisionAPI { - func provision(modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult + func provision(apiURL: URL, modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult } struct ProvisionResult { @@ -40,20 +40,18 @@ class DeviceProvision { var callbackChannel: CallbackChannel? private let timeSource: any TimeSource - var apiURL: URL var deviceProvisionAPI: DeviceProvisionAPI var backendConnectionTimeout: TimeInterval = 60 - init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, apiURL: URL, timeSource: any TimeSource = SystemTimeSource()) { + init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, timeSource: any TimeSource = SystemTimeSource()) { self.deviceConnection = deviceConnection self.callbackChannel = callbackChannel self.timeSource = timeSource - self.apiURL = apiURL - self.deviceProvisionAPI = DeviceProvisionAPIREST(apiURL: apiURL) + self.deviceProvisionAPI = DeviceProvisionAPIREST() } - public func provision(userToken: String) async { + public func provision(apiURL: URL, userToken: String) async { guard deviceConnection?.isConnected ?? false else { sendProvisionDeviceStatus(connected: false, error: .notConnected, errorMessage: "No connection established to device") return @@ -65,10 +63,10 @@ class DeviceProvision { let password = try generatePassword() - let result = try await deviceProvisionAPI.provision(modelName: deviceInfo.modelName, deviceId: deviceInfo.deviceId, password: password, token: userToken) + let result = try await deviceProvisionAPI.provision(apiURL: apiURL, modelName: deviceInfo.modelName, deviceId: deviceInfo.deviceId, password: password, token: userToken) let userName = deviceInfo.deviceId.lowercased(with: Locale(identifier: "en")) - try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL, + try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL(apiURL: apiURL), mqttUser: userName, mqttPassword: password, assetId: result.assetId, @@ -132,7 +130,7 @@ class DeviceProvision { return try generator.generate() } - private var mqttURL: String { + private func mqttURL(apiURL: URL) -> String { // TODO: is this OK or do we want to get the mqtt url from the server return "mqtts://\(apiURL.host ?? "localhost"):8883" diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvisionAPI.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvisionAPI.swift index 02c725a..c1f1acc 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvisionAPI.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvisionAPI.swift @@ -22,13 +22,7 @@ import os struct DeviceProvisionAPIREST: DeviceProvisionAPI { - init(apiURL: URL) { - self.apiURL = apiURL - } - - private var apiURL: URL - - func provision(modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult { + func provision(apiURL: URL, modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult { /* curl -v http://localhost:8080/api/master/rest/battery -d'{ "model": 0, diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift index 12b3ac6..3a4814e 100644 --- a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift @@ -43,13 +43,11 @@ class ESPProvisionProvider: NSObject { private var wifiProvisioner: WifiProvisioner? - private var apiURL = URL(string: "http://localhost:8080/api/master")! - private var blePermissionsChecker: BLEPermissionsChecker? typealias DeviceProvisionFactory = () -> DeviceProvision private lazy var deviceProvisionFactory: DeviceProvisionFactory = { - DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: self.apiURL, timeSource: self.timeSource) + DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, timeSource: self.timeSource) } private var callbackChannel: CallbackChannel? @@ -76,11 +74,6 @@ class ESPProvisionProvider: NSObject { self.init(timeSource: SystemTimeSource()) } - public convenience init(apiURL: URL = URL(string: "http://localhost:8080/api/master")!) { - self.init(timeSource: SystemTimeSource()) - self.apiURL = apiURL - } - // MARK: Standard provider lifecycle public func initialize() -> [String: Any] { @@ -205,7 +198,7 @@ class ESPProvisionProvider: NSObject { // MARK: OR Configuration - public func provisionDevice(userToken: String) { + public func provisionDevice(apiURL: URL, userToken: String) { guard deviceConnection?.isConnected ?? false else { sendProvisionDeviceError(.notConnected, errorMessage: "No connection established to device") return @@ -214,7 +207,7 @@ class ESPProvisionProvider: NSObject { do { let deviceProvision = deviceProvisionFactory() - try await deviceProvision.provision(userToken: userToken) + try await deviceProvision.provision(apiURL: apiURL, userToken: userToken) } catch let error as ESPProviderError { sendExitProvisioningError(error.errorCode, errorMessage: error.errorMessage) } catch { @@ -238,7 +231,6 @@ extension ESPProvisionProvider { public convenience init(searchDeviceTimeout: TimeInterval = 120, searchDeviceMaxIterations: Int = 25, searchWifiTimeout: TimeInterval = 120, searchWifiMaxIterations: Int = 25, deviceProvisionAPI: DeviceProvisionAPI? = nil, backendConnectionTimeout: TimeInterval? = nil, - apiURL: URL = URL(string: "http://localhost:8080/api/master")!, timeSource: (any TimeSource)? = nil) { self.init(timeSource: timeSource ?? SystemTimeSource()) self.searchDeviceTimeout = searchDeviceTimeout @@ -250,7 +242,7 @@ extension ESPProvisionProvider { self.searchWifiMaxIterations = searchWifiMaxIterations self.deviceProvisionFactory = { - let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: apiURL, timeSource: self.timeSource) + let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, timeSource: self.timeSource) if let deviceProvisionAPI { deviceProvision.deviceProvisionAPI = deviceProvisionAPI } diff --git a/ORLib/UI/ORViewController.swift b/ORLib/UI/ORViewController.swift index 6eed5fb..122589b 100644 --- a/ORLib/UI/ORViewController.swift +++ b/ORLib/UI/ORViewController.swift @@ -396,14 +396,7 @@ extension ORViewcontroller: WKScriptMessageHandler { case Providers.espprovision: switch action { case Actions.providerInit: - let urlResolver = ESPProvisionURLResolver() - let realm = urlResolver.realm(currentURL: myWebView?.url, targetUrl: targetUrl, baseUrl: baseUrl) - if let baseUrl, - let apiUrl = urlResolver.apiURL(baseUrl: baseUrl, realm: realm) { - espProvisionProvider = ESPProvisionProvider(apiURL: apiUrl) - } else { - espProvisionProvider = ESPProvisionProvider() - } + espProvisionProvider = ESPProvisionProvider() espProvisionProvider?.sendDataCallback = { [weak self] data in self?.sendData(data: data) } @@ -453,7 +446,20 @@ extension ORViewcontroller: WKScriptMessageHandler { espProvisionProvider?.exitProvisioning() case Actions.provisionDevice: if let userToken = postMessageDict["userToken"] as? String { - espProvisionProvider?.provisionDevice(userToken: userToken) + let urlResolver = ESPProvisionURLResolver() + let realm = urlResolver.realm(currentURL: myWebView?.url, targetUrl: targetUrl, baseUrl: baseUrl) + if let baseUrl, + let apiUrl = urlResolver.apiURL(baseUrl: baseUrl, realm: realm) { + espProvisionProvider?.provisionDevice(apiURL: apiUrl, userToken: userToken) + } else { + let payload: [String: Any] = [ + DefaultsKey.providerKey: provider, + DefaultsKey.actionKey: action, + "errorCode": ESPProviderErrorCode.communicationError.rawValue, + "errorMessage": "Unable to determine API URL" + ] + self.sendData(data: payload) + } } else { let payload: [String: Any] = [ DefaultsKey.providerKey: provider, diff --git a/Tests/DeviceProvisionAPIMock.swift b/Tests/DeviceProvisionAPIMock.swift index f405323..04258da 100644 --- a/Tests/DeviceProvisionAPIMock.swift +++ b/Tests/DeviceProvisionAPIMock.swift @@ -29,10 +29,12 @@ class DeviceProvisionAPIMock: DeviceProvisionAPI { var receivedDeviceId: String? var receivedPassword: String? var receivedToken: String? + var receivedApiURL: URL? var provisionCallCount = 0 - func provision(modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult { + func provision(apiURL: URL, modelName: String, deviceId: String, password: String, token: String) async throws -> ProvisionResult { provisionCallCount += 1 + receivedApiURL = apiURL receivedModelName = modelName receivedDeviceId = deviceId receivedPassword = password diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 3ab0961..73ed17b 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -1166,7 +1166,8 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + let apiURL = URL(string: "https://tenant.example.com:8443/api/tenantA")! + provider.provisionDevice(apiURL: apiURL, userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1177,7 +1178,7 @@ struct ESPProvisionProviderTest { #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") - #expect(openRemoteConfig.mqttBrokerURL == "mqtts://localhost:8883") + #expect(openRemoteConfig.mqttBrokerURL == "mqtts://tenant.example.com:8883") #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) #expect(openRemoteConfig.assetID == "AssetID") @@ -1206,6 +1207,7 @@ struct ESPProvisionProviderTest { #expect(deviceProvisionAPIMock.receivedDeviceId == expectedDeviceInfo.deviceID) #expect(deviceProvisionAPIMock.receivedPassword != nil) #expect(deviceProvisionAPIMock.receivedToken == "OAUTH_TOKEN") + #expect(deviceProvisionAPIMock.receivedApiURL == apiURL) } @Test func provisionDeviceSuccessAfterMultipleStatusRequest() async throws { @@ -1247,7 +1249,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1331,7 +1333,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1413,7 +1415,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1472,7 +1474,7 @@ struct ESPProvisionProviderTest { _ = await discoverDeviceAndStopScan(provider: provider) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.provisionDevice) { - provider.provisionDevice(userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") } #expect(receivedData["provider"] as? String == Providers.espprovision) From 6c7fd9d9de71eddc826a656f74da93a923f05348 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Wed, 27 May 2026 13:58:41 +0200 Subject: [PATCH 3/5] Make sure proper realm is part of configuration sent to device. --- .../ESPProvision/DeviceConnection.swift | 4 ++-- .../ESPProvision/DeviceProvision.swift | 3 ++- .../ESPProvision/ESPProvisionProvider.swift | 4 ++-- ORLib/UI/ORViewController.swift | 2 +- Tests/ESPProvisionProviderTest.swift | 12 ++++++------ 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift index 9c5a255..4314e8a 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift @@ -144,12 +144,12 @@ class DeviceConnection { } } - func sendOpenRemoteConfig(mqttBrokerUrl: String, mqttUser: String, mqttPassword: String, assetId: String, properties: [String: String] = [:]) async throws { + func sendOpenRemoteConfig(mqttBrokerUrl: String, mqttUser: String, mqttPassword: String, realm: String, assetId: String, properties: [String: String] = [:]) async throws { if !isConnected { throw ESPProviderError(errorCode: .notConnected, errorMessage: "No connection established to device") } do { - try await configChannel!.sendOpenRemoteConfig(mqttBrokerUrl: mqttBrokerUrl, mqttUser: mqttUser, mqttPassword: mqttPassword, assetId: assetId, properties: properties) + try await configChannel!.sendOpenRemoteConfig(mqttBrokerUrl: mqttBrokerUrl, mqttUser: mqttUser, mqttPassword: mqttPassword, realm: realm, assetId: assetId, properties: properties) } catch { throw ESPProviderError(errorCode: .communicationError, errorMessage: error.localizedDescription) } diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index d9c5326..66812ab 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -51,7 +51,7 @@ class DeviceProvision { self.deviceProvisionAPI = DeviceProvisionAPIREST() } - public func provision(apiURL: URL, userToken: String) async { + public func provision(apiURL: URL, realm: String, userToken: String) async { guard deviceConnection?.isConnected ?? false else { sendProvisionDeviceStatus(connected: false, error: .notConnected, errorMessage: "No connection established to device") return @@ -69,6 +69,7 @@ class DeviceProvision { try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL(apiURL: apiURL), mqttUser: userName, mqttPassword: password, + realm: realm, assetId: result.assetId, properties: result.properties) diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift index 3a4814e..330732c 100644 --- a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift @@ -198,7 +198,7 @@ class ESPProvisionProvider: NSObject { // MARK: OR Configuration - public func provisionDevice(apiURL: URL, userToken: String) { + public func provisionDevice(apiURL: URL, realm: String, userToken: String) { guard deviceConnection?.isConnected ?? false else { sendProvisionDeviceError(.notConnected, errorMessage: "No connection established to device") return @@ -207,7 +207,7 @@ class ESPProvisionProvider: NSObject { do { let deviceProvision = deviceProvisionFactory() - try await deviceProvision.provision(apiURL: apiURL, userToken: userToken) + try await deviceProvision.provision(apiURL: apiURL, realm: realm, userToken: userToken) } catch let error as ESPProviderError { sendExitProvisioningError(error.errorCode, errorMessage: error.errorMessage) } catch { diff --git a/ORLib/UI/ORViewController.swift b/ORLib/UI/ORViewController.swift index 122589b..eb9024d 100644 --- a/ORLib/UI/ORViewController.swift +++ b/ORLib/UI/ORViewController.swift @@ -450,7 +450,7 @@ extension ORViewcontroller: WKScriptMessageHandler { let realm = urlResolver.realm(currentURL: myWebView?.url, targetUrl: targetUrl, baseUrl: baseUrl) if let baseUrl, let apiUrl = urlResolver.apiURL(baseUrl: baseUrl, realm: realm) { - espProvisionProvider?.provisionDevice(apiURL: apiUrl, userToken: userToken) + espProvisionProvider?.provisionDevice(apiURL: apiUrl, realm: realm, userToken: userToken) } else { let payload: [String: Any] = [ DefaultsKey.providerKey: provider, diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 73ed17b..c2d95b7 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -1167,7 +1167,7 @@ struct ESPProvisionProviderTest { } let apiURL = URL(string: "https://tenant.example.com:8443/api/tenantA")! - provider.provisionDevice(apiURL: apiURL, userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: apiURL, realm: "tenantA", userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1177,7 +1177,7 @@ struct ESPProvisionProviderTest { request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { - #expect(openRemoteConfig.realm == "master") + #expect(openRemoteConfig.realm == "tenantA") #expect(openRemoteConfig.mqttBrokerURL == "mqtts://tenant.example.com:8883") #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) @@ -1249,7 +1249,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, realm: "master", userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1333,7 +1333,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, realm: "master", userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1415,7 +1415,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, realm: "master", userToken: "OAUTH_TOKEN") var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") @@ -1474,7 +1474,7 @@ struct ESPProvisionProviderTest { _ = await discoverDeviceAndStopScan(provider: provider) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.provisionDevice) { - provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, userToken: "OAUTH_TOKEN") + provider.provisionDevice(apiURL: URL(string: "http://localhost:8080/api/master")!, realm: "master", userToken: "OAUTH_TOKEN") } #expect(receivedData["provider"] as? String == Providers.espprovision) From e68bb0be7fdc26164391897c36f1ffa677b602d4 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Wed, 27 May 2026 14:16:04 +0200 Subject: [PATCH 4/5] Fall back to targetURL if baseURL not (yet) set when computing apiURL for provisioning --- .../ESPProvisionURLResolver.swift | 18 +++++++++++++++ ORLib/UI/ORViewController.swift | 6 +++-- Tests/ESPProvisionURLResolverTest.swift | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift index 0ebb171..b525896 100644 --- a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionURLResolver.swift @@ -59,6 +59,24 @@ struct ESPProvisionURLResolver { return components.url } + func apiURL(currentURL: URL?, targetUrl: String?, baseUrl: String?, realm: String) -> URL? { + let urls = [ + baseUrl.flatMap { makeURL(from: $0) }, + currentURL, + targetUrl.flatMap { makeURL(from: $0) } + ] + + for url in urls { + guard let url, + let apiURL = apiURL(baseUrl: url.absoluteString, realm: realm) else { + continue + } + return apiURL + } + + return nil + } + private func realmFromURLs(currentURL: URL?, targetUrl: String?, baseUrl: String?) -> String? { let urls = [ currentURL, diff --git a/ORLib/UI/ORViewController.swift b/ORLib/UI/ORViewController.swift index eb9024d..492bdc4 100644 --- a/ORLib/UI/ORViewController.swift +++ b/ORLib/UI/ORViewController.swift @@ -448,8 +448,10 @@ extension ORViewcontroller: WKScriptMessageHandler { if let userToken = postMessageDict["userToken"] as? String { let urlResolver = ESPProvisionURLResolver() let realm = urlResolver.realm(currentURL: myWebView?.url, targetUrl: targetUrl, baseUrl: baseUrl) - if let baseUrl, - let apiUrl = urlResolver.apiURL(baseUrl: baseUrl, realm: realm) { + if let apiUrl = urlResolver.apiURL(currentURL: myWebView?.url, + targetUrl: targetUrl, + baseUrl: baseUrl, + realm: realm) { espProvisionProvider?.provisionDevice(apiURL: apiUrl, realm: realm, userToken: userToken) } else { let payload: [String: Any] = [ diff --git a/Tests/ESPProvisionURLResolverTest.swift b/Tests/ESPProvisionURLResolverTest.swift index 9d143b7..8f12516 100644 --- a/Tests/ESPProvisionURLResolverTest.swift +++ b/Tests/ESPProvisionURLResolverTest.swift @@ -82,6 +82,28 @@ struct ESPProvisionURLResolverTest { #expect(apiURL.absoluteString == "https://example.com/api/tenant%2Fa%20b%25") } + @Test func apiURLFallsBackToTargetUrlWhenBaseUrlIsMissing() throws { + let resolver = ESPProvisionURLResolver(userDefaults: nil) + + let apiURL = try #require(resolver.apiURL(currentURL: nil, + targetUrl: "https://example.com/manager/?realm=tenantA", + baseUrl: nil, + realm: "tenantA")) + + #expect(apiURL.absoluteString == "https://example.com/api/tenantA") + } + + @Test func apiURLFallsBackToCurrentURLWhenBaseUrlIsMissing() throws { + let resolver = ESPProvisionURLResolver(userDefaults: nil) + + let apiURL = try #require(resolver.apiURL(currentURL: URL(string: "http://localhost:8080/manager/?realm=tenantA"), + targetUrl: "https://example.com/manager/?realm=tenantA", + baseUrl: nil, + realm: "tenantA")) + + #expect(apiURL.absoluteString == "http://localhost:8080/api/tenantA") + } + private func makeUserDefaults() throws -> (suiteName: String, userDefaults: UserDefaults) { let suiteName = "ESPProvisionURLResolverTest.\(UUID().uuidString)" let userDefaults = try #require(UserDefaults(suiteName: suiteName)) From d184224c9010fba1ee77ea737179c4af71e1ed39 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Wed, 27 May 2026 14:28:29 +0200 Subject: [PATCH 5/5] Fix line too long linting errors --- ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift | 7 ++++++- ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift index 4314e8a..07dd69b 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceConnection.swift @@ -149,7 +149,12 @@ class DeviceConnection { throw ESPProviderError(errorCode: .notConnected, errorMessage: "No connection established to device") } do { - try await configChannel!.sendOpenRemoteConfig(mqttBrokerUrl: mqttBrokerUrl, mqttUser: mqttUser, mqttPassword: mqttPassword, realm: realm, assetId: assetId, properties: properties) + try await configChannel!.sendOpenRemoteConfig(mqttBrokerUrl: mqttBrokerUrl, + mqttUser: mqttUser, + mqttPassword: mqttPassword, + realm: realm, + assetId: assetId, + properties: properties) } catch { throw ESPProviderError(errorCode: .communicationError, errorMessage: error.localizedDescription) } diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index 66812ab..c45ddef 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -63,7 +63,11 @@ class DeviceProvision { let password = try generatePassword() - let result = try await deviceProvisionAPI.provision(apiURL: apiURL, modelName: deviceInfo.modelName, deviceId: deviceInfo.deviceId, password: password, token: userToken) + let result = try await deviceProvisionAPI.provision(apiURL: apiURL, + modelName: deviceInfo.modelName, + deviceId: deviceInfo.deviceId, + password: password, + token: userToken) let userName = deviceInfo.deviceId.lowercased(with: Locale(identifier: "en")) try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL(apiURL: apiURL),