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:
@@ -0,0 +1,145 @@
|
||||
// MacContentView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Compact menu-bar popover for quick dictation.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacContentView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
recordButton
|
||||
Text(MacL10n.string("mac.hint.holdOption", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Text(viewModel.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
if !viewModel.transcript.isEmpty {
|
||||
ScrollView {
|
||||
Text(viewModel.transcript)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(Spacing.xs)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
|
||||
}
|
||||
|
||||
Divider().overlay(palette.divider)
|
||||
statusRow
|
||||
Divider().overlay(palette.divider)
|
||||
footer
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
viewModel.isCloudMode
|
||||
? MacL10n.string("mac.mode.cloud", language: lang)
|
||||
: MacL10n.string("mac.mode.local", language: lang),
|
||||
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
translationMenu
|
||||
|
||||
Spacer()
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
|
||||
private var translationMenu: some View {
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button(MacTranslationDisplay.label(for: language.id, language: lang)) {
|
||||
viewModel.config.translationTargetLocaleId = language.id
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "translate")
|
||||
Text(MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang))
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundStyle(palette.accent)
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
|
||||
Text(
|
||||
viewModel.isRecording
|
||||
? MacL10n.string("mac.record.stop", language: lang)
|
||||
: MacL10n.string("mac.record.start", language: lang)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.background(
|
||||
(viewModel.isRecording ? palette.recordRed : palette.accent),
|
||||
in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isProcessing)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button(MacL10n.string("mac.openWindow", language: lang)) { MacMainWindow.open() }
|
||||
.buttonStyle(.plain)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
Spacer()
|
||||
Button(MacL10n.string("mac.quit", language: lang)) { NSApplication.shared.terminate(nil) }
|
||||
.buttonStyle(.plain)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user