4c929d8b8b
Retain the external target app for menu-bar paste and polish context, retry failed middle ASR chunks once, and stop false-positive path/number violations.
255 lines
11 KiB
Swift
255 lines
11 KiB
Swift
// 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
|
|
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
|
|
|
private var appearance: MacAppearancePreference {
|
|
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
|
|
}
|
|
|
|
var body: some Scene {
|
|
Window("OSGKeyboard", id: "main") {
|
|
Group {
|
|
if hasCompletedMacOnboarding {
|
|
MacRootView(viewModel: viewModel)
|
|
} else {
|
|
MacOnboardingView(
|
|
viewModel: viewModel,
|
|
hasCompletedOnboarding: $hasCompletedMacOnboarding
|
|
)
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
.onOpenURL { url in
|
|
#if DEBUG
|
|
guard url.scheme == "osgkeyboard", url.host == "seed-demo" else { return }
|
|
DemoDataSeeder.seedRichPlaceholderData(
|
|
defaults: .standard,
|
|
historyDefaults: .standard
|
|
)
|
|
viewModel.reloadConfigFromCloud()
|
|
viewModel.refreshDictionaryFromCloud()
|
|
viewModel.usageStatistics.reloadFromDisk()
|
|
viewModel.speechHistory.reloadFromDisk()
|
|
hasCompletedMacOnboarding = true
|
|
#endif
|
|
}
|
|
}
|
|
// Borderless titlebar → content (sidebar + traffic lights) runs to the
|
|
// very top, matching macOS System Settings.
|
|
.windowStyle(.hiddenTitleBar)
|
|
.windowResizability(.contentMinSize)
|
|
// Open at the minimum size — same as `MacMetrics.windowMin*`, so the
|
|
// first launch already matches the smallest allowed window.
|
|
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
|
|
}
|
|
}
|
|
|
|
// 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, NSPopoverDelegate {
|
|
private var statusItem: NSStatusItem?
|
|
private let popover = NSPopover()
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
MacAppearancePreference.applyToApp(.current)
|
|
MacTextInsertionService.beginTrackingFrontmostApp()
|
|
configurePopover()
|
|
configureStatusItem()
|
|
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
|
|
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
|
|
|
// The menu bar always follows the *system* appearance, so the status
|
|
// item must ignore the app's forced light/dark override. Re-pin the
|
|
// button appearance whenever the system theme flips.
|
|
DistributedNotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(systemAppearanceDidChange),
|
|
name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
deinit {
|
|
DistributedNotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
/// 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 {
|
|
button.image = Self.makeStatusBarImage()
|
|
button.image?.accessibilityDescription = "OSGKeyboard"
|
|
button.action = #selector(togglePopover(_:))
|
|
button.target = self
|
|
}
|
|
statusItem = item
|
|
applyStatusItemAppearance()
|
|
}
|
|
|
|
/// Builds the menu-bar glyph from the dedicated horizontal status mark.
|
|
/// Height is pinned to the tallest practical menu-bar slot so the logo reads
|
|
/// clearly; width follows the asset's aspect ratio.
|
|
private static func makeStatusBarImage() -> NSImage? {
|
|
guard let image = NSImage(named: "OSGStatusMark")
|
|
?? NSImage(named: "OSGBrandMark")
|
|
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard") else {
|
|
return nil
|
|
}
|
|
let height: CGFloat = 9
|
|
let aspect = max(image.size.width / max(image.size.height, 1), 1)
|
|
image.size = NSSize(width: height * aspect, height: height)
|
|
image.isTemplate = true
|
|
return image
|
|
}
|
|
|
|
/// Pins the status-bar button to the current *system* appearance so its
|
|
/// template image tint matches the real menu-bar background — regardless of
|
|
/// the in-app light/dark preference forced on `NSApp.appearance`.
|
|
private func applyStatusItemAppearance() {
|
|
guard let button = statusItem?.button else { return }
|
|
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle")?
|
|
.lowercased().contains("dark") ?? false
|
|
button.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
|
|
}
|
|
|
|
@objc private func systemAppearanceDidChange() {
|
|
// The global-domain default lags the notification by a hair; hop to the
|
|
// next runloop tick so `AppleInterfaceStyle` reflects the new value.
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.applyStatusItemAppearance()
|
|
}
|
|
}
|
|
|
|
private func configurePopover() {
|
|
popover.delegate = self
|
|
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 {
|
|
// Capture before activation: once the popover becomes key,
|
|
// NSWorkspace reports OSGKeyboard instead of the user's target.
|
|
MacDictationViewModel.shared.prepareForPopoverPresentation()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
popover.contentViewController?.view.window?.makeKey()
|
|
}
|
|
}
|
|
|
|
func popoverDidClose(_ notification: Notification) {
|
|
MacDictationViewModel.shared.clearPreparedPopoverTarget()
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if hasCompletedMacOnboarding {
|
|
MacContentView(viewModel: viewModel)
|
|
} else {
|
|
onboardingPrompt
|
|
}
|
|
}
|
|
.frame(width: 340)
|
|
.macSystemPalette()
|
|
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
|
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
|
|
}
|
|
|
|
private var onboardingPrompt: some View {
|
|
VStack(spacing: Spacing.md) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: 30, weight: .semibold))
|
|
.foregroundStyle(.accent)
|
|
|
|
Text(MacL10n.string("mac.onboarding.popover.title", language: viewModel.config.uiLanguage))
|
|
.font(TypeStyle.headline)
|
|
|
|
Text(MacL10n.string("mac.onboarding.popover.subtitle", language: viewModel.config.uiLanguage))
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Button(MacL10n.string("mac.openWindow", language: viewModel.config.uiLanguage)) {
|
|
MacMainWindow.open()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.padding(Spacing.lg)
|
|
}
|
|
}
|