refactor(concurrency): isolate ProviderConfig to the main actor and drop unneeded @unchecked Sendable
- ProviderConfig is now @MainActor instead of @unchecked Sendable; AppL10n reads the persisted UI language key directly so off-main string lookups stay valid. - EnglishLearningStore serializes its read-modify-write with a type-wide lock (several instances share one defaults key). - URLSession-only transports, GatewayGrantKeychainStore and Keychain conform to Sendable without @unchecked. UserDefaults/DeviceCheck wrappers keep @unchecked because those SDK types are not Sendable.
This commit is contained in:
@@ -97,6 +97,7 @@ public struct LiveConfigurationSnapshot: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// Build from live `ProviderConfig` plus persisted App Group extras.
|
||||
@MainActor
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(
|
||||
providerId: config.providerId,
|
||||
@@ -136,6 +137,7 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
||||
self.init(snapshot: LiveConfigurationSnapshot(store: store))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable {
|
||||
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
|
||||
@@ -13,7 +13,7 @@ public protocol GatewayGrantCredentialStore: Sendable {
|
||||
func delete() async throws
|
||||
}
|
||||
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
|
||||
public enum Slot: String, Sendable {
|
||||
case account = "scope-limited.active"
|
||||
case oobe = "scope-limited.oobe"
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// UI-owned ObservableObject; construct and mutate it on the main thread.
|
||||
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
|
||||
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// UI-owned ObservableObject, isolated to the main actor. Credential observers
|
||||
/// write only Keychain, while non-secret configuration uses App Group defaults.
|
||||
@MainActor
|
||||
public final class ProviderConfig: ObservableObject {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
@Published public var providerId: String {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum Keychain: @unchecked Sendable {
|
||||
public enum Keychain: Sendable {
|
||||
|
||||
public enum KeychainError: Error, Sendable, Equatable {
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
@@ -418,6 +418,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
|
||||
public enum LLMClientFactory {
|
||||
/// Build a client from the current `ProviderConfig`.
|
||||
@MainActor
|
||||
public static func make(from config: ProviderConfig) -> LLMClient {
|
||||
make(
|
||||
providerId: config.providerId,
|
||||
|
||||
@@ -10,6 +10,10 @@ import Foundation
|
||||
public final class EnglishLearningStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "englishTyping.learnedBoosts.v1"
|
||||
|
||||
/// Static because several instances share the same defaults key; a
|
||||
/// per-instance lock would not serialize their read-modify-write.
|
||||
private static let mutationLock = NSLock()
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
||||
@@ -42,8 +46,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
|
||||
private func mutate(word: String, delta: Int) {
|
||||
let key = word.lowercased()
|
||||
guard !key.isEmpty else { return }
|
||||
var map = snapshot()
|
||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||
defaults.set(map, forKey: Self.defaultsKey)
|
||||
Self.mutationLock.withLock {
|
||||
var map = snapshot()
|
||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||
defaults.set(map, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user