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,86 @@
|
||||
// MacAppContextService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Unlike the iOS keyboard extension, macOS can read the frontmost app's
|
||||
// bundle ID via NSWorkspace and map it to `AppContext` for polish prompts.
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum MacAppContextService {
|
||||
/// Bundle IDs → coarse polish context (macOS + cross-platform).
|
||||
private static let contextByBundleId: [String: AppContext] = [
|
||||
// Code / dev
|
||||
"com.apple.dt.Xcode": .code,
|
||||
"com.microsoft.VSCode": .code,
|
||||
"com.google.android.studio": .code,
|
||||
"com.jetbrains.intellij": .code,
|
||||
"com.jetbrains.AppCode": .code,
|
||||
"com.sublimetext.4": .code,
|
||||
"com.github.GitHubClient": .code,
|
||||
"com.apple.Terminal": .code,
|
||||
"com.googlecode.iterm2": .code,
|
||||
"dev.warp.Warp-Stable": .code,
|
||||
// Email
|
||||
"com.apple.mail": .email,
|
||||
"com.microsoft.Outlook": .email,
|
||||
"com.google.Gmail": .email,
|
||||
"com.readdle.smartemail": .email,
|
||||
// Chat / IM
|
||||
"com.tencent.xinWeChat": .chat,
|
||||
"com.tencent.qq": .chat,
|
||||
"com.tencent.wework": .chat,
|
||||
"com.tinyspeck.slackmacgap": .chat,
|
||||
"com.hnc.Discord": .chat,
|
||||
"com.microsoft.teams": .chat,
|
||||
"com.microsoft.teams2": .chat,
|
||||
"ru.keepcoder.Telegram": .chat,
|
||||
"net.whatsapp.WhatsApp": .chat,
|
||||
"com.apple.MobileSMS": .chat,
|
||||
"com.facebook.archon": .chat,
|
||||
"com.laiwang.DingTalk": .chat,
|
||||
"com.bytedance.feishu": .chat,
|
||||
// Documents / notes
|
||||
"com.apple.Notes": .document,
|
||||
"com.apple.iWork.Pages": .document,
|
||||
"notion.id": .document,
|
||||
"md.obsidian": .document,
|
||||
"net.shinyfrog.bear": .document,
|
||||
"com.agiletortoise.Drafts-OSX": .document,
|
||||
"com.microsoft.Word": .document,
|
||||
"com.google.GoogleDocs": .document,
|
||||
"com.evernote.Evernote": .document,
|
||||
"com.microsoft.onenote.mac": .document,
|
||||
]
|
||||
|
||||
/// Chat-style apps from the shared host registry (iOS bundle IDs often
|
||||
/// match Mac counterparts for cross-platform IM).
|
||||
private static let chatBundleIdsFromRegistry: Set<String> = {
|
||||
Set(HostAppURLRegistry.entries.map(\.bundleId))
|
||||
}()
|
||||
|
||||
static func frontmostApplicationName() -> String? {
|
||||
NSWorkspace.shared.frontmostApplication?.localizedName
|
||||
}
|
||||
|
||||
static func frontmostBundleIdentifier() -> String? {
|
||||
NSWorkspace.shared.frontmostApplication?.bundleIdentifier
|
||||
}
|
||||
|
||||
static func detectContext() -> AppContext {
|
||||
guard let bundleId = frontmostBundleIdentifier() else { return .unknown }
|
||||
if let mapped = contextByBundleId[bundleId] { return mapped }
|
||||
if chatBundleIdsFromRegistry.contains(bundleId) { return .chat }
|
||||
if bundleId.hasPrefix("com.apple.Safari") || bundleId.contains("chrome") {
|
||||
return .document
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
/// Persist detected context into the shared configuration store so
|
||||
/// `PolishingService` reads the same signal as on iOS.
|
||||
static func captureAndPersist(to store: AppGroupStore) {
|
||||
let context = detectContext()
|
||||
store.setDetectedAppContext(context)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user