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,232 @@
|
||||
// MacComponents.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Reusable macOS UI pieces styled with the shared design tokens and the
|
||||
// system-native palette (see MacTheme.swift). Kept as plain SwiftUI (no
|
||||
// AppKit) so they can be reused on iPadOS. Settings / History / Dictionary
|
||||
// now use the native grouped `Form`, so the old hand-rolled card/row types
|
||||
// were removed — what remains here is used by the Dashboard, the status
|
||||
// footer and the menu-bar popover.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Shared layout metrics
|
||||
|
||||
/// Fixed metrics that keep every desktop surface on the same grid.
|
||||
enum MacMetrics {
|
||||
/// Uniform max width for trailing text controls (API key / model field).
|
||||
static let controlWidth: CGFloat = 240
|
||||
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
|
||||
static let sidebarWidth: CGFloat = 240
|
||||
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
|
||||
/// adds `Spacing.sm` on top of this so its left edge lines up with the
|
||||
/// SF Symbol in each nav `Label`.
|
||||
static let sidebarInset: CGFloat = Spacing.md
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
/// Reading width for single-column content.
|
||||
static let contentMaxWidth: CGFloat = 720
|
||||
/// Top inset that clears the window traffic-light buttons now that the
|
||||
/// title bar is hidden.
|
||||
static let trafficLightInset: CGFloat = 28
|
||||
}
|
||||
|
||||
// MARK: - Liquid Glass
|
||||
|
||||
private struct MacGlassSurface<S: Shape>: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let shape: S
|
||||
let fillOpacity: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
||||
.glassEffect(.regular, in: shape)
|
||||
} else {
|
||||
content
|
||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies Liquid Glass on macOS 26 while keeping the same semantic
|
||||
/// surface colour on older systems.
|
||||
func macGlassSurface<S: Shape>(
|
||||
in shape: S,
|
||||
fillOpacity: Double = 0.72
|
||||
) -> some View {
|
||||
modifier(MacGlassSurface(shape: shape, fillOpacity: fillOpacity))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text field styling
|
||||
|
||||
/// Text-field chrome aligned with native macOS Form controls: compact
|
||||
/// height, text-background fill, hairline border.
|
||||
private struct MacFieldStyleModifier: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
content
|
||||
.textFieldStyle(.plain)
|
||||
.font(TypeStyle.footnote)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 22)
|
||||
.background(Color(nsColor: .textBackgroundColor), in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Theme-aware text-field styling for the settings inputs.
|
||||
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
|
||||
/// Elevated surface used for stat tiles and the dictation canvas.
|
||||
struct MacCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.macGlassSurface(in: shape)
|
||||
.overlay(
|
||||
shape
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat tile
|
||||
|
||||
struct StatCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
let value: String
|
||||
let caption: String
|
||||
var systemImage: String?
|
||||
var accent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
MacCard {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live waveform
|
||||
|
||||
/// Compact bar visualiser reacting to the input level while recording.
|
||||
struct MiniWaveform: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let level: Float
|
||||
var barCount: Int = 5
|
||||
/// Pass nil to inherit the palette accent automatically.
|
||||
var tint: Color?
|
||||
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(tint ?? palette.accent)
|
||||
.frame(width: 3, height: barHeight(index))
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.animation(.easeOut(duration: 0.12), value: level)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func barHeight(_ index: Int) -> CGFloat {
|
||||
let base = CGFloat(level) * 22
|
||||
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
|
||||
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Translation display helper
|
||||
|
||||
/// Shared label logic for the translation control so the dashboard chip,
|
||||
/// the status footer and the menu-bar popover all read identically.
|
||||
enum MacTranslationDisplay {
|
||||
static func label(for targetLocaleId: String, language: AppUILanguage) -> String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
if TranslationLanguageCatalog.isOff(resolved.id) {
|
||||
return MacL10n.string("keyboard.translation.offMenu", language: language)
|
||||
}
|
||||
return resolved.nativeName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Bottom status strip: engine mode (cloud/local), translation target, and
|
||||
/// the connection state — icons and wording mirror the dashboard record bar.
|
||||
struct MacStatusFooter: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Spacer()
|
||||
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)
|
||||
|
||||
Label(
|
||||
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
||||
systemImage: "translate"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user