feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion
Mirror the personal dictionary through iCloud Key-Value Store with deterministic merge rules, main-app-only sync UI, and App Group as the keyboard runtime cache. Add cloud-engine ASR with dictionary bias and expand the bundled custom language model lexicon.
This commit is contained in:
@@ -30,6 +30,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
|
||||
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
@@ -53,6 +55,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var personalDictionary: PersonalDictionary
|
||||
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
|
||||
public var personalDictionaryICloudSyncEnabled: Bool
|
||||
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||
public var flowSkipAppSwitch: Bool
|
||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||
@@ -154,6 +158,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
personalDictionaryICloudSyncEnabled: {
|
||||
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||
}(),
|
||||
flowSkipAppSwitch: {
|
||||
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
||||
return true
|
||||
@@ -212,6 +222,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// CloudASRModels.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cloud-engine ASR routing: which provider uses official hotwords /
|
||||
// vocabulary APIs vs. a transcription prompt bias.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How a cloud provider applies the user's personal dictionary during ASR.
|
||||
public enum CloudASRStrategy: String, Sendable, Equatable {
|
||||
/// 智谱 GLM-ASR — `hotwords` + optional `prompt`.
|
||||
case zhipuHotwords
|
||||
/// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text.
|
||||
case alibabaVocabulary
|
||||
/// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs.
|
||||
case prompt
|
||||
/// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。
|
||||
case localFallback
|
||||
}
|
||||
|
||||
public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
|
||||
case noAPIKey
|
||||
case invalidURL
|
||||
case http(status: Int, message: String?)
|
||||
case decoding(String)
|
||||
case transport(String)
|
||||
case emptyTranscript
|
||||
case audioTooLong
|
||||
case providerUnsupported
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAPIKey:
|
||||
return SharedL10n.string("error.cloudASR.noAPIKey")
|
||||
case .invalidURL:
|
||||
return SharedL10n.string("error.cloudASR.invalidURL")
|
||||
case .http(let status, let message):
|
||||
if let message, !message.isEmpty {
|
||||
return SharedL10n.format("error.cloudASR.httpWithMessage", status, message)
|
||||
}
|
||||
return SharedL10n.format("error.cloudASR.http", status)
|
||||
case .decoding(let detail):
|
||||
return SharedL10n.format("error.cloudASR.decoding", detail)
|
||||
case .transport(let detail):
|
||||
return SharedL10n.format("error.cloudASR.transport", detail)
|
||||
case .emptyTranscript:
|
||||
return SharedL10n.string("error.cloudASR.emptyTranscript")
|
||||
case .audioTooLong:
|
||||
return SharedL10n.string("error.cloudASR.audioTooLong")
|
||||
case .providerUnsupported:
|
||||
return SharedL10n.string("error.cloudASR.providerUnsupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CloudASRModelCatalog {
|
||||
/// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary.
|
||||
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
|
||||
/// Must match the ASR model used at recognition time.
|
||||
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
|
||||
|
||||
public static let zhipuGLMASR = "glm-asr-2512"
|
||||
public static let openAITranscribe = "gpt-4o-mini-transcribe"
|
||||
public static let openAIWhisper = "whisper-1"
|
||||
public static let mimoASR = "mimo-v2.5-asr"
|
||||
|
||||
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
|
||||
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
|
||||
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
|
||||
public static let zhipuTranscriptionPath = "/audio/transcriptions"
|
||||
|
||||
public static func strategy(for providerId: String) -> CloudASRStrategy {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return .zhipuHotwords
|
||||
case "qwen":
|
||||
return .alibabaVocabulary
|
||||
case "moonshot":
|
||||
return .localFallback
|
||||
case "openai", "mimo", "custom":
|
||||
return .prompt
|
||||
default:
|
||||
return .prompt
|
||||
}
|
||||
}
|
||||
|
||||
public static func defaultModel(for providerId: String) -> String {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return zhipuGLMASR
|
||||
case "qwen":
|
||||
return alibabaFunASRFlash
|
||||
case "mimo":
|
||||
return mimoASR
|
||||
case "openai", "custom":
|
||||
return openAITranscribe
|
||||
default:
|
||||
return openAITranscribe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LLMProvider {
|
||||
public var cloudASRStrategy: CloudASRStrategy {
|
||||
CloudASRModelCatalog.strategy(for: id)
|
||||
}
|
||||
|
||||
public var defaultCloudASRModel: String {
|
||||
CloudASRModelCatalog.defaultModel(for: id)
|
||||
}
|
||||
|
||||
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
|
||||
public var supportsPersonalDictionaryCloudASR: Bool {
|
||||
switch cloudASRStrategy {
|
||||
case .zhipuHotwords, .alibabaVocabulary:
|
||||
return true
|
||||
case .prompt, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// PersonalDictionary+ASRBias.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
|
||||
// vocabulary entries, or Whisper-style prompt fragments).
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
|
||||
public let text: String
|
||||
public let weight: Int
|
||||
public let lang: String?
|
||||
|
||||
public init(text: String, weight: Int = 4, lang: String? = nil) {
|
||||
self.text = text
|
||||
self.weight = weight
|
||||
self.lang = lang
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary {
|
||||
/// Stable fingerprint used to decide when to refresh Alibaba vocabulary.
|
||||
public func vocabularySyncFingerprint() -> String {
|
||||
let payload = effectiveEntries
|
||||
.sorted { $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending }
|
||||
.map { entry in
|
||||
let aliases = entry.aliases
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.joined(separator: ",")
|
||||
return "\(entry.term.lowercased())|\(aliases)"
|
||||
}
|
||||
.joined(separator: ";")
|
||||
let digest = SHA256.hash(data: Data(payload.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 智谱 `hotwords` — canonical terms only (aliases go to `asrPromptBias`).
|
||||
public func asrHotwords(maxCount: Int = 100) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var words: [String] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
words.append(term)
|
||||
if words.count >= maxCount { break }
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/// 阿里百炼热词表 — `text` + `weight` (+ optional `lang`).
|
||||
public func alibabaHotwordEntries(maxCount: Int = 500, defaultWeight: Int = 4) -> [AlibabaHotwordEntry] {
|
||||
var seen = Set<String>()
|
||||
var entries: [AlibabaHotwordEntry] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty, term.count <= 15 else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
entries.append(
|
||||
AlibabaHotwordEntry(
|
||||
text: term,
|
||||
weight: defaultWeight,
|
||||
lang: Self.inferAlibabaLang(for: term)
|
||||
)
|
||||
)
|
||||
if entries.count >= maxCount { break }
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Whisper / OpenAI-style short prompt bias (also used by MiMo text hint).
|
||||
public func asrPromptBias(maxCharacters: Int = 800) -> String {
|
||||
let entries = effectiveEntries
|
||||
guard !entries.isEmpty else { return "" }
|
||||
|
||||
var lines: [String] = []
|
||||
for entry in entries {
|
||||
if entry.aliases.isEmpty {
|
||||
lines.append(entry.term)
|
||||
} else {
|
||||
let aliasHint = entry.aliases.prefix(4).joined(separator: ", ")
|
||||
lines.append("\(entry.term)(常见误识别:\(aliasHint))")
|
||||
}
|
||||
let joined = lines.joined(separator: ";")
|
||||
if joined.count > maxCharacters {
|
||||
if lines.count == 1 {
|
||||
return String(joined.prefix(maxCharacters))
|
||||
}
|
||||
lines.removeLast()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !lines.isEmpty else { return "" }
|
||||
let body = lines.joined(separator: ";")
|
||||
return "用户专有词汇,转写时请优先使用以下标准写法:\(body)"
|
||||
}
|
||||
|
||||
/// Compact domain context for Alibaba Fun-ASR Flash `input_text`.
|
||||
public func alibabaContextText(maxCharacters: Int = 1200) -> String {
|
||||
let prompt = asrPromptBias(maxCharacters: maxCharacters)
|
||||
guard !prompt.isEmpty else { return "" }
|
||||
return prompt
|
||||
}
|
||||
|
||||
private static func inferAlibabaLang(for term: String) -> String? {
|
||||
let hasNonASCII = term.unicodeScalars.contains { !$0.isASCII }
|
||||
if hasNonASCII { return "zh" }
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// PersonalDictionary+Merging.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Deterministic merge rules for iCloud KVS sync. Pure logic — no
|
||||
// NSUbiquitousKeyValueStore dependency so unit tests stay hermetic.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PersonalDictionary {
|
||||
/// Merges two dictionary snapshots for cross-device sync.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Same `id`: keep the entry with the newer `updatedAt`.
|
||||
/// - Same canonical term (case-insensitive) but different `id`: union
|
||||
/// aliases, take max `usageCount`, keep the newer entry's fields.
|
||||
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
|
||||
var mergedByID: [UUID: Entry] = [:]
|
||||
var canonicalOwner: [String: UUID] = [:]
|
||||
|
||||
func insertOrMerge(_ candidate: Entry) {
|
||||
let key = candidate.term.lowercased()
|
||||
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
|
||||
if candidate.id == existingID {
|
||||
mergedByID[existingID] = resolveEntryConflict(existing: existing, incoming: candidate)
|
||||
return
|
||||
}
|
||||
existing = mergeSameTerm(existing: existing, incoming: candidate)
|
||||
mergedByID[existingID] = existing
|
||||
return
|
||||
}
|
||||
|
||||
if let existing = mergedByID[candidate.id] {
|
||||
mergedByID[candidate.id] = resolveEntryConflict(existing: existing, incoming: candidate)
|
||||
canonicalOwner[key] = candidate.id
|
||||
return
|
||||
}
|
||||
|
||||
mergedByID[candidate.id] = candidate
|
||||
canonicalOwner[key] = candidate.id
|
||||
}
|
||||
|
||||
for entry in local.entries { insertOrMerge(entry) }
|
||||
for entry in remote.entries { insertOrMerge(entry) }
|
||||
|
||||
let mergedEntries = mergedByID.values.sorted {
|
||||
if $0.updatedAt != $1.updatedAt {
|
||||
return $0.updatedAt > $1.updatedAt
|
||||
}
|
||||
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
|
||||
}
|
||||
|
||||
let lastSyncedAt = [local.lastSyncedAt, remote.lastSyncedAt]
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
|
||||
return PersonalDictionary(
|
||||
entries: mergedEntries,
|
||||
version: max(local.version, remote.version) + 1,
|
||||
lastSyncedAt: lastSyncedAt
|
||||
)
|
||||
}
|
||||
|
||||
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
|
||||
incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
}
|
||||
|
||||
private static func mergeSameTerm(existing: Entry, incoming: Entry) -> Entry {
|
||||
let winner = incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
let loser = winner.id == existing.id ? incoming : existing
|
||||
var merged = winner
|
||||
merged.aliases = unionAliases(
|
||||
existing: winner.aliases,
|
||||
incoming: loser.aliases,
|
||||
excludingTerm: winner.term
|
||||
)
|
||||
merged.usageCount = max(winner.usageCount, loser.usageCount)
|
||||
merged.updatedAt = max(winner.updatedAt, loser.updatedAt)
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func unionAliases(
|
||||
existing: [String],
|
||||
incoming: [String],
|
||||
excludingTerm: String
|
||||
) -> [String] {
|
||||
let termLower = excludingTerm.lowercased()
|
||||
return Array(
|
||||
Set((existing + incoming).filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,33 @@ import Foundation
|
||||
public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
public var entries: [Entry]
|
||||
public var version: Int
|
||||
/// When this dictionary blob was last successfully pushed to iCloud KVS.
|
||||
public var lastSyncedAt: Date?
|
||||
|
||||
public init(entries: [Entry] = [], version: Int = 1) {
|
||||
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
|
||||
self.entries = entries
|
||||
self.version = version
|
||||
self.lastSyncedAt = lastSyncedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
case version
|
||||
case lastSyncedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(entries, forKey: .entries)
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
|
||||
}
|
||||
|
||||
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||||
@@ -34,6 +57,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
public var category: Category
|
||||
public var source: Source
|
||||
public var createdAt: Date
|
||||
/// Last mutation time — used for iCloud merge conflict resolution.
|
||||
public var updatedAt: Date
|
||||
public var usageCount: Int
|
||||
|
||||
public init(
|
||||
@@ -43,6 +68,7 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
category: Category,
|
||||
source: Source,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date? = nil,
|
||||
usageCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
@@ -51,9 +77,45 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
self.category = category
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt ?? createdAt
|
||||
self.usageCount = usageCount
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case term
|
||||
case aliases
|
||||
case category
|
||||
case source
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case usageCount
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
term = try container.decode(String.self, forKey: .term)
|
||||
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
|
||||
category = try container.decode(Category.self, forKey: .category)
|
||||
source = try container.decode(Source.self, forKey: .source)
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
|
||||
usageCount = try container.decodeIfPresent(Int.self, forKey: .usageCount) ?? 0
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(term, forKey: .term)
|
||||
try container.encode(aliases, forKey: .aliases)
|
||||
try container.encode(category, forKey: .category)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
try container.encode(updatedAt, forKey: .updatedAt)
|
||||
try container.encode(usageCount, forKey: .usageCount)
|
||||
}
|
||||
|
||||
public enum Category: String, Codable, Sendable, CaseIterable {
|
||||
/// Person / place / brand / organization.
|
||||
case properNoun
|
||||
@@ -140,6 +202,7 @@ extension PersonalDictionary {
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
usageCount: 0
|
||||
),
|
||||
]
|
||||
@@ -182,6 +245,7 @@ extension PersonalDictionary {
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
@@ -193,15 +257,19 @@ extension PersonalDictionary {
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let entry = Entry(
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual
|
||||
source: .manual,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
entries.append(entry)
|
||||
return entry
|
||||
@@ -216,6 +284,7 @@ extension PersonalDictionary {
|
||||
entries[idx].aliases = Array(
|
||||
Set(cleaned.filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
entries[idx].updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Renders the entire dictionary as a prompt fragment. Entries
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
/// "cloud" → provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
|
||||
Reference in New Issue
Block a user