mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
7752ae3f6a
* Keep every signed-in account live With the multi-account flag on, the accounts other than the active one are now restored at launch, one after the other once the active account's UI is up. They run their migrations, sync while the app is active, get a pusher (appended so accounts on the same homeserver keep each other's) and count towards the app badge, and background refresh and search backfill cover them too. Only the active account's remote settings are ever applied. Calls stay with the active account until the call service handles every account, and an auth error on another account is only logged until accounts can be signed out one at a time. With the flag off only the active account is live, as before. * Sum the app badge across every signed in account Each live account now reports its own unread count, and the app badge shows their sum. Delivered notifications are only cleared for the account whose rooms were read, so a room shared by two accounts keeps the other account's notifications. The room list observer for the badge and the notifications moves from the UserSessionFlowCoordinator to the AppCoordinator's per-account wiring. The flow keeps updating the chats tab badge. * Let the user session manager manage every session - The AppCoordinator tells the manager to pause, resume, go offline or run the search backfill, instead of looping over the sessions itself. - The manager also restores the other accounts, preparing each with the AppCoordinator's migrations and wiring, and resumes them while the services are running. With a single account nothing changes. - Wiring an account again (e.g. after clearing the cache) replaces its observers rather than adding to them. - Add addAccountFlowEnabled, a developer-only switch for the upcoming add account flow. It isn't a user preference, so testers enabling multi-account can't add a second account yet. * Apply review suggestions to keeping every account live - Move the notification changes (pushers for every account, the summed app badge and per-account clearing) to their own PR, so notifications follow the active account again for now. - Revert loading only the active account's remote settings: they load in the store again, as on develop, until the hooks are reworked. - The user session manager reports the auth errors of every live session, so the AppCoordinator subscribes once instead of keeping per-account observers. - Restoring a single account is private to the manager now. - Rename otherSessionsRestoreTask to otherSessionsRestorationTask. * Restore every signed in account through one path The user session manager now restores every account in a single pass, most recently selected first, instead of separate restores for the active account and the others. The AppCoordinator shows the first account that comes back and no longer reads the multi-account flag: without it, the manager only ever makes one account live. - Clearing the cache clears every live account, and the next pass replaces their sessions. - Each account is migrated once per launch, and an account that just signed in is never migrated. - A pass also runs after signing in, so the other accounts come live. - Remove UserSessionManagerError, restoreActiveSession and restoreOtherSessions. * Only clear the cache of the active account Clearing the cache goes back to clearing the account shown in the UI. Its session stays live until the next restore pass replaces it, and when that restore fails another live account becomes the active one. * Apply review suggestions to restoring every account - Remove the search backfill guard: with no sessions the manager starts nothing and reports that nothing is running, so the task completes. - Add a TODO to switch to the most recently used account when the active one signs out while others are signed in (element-meta#3325). - Disable SwiftLint's type_body_length on the AppCoordinator again, it's just over the limit with develop's new migrations.
447 lines
20 KiB
Swift
447 lines
20 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
#if IS_MAIN_APP
|
|
import EmbeddedElementCall
|
|
#endif
|
|
|
|
import Combine
|
|
import Foundation
|
|
import Macros
|
|
import SwiftUI
|
|
|
|
nonisolated enum AppBuildType {
|
|
case debug
|
|
case nightly
|
|
case release
|
|
|
|
static var current: AppBuildType {
|
|
#if DEBUG
|
|
return .debug
|
|
#else
|
|
if InfoPlistReader.main.isNightlyBuild {
|
|
.nightly
|
|
} else {
|
|
.release
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Store Element specific app settings.
|
|
///
|
|
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
|
|
final nonisolated class AppSettings: @unchecked Sendable {
|
|
static let suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
|
|
|
/// UserDefaults to be used on reads and writes.
|
|
private let store: UserDefaultsProtocol
|
|
|
|
static var appBuildType: AppBuildType {
|
|
AppBuildType.current
|
|
}
|
|
|
|
func resetAllSettings() {
|
|
MXLog.warning("Resetting the AppSettings.")
|
|
store.reset()
|
|
}
|
|
|
|
// MARK: - Hooks
|
|
|
|
// swiftlint:disable:next function_parameter_count
|
|
func override(accountProviders: [AccountProvider],
|
|
allowOtherAccountProviders: Bool,
|
|
hideBrandChrome: Bool,
|
|
pushGatewayBaseURL: URL,
|
|
oAuthRedirectURL: URL,
|
|
oAuthClientURIPath: String?,
|
|
websiteURL: URL,
|
|
logoURL: URL,
|
|
copyrightURL: URL,
|
|
acceptableUseURL: URL,
|
|
privacyURL: URL,
|
|
encryptionURL: URL,
|
|
deviceVerificationURL: URL,
|
|
chatBackupDetailsURL: URL,
|
|
identityPinningViolationDetailsURL: URL,
|
|
historySharingDetailsURL: URL,
|
|
elementWebHosts: [String],
|
|
accountProvisioningHost: String,
|
|
bugReportApplicationID: String,
|
|
analyticsTermsURL: URL?,
|
|
mapTilerConfiguration: MapTilerConfiguration) {
|
|
self.accountProviders = accountProviders
|
|
self.allowOtherAccountProviders = allowOtherAccountProviders
|
|
self.hideBrandChrome = hideBrandChrome
|
|
self.pushGatewayBaseURL = pushGatewayBaseURL
|
|
self.oAuthRedirectURL = oAuthRedirectURL
|
|
self.oAuthClientURIPath = oAuthClientURIPath
|
|
self.websiteURL = websiteURL
|
|
self.logoURL = logoURL
|
|
self.copyrightURL = copyrightURL
|
|
self.acceptableUseURL = acceptableUseURL
|
|
self.privacyURL = privacyURL
|
|
self.encryptionURL = encryptionURL
|
|
self.deviceVerificationURL = deviceVerificationURL
|
|
self.chatBackupDetailsURL = chatBackupDetailsURL
|
|
self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL
|
|
self.historySharingDetailsURL = historySharingDetailsURL
|
|
self.elementWebHosts = elementWebHosts
|
|
self.accountProvisioningHost = accountProvisioningHost
|
|
self.bugReportApplicationID = bugReportApplicationID
|
|
self.analyticsTermsURL = analyticsTermsURL
|
|
self.mapTilerConfiguration = RemotePreference(mapTilerConfiguration)
|
|
}
|
|
|
|
// MARK: - Application
|
|
|
|
/// The last known version of the app that was launched on this device, which is
|
|
/// used to detect when migrations should be run. When `nil` the app may have been
|
|
/// deleted between runs so should clear data in the shared container and keychain.
|
|
@UserPreference
|
|
var lastVersionLaunched: String?
|
|
|
|
/// The Set of room identifiers of invites that the user already saw in the invites list.
|
|
/// This Set is being used to implement badges for unread invites.
|
|
@UserPreference(defaultValue: Set<String>())
|
|
var seenInvites: Set<String>
|
|
|
|
/// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users.
|
|
@UserPreference(defaultValue: true)
|
|
var hasSeenNewSoundBanner: Bool
|
|
|
|
/// Defaults to `true` so new users never see it. Developer options arms it for now, an upgrade migration will at launch.
|
|
@UserPreference(defaultValue: true)
|
|
var hasSeenMultiAccountAnnouncement: Bool
|
|
|
|
/// The initial set of account providers shown to the user in the authentication flow.
|
|
///
|
|
/// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should
|
|
/// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`.
|
|
private(set) var accountProviders: [AccountProvider] = [.managed(serverName: "matrix.org", baseURL: "https://matrix-client.matrix.org")]
|
|
/// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`.
|
|
private(set) var allowOtherAccountProviders = true
|
|
/// Whether the components surrounding the app brand/logo should be hidden or not
|
|
private(set) var hideBrandChrome = false
|
|
|
|
/// The task identifier used for background app refresh. Also used in main target's the Info.plist
|
|
let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh"
|
|
/// The task identifier used for the search index backfill. Also used in main target's the Info.plist
|
|
let searchBackfillTaskIdentifier = "io.element.elementx.background.search-backfill"
|
|
|
|
/// A URL where users can go read more about the app.
|
|
private(set) var websiteURL: URL = "https://element.io"
|
|
/// A URL that contains the app's logo that may be used when showing content in a web view.
|
|
private(set) var logoURL: URL = "https://element.io/mobile-icon.png"
|
|
/// A URL that contains that app's copyright notice.
|
|
private(set) var copyrightURL: URL = "https://element.io/copyright"
|
|
/// A URL that contains the app's Terms of use.
|
|
private(set) var acceptableUseURL: URL = "https://element.io/acceptable-use-policy-terms"
|
|
/// A URL that contains the app's Privacy Policy.
|
|
private(set) var privacyURL: URL = "https://element.io/privacy"
|
|
/// A URL where users can go read more about encryption in general.
|
|
private(set) var encryptionURL: URL = "https://element.io/help#encryption"
|
|
/// A URL where users can go read more about device verification..
|
|
private(set) var deviceVerificationURL: URL = "https://element.io/help#encryption-device-verification"
|
|
/// A URL where users can go read more about the chat backup.
|
|
private(set) var chatBackupDetailsURL: URL = "https://element.io/help#encryption5"
|
|
/// A URL where users can go read more about identity pinning violations
|
|
private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18"
|
|
/// A URL describing how history sharing works
|
|
private(set) var historySharingDetailsURL: URL = "https://element.io/en/help#e2ee-history-sharing"
|
|
|
|
/// Any domains that Element web may be hosted on - used for handling links.
|
|
private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
|
|
/// The domain that account provisioning links will be hosted on - used for handling the links.
|
|
private(set) var accountProvisioningHost = "mobile.element.io"
|
|
/// The App Store URL for Element Pro, shown to the user when a homeserver requires that app.
|
|
/// **Note:** This property isn't overridable as it in unexpected for forks to come across the error (or to even have a "Pro" app).
|
|
let elementProAppStoreURL: URL = "https://apps.apple.com/app/element-pro-for-work/id6502951615"
|
|
|
|
@UserPreference(defaultValue: AppAppearance.system)
|
|
var appAppearance: AppAppearance
|
|
|
|
/// Tracks previous servers the user connected to for autocompletion purposes. Entries are made lowercase on write.
|
|
@UserPreference(key: "previousServers", defaultValue: [])
|
|
var previousServers: [String]
|
|
|
|
var defaultAccountProvider: AccountProvider {
|
|
if allowOtherAccountProviders {
|
|
previousServers.first.map { .generic($0) } ?? accountProviders[0]
|
|
} else {
|
|
accountProviders[0]
|
|
}
|
|
}
|
|
|
|
// MARK: - Security
|
|
|
|
/// The app must be locked with a PIN code as part of the authentication flow.
|
|
let appLockIsMandatory = false
|
|
/// The amount of time the app can remain in the background for without requesting the PIN/TouchID/FaceID.
|
|
let appLockGracePeriod: TimeInterval = 0
|
|
/// Any codes that the user isn't allowed to use for their PIN.
|
|
let appLockPINCodeBlockList = ["0000", "1234"]
|
|
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
|
|
@UserPreference(defaultValue: 0)
|
|
var appLockNumberOfPINAttempts: Int
|
|
|
|
// MARK: - Authentication
|
|
|
|
/// Any pre-defined static client registrations for OAuth issuers.
|
|
let oAuthStaticRegistrations: [URL: String] = ["https://id.thirdroom.io/realms/thirdroom": "elementx"]
|
|
/// The redirect URL used for OAuth. For the normal case we don't actually need the bundle ID as the web authentication session handles the redirect internally.
|
|
/// However in the case where MAS sends the user to an external app, we need to make sure that the system will open the correct variant of the app (e.g. Nightly).
|
|
private(set) nonisolated(unsafe) var oAuthRedirectURL: URL! = URL(string: "https://element.io/oauth/ios/\(InfoPlistReader.main.bundleIdentifier)")
|
|
/// A path that is appended to `websiteURL` to form the OAuth `clientURI`. MAS uses `clientURI` as the identifier for a specific app, allowing us to
|
|
/// distinguish the various clients we have for Android, iOS and Web from each other.
|
|
/// Intentionally a distinct property so it can be easily overridden without having to manipulate the website URL.
|
|
private(set) var oAuthClientURIPath: String? = "apps/ios"
|
|
|
|
var oAuthConfiguration: OAuthConfiguration {
|
|
OAuthConfiguration(clientName: InfoPlistReader.main.bundleDisplayName,
|
|
redirectURI: oAuthRedirectURL,
|
|
clientURI: oAuthClientURIPath.map { websiteURL.appending(path: $0) } ?? websiteURL,
|
|
logoURI: logoURL,
|
|
tosURI: acceptableUseURL,
|
|
policyURI: privacyURL,
|
|
staticRegistrations: oAuthStaticRegistrations.mapKeys { $0.absoluteString })
|
|
}
|
|
|
|
/// Whether or not the Create Account button is shown on the start screen.
|
|
///
|
|
/// **Note:** Setting this to false doesn't prevent someone from creating an account when the selected homeserver's MAS allows registration.
|
|
let showCreateAccountButton = true
|
|
|
|
// MARK: - Notifications
|
|
|
|
var pusherAppID: String {
|
|
#if DEBUG
|
|
InfoPlistReader.main.baseBundleIdentifier + ".ios.dev"
|
|
#else
|
|
InfoPlistReader.main.baseBundleIdentifier + ".ios.prod"
|
|
#endif
|
|
}
|
|
|
|
private(set) var pushGatewayBaseURL: URL = "https://matrix.org"
|
|
var pushGatewayNotifyEndpoint: URL {
|
|
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
|
|
}
|
|
|
|
@UserPreference(defaultValue: true)
|
|
var enableNotifications: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var hideQuietNotificationAlerts: Bool
|
|
|
|
/// Tag describing which set of device specific rules a pusher executes.
|
|
@UserPreference
|
|
var pusherProfileTag: String?
|
|
|
|
/// The device's last boot time as recorded by the NSE.
|
|
@UserPreference
|
|
var lastNotificationBootTime: TimeInterval?
|
|
|
|
/// The app icon badge value the app last computed from the SDK's unread notification counts.
|
|
@UserPreference(defaultValue: 0)
|
|
var lastKnownBadgeCount: Int
|
|
|
|
// MARK: - Logging
|
|
|
|
@UserPreference(defaultValue: LogLevel.info)
|
|
var logLevel: LogLevel
|
|
|
|
@UserPreference(defaultValue: Set<TraceLogPack>())
|
|
var traceLogPacks: Set<TraceLogPack>
|
|
|
|
// MARK: - Bug report
|
|
|
|
let bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> = .init(Secrets.rageshakeURL.map { .url(URL(string: $0)!) } ?? .disabled) // swiftlint:disable:this force_unwrapping
|
|
let bugReportSentryURL: URL? = Secrets.sentryDSN.map { URL(string: $0)! } // swiftlint:disable:this force_unwrapping
|
|
let bugReportSentryRustURL: URL? = Secrets.sentryRustDSN.map { URL(string: $0)! } // swiftlint:disable:this force_unwrapping
|
|
/// The name allocated by the bug report server
|
|
private(set) var bugReportApplicationID = "element-x-ios"
|
|
|
|
// MARK: - Content scanner
|
|
|
|
/// The base URL of the content scanner server used to scan media before it is downloaded.
|
|
/// `nil` when content scanning is disabled.
|
|
let contentScannerURL: RemotePreference<URL?> = .init(nil)
|
|
|
|
// MARK: - Encryption
|
|
|
|
/// Whether the server forbids the use of E2EE: new rooms are created unencrypted and
|
|
/// enabling encryption on existing rooms is not offered.
|
|
let forceDisableE2EE: RemotePreference<Bool> = .init(false)
|
|
|
|
// MARK: - Analytics
|
|
|
|
/// The configuration to use for analytics. Set to `nil` to disable analytics.
|
|
let analyticsConfiguration: AnalyticsConfiguration? = AppSettings.makeAnalyticsConfiguration()
|
|
/// The URL to open with more information about analytics terms. When this is `nil` the "Learn more" link will be hidden.
|
|
private(set) var analyticsTermsURL: URL? = "https://element.io/cookie-policy"
|
|
/// Whether or not there the app is able ask for user consent to enable analytics or sentry reporting.
|
|
var canPromptForAnalytics: Bool {
|
|
analyticsConfiguration != nil || bugReportSentryURL != nil
|
|
}
|
|
|
|
private static func makeAnalyticsConfiguration() -> AnalyticsConfiguration? {
|
|
guard let host = Secrets.postHogHost, let apiKey = Secrets.postHogAPIKey else { return nil }
|
|
return AnalyticsConfiguration(host: host, apiKey: apiKey)
|
|
}
|
|
|
|
/// Whether the user has opted in to send analytics.
|
|
@UserPreference(defaultValue: AnalyticsConsentState.unknown)
|
|
var analyticsConsentState: AnalyticsConsentState
|
|
|
|
/// Whether a user session has ever been set up on this device. Deliberately not cleared on
|
|
/// logout: it stops the Classic app migration prompt from reappearing when an Element X
|
|
/// user is signed out unexpectedly (invalidated token, corrupted storage, etc).
|
|
@UserPreference(defaultValue: false)
|
|
var hasSignedInBefore: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var hasRunNotificationPermissionsOnboarding: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var hasRequestedLocationAlwaysLocationAuthorization: Bool
|
|
|
|
@UserPreference(defaultValue: [FrequentlyUsedEmoji]())
|
|
var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji]
|
|
|
|
// MARK: - Live Location
|
|
|
|
@UserPreference(key: "liveLocationSharingTimeoutDatesByRoomID", defaultValue: [String: LiveLocationSession]())
|
|
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession]
|
|
|
|
@UserPreference(defaultValue: 10)
|
|
var liveLocationMinimumDistanceUpdate: Int
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var liveLocationDisclaimerDisplayed: Bool
|
|
|
|
// MARK: - Room Screen
|
|
|
|
@UserPreference(defaultValue: AppBuildType.current == .debug)
|
|
var viewSourceEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: true)
|
|
var optimizeMediaUploads: Bool
|
|
|
|
@UserPreference(defaultValue: AudioPlaybackSpeed.default)
|
|
var voiceMessagePlaybackSpeed: AudioPlaybackSpeed
|
|
|
|
/// Whether or not to show a warning on the media caption composer so the user knows
|
|
/// that captions might not be visible to users who are using other Matrix clients.
|
|
let shouldShowMediaCaptionWarning = true
|
|
|
|
// MARK: - Element Call
|
|
|
|
#if IS_MAIN_APP
|
|
// swiftlint:disable:next force_unwrapping
|
|
let elementCallBaseURL: URL = EmbeddedElementCall.appURL!
|
|
#endif
|
|
|
|
// These are publicly availble on https://call.element.io so we don't neeed to treat them as secrets
|
|
let elementCallPosthogAPIHost = "https://posthog-element-call.element.io"
|
|
let elementCallPosthogAPIKey = "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU"
|
|
let elementCallPosthogSentryDSN = "https://3bd2f95ba5554d4497da7153b552ffb5@sentry.tools.element.io/41"
|
|
|
|
@UserPreference
|
|
var elementCallBaseURLOverride: URL?
|
|
|
|
// MARK: - Users
|
|
|
|
/// Whether to hide the display name and avatar of ignored users as these may contain objectionable content.
|
|
let hideIgnoredUserProfiles = true
|
|
|
|
// MARK: - Maps
|
|
|
|
/// The locally-bundled MapTiler configuration.
|
|
static let bundledMapTilerConfiguration = MapTilerConfiguration(baseURL: "https://api.maptiler.com/maps",
|
|
apiKey: Secrets.mapLibreAPIKey,
|
|
lightStyleID: "9bc819c8-e627-474a-a348-ec144fe3d810",
|
|
darkStyleID: "dea61faf-292b-4774-9660-58fcef89a7f3")
|
|
|
|
/// The MapTiler configuration used to build map URLs, which defaults to the bundled one.
|
|
private(set) var mapTilerConfiguration = RemotePreference(AppSettings.bundledMapTilerConfiguration)
|
|
|
|
// MARK: - Feature Flags
|
|
|
|
/// Others
|
|
@UserPreference(defaultValue: false)
|
|
var fuzzyRoomListSearchEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var lowPriorityFilterEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var mentionsFilterEnabled: Bool
|
|
|
|
/// Configuration to enable only signed device isolation mode for crypto. In this mode only devices signed by their owner will be considered in e2ee rooms.
|
|
@UserPreference(defaultValue: false)
|
|
var enableOnlySignedDeviceIsolationMode: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var knockingEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var threadsEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var messageMultiSelectEnabled: Bool
|
|
|
|
@UserPreference(key: "globalSearchEnabledV2", defaultValue: true)
|
|
var globalSearchEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var focusEventOnNotificationTap: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var linkPreviewsEnabled: Bool
|
|
|
|
/// Enables *sending* gallery messages (multiple media in a single message).
|
|
/// Received galleries are always rendered regardless of this flag.
|
|
@UserPreference(defaultValue: false)
|
|
var galleryEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var jumpToReadMarkerEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var linkNewDeviceEnabled: Bool
|
|
|
|
@UserPreference(key: "clientPausingAndResumingEnabledV2", defaultValue: false, volatile: true)
|
|
var clientPausingAndResumingEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: AppBuildType.current != .release)
|
|
var developerOptionsEnabled: Bool
|
|
|
|
/// Runs calls through the native matrix-rust-rtc stack instead of the Element Call web view.
|
|
@UserPreference(defaultValue: false)
|
|
var nativeCallEnabled: Bool
|
|
|
|
@UserPreference(defaultValue: false)
|
|
var multiAccountEnabled: Bool
|
|
|
|
/// Lets the add account flow start once `multiAccountEnabled` shows its entry points. Not a user preference, so testers
|
|
/// can't add a second account while signing out of one isn't safe yet. Only set it to `true` locally, for development.
|
|
let addAccountFlowEnabled = false
|
|
|
|
init(store: UserDefaultsProtocol) {
|
|
self.store = store
|
|
}
|
|
|
|
func userSettings(for userID: String) -> UserSettings {
|
|
UserSettings(appSettings: self, accountSettings: .init(userID: userID, store: store))
|
|
}
|
|
|
|
static func volatile() -> AppSettings {
|
|
AppSettings(store: VolatileUserDefaults())
|
|
}
|
|
}
|