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:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
+81 -67
View File
@@ -138,8 +138,7 @@ public struct KeyboardRootView: View {
TranscriptLine(
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
micDisabled: state.micDisabled,
micVoiceAvailability: state.micVoiceAvailability,
micDisabledHint: state.micDisabledHint,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings
@@ -192,7 +191,6 @@ public struct KeyboardRootView: View {
private var micActionRow: some View {
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
let micDisabled = state.micDisabled
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
// Dragging hides the mic + bottom keys (kept in the layout via
@@ -208,7 +206,7 @@ public struct KeyboardRootView: View {
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: !micDisabled,
isEnabled: !state.micDisabled,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
@@ -284,13 +282,17 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
case .requestingPermissions: return .idle
case .recording: return .recording
case .processing: return .processing
case .error: return .error
case .denied: return .error
if case .error = state.phase { return .error }
if case .denied = state.phase { return .error }
switch state.micVoiceAvailability {
case .ready:
return .idleReady
case .unavailable:
return .idleUnavailable
case .recording:
return .recording
case .processing:
return .processing
}
}
}
@@ -330,8 +332,7 @@ private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase
let transcript: String
let flowSessionActive: Bool
let micDisabled: Bool
let micVoiceAvailability: MicVoiceAvailability
let micDisabledHint: String
let cursorDragHintActive: Bool
let openSettings: () -> Void
@@ -353,66 +354,79 @@ private struct TranscriptLine: View {
private var phaseContent: some View {
switch phase {
case .idle:
if micDisabled {
Text(micDisabledHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else if flowSessionActive {
ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
ExtL10n.text("keyboard.placeholder.preparing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
idleHint
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
ExtL10n.text("keyboard.placeholder.preparing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
case .denied(let reason):
Button(action: openSettings) {
HStack(spacing: 4) {
Text(deniedMessage(for: reason))
.lineLimit(1)
.truncationMode(.tail)
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
case .denied(let reason):
Button(action: openSettings) {
HStack(spacing: 4) {
Text(deniedMessage(for: reason))
.lineLimit(1)
.truncationMode(.tail)
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
}
}
@ViewBuilder
private var idleHint: some View {
let isWarning = micVoiceAvailability.isUnavailable
Group {
switch micVoiceAvailability {
case .ready:
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
ExtL10n.text("keyboard.flow.sessionInactive")
case .unavailable(.preparingSession):
ExtL10n.text("keyboard.flow.startingSession")
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .recording, .processing:
EmptyView()
}
}
.font(TypeStyle.caption)
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
.lineLimit(1)
.truncationMode(.tail)
}
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
switch reason {
case .mic: return ExtL10n.string("keyboard.denied.mic")