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:
@@ -12,7 +12,7 @@ protocol PublicContentHTTPTransport: Sendable {
|
|||||||
|
|
||||||
final class URLSessionPublicContentHTTPTransport:
|
final class URLSessionPublicContentHTTPTransport:
|
||||||
PublicContentHTTPTransport,
|
PublicContentHTTPTransport,
|
||||||
@unchecked Sendable {
|
Sendable {
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
init(session: URLSession = .shared) {
|
init(session: URLSession = .shared) {
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ enum AppL10n {
|
|||||||
/// screen that exists so a provisioning mistake does *not* become a crash
|
/// screen that exists so a provisioning mistake does *not* become a crash
|
||||||
/// loop. `.auto` follows the system language, which is the right answer
|
/// loop. `.auto` follows the system language, which is the right answer
|
||||||
/// when no stored preference is reachable anyway.
|
/// when no stored preference is reachable anyway.
|
||||||
|
///
|
||||||
|
/// Reads the persisted key directly rather than `ProviderConfig.shared`
|
||||||
|
/// (main-actor isolated) because localized strings are also resolved off
|
||||||
|
/// the main actor. `ProviderConfig` writes this key synchronously in its
|
||||||
|
/// `uiLanguage` didSet, so both paths observe the same value.
|
||||||
private static var resolvedUILanguage: AppUILanguage {
|
private static var resolvedUILanguage: AppUILanguage {
|
||||||
guard AppGroup.isAvailable else { return .auto }
|
guard let defaults = AppGroup.defaultsIfAvailable else { return .auto }
|
||||||
return ProviderConfig.shared.uiLanguage
|
return AppUILanguage.fromStored(
|
||||||
|
defaults.string(forKey: AppGroupConfiguration.Keys.uiLanguage)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func format(
|
static func format(
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ struct SettingsVersionRow: View {
|
|||||||
|
|
||||||
// MARK: - Config entry summaries (shown on Settings home)
|
// MARK: - Config entry summaries (shown on Settings home)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
enum SettingsConfigSummary {
|
enum SettingsConfigSummary {
|
||||||
static func speechRecognition(config: ProviderConfig) -> String {
|
static func speechRecognition(config: ProviderConfig) -> String {
|
||||||
if config.engineMode == "local" {
|
if config.engineMode == "local" {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public protocol AccountHTTPTransport: Sendable {
|
|||||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, @unchecked Sendable {
|
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, Sendable {
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
public init(session: URLSession = .shared) {
|
public init(session: URLSession = .shared) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ public struct LiveConfigurationSnapshot: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build from live `ProviderConfig` plus persisted App Group extras.
|
/// Build from live `ProviderConfig` plus persisted App Group extras.
|
||||||
|
@MainActor
|
||||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||||
self.init(
|
self.init(
|
||||||
providerId: config.providerId,
|
providerId: config.providerId,
|
||||||
@@ -136,6 +137,7 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
|||||||
self.init(snapshot: LiveConfigurationSnapshot(store: store))
|
self.init(snapshot: LiveConfigurationSnapshot(store: store))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||||
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
|
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable {
|
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, Sendable {
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
public init(session: URLSession = .shared) {
|
public init(session: URLSession = .shared) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public protocol GatewayGrantCredentialStore: Sendable {
|
|||||||
func delete() async throws
|
func delete() async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
|
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
|
||||||
public enum Slot: String, Sendable {
|
public enum Slot: String, Sendable {
|
||||||
case account = "scope-limited.active"
|
case account = "scope-limited.active"
|
||||||
case oobe = "scope-limited.oobe"
|
case oobe = "scope-limited.oobe"
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// UI-owned ObservableObject; construct and mutate it on the main thread.
|
/// UI-owned ObservableObject, isolated to the main actor. Credential observers
|
||||||
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
|
/// write only Keychain, while non-secret configuration uses App Group defaults.
|
||||||
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
|
@MainActor
|
||||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
public final class ProviderConfig: ObservableObject {
|
||||||
public static let shared = ProviderConfig()
|
public static let shared = ProviderConfig()
|
||||||
|
|
||||||
@Published public var providerId: String {
|
@Published public var providerId: String {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
public enum Keychain: @unchecked Sendable {
|
public enum Keychain: Sendable {
|
||||||
|
|
||||||
public enum KeychainError: Error, Sendable, Equatable {
|
public enum KeychainError: Error, Sendable, Equatable {
|
||||||
case unexpectedStatus(OSStatus)
|
case unexpectedStatus(OSStatus)
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
|||||||
|
|
||||||
public enum LLMClientFactory {
|
public enum LLMClientFactory {
|
||||||
/// Build a client from the current `ProviderConfig`.
|
/// Build a client from the current `ProviderConfig`.
|
||||||
|
@MainActor
|
||||||
public static func make(from config: ProviderConfig) -> LLMClient {
|
public static func make(from config: ProviderConfig) -> LLMClient {
|
||||||
make(
|
make(
|
||||||
providerId: config.providerId,
|
providerId: config.providerId,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import Foundation
|
|||||||
public final class EnglishLearningStore: @unchecked Sendable {
|
public final class EnglishLearningStore: @unchecked Sendable {
|
||||||
public static let defaultsKey = "englishTyping.learnedBoosts.v1"
|
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
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
||||||
@@ -42,8 +46,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
|
|||||||
private func mutate(word: String, delta: Int) {
|
private func mutate(word: String, delta: Int) {
|
||||||
let key = word.lowercased()
|
let key = word.lowercased()
|
||||||
guard !key.isEmpty else { return }
|
guard !key.isEmpty else { return }
|
||||||
|
Self.mutationLock.withLock {
|
||||||
var map = snapshot()
|
var map = snapshot()
|
||||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||||
defaults.set(map, forKey: Self.defaultsKey)
|
defaults.set(map, forKey: Self.defaultsKey)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ final class KeychainTests: XCTestCase {
|
|||||||
/// value must be moved to the Keychain and removed from UserDefaults
|
/// value must be moved to the Keychain and removed from UserDefaults
|
||||||
/// — otherwise a fresh install in a clean simulator would inherit the
|
/// — otherwise a fresh install in a clean simulator would inherit the
|
||||||
/// stale plaintext value.
|
/// stale plaintext value.
|
||||||
|
@MainActor
|
||||||
func testLegacyUserDefaultsKeyIsMigratedToKeychain() {
|
func testLegacyUserDefaultsKeyIsMigratedToKeychain() {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.migration.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -271,6 +272,7 @@ final class KeychainTests: XCTestCase {
|
|||||||
/// — possible on a downgrade or a torn update — Keychain wins. We
|
/// — possible on a downgrade or a torn update — Keychain wins. We
|
||||||
/// don't delete the UserDefaults value, but the running app reads from
|
/// don't delete the UserDefaults value, but the running app reads from
|
||||||
/// the Keychain only.
|
/// the Keychain only.
|
||||||
|
@MainActor
|
||||||
func testKeychainWinsOverLegacyWhenBothPresent() {
|
func testKeychainWinsOverLegacyWhenBothPresent() {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.migration2.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration2.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - ProviderConfig persistence
|
// MARK: - ProviderConfig persistence
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func testProviderConfigPersistsAcrossInstances() {
|
func testProviderConfigPersistsAcrossInstances() {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.persist.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.persist.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -64,6 +65,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Local ASR needs no cloud ASR key, but polish still requires a user API key.
|
/// Local ASR needs no cloud ASR key, but polish still requires a user API key.
|
||||||
|
@MainActor
|
||||||
func testLocalEngineWithoutAPIKeyIsNotPolishConfigured() {
|
func testLocalEngineWithoutAPIKeyIsNotPolishConfigured() {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.isconfigured.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.isconfigured.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -299,6 +301,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
|
|
||||||
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
||||||
/// be readable through `AppGroupStore` on the same suite.
|
/// be readable through `AppGroupStore` on the same suite.
|
||||||
|
@MainActor
|
||||||
func testAppGroupCrossProcessLegacyOffModeMigratesToPolish() async {
|
func testAppGroupCrossProcessLegacyOffModeMigratesToPolish() async {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -377,6 +380,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func testLiveConfigurationStoreSnapshotsAnyConfigurationStore() throws {
|
func testLiveConfigurationStoreSnapshotsAnyConfigurationStore() throws {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -561,6 +565,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
XCTAssertNil(body["thinking_config"])
|
XCTAssertNil(body["thinking_config"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func testLLMThinkingDefaultIsOffInFreshConfiguration() {
|
func testLLMThinkingDefaultIsOffInFreshConfiguration() {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.thinking.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.thinking.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|||||||
Reference in New Issue
Block a user