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 @@
|
||||
// OSGKeyboardMacApp.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Entry point. A borderless, System-Settings-style main window plus a
|
||||
// rock-solid AppKit status-bar item (NSStatusItem) with a dictation popover.
|
||||
// Light / dark follows the user's Appearance preference (Settings ▸ General).
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OSGKeyboardMacApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
|
||||
@StateObject private var viewModel = MacDictationViewModel.shared
|
||||
|
||||
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
|
||||
// and — via `applyToApp` — the AppKit window chrome / popover.
|
||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
|
||||
private var appearance: MacAppearancePreference {
|
||||
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
Window("OSGKeyboard", id: "main") {
|
||||
MacRootView(viewModel: viewModel)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.task { await viewModel.onAppear() }
|
||||
.onAppear { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
viewModel.reloadConfigFromCloud()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
viewModel.usageStatistics.reloadFromDisk()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
|
||||
viewModel.speechHistory.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
// Borderless titlebar → content (sidebar + traffic lights) runs to the
|
||||
// very top, matching macOS System Settings.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultSize(width: 1_024, height: 720)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reopening the main window from AppKit
|
||||
|
||||
/// Bridges SwiftUI's `openWindow` action out to AppKit code (the status-bar
|
||||
/// popover) that has no access to the scene environment.
|
||||
@MainActor
|
||||
final class MacWindowBridge {
|
||||
static let shared = MacWindowBridge()
|
||||
var open: (() -> Void)?
|
||||
private init() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MacMainWindow {
|
||||
/// Bring the app forward and show the main window, recreating it if the
|
||||
/// user had closed it.
|
||||
static func open() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
MacWindowBridge.shared.open?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status-bar item (AppKit)
|
||||
|
||||
/// Owns the menu-bar `NSStatusItem` and its dictation popover. Implemented in
|
||||
/// AppKit rather than SwiftUI's `MenuBarExtra` because the latter is flaky
|
||||
/// when combined with a primary `Window` scene (the icon can silently vanish).
|
||||
@MainActor
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
private let popover = NSPopover()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
MacAppearancePreference.applyToApp(.current)
|
||||
configurePopover()
|
||||
configureStatusItem()
|
||||
}
|
||||
|
||||
/// Keep the app alive after the last window closes — it lives in the menu bar.
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private func configureStatusItem() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = item.button {
|
||||
// Prefer the brand mark; fall back to an SF Symbol so the item is
|
||||
// never invisible even if the asset fails to resolve.
|
||||
let image = NSImage(named: "OSGBrandMark")
|
||||
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard")
|
||||
image?.isTemplate = true
|
||||
image?.size = NSSize(width: 18, height: 18)
|
||||
button.image = image
|
||||
button.image?.accessibilityDescription = "OSGKeyboard"
|
||||
button.action = #selector(togglePopover(_:))
|
||||
button.target = self
|
||||
}
|
||||
statusItem = item
|
||||
}
|
||||
|
||||
private func configurePopover() {
|
||||
popover.behavior = .transient
|
||||
popover.animates = true
|
||||
popover.contentSize = NSSize(width: 340, height: 420)
|
||||
popover.contentViewController = NSHostingController(rootView: MacMenuBarPopover())
|
||||
}
|
||||
|
||||
@objc private func togglePopover(_ sender: Any?) {
|
||||
guard let button = statusItem?.button else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI content hosted inside the status-bar popover. Shares the single
|
||||
/// view model and follows the same appearance preference as the main window.
|
||||
private struct MacMenuBarPopover: View {
|
||||
@ObservedObject private var viewModel = MacDictationViewModel.shared
|
||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
|
||||
var body: some View {
|
||||
MacContentView(viewModel: viewModel)
|
||||
.frame(width: 340)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user