feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
This commit is contained in:
@@ -14,12 +14,15 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var settingsSync: SettingsCloudSync!
|
||||
private let deviceA = "device-a"
|
||||
private let deviceB = "device-b"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.settings.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults.set(deviceA, forKey: "sync.deviceID.v1")
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
settingsSync = SettingsCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
@@ -27,87 +30,66 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: false)
|
||||
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
||||
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: false)
|
||||
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMergePrefersNewerUpdatedAt() {
|
||||
let older = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
providerId: "openai",
|
||||
baseURL: "https://old.example",
|
||||
model: "gpt-old",
|
||||
modeId: "polish",
|
||||
localeId: "auto",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: false,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .left,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
func testPerFieldMergeKeepsIndependentChanges() {
|
||||
let stampA = Date(timeIntervalSince1970: 100)
|
||||
let stampB = Date(timeIntervalSince1970: 200)
|
||||
let local = SyncedAppSettingsV2(
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceA),
|
||||
baseURL: SyncedField(value: "https://local.example", updatedAt: stampA, deviceID: deviceA),
|
||||
model: SyncedField(value: "gpt-local", updatedAt: stampA, deviceID: deviceA),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceA),
|
||||
localeId: SyncedField(value: "auto", updatedAt: stampA, deviceID: deviceA),
|
||||
engineMode: SyncedField(value: "cloud", updatedAt: stampA, deviceID: deviceA),
|
||||
hasAcknowledgedCloudSharing: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
|
||||
uiLanguage: SyncedField(value: .english, updatedAt: stampA, deviceID: deviceA),
|
||||
translationTargetLocaleId: SyncedField(
|
||||
value: TranslationLanguageCatalog.offLocaleId,
|
||||
updatedAt: stampA,
|
||||
deviceID: deviceA
|
||||
),
|
||||
handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA),
|
||||
cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
|
||||
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
|
||||
)
|
||||
let newer = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
providerId: "openai",
|
||||
baseURL: "https://new.example",
|
||||
model: "gpt-new",
|
||||
modeId: "polish",
|
||||
localeId: "zh-Hans",
|
||||
engineMode: "local",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .chinese,
|
||||
translationTargetLocaleId: "en",
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: false,
|
||||
polishIntensity: .light,
|
||||
flowSkipAppSwitch: false,
|
||||
flowInactivityDuration: .threeHours
|
||||
let remote = SyncedAppSettingsV2(
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceB),
|
||||
baseURL: SyncedField(value: "https://remote.example", updatedAt: stampB, deviceID: deviceB),
|
||||
model: SyncedField(value: "gpt-remote", updatedAt: stampB, deviceID: deviceB),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceB),
|
||||
localeId: SyncedField(value: "ja", updatedAt: stampB, deviceID: deviceB),
|
||||
engineMode: SyncedField(value: "local", updatedAt: stampB, deviceID: deviceB),
|
||||
hasAcknowledgedCloudSharing: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
|
||||
uiLanguage: SyncedField(value: .chinese, updatedAt: stampB, deviceID: deviceB),
|
||||
translationTargetLocaleId: SyncedField(value: "en", updatedAt: stampB, deviceID: deviceB),
|
||||
handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB),
|
||||
cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB),
|
||||
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
|
||||
)
|
||||
|
||||
let merged = SyncedAppSettings.merge(local: older, remote: newer)
|
||||
XCTAssertEqual(merged.model, "gpt-new")
|
||||
XCTAssertEqual(merged.localeId, "zh-Hans")
|
||||
XCTAssertEqual(merged.engineMode, "local")
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
|
||||
XCTAssertEqual(merged.baseURL.value, "https://remote.example")
|
||||
XCTAssertEqual(merged.localeId.value, "ja")
|
||||
XCTAssertEqual(merged.engineMode.value, "local")
|
||||
}
|
||||
|
||||
func testEnableSyncUploadsMergedSettingsAndToggle() async throws {
|
||||
store.setModeId("polish")
|
||||
store.setLocaleId("zh-Hans")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date().addingTimeInterval(3600),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
modeId: "polish",
|
||||
localeId: "en",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
try await settingsSync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.settingsICloudSyncEnabled)
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, true)
|
||||
XCTAssertEqual(settingsSync.loadRemote()?.localeId, "en")
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
|
||||
func testLegacyV1PullDoesNotClearKeychain() async throws {
|
||||
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
store.setLocaleId("auto")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 900),
|
||||
let legacy = SyncedAppSettings(
|
||||
updatedAt: Date().addingTimeInterval(3600),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
@@ -123,12 +105,44 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(legacy)
|
||||
kvs.set(data, forKey: SettingsCloudSync.legacyKVSKey)
|
||||
|
||||
await settingsSync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: false), "sk-local-openai")
|
||||
XCTAssertEqual(store.localeId, "ja")
|
||||
}
|
||||
|
||||
func testEnableSyncMigratesKeysToICloudKeychain() async throws {
|
||||
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
|
||||
|
||||
try await settingsSync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.settingsICloudSyncEnabled)
|
||||
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai")
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
store.setLocaleId("auto")
|
||||
|
||||
let deviceID = SyncDeviceID.current(defaults: defaults)
|
||||
let stamp = Date(timeIntervalSince1970: 900)
|
||||
var remote = SyncedAppSettingsV2.seeded(
|
||||
from: AppGroupConfiguration.load(fromAvailable: defaults),
|
||||
deviceID: deviceB,
|
||||
updatedAt: stamp
|
||||
)
|
||||
remote.localeId = SyncedField(value: "ja", updatedAt: stamp, deviceID: deviceB)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
await settingsSync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(store.localeId, "ja")
|
||||
XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970, 900, accuracy: 1)
|
||||
XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970 ?? 0, 900, accuracy: 1)
|
||||
}
|
||||
|
||||
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
|
||||
|
||||
Reference in New Issue
Block a user