From afb7ef517c1ecfb685d1000926dba8fa8daca8ad Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:02:38 +0000 Subject: [PATCH 1/2] feat: add tenant selection store (TenantStore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TenantStore: the single source of truth for the selected tenant and the tenant catalog, persisted to UserDefaults and posting .tenantDidChange for observers. Auto-selects the sole tenant on a single-tenant catalog, since there's no picker to choose it through. Registered in NetworkAssembly (TenantStore/TenantProvider). Not consumed by anything yet — RequestInterceptor and the rest of the storage/session layers read it starting in later PRs. Also fixes a real gap found while extracting this: the original TenantStoreTests.swift was never added to Core.xcodeproj, so it silently never ran as part of CoreTests. --- Core/Core.xcodeproj/project.pbxproj | 8 ++ .../Configuration/Config/TenantStore.swift | 94 ++++++++++++++++ .../Configuration/TenantStoreTests.swift | 103 ++++++++++++++++++ OpenEdX/DI/NetworkAssembly.swift | 8 ++ 4 files changed, 213 insertions(+) create mode 100644 Core/Core/Configuration/Config/TenantStore.swift create mode 100644 Core/CoreTests/Configuration/TenantStoreTests.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d105c1e2f..d2d2c2560 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; }; 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */; }; + 41A744FEB981583CE4B1DEB8 /* TenantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */; }; 064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; }; 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878B2B4D69FE0071642A /* WebviewInjection.swift */; }; 064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */; }; @@ -146,6 +147,7 @@ BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; + EB3F7F969DFFD23CA3BE856C /* TenantStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; @@ -292,6 +294,7 @@ 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = ""; }; 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfig.swift; sourceTree = ""; }; + 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantStore.swift; sourceTree = ""; }; 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = ""; }; 0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = ""; }; 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = ""; }; @@ -364,6 +367,7 @@ BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = ""; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; + A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantStoreTests.swift; sourceTree = ""; }; BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = ""; }; BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleConfig.swift; sourceTree = ""; }; @@ -892,6 +896,7 @@ children = ( 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */, + 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, @@ -925,6 +930,7 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, + A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1154,6 +1160,7 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, + EB3F7F969DFFD23CA3BE856C /* TenantStoreTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, @@ -1305,6 +1312,7 @@ 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */, + 41A744FEB981583CE4B1DEB8 /* TenantStore.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */, diff --git a/Core/Core/Configuration/Config/TenantStore.swift b/Core/Core/Configuration/Config/TenantStore.swift new file mode 100644 index 000000000..2ca45ca4a --- /dev/null +++ b/Core/Core/Configuration/Config/TenantStore.swift @@ -0,0 +1,94 @@ +// +// TenantStore.swift +// Core +// +// Created by Rawan Matar on 31/08/2026. +// + +import Foundation + +extension Notification.Name { + public static let tenantDidChange = Notification.Name("org.openedx.core.tenantDidChange") +} + +/// Single source of truth for the selected tenant and the tenant catalog. +public final class TenantStore: TenantProvider, @unchecked Sendable { + + private static let userDefaultsKey = "selectedTenantKey" + + private let lock = NSLock() + private let userDefaults: UserDefaults + private let localTenantsConfig: () -> TenantsConfig + private var _remoteTenantsConfig: TenantsConfig? + private var _currentTenant: Tenant? + + public var currentTenant: Tenant? { + lock.lock(); defer { lock.unlock() } + return _currentTenant + } + + /// The catalog currently in effect: the remote one once set, else `localTenantsConfig`. + public var tenantsConfig: TenantsConfig { + lock.lock(); defer { lock.unlock() } + return _remoteTenantsConfig ?? localTenantsConfig() + } + + public init( + userDefaults: UserDefaults = .standard, + tenantsConfig: @escaping () -> TenantsConfig = { TenantsConfig() } + ) { + self.userDefaults = userDefaults + self.localTenantsConfig = tenantsConfig + restoreSelection() + } + + /// Replaces the effective catalog and re-resolves the persisted selection against it. + public func updateTenantsConfig(_ tenantsConfig: TenantsConfig) { + lock.lock() + _remoteTenantsConfig = tenantsConfig + lock.unlock() + restoreSelection() + } + + /// Selects a tenant (or clears it with `nil`) and persists the choice. + public func select(_ tenant: Tenant?) { + lock.lock() + _currentTenant = tenant + lock.unlock() + + if let tenant { + userDefaults.set(tenant.key, forKey: Self.userDefaultsKey) + } else { + userDefaults.removeObject(forKey: Self.userDefaultsKey) + } + + NotificationCenter.default.post(name: .tenantDidChange, object: tenant) + + // Re-run auto-select so a deselect on a single-tenant catalog doesn't strand + // `currentTenant` at nil until the next launch. + if tenant == nil { + restoreSelection() + } + } + + public func reset() { + select(nil) + } + + private func restoreSelection() { + let catalog = tenantsConfig + let savedKey = userDefaults.string(forKey: Self.userDefaultsKey) + let resolved = savedKey.flatMap { catalog.tenant(withKey: $0) } + + // Auto-select the sole tenant in a single-tenant catalog — there's no picker + // to choose it through otherwise. + if resolved == nil, catalog.tenants.count == 1, let onlyTenant = catalog.tenants.first { + select(onlyTenant) + return + } + + lock.lock() + _currentTenant = resolved + lock.unlock() + } +} diff --git a/Core/CoreTests/Configuration/TenantStoreTests.swift b/Core/CoreTests/Configuration/TenantStoreTests.swift new file mode 100644 index 000000000..7dfa3b205 --- /dev/null +++ b/Core/CoreTests/Configuration/TenantStoreTests.swift @@ -0,0 +1,103 @@ +// +// TenantStoreTests.swift +// CoreTests +// +// Created by Rawan Matar on 31/08/2026. +// + +import XCTest +@testable import Core + +final class TenantStoreTests: XCTestCase { + + private func freshUserDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + func test_singleTenantCatalog_noPriorSelection_autoSelectsTheOnlyTenant() { + let onlyTenant = Tenant.mock(key: "acme") + let catalog = TenantsConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + + XCTAssertEqual(store.currentTenant?.key, onlyTenant.key) + // Persisted, so it survives relaunch and .tenantDidChange observers see it. + XCTAssertEqual(defaults.string(forKey: "selectedTenantKey"), "acme") + } + + func test_multiTenantCatalog_noPriorSelection_doesNotAutoSelect() { + let catalog = TenantsConfig(array: [ + ["name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + + XCTAssertNil(store.currentTenant) + } + + func test_singleTenantCatalog_staleSavedKey_reselectsTheOnlyTenant() { + let catalog = TenantsConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("some-other-tenant", forKey: "selectedTenantKey") + + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + + XCTAssertEqual(store.currentTenant?.key, "acme") + } + + func test_singleTenantCatalog_matchingSavedKey_resolvesWithoutReselecting() { + let catalog = TenantsConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("acme", forKey: "selectedTenantKey") + + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + + XCTAssertEqual(store.currentTenant?.key, "acme") + } + + func test_emptyCatalog_noPriorSelection_leavesCurrentTenantNil() { + let defaults = freshUserDefaults(#function) + + let store = TenantStore(userDefaults: defaults, tenantsConfig: { TenantsConfig() }) + + XCTAssertNil(store.currentTenant) + } + + func test_singleTenantCatalog_deselecting_reselectsTheOnlyTenant() { + let catalog = TenantsConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + XCTAssertEqual(store.currentTenant?.key, "acme") + + store.select(nil) + + XCTAssertEqual(store.currentTenant?.key, "acme") + } + + func test_multiTenantCatalog_deselecting_staysDeselected() { + let catalog = TenantsConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["KEY": "beta", "name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) + store.select(catalog.tenants[0]) + + store.select(nil) + + XCTAssertNil(store.currentTenant) + } +} diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index 904c1ea44..bf272020d 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -13,6 +13,14 @@ import Swinject class NetworkAssembly: Assembly { func assemble(container: Container) { + container.register(TenantStore.self) { _ in + TenantStore() + }.inObjectScope(.container) + + container.register(TenantProvider.self) { r in + r.resolve(TenantStore.self)! + }.inObjectScope(.container) + container.register(RequestInterceptor.self) { r in RequestInterceptor(config: r.resolve(ConfigProtocol.self)!, storage: r.resolve(CoreStorage.self)!) }.inObjectScope(.container) From 2c0786e2a148bc09c6727e70c43304f56923bbbe Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:19:01 +0000 Subject: [PATCH 2/2] refactor: rename tenant to instance Renames TenantStore -> InstanceStore (and its test file), plus the DI registrations in NetworkAssembly.swift, per the community's naming feedback. Matches the rename already applied on top of infra/01-tenant-model. --- Core/Core.xcodeproj/project.pbxproj | 16 +-- .../Configuration/Config/InstanceStore.swift | 94 ++++++++++++++++ .../Configuration/Config/TenantStore.swift | 94 ---------------- .../Configuration/InstanceStoreTests.swift | 103 ++++++++++++++++++ .../Configuration/TenantStoreTests.swift | 103 ------------------ OpenEdX/DI/NetworkAssembly.swift | 8 +- 6 files changed, 209 insertions(+), 209 deletions(-) create mode 100644 Core/Core/Configuration/Config/InstanceStore.swift delete mode 100644 Core/Core/Configuration/Config/TenantStore.swift create mode 100644 Core/CoreTests/Configuration/InstanceStoreTests.swift delete mode 100644 Core/CoreTests/Configuration/TenantStoreTests.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d2d2c2560..9ceffc670 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -90,7 +90,7 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; }; 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */; }; - 41A744FEB981583CE4B1DEB8 /* TenantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */; }; + 41A744FEB981583CE4B1DEB8 /* InstanceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */; }; 064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; }; 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878B2B4D69FE0071642A /* WebviewInjection.swift */; }; 064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */; }; @@ -147,7 +147,7 @@ BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; - EB3F7F969DFFD23CA3BE856C /* TenantStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */; }; + EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; @@ -294,7 +294,7 @@ 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = ""; }; 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfig.swift; sourceTree = ""; }; - 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantStore.swift; sourceTree = ""; }; + 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStore.swift; sourceTree = ""; }; 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = ""; }; 0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = ""; }; 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = ""; }; @@ -367,7 +367,7 @@ BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = ""; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; - A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantStoreTests.swift; sourceTree = ""; }; + A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = ""; }; BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = ""; }; BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleConfig.swift; sourceTree = ""; }; @@ -896,7 +896,7 @@ children = ( 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */, - 53CE4D43A09B50EB85B793D5 /* TenantStore.swift */, + 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, @@ -930,7 +930,7 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, - A1B6ADB9B9C23899FFDCE9D5 /* TenantStoreTests.swift */, + A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1160,7 +1160,7 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, - EB3F7F969DFFD23CA3BE856C /* TenantStoreTests.swift in Sources */, + EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, @@ -1312,7 +1312,7 @@ 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */, - 41A744FEB981583CE4B1DEB8 /* TenantStore.swift in Sources */, + 41A744FEB981583CE4B1DEB8 /* InstanceStore.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */, diff --git a/Core/Core/Configuration/Config/InstanceStore.swift b/Core/Core/Configuration/Config/InstanceStore.swift new file mode 100644 index 000000000..13bfdd1a1 --- /dev/null +++ b/Core/Core/Configuration/Config/InstanceStore.swift @@ -0,0 +1,94 @@ +// +// InstanceStore.swift +// Core +// +// Created by Rawan Matar on 31/08/2026. +// + +import Foundation + +extension Notification.Name { + public static let instanceDidChange = Notification.Name("org.openedx.core.instanceDidChange") +} + +/// Single source of truth for the selected instance and the instance catalog. +public final class InstanceStore: InstanceProvider, @unchecked Sendable { + + private static let userDefaultsKey = "selectedInstanceKey" + + private let lock = NSLock() + private let userDefaults: UserDefaults + private let localInstancesConfig: () -> InstancesConfig + private var _remoteInstancesConfig: InstancesConfig? + private var _currentInstance: Instance? + + public var currentInstance: Instance? { + lock.lock(); defer { lock.unlock() } + return _currentInstance + } + + /// The catalog currently in effect: the remote one once set, else `localInstancesConfig`. + public var instancesConfig: InstancesConfig { + lock.lock(); defer { lock.unlock() } + return _remoteInstancesConfig ?? localInstancesConfig() + } + + public init( + userDefaults: UserDefaults = .standard, + instancesConfig: @escaping () -> InstancesConfig = { InstancesConfig() } + ) { + self.userDefaults = userDefaults + self.localInstancesConfig = instancesConfig + restoreSelection() + } + + /// Replaces the effective catalog and re-resolves the persisted selection against it. + public func updateInstancesConfig(_ instancesConfig: InstancesConfig) { + lock.lock() + _remoteInstancesConfig = instancesConfig + lock.unlock() + restoreSelection() + } + + /// Selects an instance (or clears it with `nil`) and persists the choice. + public func select(_ instance: Instance?) { + lock.lock() + _currentInstance = instance + lock.unlock() + + if let instance { + userDefaults.set(instance.key, forKey: Self.userDefaultsKey) + } else { + userDefaults.removeObject(forKey: Self.userDefaultsKey) + } + + NotificationCenter.default.post(name: .instanceDidChange, object: instance) + + // Re-run auto-select so a deselect on a single-instance catalog doesn't strand + // `currentInstance` at nil until the next launch. + if instance == nil { + restoreSelection() + } + } + + public func reset() { + select(nil) + } + + private func restoreSelection() { + let catalog = instancesConfig + let savedKey = userDefaults.string(forKey: Self.userDefaultsKey) + let resolved = savedKey.flatMap { catalog.instance(withKey: $0) } + + // Auto-select the sole instance in a single-instance catalog — there's no picker + // to choose it through otherwise. + if resolved == nil, catalog.instances.count == 1, let onlyInstance = catalog.instances.first { + select(onlyInstance) + return + } + + lock.lock() + _currentInstance = resolved + lock.unlock() + } +} diff --git a/Core/Core/Configuration/Config/TenantStore.swift b/Core/Core/Configuration/Config/TenantStore.swift deleted file mode 100644 index 2ca45ca4a..000000000 --- a/Core/Core/Configuration/Config/TenantStore.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// TenantStore.swift -// Core -// -// Created by Rawan Matar on 31/08/2026. -// - -import Foundation - -extension Notification.Name { - public static let tenantDidChange = Notification.Name("org.openedx.core.tenantDidChange") -} - -/// Single source of truth for the selected tenant and the tenant catalog. -public final class TenantStore: TenantProvider, @unchecked Sendable { - - private static let userDefaultsKey = "selectedTenantKey" - - private let lock = NSLock() - private let userDefaults: UserDefaults - private let localTenantsConfig: () -> TenantsConfig - private var _remoteTenantsConfig: TenantsConfig? - private var _currentTenant: Tenant? - - public var currentTenant: Tenant? { - lock.lock(); defer { lock.unlock() } - return _currentTenant - } - - /// The catalog currently in effect: the remote one once set, else `localTenantsConfig`. - public var tenantsConfig: TenantsConfig { - lock.lock(); defer { lock.unlock() } - return _remoteTenantsConfig ?? localTenantsConfig() - } - - public init( - userDefaults: UserDefaults = .standard, - tenantsConfig: @escaping () -> TenantsConfig = { TenantsConfig() } - ) { - self.userDefaults = userDefaults - self.localTenantsConfig = tenantsConfig - restoreSelection() - } - - /// Replaces the effective catalog and re-resolves the persisted selection against it. - public func updateTenantsConfig(_ tenantsConfig: TenantsConfig) { - lock.lock() - _remoteTenantsConfig = tenantsConfig - lock.unlock() - restoreSelection() - } - - /// Selects a tenant (or clears it with `nil`) and persists the choice. - public func select(_ tenant: Tenant?) { - lock.lock() - _currentTenant = tenant - lock.unlock() - - if let tenant { - userDefaults.set(tenant.key, forKey: Self.userDefaultsKey) - } else { - userDefaults.removeObject(forKey: Self.userDefaultsKey) - } - - NotificationCenter.default.post(name: .tenantDidChange, object: tenant) - - // Re-run auto-select so a deselect on a single-tenant catalog doesn't strand - // `currentTenant` at nil until the next launch. - if tenant == nil { - restoreSelection() - } - } - - public func reset() { - select(nil) - } - - private func restoreSelection() { - let catalog = tenantsConfig - let savedKey = userDefaults.string(forKey: Self.userDefaultsKey) - let resolved = savedKey.flatMap { catalog.tenant(withKey: $0) } - - // Auto-select the sole tenant in a single-tenant catalog — there's no picker - // to choose it through otherwise. - if resolved == nil, catalog.tenants.count == 1, let onlyTenant = catalog.tenants.first { - select(onlyTenant) - return - } - - lock.lock() - _currentTenant = resolved - lock.unlock() - } -} diff --git a/Core/CoreTests/Configuration/InstanceStoreTests.swift b/Core/CoreTests/Configuration/InstanceStoreTests.swift new file mode 100644 index 000000000..4b8961b25 --- /dev/null +++ b/Core/CoreTests/Configuration/InstanceStoreTests.swift @@ -0,0 +1,103 @@ +// +// InstanceStoreTests.swift +// CoreTests +// +// Created by Rawan Matar on 31/08/2026. +// + +import XCTest +@testable import Core + +final class InstanceStoreTests: XCTestCase { + + private func freshUserDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + func test_singleInstanceCatalog_noPriorSelection_autoSelectsTheOnlyInstance() { + let onlyInstance = Instance.mock(key: "acme") + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, onlyInstance.key) + // Persisted, so it survives relaunch and .instanceDidChange observers see it. + XCTAssertEqual(defaults.string(forKey: "selectedInstanceKey"), "acme") + } + + func test_multiInstanceCatalog_noPriorSelection_doesNotAutoSelect() { + let catalog = InstancesConfig(array: [ + ["name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertNil(store.currentInstance) + } + + func test_singleInstanceCatalog_staleSavedKey_reselectsTheOnlyInstance() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("some-other-instance", forKey: "selectedInstanceKey") + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_singleInstanceCatalog_matchingSavedKey_resolvesWithoutReselecting() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("acme", forKey: "selectedInstanceKey") + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_emptyCatalog_noPriorSelection_leavesCurrentInstanceNil() { + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { InstancesConfig() }) + + XCTAssertNil(store.currentInstance) + } + + func test_singleInstanceCatalog_deselecting_reselectsTheOnlyInstance() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + XCTAssertEqual(store.currentInstance?.key, "acme") + + store.select(nil) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_multiInstanceCatalog_deselecting_staysDeselected() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["KEY": "beta", "name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + store.select(catalog.instances[0]) + + store.select(nil) + + XCTAssertNil(store.currentInstance) + } +} diff --git a/Core/CoreTests/Configuration/TenantStoreTests.swift b/Core/CoreTests/Configuration/TenantStoreTests.swift deleted file mode 100644 index 7dfa3b205..000000000 --- a/Core/CoreTests/Configuration/TenantStoreTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// TenantStoreTests.swift -// CoreTests -// -// Created by Rawan Matar on 31/08/2026. -// - -import XCTest -@testable import Core - -final class TenantStoreTests: XCTestCase { - - private func freshUserDefaults(_ name: String) -> UserDefaults { - let defaults = UserDefaults(suiteName: name)! - defaults.removePersistentDomain(forName: name) - return defaults - } - - func test_singleTenantCatalog_noPriorSelection_autoSelectsTheOnlyTenant() { - let onlyTenant = Tenant.mock(key: "acme") - let catalog = TenantsConfig(array: [ - ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] - ]) - let defaults = freshUserDefaults(#function) - - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - - XCTAssertEqual(store.currentTenant?.key, onlyTenant.key) - // Persisted, so it survives relaunch and .tenantDidChange observers see it. - XCTAssertEqual(defaults.string(forKey: "selectedTenantKey"), "acme") - } - - func test_multiTenantCatalog_noPriorSelection_doesNotAutoSelect() { - let catalog = TenantsConfig(array: [ - ["name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], - ["name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] - ]) - let defaults = freshUserDefaults(#function) - - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - - XCTAssertNil(store.currentTenant) - } - - func test_singleTenantCatalog_staleSavedKey_reselectsTheOnlyTenant() { - let catalog = TenantsConfig(array: [ - ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] - ]) - let defaults = freshUserDefaults(#function) - defaults.set("some-other-tenant", forKey: "selectedTenantKey") - - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - - XCTAssertEqual(store.currentTenant?.key, "acme") - } - - func test_singleTenantCatalog_matchingSavedKey_resolvesWithoutReselecting() { - let catalog = TenantsConfig(array: [ - ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] - ]) - let defaults = freshUserDefaults(#function) - defaults.set("acme", forKey: "selectedTenantKey") - - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - - XCTAssertEqual(store.currentTenant?.key, "acme") - } - - func test_emptyCatalog_noPriorSelection_leavesCurrentTenantNil() { - let defaults = freshUserDefaults(#function) - - let store = TenantStore(userDefaults: defaults, tenantsConfig: { TenantsConfig() }) - - XCTAssertNil(store.currentTenant) - } - - func test_singleTenantCatalog_deselecting_reselectsTheOnlyTenant() { - let catalog = TenantsConfig(array: [ - ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] - ]) - let defaults = freshUserDefaults(#function) - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - XCTAssertEqual(store.currentTenant?.key, "acme") - - store.select(nil) - - XCTAssertEqual(store.currentTenant?.key, "acme") - } - - func test_multiTenantCatalog_deselecting_staysDeselected() { - let catalog = TenantsConfig(array: [ - ["KEY": "acme", "name": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], - ["KEY": "beta", "name": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] - ]) - let defaults = freshUserDefaults(#function) - let store = TenantStore(userDefaults: defaults, tenantsConfig: { catalog }) - store.select(catalog.tenants[0]) - - store.select(nil) - - XCTAssertNil(store.currentTenant) - } -} diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index bf272020d..5b0f7bb55 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -13,12 +13,12 @@ import Swinject class NetworkAssembly: Assembly { func assemble(container: Container) { - container.register(TenantStore.self) { _ in - TenantStore() + container.register(InstanceStore.self) { _ in + InstanceStore() }.inObjectScope(.container) - container.register(TenantProvider.self) { r in - r.resolve(TenantStore.self)! + container.register(InstanceProvider.self) { r in + r.resolve(InstanceStore.self)! }.inObjectScope(.container) container.register(RequestInterceptor.self) { r in