From 28eae75265fedf000013d60561211b876927248e Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:24:29 +0300 Subject: [PATCH 1/2] fix: typo in translation.py inline comments --- i18n_scripts/translation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py index eac8a32a1..2eaaafb77 100644 --- a/i18n_scripts/translation.py +++ b/i18n_scripts/translation.py @@ -50,7 +50,7 @@ def parse_arguments(): @contextmanager def change_directory(new_dir: Path): """ - Context manager to execute `os.chidir`. + Context manager to execute `os.chdir`. Usage: @@ -69,7 +69,7 @@ def change_directory(new_dir: Path): def get_modules_dir(override: Path = None) -> Path: """ - Gets the modeles directory (repository root directory). + Gets the modules directory (repository root directory). """ if override: return override @@ -109,13 +109,13 @@ def get_translation_file_path(modules_dir: Path, module_name, lang_dir, create_d def get_modules_to_translate(modules_dir: Path): """ - Retrieve the names of modules that have translation files for a specified language. + Retrieve the names of modules that have translation files for the English language. Parameters: modules_dir (Path): The path to the directory containing all the modules. Returns: - list of str: A list of module names that have translation files for the specified language. + list of str: A list of module names that have English translation files. """ try: modules_list = [ @@ -407,7 +407,7 @@ def add_localizable(xcode_project: XcodeProject, localizable_relative_path: Path def add_translation_files_to_xcode(modules_dir: Path = None): """ - Add Localizable.strings files pulled from Transifex to XCode. + Add Localizable.strings files pulled from Transifex (or split from I18N) to XCode projects. """ try: modules_dir = get_modules_dir(override=modules_dir) From 88c039adfb769027e7fe2f06aa0158cee3f89eb5 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:19:15 +0000 Subject: [PATCH 2/2] feat: add tenant domain model and static YAML catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Sendable Tenant value type and TenantsConfig container that later multi-tenant layers build on: per-tenant API/SSO/OAuth config, UI feature flags (UIComponentsConfig, now @unchecked Sendable), and raw branding fields for a later theming layer to render. Tenant(dictionary:) parses config.yaml's new TENANTS block. Not included here: remote catalog fetching, tenant selection/session state, and anything reading TenantProvider — those land as separate PRs. TENANTS data in config.yaml is illustrative, not real tenants. --- Core/Core.xcodeproj/project.pbxproj | 4 + .../Configuration/Config/TenantsConfig.swift | 265 ++++++++++++++++++ .../Config/UIComponentsConfig.swift | 3 +- default_config/dev/config.yaml | 79 ++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 Core/Core/Configuration/Config/TenantsConfig.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 150c7377c..d23c42672 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -89,6 +89,7 @@ 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */; }; 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; }; + 12624ABABF5AE7FBF7B4F5E5 /* TenantsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC6455FBE49B22E32CB777A /* TenantsConfig.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 */; }; @@ -290,6 +291,7 @@ 02F6EF4928D9F0A700835477 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; 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 /* TenantsConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantsConfig.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 = ""; }; @@ -889,6 +891,7 @@ isa = PBXGroup; children = ( 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, + 2FC6455FBE49B22E32CB777A /* TenantsConfig.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, @@ -1301,6 +1304,7 @@ 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */, 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, + 12624ABABF5AE7FBF7B4F5E5 /* TenantsConfig.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/TenantsConfig.swift b/Core/Core/Configuration/Config/TenantsConfig.swift new file mode 100644 index 000000000..e33b3c0aa --- /dev/null +++ b/Core/Core/Configuration/Config/TenantsConfig.swift @@ -0,0 +1,265 @@ +// +// TenantsConfig.swift +// Core +// +// Created by Rawan Matar on 31/08/2026. +// + +import Foundation +import OEXFoundation + +// MARK: - TenantProvider + +/// Read-only, thread-safe access to the currently selected tenant. +public protocol TenantProvider: Sendable { + /// `nil` when no tenant is selected (e.g. single-tenant deployment). + var currentTenant: Tenant? { get } +} + +// MARK: - Tenant + +private enum TenantKeys: String, RawStringExtractable { + case key = "KEY" + case name + case tenantName = "TENANT_NAME" + case color + case oAuthClientId = "OAUTH_CLIENT_ID" + case baseURL = "API_HOST_URL" + case baseURLHiddenLogin = "API_HOST_URL_HIDDEN_LOGIN" + case baseSSOURL = "SSO_URL" + case successfulSSOLoginURL = "SSO_URL_SUCCESSFUL_LOGIN" + case environmentDisplayName = "ENVIRONMENT_DISPLAY_NAME" + case isSwitchTenantLoginEnabled = "IS_SWITCH_TENANT_LOGIN_ENABLED" + case uiComponents = "UI_COMPONENTS" + case logoURL = "LOGO_URL" + case headerBackgroundURL = "HEADER_BACKGROUND_URL" + case theme = "THEME" +} + +/// Per-mode (`light`/`dark`) color overrides parsed from the tenant's `THEME` block. +/// Missing keys fall back to `ThemeColorSet.derived(fromHex:light:dark:)`. +public struct TenantThemeColors: Codable, Sendable, Equatable { + public let light: [String: String] + public let dark: [String: String] + + public init(light: [String: String], dark: [String: String]) { + self.light = light + self.dark = dark + } +} + +public struct Tenant: Codable, Identifiable, Sendable, Equatable, Hashable { + /// Same as `key`. + public var id: String { key } + + /// Machine-readable identifier, e.g. "niepd". Falls back to a slugified `name`. + public let key: String + public let name: String + public let tenantName: String + public let color: String + public let oAuthClientId: String + public let baseURL: URL + /// Falls back to `baseURL` if not set. + public let baseURLHiddenLogin: URL + public let baseSSOURL: URL? + public let successfulSSOLoginURL: URL? + public let environmentDisplayName: String? + public let isSwitchTenantLoginEnabled: Bool + public let uiComponents: UIComponentsConfig + /// Remote URL, or a bundled asset name. `nil` keeps the default logo. + public let logoURLString: String? + /// Remote URL only. `nil` keeps the default header background. + public let headerBackgroundURLString: String? + /// `nil` derives the palette from `color` instead. + public let themeColors: TenantThemeColors? + + public static func == (lhs: Tenant, rhs: Tenant) -> Bool { + lhs.key == rhs.key + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + + /// Fails if required fields (`name`, `API_HOST_URL`, `OAUTH_CLIENT_ID`) are missing. + public init?(dictionary: [String: Any]) { + guard let name = dictionary[TenantKeys.name] as? String, !name.isEmpty, + let urlString = dictionary[TenantKeys.baseURL] as? String, + let baseURL = URL(string: urlString), + let oAuthClientId = dictionary[TenantKeys.oAuthClientId] as? String, !oAuthClientId.isEmpty + else { return nil } + + self.name = name + self.baseURL = baseURL + self.oAuthClientId = oAuthClientId + + if let explicitKey = dictionary[TenantKeys.key] as? String, !explicitKey.isEmpty { + self.key = explicitKey + } else { + self.key = Tenant.slugify(name) + } + + var languageCode = "en" + if let langCode = Locale.preferredLanguages.first?.prefix(2) { + languageCode = String(langCode) + } + if let tenantNameDict = dictionary[TenantKeys.tenantName] as? [String: Any] { + self.tenantName = (tenantNameDict[languageCode] as? String) + ?? (tenantNameDict["en"] as? String) + ?? name + } else { + self.tenantName = name + } + + self.color = dictionary[TenantKeys.color] as? String ?? "#007AFF" + self.isSwitchTenantLoginEnabled = dictionary[TenantKeys.isSwitchTenantLoginEnabled] as? Bool ?? false + + if let hiddenLoginString = dictionary[TenantKeys.baseURLHiddenLogin] as? String, + let hiddenLoginURL = URL(string: hiddenLoginString) { + self.baseURLHiddenLogin = hiddenLoginURL + } else { + self.baseURLHiddenLogin = baseURL + } + + if let ssoString = dictionary[TenantKeys.baseSSOURL] as? String { + self.baseSSOURL = URL(string: ssoString) + } else { + self.baseSSOURL = nil + } + + if let successString = dictionary[TenantKeys.successfulSSOLoginURL] as? String { + self.successfulSSOLoginURL = URL(string: successString) + } else { + self.successfulSSOLoginURL = nil + } + + self.environmentDisplayName = dictionary[TenantKeys.environmentDisplayName] as? String + + if let uiDict = dictionary[TenantKeys.uiComponents] as? [String: Any] { + self.uiComponents = UIComponentsConfig(dictionary: uiDict) + } else { + self.uiComponents = UIComponentsConfig(dictionary: [:]) + } + + self.logoURLString = dictionary[TenantKeys.logoURL] as? String + self.headerBackgroundURLString = dictionary[TenantKeys.headerBackgroundURL] as? String + + if let themeDict = dictionary[TenantKeys.theme] as? [String: Any] { + self.themeColors = TenantThemeColors( + light: themeDict["light"] as? [String: String] ?? [:], + dark: themeDict["dark"] as? [String: String] ?? [:] + ) + } else { + self.themeColors = nil + } + } + + private static func slugify(_ name: String) -> String { + name.lowercased() + .folding(options: .diacriticInsensitive, locale: .current) + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + // MARK: Codable + // `uiComponents` and remote-media fields aren't persisted; re-populated from + // `TenantStore.tenantsConfig` right after decode. + private enum CodingKeys: String, CodingKey { + case key, name, tenantName, color, oAuthClientId + case baseURL, baseURLHiddenLogin, baseSSOURL, successfulSSOLoginURL + case environmentDisplayName, isSwitchTenantLoginEnabled + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + name = try container.decode(String.self, forKey: .name) + tenantName = try container.decode(String.self, forKey: .tenantName) + color = try container.decode(String.self, forKey: .color) + oAuthClientId = try container.decode(String.self, forKey: .oAuthClientId) + baseURL = try container.decode(URL.self, forKey: .baseURL) + baseURLHiddenLogin = try container.decode(URL.self, forKey: .baseURLHiddenLogin) + baseSSOURL = try container.decodeIfPresent(URL.self, forKey: .baseSSOURL) + successfulSSOLoginURL = try container.decodeIfPresent(URL.self, forKey: .successfulSSOLoginURL) + environmentDisplayName = try container.decodeIfPresent(String.self, forKey: .environmentDisplayName) + isSwitchTenantLoginEnabled = try container.decode(Bool.self, forKey: .isSwitchTenantLoginEnabled) + uiComponents = UIComponentsConfig(dictionary: [:]) + logoURLString = nil + headerBackgroundURLString = nil + themeColors = nil + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: .key) + try container.encode(name, forKey: .name) + try container.encode(tenantName, forKey: .tenantName) + try container.encode(color, forKey: .color) + try container.encode(oAuthClientId, forKey: .oAuthClientId) + try container.encode(baseURL, forKey: .baseURL) + try container.encode(baseURLHiddenLogin, forKey: .baseURLHiddenLogin) + try container.encodeIfPresent(baseSSOURL, forKey: .baseSSOURL) + try container.encodeIfPresent(successfulSSOLoginURL, forKey: .successfulSSOLoginURL) + try container.encodeIfPresent(environmentDisplayName, forKey: .environmentDisplayName) + try container.encode(isSwitchTenantLoginEnabled, forKey: .isSwitchTenantLoginEnabled) + } +} + +// MARK: - TenantsConfig + +public struct TenantsConfig: Sendable { + public let tenants: [Tenant] + + public init() { + self.tenants = [] + } + + /// - Parameter fallback: base `config.yaml` values used to fill fields a + /// `TENANTS` entry omits (tenant-level wins). + public init(array: [[String: Any]], fallback: [String: Any] = [:]) { + // Drop invalid entries instead of producing a half-populated Tenant. + self.tenants = array.compactMap { dict in + let merged = fallback.merging(dict) { _, tenantValue in tenantValue } + guard let tenant = Tenant(dictionary: merged) else { + #if DEBUG + print("⚠️ TenantsConfig: skipping malformed TENANTS entry: \(dict["name"] ?? "")") + #endif + return nil + } + return tenant + } + } + + public func tenant(withKey key: String) -> Tenant? { + tenants.first { $0.key == key } + } +} + +#if DEBUG +public extension Tenant { + static func mock( + key: String = "mock", + name: String = "Test", + tenantName: String = "Test", + color: String = "#fff", + oAuthClientId: String = "mockClientId", + baseURL: URL = URL(string: "https://mock.base.url")! + ) -> Tenant { + Tenant(dictionary: [ + "KEY": key, + "name": name, + "TENANT_NAME": ["en": tenantName], + "color": color, + "OAUTH_CLIENT_ID": oAuthClientId, + "API_HOST_URL": baseURL.absoluteString + ])! + } +} + +public final class TenantProviderMock: TenantProvider, @unchecked Sendable { + public var currentTenant: Tenant? + public init(currentTenant: Tenant? = .mock()) { + self.currentTenant = currentTenant + } +} +#endif diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index c62b20774..45541cf9e 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -16,7 +16,8 @@ private enum Keys: String, RawStringExtractable { case samlSSODefaultLoginButton = "SAML_SSO_DEFAULT_LOGIN_BUTTON" } -public class UIComponentsConfig: NSObject { +// `@unchecked Sendable`: properties are set once in `init` and never mutated. +public class UIComponentsConfig: NSObject, @unchecked Sendable { public var courseDropDownNavigationEnabled: Bool public var courseUnitProgressEnabled: Bool public var loginRegistrationEnabled: Bool diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 48d005148..de44497aa 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -21,3 +21,82 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + + +# Multi-tenant configuration. Each TENANTS entry overrides the top-level +# values above for that tenant; fields it omits fall back to them via +# TenantsConfig.init(array:fallback:) — see +# Core/Core/Configuration/Config/TenantsConfig.swift. +TENANTS: + - + KEY: "example-tenant" + name: "Example Tenant" + TENANT_NAME: + ar: "مثال المستأجر" + en: "Example Tenant" + color: "#3C68FF" + ENVIRONMENT_DISPLAY_NAME: "Example Tenant" + OAUTH_CLIENT_ID: 'example-oauth-client-id' + API_HOST_URL: 'https://example-tenant.example.com' + API_HOST_URL_HIDDEN_LOGIN: 'https://example-tenant-hidden.example.com' + SSO_URL: 'https://example-tenant.example.com/auth/login/tpa-saml/?auth_entry=login' + SSO_URL_SUCCESSFUL_LOGIN: "https://example-tenant.example.com/auth/login/tpa-saml/auth/complete/tpa-saml" + SSO_BUTTON_TITLE: + ar: "الدخول عبر SSO" + en: "Sign in with SSO" + IS_SWITCH_TENANT_LOGIN_ENABLED: true + + LOGO_URL: "exampleTenantAppLogo" + THEME: + light: + accent_color: "#3C68FF" + accent_x_color: "#1C355E" + alert: "#FF3D71" + avatar_stroke: "#3D4964" + background: "#FFFFFF" + background_stroke: "#FFFFFF" + card_view_background: "#CCD3DF" + card_view_stroke: "#CCD3DF" + certificate_foreground: "#2EB865" + comment_cell_background: "#F5F5F5" + next_week_timeline_color: "#97A5BB" + past_due_timeline_color: "#FFC94D" + this_week_timeline_color: "#3D4964" + today_timeline_color: "#42AAFF" + upcoming_timeline_color: "#CCD4E0" + shadow_color: "#000000" + snackbar_error_color: "#EB4F4F" + snackbar_info_color: "#42AAFF" + snackbar_text_color: "#FFFFFF" + styled_button_text: "#FFFFFF" + text_primary: "#18202E" + text_secondary: "#31CAB4" + text_secondary_light: "#97A5BB" + text_input_background: "#FFFFFF" + text_input_stroke: "#97A5BB" + text_input_unfocused_background: "#F9FAFB" + text_input_unfocused_stroke: "#CCD4E0" + warning: "#FFC248" + white: "#FFFFFF" + on_progress: "#F0CC00" + progress_done: "#BBE6D7" + progress_skip: "#DDD9DA" + dates_section_background: "#F9FAFB" + dates_section_stroke: "#CCD4E0" + navigation_bar_tint_color: "#FFFFFF" + secondary_button_border_color: "#3C68FF" + secondary_button_text_color: "#3C68FF" + success: "#30A171" + primary_button_text_color: "#FFFFFF" + toggle_switch_color: "#3C68FF" + text_input_text_color: "#19212F" + text_input_placeholder_color: "#97A5BB" + info_color: "#3C68FF" + irreversible_alert: "#FF3D71" + + UI_COMPONENTS: + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false + LOGIN_REGISTRATION_ENABLED: true + SAML_SSO_LOGIN_ENABLED: false + SAML_SSO_DEFAULT_LOGIN_BUTTON: false